formatted
CI/CD / test_and_check_app (push) Successful in 3m53s
CI/CD / Publish immutable image (push) Successful in 1m33s

This commit is contained in:
2026-09-08 14:24:04 +02:00
parent bd7be4e41e
commit 1d8fff1d51
20 changed files with 395 additions and 151 deletions
+40 -12
View File
@@ -15,7 +15,10 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectsIndex, show as projectShow } from '@/routes/projects/index';
import {
index as projectsIndex,
show as projectShow,
} from '@/routes/projects/index';
import {
create as projectTemplateCreate,
store as projectTemplateStore,
@@ -40,7 +43,9 @@ const breadcrumbs: BreadcrumbItem[] = [
];
const form = useForm({
copy_areas: props.copyAreaOptions.map((copyAreaOption) => copyAreaOption.value),
copy_areas: props.copyAreaOptions.map(
(copyAreaOption) => copyAreaOption.value,
),
name: `${props.project.name} template`,
slug: `${props.project.key.toLowerCase()}-template`,
});
@@ -48,7 +53,9 @@ const form = useForm({
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
@@ -66,7 +73,9 @@ const submit = (): void => {
<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>
<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."
@@ -77,7 +86,8 @@ const submit = (): void => {
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
Pick the configuration groups that should be reusable in future projects.
Pick the configuration groups that should be reusable in
future projects.
</CardDescription>
</CardHeader>
@@ -85,15 +95,26 @@ const submit = (): void => {
<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" />
<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" />
<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.
Slugs are normalized automatically and used to
identify reusable templates.
</p>
<InputError :message="form.errors.slug" />
</div>
@@ -102,7 +123,8 @@ const submit = (): void => {
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Only the selected groups will be stored in the template.
Only the selected groups will be stored in
the template.
</p>
</div>
@@ -119,8 +141,12 @@ const submit = (): void => {
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">{{ copyAreaOption.label }}</div>
<p class="text-muted-foreground text-sm">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
@@ -135,7 +161,9 @@ const submit = (): void => {
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)">Cancel</Link>
<Link :href="projectShow(project.id)"
>Cancel</Link
>
</Button>
</div>
</form>
+67 -19
View File
@@ -44,12 +44,16 @@ const form = useForm({
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1] ??
''
);
});
const linkedProjectCount = computed(() => props.template.linked_project_count ?? 0);
const linkedProjectCount = computed(
() => props.template.linked_project_count ?? 0,
);
const submit = (): void => {
form.patch(projectTemplatesUpdate.url(props.template.id), {
@@ -65,9 +69,12 @@ const submit = (): void => {
<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 variant="outline" class="px-3 py-1">{{
template.slug
}}</Badge>
<Badge v-if="linkedProjectCount > 0" variant="secondary">
{{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}
{{ linkedProjectCount }} linked
{{ linkedProjectCount === 1 ? 'project' : 'projects' }}
</Badge>
</div>
<Heading
@@ -81,10 +88,14 @@ const submit = (): void => {
<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.
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.
This template baseline is rebuilt from its source
project when you save.
</template>
</CardDescription>
</CardHeader>
@@ -93,15 +104,25 @@ const submit = (): void => {
<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" />
<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 />
<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.
The slug stays locked so existing template
references remain stable.
</p>
</div>
@@ -109,7 +130,8 @@ const submit = (): void => {
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Saving rebuilds the selected groups from the current source-project configuration.
Saving rebuilds the selected groups from the
current source-project configuration.
</p>
</div>
@@ -126,8 +148,12 @@ const submit = (): void => {
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">{{ copyAreaOption.label }}</div>
<p class="text-muted-foreground text-sm">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
{{ copyAreaOption.description }}
</p>
</div>
@@ -143,19 +169,39 @@ const submit = (): void => {
<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.
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" />
<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.
I understand that linked projects inheriting
from this template will be affected.
</span>
</Label>
<InputError :message="form.errors.confirm_linked_projects_update" />
<InputError
:message="
form.errors.confirm_linked_projects_update
"
/>
</div>
<div class="flex items-center gap-3">
@@ -164,7 +210,9 @@ const submit = (): void => {
</Button>
<Button as-child variant="outline">
<Link :href="projectTemplatesIndex()">Cancel</Link>
<Link :href="projectTemplatesIndex()"
>Cancel</Link
>
</Button>
</div>
</form>
+40 -9
View File
@@ -16,7 +16,10 @@ import {
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
} from '@/routes/project-templates';
import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
import {
create as projectsCreate,
index as projectsIndex,
} from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
const props = defineProps<ProjectTemplatesIndexPageProps>();
@@ -40,7 +43,9 @@ const removeTemplate = (templateId: number): void => {
<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">
<div
class="flex flex-col gap-4 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."
@@ -64,23 +69,43 @@ const removeTemplate = (templateId: number): void => {
{{ status }}
</div>
<div v-if="templates.length === 0" class="text-muted-foreground text-sm">
<div
v-if="templates.length === 0"
class="text-muted-foreground text-sm"
>
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 v-for="template in templates" :key="template.id" class="h-full">
<Card
v-for="template in templates"
:key="template.id"
class="h-full"
>
<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">
{{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }}
<Badge variant="secondary">{{
template.slug
}}</Badge>
<Badge
v-if="template.linked_project_count"
variant="outline"
>
{{ template.linked_project_count }} linked
{{
template.linked_project_count === 1
? 'project'
: 'projects'
}}
</Badge>
</div>
<CardDescription>
<template v-if="template.source_project">
Saved from {{ template.source_project.name }} ({{ template.source_project.key }}).
Saved from
{{ template.source_project.name }} ({{
template.source_project.key
}}).
</template>
<template v-else>
Saved template configuration.
@@ -106,7 +131,13 @@ const removeTemplate = (templateId: number): void => {
</Button>
<Button as-child variant="outline">
<Link :href="projectsCreate({ query: { template: template.id } })">
<Link
:href="
projectsCreate({
query: { template: template.id },
})
"
>
Use template
</Link>
</Button>