fix(KAN-14): preserve template inheritance and project data
This commit is contained in:
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -12,7 +11,13 @@ 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 {
|
||||
index,
|
||||
update,
|
||||
store as storeStandalone,
|
||||
} from '@/routes/project-templates';
|
||||
import TemplateConfigurationEditor from '@/components/projects/TemplateConfigurationEditor.vue';
|
||||
import { createTemplateConfiguration } from '@/lib/project-template-configuration';
|
||||
import { show } from '@/routes/projects';
|
||||
import { store } from '@/routes/projects/templates';
|
||||
import type { ProjectDuplicateCopyAreaOption } from '@/types';
|
||||
@@ -22,11 +27,18 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model
|
||||
|
||||
const props = defineProps<{
|
||||
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||
project?: ProjectModel;
|
||||
project?: ProjectModel | null;
|
||||
initialConfiguration?: Record<string, unknown>;
|
||||
template?: ProjectTemplateModel;
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const { areaText } = useProjectTemplateAreas();
|
||||
const publishingBase = computed(() =>
|
||||
props.project?.project_template &&
|
||||
!props.project.project_template.is_published
|
||||
? props.project.project_template
|
||||
: null,
|
||||
);
|
||||
const source = computed(() => props.template?.source_project ?? props.project);
|
||||
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
|
||||
const form = useForm(
|
||||
@@ -34,14 +46,23 @@ const form = useForm(
|
||||
{
|
||||
name:
|
||||
props.template?.name ??
|
||||
t('templates.defaultName', { project: props.project?.name ?? '' }),
|
||||
(props.project
|
||||
? t('templates.defaultName', { project: props.project.name })
|
||||
: ''),
|
||||
slug:
|
||||
props.template?.slug ??
|
||||
`${props.project?.key.toLowerCase()}-template`,
|
||||
publishingBase.value?.slug ??
|
||||
(props.project
|
||||
? `${props.project.key.toLowerCase()}-template`
|
||||
: ''),
|
||||
copy_areas: props.template
|
||||
? [...props.template.copy_areas]
|
||||
: props.copyAreaOptions.map((area) => area.value),
|
||||
confirm_linked_projects_update: false,
|
||||
configuration_snapshot: createTemplateConfiguration(
|
||||
props.template?.configuration_snapshot ??
|
||||
props.initialConfiguration,
|
||||
),
|
||||
},
|
||||
);
|
||||
const protection = useFormProtection(form);
|
||||
@@ -53,13 +74,10 @@ const copyAreasError = computed(
|
||||
)?.[1],
|
||||
);
|
||||
const cancelTarget = computed(() =>
|
||||
props.template ? index() : show(props.project!.id),
|
||||
props.project ? show(props.project.id) : index(),
|
||||
);
|
||||
function submit(): void {
|
||||
if (
|
||||
!source.value ||
|
||||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
|
||||
) {
|
||||
if (linkedCount.value > 0 && !form.confirm_linked_projects_update) {
|
||||
return;
|
||||
}
|
||||
form.transform((data) =>
|
||||
@@ -67,6 +85,7 @@ function submit(): void {
|
||||
? {
|
||||
name: data.name,
|
||||
copy_areas: data.copy_areas,
|
||||
configuration_snapshot: data.configuration_snapshot,
|
||||
...(linkedCount.value > 0
|
||||
? {
|
||||
confirm_linked_projects_update:
|
||||
@@ -74,10 +93,21 @@ function submit(): void {
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
|
||||
: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
copy_areas: data.copy_areas,
|
||||
...(!props.project
|
||||
? { configuration_snapshot: data.configuration_snapshot }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
protection.submit(
|
||||
props.template ? update(props.template.id) : store(props.project!.id),
|
||||
props.template
|
||||
? update(props.template.id)
|
||||
: props.project
|
||||
? store(props.project.id)
|
||||
: storeStandalone(),
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
}
|
||||
@@ -90,12 +120,6 @@ function submit(): void {
|
||||
@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]"
|
||||
>
|
||||
@@ -115,7 +139,7 @@ function submit(): void {
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="form.processing || !source"
|
||||
:disabled="form.processing"
|
||||
:placeholder="t('templates.namePlaceholder')"
|
||||
:invalid="!!form.errors.name"
|
||||
aria-describedby="name-error"
|
||||
@@ -139,7 +163,11 @@ function submit(): void {
|
||||
name="slug"
|
||||
required
|
||||
maxlength="255"
|
||||
:disabled="!!template || form.processing || !source"
|
||||
:disabled="
|
||||
!!template ||
|
||||
!!publishingBase ||
|
||||
form.processing
|
||||
"
|
||||
:placeholder="t('templates.slugPlaceholder')"
|
||||
:invalid="!!form.errors.slug"
|
||||
aria-describedby="slug-help slug-error"
|
||||
@@ -152,7 +180,7 @@ function submit(): void {
|
||||
>
|
||||
{{
|
||||
t(
|
||||
template
|
||||
template || publishingBase
|
||||
? 'templates.slugLocked'
|
||||
: 'templates.slugHelp',
|
||||
)
|
||||
@@ -166,7 +194,7 @@ function submit(): void {
|
||||
</section>
|
||||
<fieldset
|
||||
class="bg-card min-w-0 rounded-md border p-5"
|
||||
:disabled="form.processing || !source"
|
||||
:disabled="form.processing"
|
||||
aria-describedby="areas-help copy-areas-error"
|
||||
>
|
||||
<legend class="text-title px-1">
|
||||
@@ -231,6 +259,14 @@ function submit(): void {
|
||||
:message="copyAreasError"
|
||||
/>
|
||||
</fieldset>
|
||||
<TemplateConfigurationEditor
|
||||
v-if="template || !project"
|
||||
v-model="form.configuration_snapshot"
|
||||
:areas="form.copy_areas"
|
||||
:options="copyAreaOptions"
|
||||
:errors="form.errors"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
</div>
|
||||
<aside
|
||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||
@@ -310,7 +346,7 @@ function submit(): void {
|
||||
id="confirm-linked"
|
||||
v-model="form.confirm_linked_projects_update"
|
||||
type="checkbox"
|
||||
:disabled="form.processing || !source"
|
||||
:disabled="form.processing"
|
||||
:aria-invalid="!!form.errors.confirm_linked_projects_update"
|
||||
aria-describedby="linked-help linked-error"
|
||||
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||
@@ -335,8 +371,7 @@ function submit(): void {
|
||||
:loading="form.processing"
|
||||
:loading-label="t('forms.saving')"
|
||||
:disabled="
|
||||
!source ||
|
||||
(linkedCount > 0 && !form.confirm_linked_projects_update)
|
||||
linkedCount > 0 && !form.confirm_linked_projects_update
|
||||
"
|
||||
>{{
|
||||
t(template ? 'templates.saveChanges' : 'templates.saveNew')
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { notificationLabel } from '@/lib/notification-labels';
|
||||
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||
import type {
|
||||
TemplateConfiguration,
|
||||
TemplateConfigurationEditorProps,
|
||||
TemplateConfigurationItem,
|
||||
TemplateListArea,
|
||||
TemplateTextField,
|
||||
TemplateNotificationFilter,
|
||||
} from '@/types/project-template-configuration';
|
||||
|
||||
const props = defineProps<TemplateConfigurationEditorProps>();
|
||||
const configuration = defineModel<TemplateConfiguration>({ required: true });
|
||||
const { t } = useLocalization();
|
||||
const { areaText } = useProjectTemplateAreas();
|
||||
const listAreas = computed(
|
||||
() =>
|
||||
props.options
|
||||
.map((option) => option.value)
|
||||
.filter(
|
||||
(area) =>
|
||||
props.areas.includes(area) &&
|
||||
area !== 'project_settings' &&
|
||||
area !== 'status_transitions',
|
||||
) as TemplateListArea[],
|
||||
);
|
||||
const selectClass =
|
||||
'border-input bg-background min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm';
|
||||
const filterAttributes = [
|
||||
'status.from.id',
|
||||
'status.to.id',
|
||||
'status.from.label',
|
||||
'status.to.label',
|
||||
'project.id',
|
||||
'project.key',
|
||||
'project.name',
|
||||
'ticket.id',
|
||||
'ticket.key',
|
||||
'ticket.title',
|
||||
'ticket.url',
|
||||
'actor.id',
|
||||
'actor.name',
|
||||
'assignee.from.id',
|
||||
'assignee.from.name',
|
||||
'assignee.to.id',
|
||||
'assignee.to.name',
|
||||
];
|
||||
const filterOperators = ['equals', 'not_equals', 'exists', 'not_exists', 'in'];
|
||||
|
||||
function fields(area: TemplateListArea): TemplateTextField[] {
|
||||
if (area === 'ticket_fields') {
|
||||
return [{ key: 'label', label: t('templateEditor.label') }];
|
||||
}
|
||||
if (area === 'ticket_link_types') {
|
||||
return [
|
||||
{ key: 'label', label: t('templateEditor.label') },
|
||||
{
|
||||
key: 'counterpart_label',
|
||||
label: t('templateEditor.counterpart'),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (area === 'repository_connections') {
|
||||
return [
|
||||
{ key: 'base_url', label: t('templateEditor.server') },
|
||||
{
|
||||
key: 'provider_repository_id',
|
||||
label: t('templateEditor.repositoryId'),
|
||||
},
|
||||
{ key: 'owner_name', label: t('templateEditor.owner') },
|
||||
{
|
||||
key: 'repository_name',
|
||||
label: t('templateEditor.repositoryName'),
|
||||
},
|
||||
{
|
||||
key: 'repository_full_name',
|
||||
label: t('templateEditor.fullName'),
|
||||
},
|
||||
{ key: 'repository_url', label: t('templateEditor.repositoryUrl') },
|
||||
];
|
||||
}
|
||||
return [{ key: 'name', label: t('templateEditor.name') }];
|
||||
}
|
||||
function reindex(area: TemplateListArea): void {
|
||||
const counters = new Map<string, number>();
|
||||
configuration.value[area].forEach((item, index) => {
|
||||
if (
|
||||
area === 'repository_connections' ||
|
||||
area === 'notification_actions'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const group = item.board_column_key ?? '';
|
||||
counters.set(group, (counters.get(group) ?? 0) + 1);
|
||||
item.position =
|
||||
area === 'ticket_statuses' ? counters.get(group) : index + 1;
|
||||
});
|
||||
}
|
||||
function add(area: TemplateListArea): void {
|
||||
const item: TemplateConfigurationItem = {
|
||||
configuration_key: crypto.randomUUID(),
|
||||
};
|
||||
if (area === 'ticket_fields') {
|
||||
Object.assign(item, { label: '', field_type: 'number' });
|
||||
} else if (area === 'ticket_link_types') {
|
||||
Object.assign(item, { label: '', counterpart_label: '' });
|
||||
} else if (area === 'repository_connections') {
|
||||
Object.assign(item, {
|
||||
provider: 'gitea',
|
||||
base_url: '',
|
||||
provider_repository_id: '',
|
||||
owner_name: '',
|
||||
repository_name: '',
|
||||
repository_full_name: '',
|
||||
repository_url: '',
|
||||
});
|
||||
} else {
|
||||
Object.assign(item, { name: '' });
|
||||
}
|
||||
if (area === 'ticket_statuses') {
|
||||
item.board_column_key = null;
|
||||
configuration.value.status_transitions.push({
|
||||
status_key: item.configuration_key,
|
||||
target_keys: [],
|
||||
});
|
||||
}
|
||||
if (area === 'ticket_types') {
|
||||
item.is_parent = false;
|
||||
}
|
||||
if (area === 'notification_actions') {
|
||||
Object.assign(item, {
|
||||
enabled: true,
|
||||
event_type: 'ticket_status_changed',
|
||||
channel: 'webhook',
|
||||
delivery_url: '',
|
||||
message_template: '',
|
||||
channel_configuration: { allowed_mentions: [] },
|
||||
filters: [],
|
||||
});
|
||||
}
|
||||
configuration.value[area].push(item);
|
||||
reindex(area);
|
||||
}
|
||||
function remove(area: TemplateListArea, index: number): void {
|
||||
const item = configuration.value[area][index];
|
||||
if (area === 'board_columns') {
|
||||
configuration.value.ticket_statuses.forEach((status) => {
|
||||
if (status.board_column_key === item.configuration_key) {
|
||||
status.board_column_key = null;
|
||||
}
|
||||
});
|
||||
reindex('ticket_statuses');
|
||||
}
|
||||
if (area === 'ticket_statuses') {
|
||||
configuration.value.status_transitions =
|
||||
configuration.value.status_transitions.filter(
|
||||
(transition) =>
|
||||
transition.status_key !== item.configuration_key,
|
||||
);
|
||||
configuration.value.status_transitions.forEach((transition) => {
|
||||
transition.target_keys = transition.target_keys.filter(
|
||||
(key) => key !== item.configuration_key,
|
||||
);
|
||||
});
|
||||
}
|
||||
configuration.value[area].splice(index, 1);
|
||||
reindex(area);
|
||||
}
|
||||
function move(area: TemplateListArea, index: number, offset: number): void {
|
||||
const entries = configuration.value[area];
|
||||
const next = index + offset;
|
||||
if (next < 0 || next >= entries.length) {
|
||||
return;
|
||||
}
|
||||
[entries[index], entries[next]] = [entries[next], entries[index]];
|
||||
reindex(area);
|
||||
}
|
||||
function targets(statusKey: string): string[] {
|
||||
return (
|
||||
configuration.value.status_transitions.find(
|
||||
(transition) => transition.status_key === statusKey,
|
||||
)?.target_keys ?? []
|
||||
);
|
||||
}
|
||||
function toggleTarget(from: string, to: string, checked: boolean): void {
|
||||
let transition = configuration.value.status_transitions.find(
|
||||
(entry) => entry.status_key === from,
|
||||
);
|
||||
if (!transition) {
|
||||
transition = { status_key: from, target_keys: [] };
|
||||
configuration.value.status_transitions.push(transition);
|
||||
}
|
||||
transition.target_keys = checked
|
||||
? [...new Set([...transition.target_keys, to])]
|
||||
: transition.target_keys.filter((key) => key !== to);
|
||||
}
|
||||
function filterChanged(filter: TemplateNotificationFilter): void {
|
||||
if (['exists', 'not_exists'].includes(filter.operator)) {
|
||||
filter.value = null;
|
||||
} else if (filter.operator === 'in') {
|
||||
filter.value = Array.isArray(filter.value)
|
||||
? filter.value
|
||||
: filter.value
|
||||
? [filter.value]
|
||||
: [];
|
||||
} else if (Array.isArray(filter.value)) {
|
||||
filter.value = filter.value[0] ?? '';
|
||||
}
|
||||
}
|
||||
function mention(
|
||||
item: TemplateConfigurationItem,
|
||||
value: string,
|
||||
checked: boolean,
|
||||
): void {
|
||||
const settings = (item.channel_configuration ??= { allowed_mentions: [] });
|
||||
settings.allowed_mentions = checked
|
||||
? [...new Set([...(settings.allowed_mentions ?? []), value])]
|
||||
: (settings.allowed_mentions ?? []).filter((entry) => entry !== value);
|
||||
}
|
||||
function areaErrors(area: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
Object.entries(props.errors)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key === `configuration_snapshot.${area}` ||
|
||||
key.startsWith(`configuration_snapshot.${area}.`),
|
||||
)
|
||||
.map(([, value]) => value)
|
||||
.filter((value): value is string => !!value),
|
||||
),
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="gap-column grid min-w-0" :disabled="disabled">
|
||||
<legend class="text-title mb-4">{{ t('templateEditor.title') }}</legend>
|
||||
<p class="text-muted-foreground text-caption">
|
||||
{{ t('templateEditor.help') }}
|
||||
</p>
|
||||
<details
|
||||
v-if="areas.includes('project_settings')"
|
||||
class="bg-card rounded-md border p-5"
|
||||
open
|
||||
>
|
||||
<summary class="cursor-pointer font-medium">
|
||||
{{ areaText('project_settings', 'label') }}
|
||||
</summary>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<Label class="flex items-center gap-3"
|
||||
><Checkbox
|
||||
v-model="configuration.project_settings.show_author"
|
||||
/>{{ t('templateEditor.showAuthor') }}</Label
|
||||
>
|
||||
<Label class="flex items-center gap-3"
|
||||
><Checkbox
|
||||
v-model="
|
||||
configuration.project_settings.show_story_points
|
||||
"
|
||||
/>{{ t('templateEditor.showPoints') }}</Label
|
||||
>
|
||||
<InputError
|
||||
v-for="error in areaErrors('project_settings')"
|
||||
:key="error"
|
||||
:message="error"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<details
|
||||
v-for="area in listAreas"
|
||||
:key="area"
|
||||
class="bg-card min-w-0 rounded-md border p-5"
|
||||
:open="
|
||||
['board_columns', 'ticket_statuses', 'ticket_types'].includes(
|
||||
area,
|
||||
)
|
||||
"
|
||||
>
|
||||
<summary class="cursor-pointer font-medium">
|
||||
{{ areaText(area, 'label') }} ({{ configuration[area].length }})
|
||||
</summary>
|
||||
<div class="mt-4 grid min-w-0 gap-4">
|
||||
<article
|
||||
v-for="(item, index) in configuration[area]"
|
||||
:key="item.configuration_key"
|
||||
class="grid min-w-0 gap-4 rounded-md border p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-caption font-medium">{{
|
||||
item.name ||
|
||||
item.label ||
|
||||
t('templateEditor.entry', { number: index + 1 })
|
||||
}}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:disabled="index === 0"
|
||||
:aria-label="t('templateEditor.moveUp')"
|
||||
@click="move(area, index, -1)"
|
||||
><ArrowUp
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:disabled="
|
||||
index === configuration[area].length - 1
|
||||
"
|
||||
:aria-label="t('templateEditor.moveDown')"
|
||||
@click="move(area, index, 1)"
|
||||
><ArrowDown
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('templateEditor.remove')"
|
||||
@click="remove(area, index)"
|
||||
><Trash2
|
||||
/></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="field in fields(area)"
|
||||
:key="field.key"
|
||||
class="grid min-w-0 gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="`${item.configuration_key}-${field.key}`"
|
||||
>{{ field.label }}</Label
|
||||
>
|
||||
<Input
|
||||
:id="`${item.configuration_key}-${field.key}`"
|
||||
v-model="item[field.key]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="area === 'ticket_statuses'"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label :for="`${item.configuration_key}-column`">{{
|
||||
t('templateEditor.column')
|
||||
}}</Label>
|
||||
<select
|
||||
:id="`${item.configuration_key}-column`"
|
||||
v-model="item.board_column_key"
|
||||
:class="selectClass"
|
||||
@change="reindex('ticket_statuses')"
|
||||
>
|
||||
<option :value="null">
|
||||
{{ t('templateEditor.backlog') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="column in configuration.board_columns"
|
||||
:key="column.configuration_key"
|
||||
:value="column.configuration_key"
|
||||
>
|
||||
{{ column.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Label
|
||||
v-if="area === 'ticket_types'"
|
||||
class="flex items-center gap-3"
|
||||
><Checkbox v-model="item.is_parent" />{{
|
||||
t('templateEditor.parentType')
|
||||
}}</Label
|
||||
>
|
||||
<div v-if="area === 'ticket_fields'" class="grid gap-2">
|
||||
<Label :for="`${item.configuration_key}-type`">{{
|
||||
t('templateEditor.fieldType')
|
||||
}}</Label>
|
||||
<select
|
||||
:id="`${item.configuration_key}-type`"
|
||||
v-model="item.field_type"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option value="number">
|
||||
{{ t('templateEditor.number') }}
|
||||
</option>
|
||||
<option value="user">
|
||||
{{ t('templateEditor.user') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="area === 'notification_actions'">
|
||||
<Label class="flex items-center gap-3"
|
||||
><Checkbox v-model="item.enabled" />{{
|
||||
t('templateEditor.enabled')
|
||||
}}</Label
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('templateEditor.event')
|
||||
}}<select
|
||||
v-model="item.event_type"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option
|
||||
v-for="event in [
|
||||
'ticket_status_changed',
|
||||
'ticket_create',
|
||||
'ticket_assignee_changed',
|
||||
]"
|
||||
:key="event"
|
||||
:value="event"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'event',
|
||||
event,
|
||||
event,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('templateEditor.channel')
|
||||
}}<select
|
||||
v-model="item.channel"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option value="webhook">
|
||||
{{ t('notifications.channel.webhook') }}
|
||||
</option>
|
||||
<option value="discord">
|
||||
{{ t('notifications.channel.discord') }}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label class="grid gap-2 sm:col-span-2"
|
||||
>{{ t('templateEditor.deliveryUrl')
|
||||
}}<Input
|
||||
v-model="item.delivery_url"
|
||||
required
|
||||
type="url"
|
||||
/></Label>
|
||||
<Label class="grid gap-2 sm:col-span-2"
|
||||
>{{ t('templateEditor.message')
|
||||
}}<textarea
|
||||
v-model="item.message_template"
|
||||
:class="selectClass"
|
||||
rows="4"
|
||||
:required="item.channel === 'discord'"
|
||||
maxlength="2000"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<fieldset
|
||||
v-if="item.channel === 'discord'"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
>
|
||||
<legend>
|
||||
{{ t('templateEditor.allowedMentions') }}
|
||||
</legend>
|
||||
<Label
|
||||
v-for="value in ['everyone', 'roles', 'users']"
|
||||
:key="value"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="
|
||||
item.channel_configuration?.allowed_mentions?.includes(
|
||||
value,
|
||||
) ?? false
|
||||
"
|
||||
@update:model-value="
|
||||
mention(item, value, $event === true)
|
||||
"
|
||||
/>{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'mention',
|
||||
value,
|
||||
value,
|
||||
)
|
||||
}}
|
||||
</Label>
|
||||
</fieldset>
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="(filter, filterIndex) in item.filters"
|
||||
:key="filterIndex"
|
||||
class="grid gap-3 rounded-md border p-3 sm:grid-cols-2"
|
||||
>
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('notifications.attribute')
|
||||
}}<select
|
||||
v-model="filter.attribute"
|
||||
@change="
|
||||
filter.value =
|
||||
filter.operator === 'in'
|
||||
? []
|
||||
: null
|
||||
"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option
|
||||
v-for="attribute in filterAttributes"
|
||||
:key="attribute"
|
||||
:value="attribute"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'attribute',
|
||||
attribute,
|
||||
attribute,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label class="grid gap-2"
|
||||
>{{ t('notifications.operator')
|
||||
}}<select
|
||||
v-model="filter.operator"
|
||||
@change="filterChanged(filter)"
|
||||
:class="selectClass"
|
||||
>
|
||||
<option
|
||||
v-for="operator in filterOperators"
|
||||
:key="operator"
|
||||
:value="operator"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'operator',
|
||||
operator,
|
||||
operator,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select></Label
|
||||
>
|
||||
<Label
|
||||
v-if="
|
||||
!['exists', 'not_exists'].includes(
|
||||
filter.operator,
|
||||
)
|
||||
"
|
||||
class="grid gap-2"
|
||||
>{{ t('notifications.value')
|
||||
}}<Input
|
||||
:model-value="
|
||||
Array.isArray(filter.value)
|
||||
? filter.value.join(', ')
|
||||
: (filter.value ?? '')
|
||||
"
|
||||
@update:model-value="
|
||||
filter.value = String($event)
|
||||
"
|
||||
/></Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="
|
||||
item.filters?.splice(filterIndex, 1)
|
||||
"
|
||||
>{{
|
||||
t('notifications.removeFilter')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="
|
||||
(item.filters ??= []).push({
|
||||
attribute: 'status.to.label',
|
||||
operator: 'equals',
|
||||
value: '',
|
||||
})
|
||||
"
|
||||
><Plus />{{
|
||||
t('notifications.addFilter')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
<InputError
|
||||
v-for="error in areaErrors(area)"
|
||||
:key="error"
|
||||
:message="error"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="add(area)"
|
||||
><Plus />{{ t('templateEditor.addEntry') }}</Button
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
<details
|
||||
v-if="areas.includes('status_transitions')"
|
||||
class="bg-card rounded-md border p-5"
|
||||
open
|
||||
>
|
||||
<summary class="cursor-pointer font-medium">
|
||||
{{ areaText('status_transitions', 'label') }}
|
||||
</summary>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<fieldset
|
||||
v-for="status in configuration.ticket_statuses"
|
||||
:key="status.configuration_key"
|
||||
class="grid gap-3 rounded-md border p-4"
|
||||
>
|
||||
<legend class="px-1 font-medium">{{ status.name }}</legend>
|
||||
<Label
|
||||
v-for="target in configuration.ticket_statuses.filter(
|
||||
(entry) =>
|
||||
entry.configuration_key !==
|
||||
status.configuration_key,
|
||||
)"
|
||||
:key="target.configuration_key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="
|
||||
targets(status.configuration_key).includes(
|
||||
target.configuration_key,
|
||||
)
|
||||
"
|
||||
@update:model-value="
|
||||
toggleTarget(
|
||||
status.configuration_key,
|
||||
target.configuration_key,
|
||||
$event === true,
|
||||
)
|
||||
"
|
||||
/>{{ target.name }}
|
||||
</Label>
|
||||
</fieldset>
|
||||
<InputError
|
||||
v-for="error in areaErrors('status_transitions')"
|
||||
:key="error"
|
||||
:message="error"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TemplateResetForm } from '@/types/project-template-configuration';
|
||||
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -27,10 +28,7 @@ const props = defineProps<{
|
||||
templateState?: ProjectTemplateState | null;
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm<{
|
||||
areas: string[];
|
||||
status_replacements: Record<string, number | undefined>;
|
||||
}>({
|
||||
const form = useForm<TemplateResetForm>({
|
||||
areas: [],
|
||||
status_replacements: {},
|
||||
});
|
||||
@@ -245,6 +243,15 @@ function resetArea(): void {
|
||||
v-if="statusRequirement"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<p
|
||||
v-if="
|
||||
statusRequirement.replacementOptions
|
||||
.length === 0
|
||||
"
|
||||
class="text-destructive text-sm"
|
||||
>
|
||||
{{ t('inheritance.noReplacementTargets') }}
|
||||
</p>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('inheritance.statusHelp') }}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TemplateConfiguration } from '@/types/project-template-configuration';
|
||||
|
||||
export function createTemplateConfiguration(
|
||||
snapshot?: Record<string, unknown>,
|
||||
): TemplateConfiguration {
|
||||
return JSON.parse(
|
||||
JSON.stringify({
|
||||
project_settings: { show_author: true, show_story_points: true },
|
||||
board_columns: [],
|
||||
ticket_statuses: [],
|
||||
status_transitions: [],
|
||||
ticket_types: [],
|
||||
ticket_priorities: [],
|
||||
ticket_fields: [],
|
||||
ticket_link_types: [],
|
||||
repository_connections: [],
|
||||
notification_actions: [],
|
||||
...snapshot,
|
||||
}),
|
||||
) as TemplateConfiguration;
|
||||
}
|
||||
@@ -248,6 +248,38 @@ export const en = {
|
||||
'Previous assignee name when the event updates assignment; blank otherwise.',
|
||||
'notifications.variable.assignee_to':
|
||||
'New assignee name when the event updates assignment; blank when unassigned.',
|
||||
'templateEditor.title': 'Template configuration',
|
||||
'templateEditor.help':
|
||||
'Changes apply to linked projects that still inherit each group. Used entries remain protected as local overrides.',
|
||||
'templateEditor.name': 'Name',
|
||||
'templateEditor.label': 'Label',
|
||||
'templateEditor.counterpart': 'Reverse label',
|
||||
'templateEditor.server': 'Server URL',
|
||||
'templateEditor.repositoryId': 'Repository ID',
|
||||
'templateEditor.owner': 'Owner',
|
||||
'templateEditor.repositoryName': 'Repository name',
|
||||
'templateEditor.fullName': 'Owner / repository',
|
||||
'templateEditor.repositoryUrl': 'Repository URL',
|
||||
'templateEditor.entry': 'Entry {number}',
|
||||
'templateEditor.moveUp': 'Move up',
|
||||
'templateEditor.moveDown': 'Move down',
|
||||
'templateEditor.remove': 'Remove entry',
|
||||
'templateEditor.addEntry': 'Add entry',
|
||||
'templateEditor.showAuthor': 'Show ticket author',
|
||||
'templateEditor.showPoints': 'Show story points',
|
||||
'templateEditor.column': 'Board column',
|
||||
'templateEditor.backlog': 'Backlog (no board column)',
|
||||
'templateEditor.parentType': 'Allows child tickets',
|
||||
'templateEditor.fieldType': 'Field type',
|
||||
'templateEditor.number': 'Number',
|
||||
'templateEditor.user': 'User',
|
||||
'templateEditor.allowedMentions': 'Allowed Discord mentions',
|
||||
'templateEditor.enabled': 'Enabled',
|
||||
'templateEditor.event': 'Event',
|
||||
'templateEditor.channel': 'Channel',
|
||||
'templateEditor.deliveryUrl': 'Delivery URL',
|
||||
'templateEditor.message': 'Message template',
|
||||
'templates.createEmpty': 'New empty template',
|
||||
'templates.description':
|
||||
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
|
||||
'templates.updated': 'Project templates updated.',
|
||||
@@ -264,7 +296,7 @@ export const en = {
|
||||
other: '{count} linked projects',
|
||||
},
|
||||
'templates.savedFrom': 'Saved from {project} ({key}).',
|
||||
'templates.sourceUnavailable': 'Source project unavailable',
|
||||
'templates.sourceUnavailable': 'Independent template',
|
||||
'templates.edit': 'Edit',
|
||||
'templates.use': 'Use template',
|
||||
'templates.createTitle': 'Save {project} as a template',
|
||||
@@ -272,7 +304,7 @@ export const en = {
|
||||
'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.',
|
||||
'Edit the template configuration and review the impact on linked projects.',
|
||||
'templates.defaultName': '{project} template',
|
||||
'templates.details': 'Template details',
|
||||
'templates.name': 'Template name',
|
||||
@@ -285,13 +317,15 @@ export const en = {
|
||||
'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.',
|
||||
'Only selected groups are saved. Statuses, columns and transitions inherit independently.',
|
||||
'templates.editGroupsHelp':
|
||||
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
|
||||
'Edit the selected groups directly. Linked projects keep their existing local overrides.',
|
||||
'templates.impact': 'Template impact',
|
||||
'templates.inheritance': 'Inheritance',
|
||||
'templates.emptyStart':
|
||||
'Templates contain configuration only. New projects start with a clean ticket history.',
|
||||
'templates.cleanStart':
|
||||
'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
|
||||
'Tickets are never copied. Saving a project as a template links that project to the new template.',
|
||||
'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',
|
||||
@@ -1331,8 +1365,10 @@ export const en = {
|
||||
'inheritance.resetWarning':
|
||||
'Local configuration in this area may be replaced or removed.',
|
||||
'inheritance.statusHelp':
|
||||
'Choose where tickets in local statuses should move before those statuses are removed.',
|
||||
'Choose a replacement for each used local status. Its tickets and local status transitions move to that replacement.',
|
||||
'inheritance.statusLabel': '{status} ({count} tickets)',
|
||||
'inheritance.noReplacementTargets':
|
||||
'Add a status to the template before resetting used local statuses.',
|
||||
'inheritance.selectStatus': 'Select replacement status',
|
||||
'inheritance.area.projectSettings': 'Project settings',
|
||||
'inheritance.area.boardColumns': 'Board columns',
|
||||
|
||||
@@ -6,31 +6,58 @@ import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { create } from '@/routes/projects/templates';
|
||||
import {
|
||||
create as createEmpty,
|
||||
index as templatesIndex,
|
||||
} from '@/routes/project-templates';
|
||||
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
||||
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('projects.saveTemplate'), href: create(props.project.id) },
|
||||
]);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() =>
|
||||
props.project
|
||||
? [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{
|
||||
title: t('projects.saveTemplate'),
|
||||
href: create(props.project.id),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ title: t('navigation.templates'), href: templatesIndex() },
|
||||
{ title: t('templates.createEmpty'), href: createEmpty() },
|
||||
],
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('templates.createTitle', { project: project.name })" />
|
||||
<Head
|
||||
:title="
|
||||
project
|
||||
? t('templates.createTitle', { project: project.name })
|
||||
: t('templates.createEmpty')
|
||||
"
|
||||
/>
|
||||
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
|
||||
<header class="gap-stack grid">
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{ t('templates.createTitle', { project: project.name }) }}
|
||||
{{
|
||||
project
|
||||
? t('templates.createTitle', {
|
||||
project: project.name,
|
||||
})
|
||||
: t('templates.createEmpty')
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templates.createDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
<ProjectTemplateForm
|
||||
:key="project.id"
|
||||
:key="project?.id ?? 'empty'"
|
||||
:project="project"
|
||||
:initial-configuration="initialConfiguration"
|
||||
:copy-area-options="copyAreaOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ 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, edit, index, create } from '@/routes/project-templates';
|
||||
import {
|
||||
create as createProject,
|
||||
index as projectsIndex,
|
||||
@@ -80,9 +80,9 @@ function removeTemplate(): void {
|
||||
</p>
|
||||
</div>
|
||||
<Button as-child class="shrink-0"
|
||||
><Link :href="createProject()"
|
||||
><Link :href="create()"
|
||||
><Plus aria-hidden="true" />{{
|
||||
t('projects.create')
|
||||
t('templates.createEmpty')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
|
||||
@@ -88,7 +88,10 @@ const settingsItems = computed(() =>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
v-if="
|
||||
$page.props.auth.permissions.projectCreate &&
|
||||
canManageAccess
|
||||
"
|
||||
as-child
|
||||
variant="outline"
|
||||
class="self-start"
|
||||
|
||||
@@ -67,7 +67,8 @@ export type ProjectTemplatesIndexPageProps = {
|
||||
|
||||
export type ProjectTemplatesCreatePageProps = {
|
||||
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||
project: ProjectModel;
|
||||
project: ProjectModel | null;
|
||||
initialConfiguration?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProjectTemplatesEditPageProps = {
|
||||
@@ -82,6 +83,8 @@ export type ProjectTemplateStateArea = {
|
||||
|
||||
export type ProjectTemplateState = {
|
||||
effectiveConfiguration: Record<string, unknown>;
|
||||
localConfiguration?: Record<string, unknown>;
|
||||
localAreas?: ProjectTemplateStateArea[];
|
||||
inheritedAreas: ProjectTemplateStateArea[];
|
||||
inheritedConfiguration?: Record<string, unknown>;
|
||||
inheritsFromTemplate: boolean;
|
||||
@@ -95,7 +98,7 @@ export type ProjectTemplateState = {
|
||||
name: string;
|
||||
ticketCount: number;
|
||||
}>;
|
||||
replacementOptions: Array<{ id: number; name: string }>;
|
||||
replacementOptions: Array<{ id: number | string; name: string }>;
|
||||
}
|
||||
>;
|
||||
templateConfiguration?: Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ProjectDuplicateCopyAreaOption } from '@/types/page-props';
|
||||
|
||||
export type TemplateNotificationFilter = {
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string | string[] | null;
|
||||
};
|
||||
|
||||
export type TemplateConfigurationItem = {
|
||||
configuration_key: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
counterpart_label?: string;
|
||||
position?: number;
|
||||
is_parent?: boolean;
|
||||
field_type?: string;
|
||||
board_column_key?: string | null;
|
||||
provider?: string;
|
||||
base_url?: string;
|
||||
provider_repository_id?: string;
|
||||
owner_name?: string;
|
||||
repository_name?: string;
|
||||
repository_full_name?: string;
|
||||
repository_url?: string;
|
||||
enabled?: boolean;
|
||||
event_type?: string;
|
||||
channel?: string;
|
||||
channel_configuration?: { allowed_mentions?: string[] };
|
||||
message_template?: string | null;
|
||||
delivery_url?: string;
|
||||
filters?: TemplateNotificationFilter[];
|
||||
};
|
||||
|
||||
export type TemplateStatusTransition = {
|
||||
status_key: string;
|
||||
target_keys: string[];
|
||||
};
|
||||
export type TemplateConfiguration = {
|
||||
project_settings: { show_author: boolean; show_story_points: boolean };
|
||||
board_columns: TemplateConfigurationItem[];
|
||||
ticket_statuses: TemplateConfigurationItem[];
|
||||
status_transitions: TemplateStatusTransition[];
|
||||
ticket_types: TemplateConfigurationItem[];
|
||||
ticket_priorities: TemplateConfigurationItem[];
|
||||
ticket_fields: TemplateConfigurationItem[];
|
||||
ticket_link_types: TemplateConfigurationItem[];
|
||||
repository_connections: TemplateConfigurationItem[];
|
||||
notification_actions: TemplateConfigurationItem[];
|
||||
};
|
||||
export type TemplateListArea = Exclude<
|
||||
keyof TemplateConfiguration,
|
||||
'project_settings' | 'status_transitions'
|
||||
>;
|
||||
export type TemplateTextField = {
|
||||
key:
|
||||
| 'name'
|
||||
| 'label'
|
||||
| 'counterpart_label'
|
||||
| 'base_url'
|
||||
| 'provider_repository_id'
|
||||
| 'owner_name'
|
||||
| 'repository_name'
|
||||
| 'repository_full_name'
|
||||
| 'repository_url'
|
||||
| 'delivery_url';
|
||||
label: string;
|
||||
};
|
||||
export type TemplateConfigurationEditorProps = {
|
||||
areas: string[];
|
||||
options: ProjectDuplicateCopyAreaOption[];
|
||||
errors: Record<string, string | undefined>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export type TemplateResetForm = {
|
||||
areas: string[];
|
||||
status_replacements: Record<string, number | string | undefined>;
|
||||
};
|
||||
Reference in New Issue
Block a user