fix(KAN-14): preserve template inheritance and project data
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user