Add reusable project templates

This commit is contained in:
2026-09-08 10:25:52 +00:00
committed by Twyco
parent 5abfae2736
commit 40f9b473fc
24 changed files with 1154 additions and 88 deletions
+9
View File
@@ -5,6 +5,7 @@ import {
Folder,
FolderGit2,
LayoutGrid,
Layers3,
Shield,
Users,
} from 'lucide-vue-next';
@@ -44,6 +45,14 @@ const mainNavItems = computed<NavItem[]>(() => {
},
];
if (page.props.auth.permissions.projectCreate) {
items.push({
title: 'Project templates',
href: '/project-templates',
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Head, Link, useForm } 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 AppLayout from '@/layouts/AppLayout.vue';
import { show as projectShow } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: '/projects',
},
{
title: props.project.name,
href: projectShow(props.project.id),
},
{
title: 'Save as template',
href: `/projects/${props.project.id}/templates/create`,
},
];
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(`/projects/${props.project.id}/templates`, {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Save ${project.name} as 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 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 template
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)">Cancel</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
const props = defineProps<ProjectTemplatesIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Project templates',
href: '/project-templates',
},
];
const removeTemplate = (templateId: number): void => {
router.delete(`/project-templates/${templateId}`, {
preserveScroll: true,
});
};
</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">
<Heading
title="Project templates"
description="Reusable project configuration templates saved from existing projects."
/>
<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>
</div>
<div
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"
>
{{ status }}
</div>
<div v-if="templates.length === 0" class="text-muted-foreground text-sm">
No 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">
<CardHeader>
<div class="flex flex-wrap items-center gap-2">
<CardTitle>{{ template.name }}</CardTitle>
<Badge variant="secondary">{{ template.slug }}</Badge>
</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="outline">
<Link :href="projectsCreate({ query: { template: template.id } })">
Use template
</Link>
</Button>
<Button
type="button"
variant="destructive"
@click="removeTemplate(template.id)"
>
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>
+63 -5
View File
@@ -31,15 +31,19 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const defaultTemplateId = props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm({
copy_areas: [] as string[],
creation_mode: 'scratch',
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 (
@@ -55,6 +59,10 @@ const submit = (): void => {
form.copy_areas = [];
}
if (form.creation_mode !== 'template') {
form.source_template_id = '';
}
form.post(ProjectController.store(), {
preserveScroll: true,
});
@@ -68,7 +76,7 @@ const submit = (): void => {
<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 or start from an existing project's settings without copying any tickets."
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
/>
<Card>
@@ -84,7 +92,7 @@ const submit = (): void => {
<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-2">
<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"
@@ -118,6 +126,25 @@ const submit = (): void => {
</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>
@@ -213,8 +240,39 @@ const submit = (): void => {
</div>
</div>
<div v-else-if="!hasSourceProjects" class="text-muted-foreground text-sm">
No accessible source projects are available for duplication yet.
<div
v-else-if="form.creation_mode === 'template'"
class="space-y-4 rounded-xl border border-dashed p-4"
>
<div class="grid gap-2">
<Label for="source_template_id">Template</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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceTemplates"
>
<option value="">Select a template</option>
<option
v-for="sourceTemplate in sourceTemplates"
:key="sourceTemplate.id"
:value="String(sourceTemplate.id)"
>
{{ sourceTemplate.name }} ({{ sourceTemplate.slug }})
</option>
</select>
<p class="text-muted-foreground text-sm">
Templates apply their saved configuration groups to the new project.
</p>
<InputError :message="form.errors.source_template_id" />
</div>
</div>
<div
v-else-if="!hasSourceProjects && !hasSourceTemplates"
class="text-muted-foreground text-sm"
>
No accessible source projects or saved templates are available yet.
</div>
<div class="flex items-center gap-3">
+10
View File
@@ -94,6 +94,16 @@ const projectAreas = [
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="`/projects/${project.id}/templates/create`">
Save as template
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="index()">Back to projects</Link>
</Button>
@@ -0,0 +1,12 @@
import type { ProjectModel } from '@/types/models/project-model';
export type ProjectTemplateModel = {
id: number;
source_project_id: number | null;
source_project: ProjectModel | null;
name: string;
slug: string;
copy_areas: string[];
created_at: string | null;
updated_at: string | null;
};
+13
View File
@@ -4,6 +4,7 @@ import type { NotificationActionModel } from '@/types/models/notification-action
import type { NotificationActionOptionModel } from '@/types/models/notification-action-option-model';
import type { NotificationActionTemplateVariableModel } from '@/types/models/notification-action-template-variable-model';
import type { ProjectModel } from '@/types/models/project-model';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TeamModel } from '@/types/models/team-model';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
@@ -45,7 +46,19 @@ export type ProjectsIndexPageProps = {
export type ProjectsCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
selectedSourceTemplateId?: number | null;
sourceProjects: ProjectModel[];
sourceTemplates: ProjectTemplateModel[];
};
export type ProjectTemplatesIndexPageProps = {
status?: string | null;
templates: ProjectTemplateModel[];
};
export type ProjectTemplatesCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project: ProjectModel;
};
export type ProjectShowPageProps = {