634 lines
30 KiB
Vue
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>
|