feat(KAN-46): rebuild reusable project template management
This commit is contained in:
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
|
|||||||
'copy_areas' => ['required', 'array', 'min:1'],
|
'copy_areas' => ['required', 'array', 'min:1'],
|
||||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
'confirm_linked_projects_update' => [
|
'confirm_linked_projects_update' => [
|
||||||
|
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||||
Rule::requiredIf($requiresImpactConfirmation),
|
Rule::requiredIf($requiresImpactConfirmation),
|
||||||
'accepted',
|
'accepted',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,68 @@
|
|||||||
export const en = {
|
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.title': 'Board workflow',
|
||||||
'workflow.pageTitle': '{project} · Board workflow',
|
'workflow.pageTitle': '{project} · Board workflow',
|
||||||
'workflow.description':
|
'workflow.description':
|
||||||
|
|||||||
@@ -1,174 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import { index, show } from '@/routes/projects';
|
||||||
index as projectsIndex,
|
import { create } from '@/routes/projects/templates';
|
||||||
show as projectShow,
|
|
||||||
} from '@/routes/projects/index';
|
|
||||||
import {
|
|
||||||
create as projectTemplateCreate,
|
|
||||||
store as projectTemplateStore,
|
|
||||||
} from '@/routes/projects/templates';
|
|
||||||
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
href: projectsIndex(),
|
{ title: t('projects.saveTemplate'), href: create(props.project.id) },
|
||||||
},
|
]);
|
||||||
{
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Save ${project.name} as new template`" />
|
<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">
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<header class="gap-stack grid">
|
||||||
<div class="space-y-3">
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
<Badge variant="outline" class="px-3 py-1">{{
|
{{ t('templates.createTitle', { project: project.name }) }}
|
||||||
project.key
|
</h1>
|
||||||
}}</Badge>
|
<p class="text-muted-foreground">
|
||||||
<Heading
|
{{ t('templates.createDescription') }}
|
||||||
:title="`Save ${project.name} as new template`"
|
</p>
|
||||||
description="Capture reusable project configuration without copying operational ticket data."
|
</header>
|
||||||
/>
|
<ProjectTemplateForm
|
||||||
</div>
|
:key="project.id"
|
||||||
|
:project="project"
|
||||||
<Card>
|
:copy-area-options="copyAreaOptions"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,223 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import { index, edit } from '@/routes/project-templates';
|
||||||
edit as projectTemplatesEdit,
|
|
||||||
index as projectTemplatesIndex,
|
|
||||||
update as projectTemplatesUpdate,
|
|
||||||
} from '@/routes/project-templates';
|
|
||||||
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectTemplatesEditPageProps>();
|
const props = defineProps<ProjectTemplatesEditPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.templates'), href: index() },
|
||||||
title: 'Project templates',
|
{ title: props.template.name, href: edit(props.template.id) },
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Edit ${template.name}`" />
|
<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">
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<header class="gap-stack grid">
|
||||||
<div class="space-y-3">
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
{{ t('templates.editTitle', { name: template.name }) }}
|
||||||
<Badge variant="outline" class="px-3 py-1">{{
|
</h1>
|
||||||
template.slug
|
<p class="text-muted-foreground">
|
||||||
}}</Badge>
|
{{ t('templates.editDescription') }}
|
||||||
<Badge v-if="linkedProjectCount > 0" variant="secondary">
|
</p>
|
||||||
{{ linkedProjectCount }} linked
|
</header>
|
||||||
{{ linkedProjectCount === 1 ? 'project' : 'projects' }}
|
<ProjectTemplateForm
|
||||||
</Badge>
|
:key="template.id"
|
||||||
</div>
|
:template="template"
|
||||||
<Heading
|
:copy-area-options="copyAreaOptions"
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,158 +1,258 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, router } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import Heading from '@/components/Heading.vue';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { destroy, edit, index } from '@/routes/project-templates';
|
||||||
import {
|
import {
|
||||||
destroy as projectTemplatesDestroy,
|
create as createProject,
|
||||||
edit as projectTemplatesEdit,
|
|
||||||
index as projectTemplatesIndex,
|
|
||||||
} from '@/routes/project-templates';
|
|
||||||
import {
|
|
||||||
create as projectsCreate,
|
|
||||||
index as projectsIndex,
|
index as projectsIndex,
|
||||||
} from '@/routes/projects/index';
|
} from '@/routes/projects';
|
||||||
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
||||||
|
|
||||||
|
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||||
|
|
||||||
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const { areaText } = useProjectTemplateAreas();
|
||||||
{
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
title: 'Project templates',
|
{ title: t('navigation.templates'), href: index() },
|
||||||
href: projectTemplatesIndex(),
|
]);
|
||||||
},
|
const templates = computed(() =>
|
||||||
];
|
props.templates.filter((template) => template.is_published),
|
||||||
|
);
|
||||||
const removeTemplate = (templateId: number): void => {
|
const pending = ref<ProjectTemplateModel | null>(null);
|
||||||
router.delete(projectTemplatesDestroy.url(templateId), {
|
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,
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
pending.value = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Project templates" />
|
<Head :title="t('navigation.templates')" />
|
||||||
|
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<header
|
||||||
<div
|
class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
||||||
>
|
>
|
||||||
<Heading
|
<div class="gap-stack grid min-w-0">
|
||||||
title="Project templates"
|
<h1 class="text-headline-mobile md:text-headline">
|
||||||
description="Reusable published project templates. Internal project base templates stay hidden here by default."
|
{{ t('navigation.templates') }}
|
||||||
/>
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
<div class="flex flex-wrap gap-3">
|
{{ t('templates.description') }}
|
||||||
<Button as-child variant="outline">
|
</p>
|
||||||
<Link :href="projectsIndex()">Back to projects</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child>
|
|
||||||
<Link :href="projectsCreate()">Create project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button as-child class="shrink-0"
|
||||||
|
><Link :href="createProject()"
|
||||||
<div
|
><Plus aria-hidden="true" />{{
|
||||||
|
t('projects.create')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<FeedbackState
|
||||||
v-if="status"
|
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 }}
|
<LayoutTemplate
|
||||||
</div>
|
class="text-primary size-6 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
<div
|
/>
|
||||||
|
<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"
|
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.
|
<li
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<Card
|
|
||||||
v-for="template in templates"
|
v-for="template in templates"
|
||||||
:key="template.id"
|
: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="gap-column grid min-w-0 p-5">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="gap-stack grid min-w-0">
|
||||||
<CardTitle>{{ template.name }}</CardTitle>
|
<h2 class="text-title break-words">
|
||||||
<Badge variant="secondary">{{
|
{{ template.name }}
|
||||||
template.slug
|
</h2>
|
||||||
}}</Badge>
|
<p
|
||||||
<Badge
|
class="text-caption text-muted-foreground break-all"
|
||||||
v-if="template.linked_project_count"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{{ template.linked_project_count }} linked
|
{{ template.slug }}
|
||||||
{{
|
</p>
|
||||||
template.linked_project_count === 1
|
|
||||||
? 'project'
|
|
||||||
: 'projects'
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<Badge
|
||||||
<template v-if="template.source_project">
|
variant="secondary"
|
||||||
Saved from
|
class="w-fit whitespace-normal"
|
||||||
{{ template.source_project.name }} ({{
|
>{{
|
||||||
template.source_project.key
|
t('templates.linkedCount', {
|
||||||
}}).
|
count: template.linked_project_count ?? 0,
|
||||||
</template>
|
})
|
||||||
<template v-else>
|
}}</Badge
|
||||||
Saved template configuration.
|
>
|
||||||
</template>
|
<p
|
||||||
</CardDescription>
|
class="text-caption text-muted-foreground break-words"
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent class="space-y-4">
|
{{
|
||||||
<div class="flex flex-wrap gap-2">
|
template.source_project
|
||||||
<Badge
|
? t('templates.savedFrom', {
|
||||||
v-for="copyArea in template.copy_areas"
|
project: template.source_project.name,
|
||||||
:key="copyArea"
|
key: template.source_project.key,
|
||||||
variant="outline"
|
})
|
||||||
>
|
: t('templates.sourceUnavailable')
|
||||||
{{ copyArea }}
|
}}
|
||||||
</Badge>
|
</p>
|
||||||
</div>
|
<ul
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
<div class="flex flex-wrap gap-3">
|
:aria-label="t('templates.groups')"
|
||||||
<Button as-child variant="secondary">
|
>
|
||||||
<Link :href="projectTemplatesEdit(template.id)">
|
<li v-for="area in template.copy_areas" :key="area">
|
||||||
Edit
|
<Badge
|
||||||
</Link>
|
variant="outline"
|
||||||
</Button>
|
class="whitespace-normal"
|
||||||
|
>{{ areaText(area, 'label') }}</Badge
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link
|
|
||||||
:href="
|
|
||||||
projectsCreate({
|
|
||||||
query: { template: template.id },
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
Use template
|
</li>
|
||||||
</Link>
|
</ul>
|
||||||
</Button>
|
</div>
|
||||||
|
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
|
||||||
<Button
|
<Button as-child variant="secondary"
|
||||||
type="button"
|
><Link
|
||||||
variant="destructive"
|
:href="
|
||||||
@click="removeTemplate(template.id)"
|
createProject({
|
||||||
>
|
query: { template: template.id },
|
||||||
Delete
|
})
|
||||||
</Button>
|
"
|
||||||
</div>
|
>{{ t('templates.use') }}</Link
|
||||||
</CardContent>
|
></Button
|
||||||
</Card>
|
>
|
||||||
</div>
|
<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>
|
</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>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -163,6 +163,26 @@ class ProjectTemplateControllerTest extends TestCase
|
|||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_unlinked_templates_can_be_updated_without_an_impact_confirmation(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$source = Project::factory()->create();
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'source_project_id' => $source->id,
|
||||||
|
]);
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('project-templates.update', $template), [
|
||||||
|
'name' => 'Updated reusable template',
|
||||||
|
'copy_areas' => ['project_settings'],
|
||||||
|
])
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('project-templates.index'));
|
||||||
|
|
||||||
|
$this->assertSame('Updated reusable template', $template->fresh()->name);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
|
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectTemplatesUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_template_pages_show_reusable_configuration_and_protect_linked_projects(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-templates.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project template checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const base = 'http://127.0.0.1:4191';
|
||||||
|
const errors = [];
|
||||||
|
const mutations = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
|
||||||
|
mutations.push(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const result = (value) =>
|
||||||
|
context.addCookies([{ name: 'template_test_result', value, url: base }]);
|
||||||
|
const visit = async (path) => {
|
||||||
|
await result('success');
|
||||||
|
await page.goto(base + path);
|
||||||
|
await page.locator('h1').waitFor();
|
||||||
|
};
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const feedback = (state) =>
|
||||||
|
page
|
||||||
|
.locator('[data-form-state="' + state + '"]')
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
for (const path of [
|
||||||
|
'/project-templates?reset',
|
||||||
|
'/projects/1/templates/create?reset',
|
||||||
|
'/project-templates/2/edit?reset',
|
||||||
|
]) {
|
||||||
|
await visit(path);
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((el) => el.classList.contains('dark')),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
if (width === 390 || width === 1440) {
|
||||||
|
await page.screenshot({
|
||||||
|
path:
|
||||||
|
'/private/tmp/KAN-46-' +
|
||||||
|
width +
|
||||||
|
'-' +
|
||||||
|
theme +
|
||||||
|
'-' +
|
||||||
|
(path.includes('/edit')
|
||||||
|
? 'edit'
|
||||||
|
: path.includes('/create')
|
||||||
|
? 'create'
|
||||||
|
: 'index') +
|
||||||
|
'.png',
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(await page.locator('#slug').isDisabled(), true);
|
||||||
|
assert.equal(
|
||||||
|
await button('Save template changes').isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const checkbox = page.locator('#confirm-linked');
|
||||||
|
await checkbox.focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(await checkbox.isChecked(), true);
|
||||||
|
assert.equal(
|
||||||
|
await button('Save template changes').isEnabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await visit('/project-templates?reset');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByText('Internal baseline', { exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const trigger = button('Delete Foundation');
|
||||||
|
await trigger.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.waitFor();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog.evaluate((el) =>
|
||||||
|
el.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await noOverflow();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await dialog.waitFor({ state: 'hidden' });
|
||||||
|
assert.equal(
|
||||||
|
await trigger.evaluate((el) => document.activeElement === el),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(mutations.length, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('/projects/1/templates/create?reset');
|
||||||
|
await page.locator('#name').fill('Release setup');
|
||||||
|
await page.locator('#slug').fill('release-setup');
|
||||||
|
const groups = page.locator('fieldset input[type="checkbox"]');
|
||||||
|
for (const group of await groups.all()) {
|
||||||
|
await group.uncheck();
|
||||||
|
}
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Project settings', exact: true })
|
||||||
|
.check();
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: 'Ticket types', exact: true })
|
||||||
|
.check();
|
||||||
|
await result('validation');
|
||||||
|
await button('Save new template').click();
|
||||||
|
await feedback('validation');
|
||||||
|
assert.equal(await page.locator('#name').inputValue(), 'Release setup');
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#name').getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByText('Choose a valid configuration group.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('[data-form-state="validation"]')
|
||||||
|
.evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await result('server');
|
||||||
|
await button('Save new template').click();
|
||||||
|
await feedback('server');
|
||||||
|
await result('success');
|
||||||
|
const created = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'POST',
|
||||||
|
);
|
||||||
|
await button('Try again').click();
|
||||||
|
assert.deepEqual((await created).postDataJSON(), {
|
||||||
|
name: 'Release setup',
|
||||||
|
slug: 'release-setup',
|
||||||
|
copy_areas: ['project_settings', 'ticket_types'],
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await visit('/project-templates/2/edit?reset');
|
||||||
|
const before = mutations.length;
|
||||||
|
await page
|
||||||
|
.locator('form')
|
||||||
|
.evaluate((el) =>
|
||||||
|
el.dispatchEvent(
|
||||||
|
new Event('submit', { bubbles: true, cancelable: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
mutations.length,
|
||||||
|
before,
|
||||||
|
'Linked updates must wait for acknowledgement',
|
||||||
|
);
|
||||||
|
await page.locator('#confirm-linked').check();
|
||||||
|
await page.locator('#name').fill('Updated foundation');
|
||||||
|
await page.route('**/project-templates/2', (route) =>
|
||||||
|
route.abort('failed'),
|
||||||
|
);
|
||||||
|
await button('Save template changes').click();
|
||||||
|
await feedback('network');
|
||||||
|
assert.equal(await page.locator('#confirm-linked').isChecked(), true);
|
||||||
|
await page.unroute('**/project-templates/2');
|
||||||
|
const updated = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'PATCH',
|
||||||
|
);
|
||||||
|
await button('Try again').click();
|
||||||
|
assert.deepEqual((await updated).postDataJSON(), {
|
||||||
|
name: 'Updated foundation',
|
||||||
|
copy_areas: ['project_settings', 'ticket_types'],
|
||||||
|
confirm_linked_projects_update: true,
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await visit('/project-templates/2/edit?reset&unlinked');
|
||||||
|
assert.equal(await page.locator('#confirm-linked').count(), 0);
|
||||||
|
const unlinked = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'PATCH',
|
||||||
|
);
|
||||||
|
await button('Save template changes').click();
|
||||||
|
assert.deepEqual((await unlinked).postDataJSON(), {
|
||||||
|
name: 'Foundation',
|
||||||
|
copy_areas: ['project_settings', 'ticket_types'],
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await visit('/project-templates/2/edit?reset&missing');
|
||||||
|
await page
|
||||||
|
.getByText('This template cannot be refreshed', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await button('Save template changes').isDisabled(), true);
|
||||||
|
|
||||||
|
await visit('/projects/1/templates/create?reset');
|
||||||
|
await page.locator('#name').fill('Unsaved work');
|
||||||
|
let prompted = false;
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
prompted = true;
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await page.getByRole('link', { name: 'Cancel', exact: true }).click();
|
||||||
|
assert.equal(prompted, true);
|
||||||
|
assert.equal(await page.locator('#name').inputValue(), 'Unsaved work');
|
||||||
|
// Reload starts the next isolated scenario; accept the browser's unload prompt.
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await visit('/project-templates?reset');
|
||||||
|
await button('Delete Foundation').click();
|
||||||
|
await result('server');
|
||||||
|
await button('Delete template').click();
|
||||||
|
await feedback('server');
|
||||||
|
await result('success');
|
||||||
|
const deleted = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'DELETE',
|
||||||
|
);
|
||||||
|
await button('Try again').click();
|
||||||
|
assert.equal(
|
||||||
|
new URL((await deleted).url()).pathname,
|
||||||
|
'/project-templates/2',
|
||||||
|
);
|
||||||
|
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||||
|
await page
|
||||||
|
.getByText('No published templates yet', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await visit('/project-templates?reset');
|
||||||
|
await page.getByRole('link', { name: 'Use template', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Destination reached' }).waitFor();
|
||||||
|
assert.match(page.url(), /\/projects\/create\?template=2$/);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Project template browser checks passed: create/edit payloads, confirmations, slug lock, validation/network/server retries, missing source, deletion, navigation, keyboard, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Index from '../../resources/js/pages/project-templates/Index.vue';
|
||||||
|
import Create from '../../resources/js/pages/project-templates/Create.vue';
|
||||||
|
import Edit from '../../resources/js/pages/project-templates/Edit.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
({ Index, Create, Edit })[name] ?? {
|
||||||
|
render: () => h('h1', 'Destination reached'),
|
||||||
|
},
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/project-templates-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const project = { id: 1, name: 'Core platform', key: 'CORE' };
|
||||||
|
const areas = [
|
||||||
|
'project_settings',
|
||||||
|
'board_columns',
|
||||||
|
'ticket_statuses',
|
||||||
|
'status_transitions',
|
||||||
|
'ticket_types',
|
||||||
|
'ticket_priorities',
|
||||||
|
'ticket_fields',
|
||||||
|
'ticket_link_types',
|
||||||
|
'repository_connections',
|
||||||
|
'notification_actions',
|
||||||
|
];
|
||||||
|
const template = {
|
||||||
|
id: 2,
|
||||||
|
name: 'Foundation',
|
||||||
|
slug: 'foundation-template',
|
||||||
|
is_published: true,
|
||||||
|
source_project: project,
|
||||||
|
copy_areas: ['project_settings', 'ticket_types'],
|
||||||
|
linked_project_count: 2,
|
||||||
|
};
|
||||||
|
const initial = () => ({
|
||||||
|
project,
|
||||||
|
copyAreaOptions: areas.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value,
|
||||||
|
description: value,
|
||||||
|
})),
|
||||||
|
template: structuredClone(template),
|
||||||
|
templates: [
|
||||||
|
structuredClone(template),
|
||||||
|
{ ...template, id: 3, name: 'Internal baseline', is_published: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
function makePage(component = 'Index', extra = {}, url = '/project-templates') {
|
||||||
|
return {
|
||||||
|
component,
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: true,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
...initial(),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
let data = initial();
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/project')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
let component = url.pathname.endsWith('/edit')
|
||||||
|
? 'Edit'
|
||||||
|
: url.pathname.includes('/templates/create')
|
||||||
|
? 'Create'
|
||||||
|
: url.pathname === '/project-templates'
|
||||||
|
? 'Index'
|
||||||
|
: 'Destination';
|
||||||
|
if (url.searchParams.has('reset')) {
|
||||||
|
data = initial();
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('unlinked')) {
|
||||||
|
data.template.linked_project_count = 0;
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('missing')) {
|
||||||
|
data.template.source_project = null;
|
||||||
|
}
|
||||||
|
if (url.searchParams.has('empty')) {
|
||||||
|
data.templates = [];
|
||||||
|
}
|
||||||
|
let errors = {};
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const submitted = JSON.parse(body || '{}');
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(
|
||||||
|
/template_test_result=(\w+)/,
|
||||||
|
)?.[1] ?? 'success';
|
||||||
|
if (result === 'server') {
|
||||||
|
response.statusCode = 500;
|
||||||
|
response.end('Unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result === 'validation') {
|
||||||
|
component =
|
||||||
|
request.method === 'POST'
|
||||||
|
? 'Create'
|
||||||
|
: request.method === 'PATCH'
|
||||||
|
? 'Edit'
|
||||||
|
: 'Index';
|
||||||
|
errors = {
|
||||||
|
name: 'This template name is unavailable.',
|
||||||
|
'copy_areas.0': 'Choose a valid configuration group.',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (request.method === 'DELETE') {
|
||||||
|
data.templates = data.templates.filter(
|
||||||
|
(item) => item.id !== 2,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
data.templates = [{ ...data.template, ...submitted }];
|
||||||
|
}
|
||||||
|
component = 'Index';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = makePage(
|
||||||
|
component,
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
errors,
|
||||||
|
status:
|
||||||
|
request.method !== 'GET' && !Object.keys(errors).length
|
||||||
|
? 'Saved'
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
component === 'Index' ? '/project-templates' : request.url,
|
||||||
|
);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-templates-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Project template fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const render = async (
|
||||||
|
name,
|
||||||
|
extra = {},
|
||||||
|
locale = createLocalization(),
|
||||||
|
) => {
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/project-templates/' + name + '.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
return (
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(name, extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body.replace(/ data-page="[^"]*"/, '');
|
||||||
|
};
|
||||||
|
const list = await render('Index');
|
||||||
|
assert.match(list, /Foundation/);
|
||||||
|
assert.doesNotMatch(list, /Internal baseline/);
|
||||||
|
assert.match(list, /2 linked projects/);
|
||||||
|
assert.match(list, /Project settings/);
|
||||||
|
assert.match(list, /href="\/projects\/create\?template=2"/);
|
||||||
|
assert.match(
|
||||||
|
await render('Index', { templates: [] }),
|
||||||
|
/No published templates yet/,
|
||||||
|
);
|
||||||
|
const create = await render('Create');
|
||||||
|
assert.match(create, /value="Core platform template"/);
|
||||||
|
assert.equal((create.match(/type="checkbox"/g) ?? []).length, 10);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
create.match(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
|
||||||
|
/ disabled(?:[ =>])/,
|
||||||
|
);
|
||||||
|
const edit = await render('Edit');
|
||||||
|
assert.match(
|
||||||
|
edit.match(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
|
||||||
|
/ disabled(?:[ =>])/,
|
||||||
|
);
|
||||||
|
assert.match(edit, /Linked projects will be affected/);
|
||||||
|
assert.match(
|
||||||
|
edit.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
|
||||||
|
/ disabled(?:[ =>])/,
|
||||||
|
);
|
||||||
|
const unlinked = await render('Edit', {
|
||||||
|
template: { ...template, linked_project_count: 0 },
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(unlinked, /id="confirm-linked"/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
|
||||||
|
/ disabled(?:[ =>])/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render('Edit', {
|
||||||
|
template: { ...template, source_project: null },
|
||||||
|
}),
|
||||||
|
/This template cannot be refreshed/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render(
|
||||||
|
'Index',
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'navigation.templates': 'Projektvorlagen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/Projektvorlagen/,
|
||||||
|
);
|
||||||
|
console.log('Project template checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user