663 lines
28 KiB
Vue
663 lines
28 KiB
Vue
<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>
|