feat(KAN-24): rebuild project creation flow
This commit is contained in:
@@ -1,4 +1,86 @@
|
||||
export const en = {
|
||||
'projectCreation.title': 'Create a project',
|
||||
'projectCreation.description':
|
||||
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
|
||||
'projectCreation.start': 'How would you like to start?',
|
||||
'projectCreation.scratch': 'Start from scratch',
|
||||
'projectCreation.scratchDescription':
|
||||
'Create an empty project with the default setup.',
|
||||
'projectCreation.duplicate': 'Duplicate a project',
|
||||
'projectCreation.duplicateDescription':
|
||||
'Choose an existing project and copy selected settings.',
|
||||
'projectCreation.template': 'Use a template',
|
||||
'projectCreation.templateDescription':
|
||||
'Start with a saved, reusable project configuration.',
|
||||
'projectCreation.noProjects':
|
||||
'No accessible projects are available to copy yet.',
|
||||
'projectCreation.noTemplates': 'No published templates are available yet.',
|
||||
'projectCreation.details': 'Project details',
|
||||
'projectCreation.name': 'Project name',
|
||||
'projectCreation.namePlaceholder': 'Platform engineering',
|
||||
'projectCreation.key': 'Project key',
|
||||
'projectCreation.keyPlaceholder': 'CORE',
|
||||
'projectCreation.keyHelp':
|
||||
'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.',
|
||||
'projectCreation.copyTitle': 'Choose what to copy',
|
||||
'projectCreation.copyHelp':
|
||||
'Copy configuration into an independent project. Select only what you need.',
|
||||
'projectCreation.sourceProject': 'Source project',
|
||||
'projectCreation.selectProject': 'Select a source project',
|
||||
'projectCreation.copyOptions': 'Configuration areas',
|
||||
'projectCreation.copyDefaults':
|
||||
'Unselected areas keep the default setup. You can also continue without copying any settings.',
|
||||
'projectCreation.chooseTemplate': 'Choose a template',
|
||||
'projectCreation.templateLabel': 'Template',
|
||||
'projectCreation.selectTemplate': 'Select a template',
|
||||
'projectCreation.templateHelp':
|
||||
'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.',
|
||||
'projectCreation.includedAreas': 'Included configuration',
|
||||
'projectCreation.templateDefaults':
|
||||
'This template uses the default project setup.',
|
||||
'projectCreation.summary': 'Your new project',
|
||||
'projectCreation.untitled': 'Add a project name',
|
||||
'projectCreation.ticketPreview': 'First ticket key',
|
||||
'projectCreation.setup': 'Starting point',
|
||||
'projectCreation.cleanStart':
|
||||
'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.',
|
||||
'projectCreation.submit': 'Create project',
|
||||
'projectCreation.creating': 'Creating project…',
|
||||
'projectCreation.areas.projectSettings.label': 'Project settings',
|
||||
'projectCreation.areas.projectSettings.description':
|
||||
'Story points and author visibility.',
|
||||
'projectCreation.areas.boardColumns.label': 'Board columns',
|
||||
'projectCreation.areas.boardColumns.description':
|
||||
'Columns and their order on the board.',
|
||||
'projectCreation.areas.ticketStatuses.label': 'Ticket statuses',
|
||||
'projectCreation.areas.ticketStatuses.description':
|
||||
'Statuses and their board columns.',
|
||||
'projectCreation.areas.statusTransitions.label': 'Status transitions',
|
||||
'projectCreation.areas.statusTransitions.description':
|
||||
'Allowed moves between statuses.',
|
||||
'projectCreation.areas.ticketTypes.label': 'Ticket types',
|
||||
'projectCreation.areas.ticketTypes.description':
|
||||
'Available types, including parent types.',
|
||||
'projectCreation.areas.ticketPriorities.label': 'Ticket priorities',
|
||||
'projectCreation.areas.ticketPriorities.description':
|
||||
'Available priority levels.',
|
||||
'projectCreation.areas.ticketFields.label': 'Custom ticket fields',
|
||||
'projectCreation.areas.ticketFields.description':
|
||||
'Field definitions without ticket values.',
|
||||
'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types',
|
||||
'projectCreation.areas.ticketLinkTypes.description':
|
||||
'Relationship labels between tickets.',
|
||||
'projectCreation.areas.repositories.label': 'Repository connections',
|
||||
'projectCreation.areas.repositories.description':
|
||||
'Repositories linked to the project.',
|
||||
'projectCreation.areas.notifications.label': 'Notification actions',
|
||||
'projectCreation.areas.notifications.description':
|
||||
'Notification rules and delivery settings.',
|
||||
'projectCreation.selectedCount': {
|
||||
one: '{count} area selected',
|
||||
other: '{count} areas selected',
|
||||
},
|
||||
|
||||
'projects.workspace': 'Workspace',
|
||||
'projects.description':
|
||||
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
|
||||
|
||||
@@ -1,349 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
FolderPlus,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, watch } from 'vue';
|
||||
import { store } from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { create, index } from '@/routes/projects/index';
|
||||
import { create, index } from '@/routes/projects';
|
||||
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectsCreatePageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: 'New project',
|
||||
href: create(),
|
||||
},
|
||||
];
|
||||
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: t('projects.create'), href: create() },
|
||||
]);
|
||||
const defaultTemplateId =
|
||||
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||
|
||||
const form = useForm({
|
||||
copy_areas: [] as string[],
|
||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||
key: '',
|
||||
name: '',
|
||||
source_project_id: props.sourceProjects[0]?.id
|
||||
? String(props.sourceProjects[0].id)
|
||||
: '',
|
||||
source_template_id:
|
||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||
});
|
||||
|
||||
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
||||
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
||||
|
||||
const copyAreasError = computed(() => {
|
||||
return (
|
||||
const form = useForm(
|
||||
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
|
||||
{
|
||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||
name: '',
|
||||
key: '',
|
||||
source_project_id: props.sourceProjects[0]?.id
|
||||
? String(props.sourceProjects[0].id)
|
||||
: '',
|
||||
source_template_id:
|
||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||
copy_areas: [] as string[],
|
||||
},
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
const modes = computed(() => [
|
||||
{
|
||||
value: 'scratch',
|
||||
icon: FolderPlus,
|
||||
title: t('projectCreation.scratch'),
|
||||
description: t('projectCreation.scratchDescription'),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: 'duplicate',
|
||||
icon: Copy,
|
||||
title: t('projectCreation.duplicate'),
|
||||
description: t(
|
||||
props.sourceProjects.length
|
||||
? 'projectCreation.duplicateDescription'
|
||||
: 'projectCreation.noProjects',
|
||||
),
|
||||
disabled: props.sourceProjects.length === 0,
|
||||
},
|
||||
{
|
||||
value: 'template',
|
||||
icon: LayoutTemplate,
|
||||
title: t('projectCreation.template'),
|
||||
description: t(
|
||||
props.sourceTemplates.length
|
||||
? 'projectCreation.templateDescription'
|
||||
: 'projectCreation.noTemplates',
|
||||
),
|
||||
disabled: props.sourceTemplates.length === 0,
|
||||
},
|
||||
]);
|
||||
const areaKeys = {
|
||||
project_settings: 'projectCreation.areas.projectSettings',
|
||||
board_columns: 'projectCreation.areas.boardColumns',
|
||||
ticket_statuses: 'projectCreation.areas.ticketStatuses',
|
||||
status_transitions: 'projectCreation.areas.statusTransitions',
|
||||
ticket_types: 'projectCreation.areas.ticketTypes',
|
||||
ticket_priorities: 'projectCreation.areas.ticketPriorities',
|
||||
ticket_fields: 'projectCreation.areas.ticketFields',
|
||||
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
|
||||
repository_connections: 'projectCreation.areas.repositories',
|
||||
notification_actions: 'projectCreation.areas.notifications',
|
||||
} as const;
|
||||
function areaText(value: string, part: 'label' | 'description'): string {
|
||||
const key = areaKeys[value as keyof typeof areaKeys];
|
||||
return key
|
||||
? t(`${key}.${part}`)
|
||||
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
|
||||
value);
|
||||
}
|
||||
const selectedProject = computed(() =>
|
||||
props.sourceProjects.find(
|
||||
(project) => String(project.id) === form.source_project_id,
|
||||
),
|
||||
);
|
||||
const selectedTemplate = computed(() =>
|
||||
props.sourceTemplates.find(
|
||||
(template) => String(template.id) === form.source_template_id,
|
||||
),
|
||||
);
|
||||
const selectedMode = computed(() =>
|
||||
modes.value.find((mode) => mode.value === form.creation_mode),
|
||||
);
|
||||
const ticketKey = computed(
|
||||
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
|
||||
);
|
||||
const copyAreasError = computed(
|
||||
() =>
|
||||
form.errors.copy_areas ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('copy_areas.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
)?.[1],
|
||||
);
|
||||
const selectClass =
|
||||
'border-input bg-card min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
|
||||
|
||||
const submit = (): void => {
|
||||
if (form.creation_mode !== 'duplicate') {
|
||||
form.source_project_id = '';
|
||||
form.copy_areas = [];
|
||||
}
|
||||
watch(
|
||||
() => form.creation_mode,
|
||||
() => {
|
||||
form.clearErrors(
|
||||
'creation_mode',
|
||||
'source_project_id',
|
||||
'source_template_id',
|
||||
'copy_areas',
|
||||
);
|
||||
for (const key of Object.keys(form.errors)) {
|
||||
if (key.startsWith('copy_areas.')) {
|
||||
form.clearErrors(key as 'copy_areas');
|
||||
}
|
||||
}
|
||||
protection.clearFeedback();
|
||||
},
|
||||
);
|
||||
|
||||
if (form.creation_mode !== 'template') {
|
||||
form.source_template_id = '';
|
||||
}
|
||||
|
||||
form.post(ProjectController.store.url(), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
function submit(): void {
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
source_project_id:
|
||||
data.creation_mode === 'duplicate' ? data.source_project_id : '',
|
||||
source_template_id:
|
||||
data.creation_mode === 'template' ? data.source_template_id : '',
|
||||
copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
|
||||
}));
|
||||
protection.submit(store(), { preserveScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="New project" />
|
||||
<Head :title="t('projects.create')" />
|
||||
<div
|
||||
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||
>
|
||||
<header class="gap-stack grid">
|
||||
<Link
|
||||
:href="index()"
|
||||
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
|
||||
>
|
||||
<ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||
t('projects.back')
|
||||
}}
|
||||
</Link>
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('projects.workspace') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile md:text-headline">
|
||||
{{ t('projectCreation.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('projectCreation.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<Heading
|
||||
title="Create project"
|
||||
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project details</CardTitle>
|
||||
<CardDescription>
|
||||
The key is uppercased automatically, must be unique, and
|
||||
can contain up to 5 characters.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form class="space-y-6" @submit.prevent="submit">
|
||||
<div class="grid gap-3">
|
||||
<Label>How do you want to start?</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<Label
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
value="scratch"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
Create from scratch
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Start with the default project
|
||||
setup.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
class="flex items-start gap-3 rounded-xl border p-4"
|
||||
:class="
|
||||
hasSourceProjects
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-60'
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
value="duplicate"
|
||||
class="mt-1"
|
||||
:disabled="!hasSourceProjects"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
Create from existing project
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Copy only the settings you
|
||||
explicitly select. Tickets are never
|
||||
duplicated.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
class="flex items-start gap-3 rounded-xl border p-4"
|
||||
:class="
|
||||
hasSourceTemplates
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-60'
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
value="template"
|
||||
class="mt-1"
|
||||
:disabled="!hasSourceTemplates"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
Create from template
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Reuse a saved configuration
|
||||
template.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Project name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Kanban foundation"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="key">Project key</Label>
|
||||
<Input
|
||||
id="key"
|
||||
v-model="form.key"
|
||||
required
|
||||
maxlength="5"
|
||||
placeholder="CORE"
|
||||
class="uppercase"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Example ticket keys will look like
|
||||
<span class="text-foreground font-medium"
|
||||
>CORE-1</span
|
||||
>.
|
||||
</p>
|
||||
<InputError :message="form.errors.key" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.creation_mode === 'duplicate'"
|
||||
class="space-y-6 rounded-xl border border-dashed p-4"
|
||||
<form
|
||||
class="gap-page grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
:title="
|
||||
form.processing
|
||||
? t('projectCreation.creating')
|
||||
: undefined
|
||||
"
|
||||
@retry="submit"
|
||||
/>
|
||||
<fieldset
|
||||
class="gap-column grid min-w-0"
|
||||
:disabled="form.processing"
|
||||
aria-describedby="creation-mode-error"
|
||||
>
|
||||
<legend class="text-title mb-3">
|
||||
{{ t('projectCreation.start') }}
|
||||
</legend>
|
||||
<div class="gap-column grid md:grid-cols-3">
|
||||
<label
|
||||
v-for="mode in modes"
|
||||
:key="mode.value"
|
||||
class="relative min-w-0"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="source_project_id"
|
||||
>Source project</Label
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
name="creation_mode"
|
||||
:value="mode.value"
|
||||
:disabled="mode.disabled"
|
||||
:aria-invalid="!!form.errors.creation_mode"
|
||||
:aria-labelledby="`mode-${mode.value}-title`"
|
||||
:aria-describedby="`mode-${mode.value}-description`"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
|
||||
:class="
|
||||
!mode.disabled
|
||||
? 'hover:border-primary/50'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<component
|
||||
:is="mode.icon"
|
||||
class="text-primary size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Check
|
||||
v-if="form.creation_mode === mode.value"
|
||||
class="text-primary size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
:id="`mode-${mode.value}-title`"
|
||||
class="font-semibold"
|
||||
>{{ mode.title }}</span
|
||||
>
|
||||
<span
|
||||
:id="`mode-${mode.value}-description`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>{{ mode.description }}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<InputError
|
||||
id="creation-mode-error"
|
||||
:message="form.errors.creation_mode"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
|
||||
>
|
||||
<div class="gap-page grid min-w-0">
|
||||
<section
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="project-details-title"
|
||||
>
|
||||
<h2 id="project-details-title" class="text-title">
|
||||
{{ t('projectCreation.details') }}
|
||||
</h2>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="name">{{
|
||||
t('projectCreation.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="form.processing"
|
||||
:placeholder="
|
||||
t('projectCreation.namePlaceholder')
|
||||
"
|
||||
:invalid="!!form.errors.name"
|
||||
aria-describedby="name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="key">{{
|
||||
t('projectCreation.key')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="key"
|
||||
v-model="form.key"
|
||||
name="key"
|
||||
required
|
||||
maxlength="5"
|
||||
:disabled="form.processing"
|
||||
:placeholder="
|
||||
t('projectCreation.keyPlaceholder')
|
||||
"
|
||||
class="uppercase sm:max-w-48"
|
||||
:invalid="!!form.errors.key"
|
||||
aria-describedby="key-help key-error"
|
||||
autocapitalize="characters"
|
||||
:spellcheck="false"
|
||||
/>
|
||||
<p
|
||||
id="key-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('projectCreation.keyHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="key-error"
|
||||
:message="form.errors.key"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="form.creation_mode === 'duplicate'"
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="copy-title"
|
||||
>
|
||||
<div class="gap-stack grid">
|
||||
<h2 id="copy-title" class="text-title">
|
||||
{{ t('projectCreation.copyTitle') }}
|
||||
</h2>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.copyHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<Label for="source_project_id">{{
|
||||
t('projectCreation.sourceProject')
|
||||
}}</Label>
|
||||
<select
|
||||
id="source_project_id"
|
||||
v-model="form.source_project_id"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasSourceProjects"
|
||||
required
|
||||
:class="selectClass"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="
|
||||
!!form.errors.source_project_id
|
||||
"
|
||||
aria-describedby="source-project-error"
|
||||
>
|
||||
<option value="">
|
||||
Select a source project
|
||||
{{ t('projectCreation.selectProject') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="sourceProject in sourceProjects"
|
||||
:key="sourceProject.id"
|
||||
:value="String(sourceProject.id)"
|
||||
v-for="project in sourceProjects"
|
||||
:key="project.id"
|
||||
:value="String(project.id)"
|
||||
>
|
||||
{{ sourceProject.name }} ({{
|
||||
sourceProject.key
|
||||
}})
|
||||
{{ project.name }} ({{ project.key }})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
The new project keeps its own identity, key,
|
||||
and future ticket numbering.
|
||||
</p>
|
||||
<InputError
|
||||
id="source-project-error"
|
||||
:message="form.errors.source_project_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div>
|
||||
<Label>Copy options</Label>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Choose which settings should be copied
|
||||
into the new project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<Label
|
||||
v-for="copyAreaOption in copyAreaOptions"
|
||||
:key="copyAreaOption.value"
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||
<fieldset
|
||||
class="min-w-0"
|
||||
:disabled="form.processing"
|
||||
aria-describedby="copy-help copy-areas-error"
|
||||
>
|
||||
<legend class="mb-2 font-semibold">
|
||||
{{ t('projectCreation.copyOptions') }}
|
||||
</legend>
|
||||
<p
|
||||
id="copy-help"
|
||||
class="text-caption text-muted-foreground mb-3"
|
||||
>
|
||||
{{ t('projectCreation.copyDefaults') }}
|
||||
</p>
|
||||
<div class="gap-stack grid sm:grid-cols-2">
|
||||
<label
|
||||
v-for="area in copyAreaOptions"
|
||||
:key="area.value"
|
||||
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
|
||||
>
|
||||
<input
|
||||
v-model="form.copy_areas"
|
||||
type="checkbox"
|
||||
:value="copyAreaOption.value"
|
||||
class="mt-1"
|
||||
:value="area.value"
|
||||
:aria-labelledby="`copy-${area.value}-label`"
|
||||
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
|
||||
:aria-invalid="!!copyAreasError"
|
||||
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ copyAreaOption.label }}
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
<span class="gap-stack grid min-w-0">
|
||||
<span
|
||||
:id="`copy-${area.value}-label`"
|
||||
class="text-body font-semibold"
|
||||
>{{
|
||||
areaText(
|
||||
area.value,
|
||||
'label',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
{{ copyAreaOption.description }}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
<span
|
||||
:id="`copy-${area.value}-description`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>{{
|
||||
areaText(
|
||||
area.value,
|
||||
'description',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<InputError
|
||||
id="copy-areas-error"
|
||||
class="mt-3"
|
||||
:message="copyAreasError"
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Operational ticket data is always left
|
||||
behind so the new project starts clean.
|
||||
</p>
|
||||
<InputError :message="copyAreasError" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<section
|
||||
v-else-if="form.creation_mode === 'template'"
|
||||
class="space-y-4 rounded-xl border border-dashed p-4"
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="template-title"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="source_template_id">Template</Label>
|
||||
<h2 id="template-title" class="text-title">
|
||||
{{ t('projectCreation.chooseTemplate') }}
|
||||
</h2>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<Label for="source_template_id">{{
|
||||
t('projectCreation.templateLabel')
|
||||
}}</Label>
|
||||
<select
|
||||
id="source_template_id"
|
||||
v-model="form.source_template_id"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasSourceTemplates"
|
||||
required
|
||||
:class="selectClass"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="
|
||||
!!form.errors.source_template_id
|
||||
"
|
||||
aria-describedby="template-help source-template-error"
|
||||
>
|
||||
<option value="">Select a template</option>
|
||||
<option value="">
|
||||
{{
|
||||
t('projectCreation.selectTemplate')
|
||||
}}
|
||||
</option>
|
||||
<option
|
||||
v-for="sourceTemplate in sourceTemplates"
|
||||
:key="sourceTemplate.id"
|
||||
:value="String(sourceTemplate.id)"
|
||||
v-for="template in sourceTemplates"
|
||||
:key="template.id"
|
||||
:value="String(template.id)"
|
||||
>
|
||||
{{ sourceTemplate.name }} ({{
|
||||
sourceTemplate.slug
|
||||
{{ template.name }} ({{
|
||||
template.slug
|
||||
}})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Templates apply their saved configuration
|
||||
groups to the new project.
|
||||
<p
|
||||
id="template-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('projectCreation.templateHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="source-template-error"
|
||||
:message="form.errors.source_template_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTemplate"
|
||||
class="gap-stack grid"
|
||||
aria-live="polite"
|
||||
>
|
||||
<h3 class="text-body font-semibold">
|
||||
{{ t('projectCreation.includedAreas') }}
|
||||
</h3>
|
||||
<ul
|
||||
v-if="selectedTemplate.copy_areas.length"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<li
|
||||
v-for="area in selectedTemplate.copy_areas"
|
||||
:key="area"
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="whitespace-normal"
|
||||
>{{
|
||||
areaText(area, 'label')
|
||||
}}</Badge
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('projectCreation.templateDefaults') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
!hasSourceProjects && !hasSourceTemplates
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
<aside
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
aria-labelledby="summary-title"
|
||||
>
|
||||
<h2 id="summary-title" class="text-title">
|
||||
{{ t('projectCreation.summary') }}
|
||||
</h2>
|
||||
<dl class="gap-column grid min-w-0">
|
||||
<div class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.name') }}
|
||||
</dt>
|
||||
<dd class="break-words font-semibold">
|
||||
{{
|
||||
form.name.trim() ||
|
||||
t('projectCreation.untitled')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.ticketPreview') }}
|
||||
</dt>
|
||||
<dd>
|
||||
<Badge variant="outline">{{
|
||||
ticketKey
|
||||
}}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="gap-stack grid">
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.setup') }}
|
||||
</dt>
|
||||
<dd class="break-words">
|
||||
{{ selectedMode?.title }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.creation_mode === 'duplicate'"
|
||||
class="gap-stack grid"
|
||||
>
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.sourceProject') }}
|
||||
</dt>
|
||||
<dd class="break-words">
|
||||
{{
|
||||
selectedProject?.name ||
|
||||
t('projectCreation.selectProject')
|
||||
}}
|
||||
</dd>
|
||||
<dd class="text-caption text-muted-foreground">
|
||||
{{
|
||||
t('projectCreation.selectedCount', {
|
||||
count: form.copy_areas.length,
|
||||
})
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="form.creation_mode === 'template'"
|
||||
class="gap-stack grid"
|
||||
>
|
||||
<dt class="text-caption text-muted-foreground">
|
||||
{{ t('projectCreation.templateLabel') }}
|
||||
</dt>
|
||||
<dd class="break-words">
|
||||
{{
|
||||
selectedTemplate?.name ||
|
||||
t('projectCreation.selectTemplate')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
class="text-caption text-muted-foreground border-t pt-4"
|
||||
>
|
||||
No accessible source projects or saved templates are
|
||||
available yet.
|
||||
</div>
|
||||
{{ t('projectCreation.cleanStart') }}
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Create project
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="index()">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<footer
|
||||
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
><Link :href="index()">{{
|
||||
t('common.cancel')
|
||||
}}</Link></Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="form.processing"
|
||||
:aria-label="
|
||||
t(
|
||||
form.processing
|
||||
? 'projectCreation.creating'
|
||||
: 'projectCreation.submit',
|
||||
)
|
||||
"
|
||||
:loading-label="t('projectCreation.creating')"
|
||||
><FolderPlus
|
||||
v-if="!form.processing"
|
||||
aria-hidden="true"
|
||||
/>{{
|
||||
t(
|
||||
form.processing
|
||||
? 'projectCreation.creating'
|
||||
: 'projectCreation.submit',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user