Files
KanbanGit/resources/js/pages/projects/Create.vue
T
Twyco cd7fae3841
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
feat(KAN-24): rebuild project creation flow
2026-09-11 14:19:32 +02:00

634 lines
30 KiB
Vue

<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import {
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';
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
const props = defineProps<ProjectsCreatePageProps>();
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(
`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],
);
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';
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();
},
);
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="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>
<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"
>
<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"
required
:class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_project_id
"
aria-describedby="source-project-error"
>
<option value="">
{{ t('projectCreation.selectProject') }}
</option>
<option
v-for="project in sourceProjects"
:key="project.id"
:value="String(project.id)"
>
{{ project.name }} ({{ project.key }})
</option>
</select>
<InputError
id="source-project-error"
:message="form.errors.source_project_id"
/>
</div>
<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="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"
/>
<span class="gap-stack grid min-w-0">
<span
:id="`copy-${area.value}-label`"
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
)
}}</span
>
<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>
<section
v-else-if="form.creation_mode === 'template'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-title"
>
<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"
required
:class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_template_id
"
aria-describedby="template-help source-template-error"
>
<option value="">
{{
t('projectCreation.selectTemplate')
}}
</option>
<option
v-for="template in sourceTemplates"
:key="template.id"
:value="String(template.id)"
>
{{ template.name }} ({{
template.slug
}})
</option>
</select>
<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
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>
<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"
>
{{ t('projectCreation.cleanStart') }}
</p>
</aside>
</div>
<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>