348 lines
14 KiB
Vue
348 lines
14 KiB
Vue
<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>
|