feat(KAN-46): rebuild reusable project template management

This commit is contained in:
2026-09-16 07:01:03 +00:00
parent 5a10c85e61
commit 0941745883
12 changed files with 1322 additions and 500 deletions
@@ -0,0 +1,347 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { Layers3, LockKeyhole } from 'lucide-vue-next';
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.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 { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import { index, update } from '@/routes/project-templates';
import { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types';
import type { ProjectModel } from '@/types/models/project-model';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel;
template?: ProjectTemplateModel;
}>();
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm(
`ProjectTemplate:${props.template ? `edit:${props.template.id}` : `create:${props.project?.id}`}`,
{
name:
props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }),
slug:
props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`,
copy_areas: props.template
? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false,
},
);
const protection = useFormProtection(form);
const copyAreasError = computed(
() =>
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1],
);
const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id),
);
function submit(): void {
if (
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
return;
}
form.transform((data) =>
props.template
? {
name: data.name,
copy_areas: data.copy_areas,
...(linkedCount.value > 0
? {
confirm_linked_projects_update:
data.confirm_linked_projects_update,
}
: {}),
}
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
);
protection.submit(
props.template ? update(props.template.id) : store(props.project!.id),
{ preserveScroll: true },
);
}
</script>
<template>
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
>
<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="template-details-title"
>
<h2 id="template-details-title" class="text-title">
{{ t('templates.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{ t('templates.name') }}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing || !source"
:placeholder="t('templates.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="slug"
><LockKeyhole
v-if="template"
class="size-4"
aria-hidden="true"
/>{{ t('templates.slug') }}</Label
>
<Input
id="slug"
v-model="form.slug"
name="slug"
required
maxlength="255"
:disabled="!!template || form.processing || !source"
:placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error"
:spellcheck="false"
autocapitalize="none"
/>
<p
id="slug-help"
class="text-caption text-muted-foreground"
>
{{
t(
template
? 'templates.slugLocked'
: 'templates.slugHelp',
)
}}
</p>
<InputError
id="slug-error"
:message="form.errors.slug"
/>
</div>
</section>
<fieldset
class="bg-card min-w-0 rounded-md border p-5"
:disabled="form.processing || !source"
aria-describedby="areas-help copy-areas-error"
>
<legend class="text-title px-1">
{{ t('templates.groups') }}
</legend>
<p
id="areas-help"
class="text-caption text-muted-foreground mb-4"
>
{{
t(
template
? 'templates.editGroupsHelp'
: 'templates.createGroupsHelp',
)
}}
</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 min-w-0 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="`area-${area.value}`"
:aria-describedby="`area-${area.value}-help 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="`area-${area.value}`"
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
area.label,
)
}}</span
>
<span
:id="`area-${area.value}-help`"
class="text-caption text-muted-foreground"
>{{
areaText(
area.value,
'description',
area.description,
)
}}</span
>
</span>
</label>
</div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</div>
<aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-impact-title"
>
<h2
id="template-impact-title"
class="text-title flex items-center gap-2"
>
<Layers3 class="text-primary size-5" aria-hidden="true" />{{
t('templates.impact')
}}
</h2>
<dl class="gap-column grid">
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words font-semibold">
{{
source?.name ?? t('templates.sourceUnavailable')
}}
</dd>
<dd v-if="source">
<Badge variant="outline">{{ source.key }}</Badge>
</dd>
</div>
<div class="gap-stack grid" aria-live="polite">
<dt class="text-caption text-muted-foreground">
{{ t('templates.groups') }}
</dt>
<dd>
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div v-if="template" class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('templates.inheritance') }}
</dt>
<dd>
{{
t('templates.linkedCount', {
count: linkedCount,
})
}}
</dd>
</div>
</dl>
<p class="text-caption text-muted-foreground border-t pt-4">
{{
t(
template
? 'templates.refreshHelp'
: 'templates.cleanStart',
)
}}
</p>
</aside>
</div>
<section
v-if="linkedCount > 0"
class="border-warning/50 bg-warning/10 gap-column grid rounded-md border p-5"
aria-labelledby="linked-title"
>
<h2 id="linked-title" class="text-title">
{{ t('templates.linkedWarning') }}
</h2>
<p id="linked-help" class="text-body">
{{ t('templates.linkedImpact', { count: linkedCount }) }}
</p>
<label class="flex min-h-11 cursor-pointer items-start gap-3">
<input
id="confirm-linked"
v-model="form.confirm_linked_projects_update"
type="checkbox"
:disabled="form.processing || !source"
:aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0"
/>
<span>{{ t('templates.acknowledge') }}</span>
</label>
<InputError
id="linked-error"
:message="form.errors.confirm_linked_projects_update"
/>
</section>
<footer
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:justify-end"
>
<Button as-child variant="outline" :disabled="form.processing"
><Link :href="cancelTarget">{{
t('common.cancel')
}}</Link></Button
>
<Button
type="submit"
:loading="form.processing"
:loading-label="t('forms.saving')"
:disabled="
!source ||
(linkedCount > 0 && !form.confirm_linked_projects_update)
"
>{{
t(template ? 'templates.saveChanges' : 'templates.saveNew')
}}</Button
>
</footer>
</form>
</template>
@@ -0,0 +1,27 @@
import { useLocalization } from '@/composables/useLocalization';
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;
export function useProjectTemplateAreas() {
const { t } = useLocalization();
function areaText(
value: string,
part: 'label' | 'description',
fallback = value,
): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key ? t(`${key}.${part}`) : fallback;
}
return { areaText };
}
+64
View File
@@ -1,4 +1,68 @@
export const en = {
'templates.description':
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
'templates.updated': 'Project templates updated.',
'templates.fromProject': 'Start with an existing project',
'templates.fromProjectHelp':
'Open a project and choose “Save as new template” to capture its configuration.',
'templates.browseProjects': 'Browse projects',
'templates.empty': 'No published templates yet',
'templates.emptyHelp':
'Save a project as a template to reuse its settings. Internal project baselines are kept out of this list.',
'templates.published': 'Published project templates',
'templates.linkedCount': {
one: '{count} linked project',
other: '{count} linked projects',
},
'templates.savedFrom': 'Saved from {project} ({key}).',
'templates.sourceUnavailable': 'Source project unavailable',
'templates.edit': 'Edit',
'templates.use': 'Use template',
'templates.createTitle': 'Save {project} as a template',
'templates.createDescription':
'Choose the configuration to reuse in future projects.',
'templates.editTitle': 'Edit {name}',
'templates.editDescription':
'Refresh the template from its source project and review the impact on linked projects.',
'templates.defaultName': '{project} template',
'templates.details': 'Template details',
'templates.name': 'Template name',
'templates.namePlaceholder': 'Foundation template',
'templates.slug': 'Template slug',
'templates.slugPlaceholder': 'foundation-template',
'templates.slugHelp':
'Slugs are normalized automatically. After creation, the slug cannot be changed.',
'templates.slugLocked':
'The slug is locked so existing template references remain stable.',
'templates.groups': 'Configuration groups',
'templates.createGroupsHelp':
'Only selected groups are saved. Related groups required by the workflow are included automatically.',
'templates.editGroupsHelp':
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
'templates.impact': 'Template impact',
'templates.inheritance': 'Inheritance',
'templates.cleanStart':
'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
'templates.refreshHelp':
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
'templates.linkedWarning': 'Linked projects will be affected',
'templates.linkedImpact':
'Saving updates the baseline for {count} linked projects. Inherited groups follow the new configuration; existing local overrides stay unchanged.',
'templates.acknowledge':
'I understand that projects inheriting from this template will be affected.',
'templates.saveNew': 'Save new template',
'templates.saveChanges': 'Save template changes',
'templates.missingSource': 'This template cannot be refreshed',
'templates.missingSourceHelp':
'The source project is no longer available. You can still use the saved template configuration to create a project.',
'templates.deleteTitle': 'Delete {name}',
'templates.delete': 'Delete template',
'templates.deleting': 'Deleting template…',
'templates.deleteHelp':
'This permanently removes the reusable template. It will no longer be available for new projects.',
'templates.deleteImpact':
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
'workflow.title': 'Board workflow',
'workflow.pageTitle': '{project} · Board workflow',
'workflow.description':
+26 -162
View File
@@ -1,174 +1,38 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import {
index as projectsIndex,
show as projectShow,
} from '@/routes/projects/index';
import {
create as projectTemplateCreate,
store as projectTemplateStore,
} from '@/routes/projects/templates';
import { index, show } from '@/routes/projects';
import { create } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectsIndex(),
},
{
title: props.project.name,
href: projectShow(props.project.id),
},
{
title: 'Save as new template',
href: projectTemplateCreate(props.project.id),
},
];
const form = useForm({
copy_areas: props.copyAreaOptions.map(
(copyAreaOption) => copyAreaOption.value,
),
name: `${props.project.name} template`,
slug: `${props.project.key.toLowerCase()}-template`,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
const submit = (): void => {
form.post(projectTemplateStore.url(props.project.id), {
preserveScroll: true,
});
};
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('projects.saveTemplate'), href: create(props.project.id) },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Save ${project.name} as new template`" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">{{
project.key
}}</Badge>
<Heading
:title="`Save ${project.name} as new template`"
description="Capture reusable project configuration without copying operational ticket data."
/>
</div>
<Card>
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
Pick the configuration groups that should be reusable in
future projects.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input
id="slug"
v-model="form.slug"
required
placeholder="foundation-template"
/>
<p class="text-muted-foreground text-sm">
Slugs are normalized automatically and used to
identify reusable templates.
</p>
<InputError :message="form.errors.slug" />
</div>
<div class="grid gap-3">
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Only the selected groups will be stored in
the template.
</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"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save new template
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)"
>Cancel</Link
>
</Button>
</div>
</form>
</CardContent>
</Card>
<Head :title="t('templates.createTitle', { project: project.name })" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<header class="gap-stack grid">
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('templates.createTitle', { project: project.name }) }}
</h1>
<p class="text-muted-foreground">
{{ t('templates.createDescription') }}
</p>
</header>
<ProjectTemplateForm
:key="project.id"
:project="project"
:copy-area-options="copyAreaOptions"
/>
</div>
</AppLayout>
</template>
+24 -211
View File
@@ -1,223 +1,36 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import {
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
update as projectTemplatesUpdate,
} from '@/routes/project-templates';
import { index, edit } from '@/routes/project-templates';
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
const props = defineProps<ProjectTemplatesEditPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Project templates',
href: projectTemplatesIndex(),
},
{
title: props.template.name,
href: projectTemplatesEdit(props.template.id),
},
];
const form = useForm({
confirm_linked_projects_update: false,
copy_areas: [...props.template.copy_areas],
name: props.template.name,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
const linkedProjectCount = computed(
() => props.template.linked_project_count ?? 0,
);
const submit = (): void => {
form.patch(projectTemplatesUpdate.url(props.template.id), {
preserveScroll: true,
});
};
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.templates'), href: index() },
{ title: props.template.name, href: edit(props.template.id) },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Edit ${template.name}`" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3">
<Badge variant="outline" class="px-3 py-1">{{
template.slug
}}</Badge>
<Badge v-if="linkedProjectCount > 0" variant="secondary">
{{ linkedProjectCount }} linked
{{ linkedProjectCount === 1 ? 'project' : 'projects' }}
</Badge>
</div>
<Heading
:title="`Edit ${template.name}`"
description="Refresh the template baseline from its source project while keeping immutable identifiers protected."
/>
</div>
<Card>
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
<template v-if="template.source_project">
This template baseline is rebuilt from
{{ template.source_project.name }} ({{
template.source_project.key
}}) when you save.
</template>
<template v-else>
This template baseline is rebuilt from its source
project when you save.
</template>
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input
id="name"
v-model="form.name"
required
placeholder="Foundation template"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input
id="slug"
:model-value="template.slug"
disabled
/>
<p class="text-muted-foreground text-sm">
The slug stays locked so existing template
references remain stable.
</p>
</div>
<div class="grid gap-3">
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Saving rebuilds the selected groups from the
current source-project configuration.
</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"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div
v-if="linkedProjectCount > 0"
class="space-y-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
<div class="space-y-1">
<div class="font-medium">Warning</div>
<p class="text-sm">
Saving this template updates the baseline
for {{ linkedProjectCount }} linked
{{
linkedProjectCount === 1
? 'project'
: 'projects'
}}. Inherited areas will follow the new
template version, while existing overrides
stay untouched.
</p>
</div>
<Label
class="flex cursor-pointer items-start gap-3"
>
<input
v-model="
form.confirm_linked_projects_update
"
type="checkbox"
class="mt-1"
/>
<span class="text-sm">
I understand that linked projects inheriting
from this template will be affected.
</span>
</Label>
<InputError
:message="
form.errors.confirm_linked_projects_update
"
/>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save template changes
</Button>
<Button as-child variant="outline">
<Link :href="projectTemplatesIndex()"
>Cancel</Link
>
</Button>
</div>
</form>
</CardContent>
</Card>
<Head :title="t('templates.editTitle', { name: template.name })" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
<header class="gap-stack grid">
<h1 class="text-headline-mobile md:text-headline break-words">
{{ t('templates.editTitle', { name: template.name }) }}
</h1>
<p class="text-muted-foreground">
{{ t('templates.editDescription') }}
</p>
</header>
<ProjectTemplateForm
:key="template.id"
:template="template"
:copy-area-options="copyAreaOptions"
/>
</div>
</AppLayout>
</template>
+227 -127
View File
@@ -1,158 +1,258 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { ArrowRight, LayoutTemplate, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, edit, index } from '@/routes/project-templates';
import {
destroy as projectTemplatesDestroy,
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
} from '@/routes/project-templates';
import {
create as projectsCreate,
create as createProject,
index as projectsIndex,
} from '@/routes/projects/index';
} from '@/routes/projects';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
const props = defineProps<ProjectTemplatesIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Project templates',
href: projectTemplatesIndex(),
},
];
const removeTemplate = (templateId: number): void => {
router.delete(projectTemplatesDestroy.url(templateId), {
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.templates'), href: index() },
]);
const templates = computed(() =>
props.templates.filter((template) => template.is_published),
);
const pending = ref<ProjectTemplateModel | null>(null);
const form = useForm({});
const protection = useFormProtection(form);
function requestDelete(template: ProjectTemplateModel): void {
form.resetAndClearErrors();
protection.clearFeedback();
pending.value = template;
}
function close(open: boolean): void {
if (!open && !form.processing) {
pending.value = null;
form.clearErrors();
protection.clearFeedback();
}
}
function removeTemplate(): void {
if (!pending.value) {
return;
}
protection.submit(destroy(pending.value.id), {
preserveScroll: true,
onSuccess: () => {
pending.value = null;
},
});
};
}
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Project templates" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
<Head :title="t('navigation.templates')" />
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
<header
class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
>
<Heading
title="Project templates"
description="Reusable published project templates. Internal project base templates stay hidden here by default."
/>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="projectsIndex()">Back to projects</Link>
</Button>
<Button as-child>
<Link :href="projectsCreate()">Create project</Link>
</Button>
<div class="gap-stack grid min-w-0">
<h1 class="text-headline-mobile md:text-headline">
{{ t('navigation.templates') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('templates.description') }}
</p>
</div>
</div>
<div
<Button as-child class="shrink-0"
><Link :href="createProject()"
><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
></Button
>
</header>
<FeedbackState
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
state="success"
:title="t('templates.updated')"
/>
<section
class="bg-card gap-column flex min-w-0 flex-col rounded-md border p-5 sm:flex-row sm:items-center"
aria-labelledby="save-template-help"
>
{{ status }}
</div>
<div
<LayoutTemplate
class="text-primary size-6 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0 flex-1">
<h2 id="save-template-help" class="text-title">
{{ t('templates.fromProject') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('templates.fromProjectHelp') }}
</p>
</div>
<Button as-child variant="outline" class="shrink-0"
><Link :href="projectsIndex()"
>{{ t('templates.browseProjects')
}}<ArrowRight aria-hidden="true" /></Link
></Button>
</section>
<FeedbackState
v-if="templates.length === 0"
class="text-muted-foreground text-sm"
state="empty"
:title="t('templates.empty')"
:description="t('templates.emptyHelp')"
/>
<ul
v-else
class="gap-column grid min-w-0 md:grid-cols-2 xl:grid-cols-3"
:aria-label="t('templates.published')"
>
No published project templates have been saved yet.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card
<li
v-for="template in templates"
:key="template.id"
class="h-full"
class="bg-card flex min-w-0 flex-col rounded-md border"
:data-template="template.id"
>
<CardHeader>
<div class="flex flex-wrap items-center gap-2">
<CardTitle>{{ template.name }}</CardTitle>
<Badge variant="secondary">{{
template.slug
}}</Badge>
<Badge
v-if="template.linked_project_count"
variant="outline"
<div class="gap-column grid min-w-0 p-5">
<div class="gap-stack grid min-w-0">
<h2 class="text-title break-words">
{{ template.name }}
</h2>
<p
class="text-caption text-muted-foreground break-all"
>
{{ template.linked_project_count }} linked
{{
template.linked_project_count === 1
? 'project'
: 'projects'
}}
</Badge>
{{ template.slug }}
</p>
</div>
<CardDescription>
<template v-if="template.source_project">
Saved from
{{ template.source_project.name }} ({{
template.source_project.key
}}).
</template>
<template v-else>
Saved template configuration.
</template>
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="copyArea in template.copy_areas"
:key="copyArea"
variant="outline"
>
{{ copyArea }}
</Badge>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="secondary">
<Link :href="projectTemplatesEdit(template.id)">
Edit
</Link>
</Button>
<Button as-child variant="outline">
<Link
:href="
projectsCreate({
query: { template: template.id },
})
"
<Badge
variant="secondary"
class="w-fit whitespace-normal"
>{{
t('templates.linkedCount', {
count: template.linked_project_count ?? 0,
})
}}</Badge
>
<p
class="text-caption text-muted-foreground break-words"
>
{{
template.source_project
? t('templates.savedFrom', {
project: template.source_project.name,
key: template.source_project.key,
})
: t('templates.sourceUnavailable')
}}
</p>
<ul
class="flex flex-wrap gap-2"
:aria-label="t('templates.groups')"
>
<li v-for="area in template.copy_areas" :key="area">
<Badge
variant="outline"
class="whitespace-normal"
>{{ areaText(area, 'label') }}</Badge
>
Use template
</Link>
</Button>
<Button
type="button"
variant="destructive"
@click="removeTemplate(template.id)"
>
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
</li>
</ul>
</div>
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
<Button as-child variant="secondary"
><Link
:href="
createProject({
query: { template: template.id },
})
"
>{{ t('templates.use') }}</Link
></Button
>
<Button as-child variant="outline"
><Link
:href="edit(template.id)"
:aria-label="
t('templates.editTitle', {
name: template.name,
})
"
>{{ t('templates.edit') }}</Link
></Button
>
<Button
variant="ghost"
size="icon"
class="text-destructive ml-auto"
:aria-label="
t('templates.deleteTitle', {
name: template.name,
})
"
@click="requestDelete(template)"
><Trash2 aria-hidden="true"
/></Button>
</div>
</li>
</ul>
</div>
<Dialog :open="pending !== null" @update:open="close">
<DialogContent :show-close-button="!form.processing">
<DialogHeader>
<DialogTitle>{{
t('templates.deleteTitle', {
name: pending?.name ?? '',
})
}}</DialogTitle>
<DialogDescription>{{
t('templates.deleteHelp')
}}</DialogDescription>
</DialogHeader>
<p v-if="pending?.linked_project_count" class="text-body">
{{
t('templates.deleteImpact', {
count: pending.linked_project_count,
})
}}
</p>
<FormFeedback
:state="protection.state.value"
@retry="removeTemplate"
/>
<DialogFooter>
<Button
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
variant="destructive"
:loading="form.processing"
:loading-label="t('templates.deleting')"
@click="removeTemplate"
>{{ t('templates.delete') }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</AppLayout>
</template>