feat(KAN-46): rebuild reusable project template management
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user