feat(KAN-45): rebuild project notification settings
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { notificationLabel } from '@/lib/notification-labels';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
serializeNotificationFilters,
|
||||
supportsFilterValue,
|
||||
} from '@/lib/notification-filters';
|
||||
import type { ProjectNotificationSettingsPageProps } from '@/types';
|
||||
import type {
|
||||
NotificationEditorTarget,
|
||||
NotificationFilterFormState,
|
||||
} from '@/types/project-notifications';
|
||||
|
||||
const props = defineProps<{
|
||||
target: NotificationEditorTarget | null;
|
||||
settings: ProjectNotificationSettingsPageProps;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
name: '',
|
||||
event_type: '',
|
||||
channel: '',
|
||||
enabled: true,
|
||||
delivery_url: '',
|
||||
message_template: '',
|
||||
allowed_mentions: [] as string[],
|
||||
filters: [] as NotificationFilterFormState[],
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() =>
|
||||
t(
|
||||
deleting.value
|
||||
? 'notifications.delete'
|
||||
: props.target?.action === 'edit'
|
||||
? 'notifications.edit'
|
||||
: 'notifications.create',
|
||||
),
|
||||
);
|
||||
const inputClass =
|
||||
'border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2';
|
||||
let nextFilterId = 0;
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const entry = target.entry;
|
||||
const mentions = entry?.channel_configuration.allowed_mentions;
|
||||
form.defaults({
|
||||
name: entry?.name ?? '',
|
||||
event_type:
|
||||
entry?.event_type ??
|
||||
props.settings.eventTypeOptions[0]?.value ??
|
||||
'',
|
||||
channel:
|
||||
entry?.channel ??
|
||||
props.settings.channelOptions.find(
|
||||
(option) => option.value === 'webhook',
|
||||
)?.value ??
|
||||
props.settings.channelOptions[0]?.value ??
|
||||
'',
|
||||
enabled: entry?.enabled ?? true,
|
||||
delivery_url: '',
|
||||
message_template: entry?.message_template ?? '',
|
||||
allowed_mentions: Array.isArray(mentions)
|
||||
? mentions.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
)
|
||||
: [],
|
||||
filters: (entry?.filters ?? []).map((filter) => ({
|
||||
id: nextFilterId++,
|
||||
attribute: filter.attribute,
|
||||
operator: filter.operator,
|
||||
value: Array.isArray(filter.value)
|
||||
? filter.value.join(', ')
|
||||
: (filter.value ?? ''),
|
||||
})),
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function addFilter(): void {
|
||||
form.filters.push({
|
||||
id: nextFilterId++,
|
||||
attribute: props.settings.filterAttributeOptions[0]?.value ?? '',
|
||||
operator: 'equals',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
function removeFilter(index: number): void {
|
||||
form.filters.splice(index, 1);
|
||||
form.clearErrors(
|
||||
...(Object.keys(form.errors).filter((key) =>
|
||||
key.startsWith('filters'),
|
||||
) as (keyof typeof form.errors)[]),
|
||||
);
|
||||
}
|
||||
function errorFor(prefix: string): string | undefined {
|
||||
return Object.entries(form.errors).find(
|
||||
([key]) => key === prefix || key.startsWith(`${prefix}.`),
|
||||
)?.[1];
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (!target || form.processing) {
|
||||
return;
|
||||
}
|
||||
const project = props.settings.project.id;
|
||||
const route =
|
||||
target.action === 'create'
|
||||
? store(project)
|
||||
: target.action === 'edit'
|
||||
? update([project, target.entry.id])
|
||||
: destroy([project, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
deleting.value
|
||||
? {}
|
||||
: {
|
||||
name: data.name,
|
||||
event_type: data.event_type,
|
||||
channel: data.channel,
|
||||
enabled: data.enabled,
|
||||
delivery_url: data.delivery_url || null,
|
||||
channel_configuration:
|
||||
data.channel === 'discord'
|
||||
? { allowed_mentions: data.allowed_mentions }
|
||||
: {},
|
||||
message_template:
|
||||
data.channel === 'discord' ? data.message_template : null,
|
||||
filters: serializeNotificationFilters(data.filters),
|
||||
},
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
class="sm:max-w-2xl"
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
deleting
|
||||
? t('notifications.deleteHelp', {
|
||||
name: target?.entry?.name ?? '',
|
||||
})
|
||||
: t('notifications.editorHelp')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-5"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-5"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="notification-name">{{
|
||||
t('notifications.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="notification-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="notification-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="notification-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="notification-event">{{
|
||||
t('notifications.event')
|
||||
}}</Label>
|
||||
<select
|
||||
id="notification-event"
|
||||
v-model="form.event_type"
|
||||
name="event_type"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.event_type"
|
||||
aria-describedby="notification-event-error"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.eventTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'event',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
id="notification-event-error"
|
||||
:message="form.errors.event_type"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="notification-channel">{{
|
||||
t('notifications.channel')
|
||||
}}</Label>
|
||||
<select
|
||||
id="notification-channel"
|
||||
v-model="form.channel"
|
||||
name="channel"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.channel"
|
||||
aria-describedby="notification-channel-error"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.channelOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'channel',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
id="notification-channel-error"
|
||||
:message="form.errors.channel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-4"
|
||||
aria-labelledby="notification-delivery-title"
|
||||
>
|
||||
<h3
|
||||
id="notification-delivery-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ t('notifications.delivery') }}
|
||||
</h3>
|
||||
<p v-if="target?.action === 'edit'" class="text-sm">
|
||||
{{
|
||||
t(
|
||||
target.entry.has_delivery_url
|
||||
? 'notifications.keepUrl'
|
||||
: 'notifications.urlMissing',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Label for="notification-url">{{
|
||||
t(
|
||||
target?.action === 'edit'
|
||||
? 'notifications.replaceUrl'
|
||||
: 'notifications.url',
|
||||
)
|
||||
}}</Label>
|
||||
<Input
|
||||
id="notification-url"
|
||||
v-model="form.delivery_url"
|
||||
name="delivery_url"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:spellcheck="false"
|
||||
:required="target?.action === 'create'"
|
||||
maxlength="2048"
|
||||
:aria-invalid="!!form.errors.delivery_url"
|
||||
aria-describedby="notification-url-help notification-url-error"
|
||||
/>
|
||||
<p
|
||||
id="notification-url-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.secretHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-url-error"
|
||||
:message="form.errors.delivery_url"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-if="form.channel === 'discord'"
|
||||
class="grid min-w-0 gap-4"
|
||||
aria-labelledby="notification-message-title"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
id="notification-message-title"
|
||||
for="notification-message"
|
||||
>{{ t('notifications.message') }}</Label
|
||||
>
|
||||
<textarea
|
||||
id="notification-message"
|
||||
v-model="form.message_template"
|
||||
name="message_template"
|
||||
rows="4"
|
||||
required
|
||||
maxlength="2000"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.message_template"
|
||||
aria-describedby="notification-message-help notification-message-error"
|
||||
/>
|
||||
<p
|
||||
id="notification-message-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.messageHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-message-error"
|
||||
:message="form.errors.message_template"
|
||||
/>
|
||||
</div>
|
||||
<details class="rounded-lg border p-3">
|
||||
<summary
|
||||
class="focus-visible:ring-ring cursor-pointer rounded font-medium focus-visible:ring-2"
|
||||
>
|
||||
{{ t('notifications.variables') }}
|
||||
</summary>
|
||||
<dl class="mt-3 grid min-w-0 gap-3 text-sm">
|
||||
<div
|
||||
v-for="variable in settings.templateVariables"
|
||||
:key="variable.value"
|
||||
class="grid min-w-0 gap-1"
|
||||
>
|
||||
<dt class="break-all font-mono">
|
||||
{{ variable.value }}
|
||||
</dt>
|
||||
<dd class="text-muted-foreground">
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'variable',
|
||||
variable.value.slice(1),
|
||||
variable.description,
|
||||
)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
<fieldset
|
||||
class="grid gap-2"
|
||||
aria-describedby="notification-mentions-help notification-mentions-error"
|
||||
>
|
||||
<legend class="mb-2 text-sm font-medium">
|
||||
{{ t('notifications.mentions') }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="option in settings.discordAllowedMentionOptions"
|
||||
:key="option.value"
|
||||
class="flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowed_mentions"
|
||||
type="checkbox"
|
||||
:value="option.value"
|
||||
:aria-invalid="
|
||||
!!errorFor('channel_configuration')
|
||||
"
|
||||
class="accent-primary size-4"
|
||||
/>{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'mention',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p
|
||||
id="notification-mentions-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.mentionsHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-mentions-error"
|
||||
:message="errorFor('channel_configuration')"
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<section
|
||||
class="grid min-w-0 gap-3 border-t pt-4"
|
||||
aria-labelledby="notification-filters-title"
|
||||
>
|
||||
<h3
|
||||
id="notification-filters-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ t('notifications.filters') }}
|
||||
</h3>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('notifications.filtersHelp') }}
|
||||
</p>
|
||||
<p v-if="!form.filters.length" class="text-sm">
|
||||
{{ t('notifications.noFilters') }}
|
||||
</p>
|
||||
<fieldset
|
||||
v-for="(filter, index) in form.filters"
|
||||
:key="filter.id"
|
||||
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<legend class="px-1 text-sm font-medium">
|
||||
{{
|
||||
t('notifications.filterNumber', {
|
||||
number: index + 1,
|
||||
})
|
||||
}}
|
||||
</legend>
|
||||
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label
|
||||
:for="`filter-attribute-${filter.id}`"
|
||||
>{{
|
||||
t('notifications.attribute')
|
||||
}}</Label
|
||||
>
|
||||
<select
|
||||
:id="`filter-attribute-${filter.id}`"
|
||||
v-model="filter.attribute"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.filterAttributeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'attribute',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label
|
||||
:for="`filter-operator-${filter.id}`"
|
||||
>{{
|
||||
t('notifications.operator')
|
||||
}}</Label
|
||||
>
|
||||
<select
|
||||
:id="`filter-operator-${filter.id}`"
|
||||
v-model="filter.operator"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.filterOperatorOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'operator',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label :for="`filter-value-${filter.id}`">{{
|
||||
t(
|
||||
filter.operator === 'in'
|
||||
? 'notifications.values'
|
||||
: 'notifications.value',
|
||||
)
|
||||
}}</Label>
|
||||
<textarea
|
||||
:id="`filter-value-${filter.id}`"
|
||||
v-model="filter.value"
|
||||
rows="2"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
/>
|
||||
</div>
|
||||
<InputError
|
||||
:id="`filter-error-${filter.id}`"
|
||||
:message="errorFor(`filters.${index}`)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-end"
|
||||
:aria-label="
|
||||
t('notifications.removeFilterNumber', {
|
||||
number: index + 1,
|
||||
})
|
||||
"
|
||||
@click="removeFilter(index)"
|
||||
>{{ t('notifications.removeFilter') }}</Button
|
||||
>
|
||||
</fieldset>
|
||||
<InputError :message="form.errors.filters" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="addFilter"
|
||||
>{{ t('notifications.addFilter') }}</Button
|
||||
>
|
||||
</section>
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label
|
||||
for="notification-enabled"
|
||||
class="flex min-h-11 items-center gap-3"
|
||||
><Checkbox
|
||||
id="notification-enabled"
|
||||
v-model="form.enabled"
|
||||
:invalid="!!form.errors.enabled"
|
||||
aria-describedby="notification-enabled-help notification-enabled-error"
|
||||
/>{{ t('notifications.enabled') }}</Label
|
||||
>
|
||||
<p
|
||||
id="notification-enabled-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.enabledHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-enabled-error"
|
||||
:message="form.errors.enabled"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<InputError v-else :message="errorFor('notificationAction')" />
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
>{{
|
||||
deleting
|
||||
? t('notifications.confirmDelete')
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||
import type { NotificationFilterFormState } from '@/types/project-notifications';
|
||||
|
||||
export const supportsFilterValue = (operator: string): boolean =>
|
||||
!['exists', 'not_exists'].includes(operator);
|
||||
|
||||
export function serializeNotificationFilters(
|
||||
filters: NotificationFilterFormState[],
|
||||
): NotificationActionFilterModel[] {
|
||||
return filters.map(({ attribute, operator, value }) => ({
|
||||
attribute,
|
||||
operator,
|
||||
value:
|
||||
operator === 'in'
|
||||
? value
|
||||
.split(/[\n,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: supportsFilterValue(operator)
|
||||
? value.trim()
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Localization } from '@/lib/localization';
|
||||
import { en } from '@/locales/en';
|
||||
import type { TranslationKey } from '@/types/localization';
|
||||
|
||||
export function notificationLabel(
|
||||
t: Localization['t'],
|
||||
group: string,
|
||||
value: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const key = `notifications.${group}.${value}`;
|
||||
return Object.hasOwn(en, key) ? t(key as TranslationKey) : fallback;
|
||||
}
|
||||
@@ -1,4 +1,113 @@
|
||||
export const en = {
|
||||
'notifications.title': 'Notifications',
|
||||
'notifications.pageTitle': '{project} · Notifications',
|
||||
'notifications.description':
|
||||
'Send ticket updates to your team with webhook and Discord actions.',
|
||||
'notifications.saved': 'Notification settings saved.',
|
||||
'notifications.actions': 'Notification actions',
|
||||
'notifications.create': 'Add notification',
|
||||
'notifications.edit': 'Edit notification',
|
||||
'notifications.delete': 'Delete notification?',
|
||||
'notifications.editNamed': 'Edit {name}',
|
||||
'notifications.deleteNamed': 'Delete {name}',
|
||||
'notifications.empty': 'No notifications yet',
|
||||
'notifications.emptyHelp':
|
||||
'Add an action to send updates when a ticket event matches your rules.',
|
||||
'notifications.enabled': 'Enabled',
|
||||
'notifications.disabled': 'Disabled',
|
||||
'notifications.delivery': 'Delivery',
|
||||
'notifications.urlSaved': 'Secret URL saved',
|
||||
'notifications.urlMissing': 'No delivery URL saved',
|
||||
'notifications.filterCount': {
|
||||
one: '{count} filter · all must match',
|
||||
other: '{count} filters · all must match',
|
||||
},
|
||||
'notifications.noFiltersShort': 'All matching events',
|
||||
'notifications.noChanges': 'No saved changes yet',
|
||||
'notifications.createdAt': 'Created {date}',
|
||||
'notifications.updatedAt': 'Updated {date}',
|
||||
'notifications.deleteHelp':
|
||||
'Delete “{name}”? This action will stop sending notifications. This cannot be undone.',
|
||||
'notifications.editorHelp':
|
||||
'Choose a ticket event, a delivery channel and optional filters.',
|
||||
'notifications.name': 'Name',
|
||||
'notifications.event': 'Trigger',
|
||||
'notifications.channel': 'Channel',
|
||||
'notifications.keepUrl':
|
||||
'A secret URL is saved. Leave the field blank to keep it.',
|
||||
'notifications.replaceUrl': 'Replace delivery URL (optional)',
|
||||
'notifications.url': 'Delivery URL',
|
||||
'notifications.secretHelp':
|
||||
'URLs are stored encrypted and never shown again after saving. Enter the complete webhook URL.',
|
||||
'notifications.message': 'Discord message template',
|
||||
'notifications.messageHelp':
|
||||
'Use the variables below to include ticket details. Maximum 2,000 characters.',
|
||||
'notifications.variables': 'Available template variables',
|
||||
'notifications.mentions': 'Allowed mentions',
|
||||
'notifications.mentionsHelp':
|
||||
'Leave all unchecked to disable mentions. Only the selected mention types may notify people.',
|
||||
'notifications.filters': 'Filters',
|
||||
'notifications.filtersHelp':
|
||||
'All filters must match before this action sends a notification.',
|
||||
'notifications.noFilters':
|
||||
'No filters. This action runs for every matching event.',
|
||||
'notifications.filterNumber': 'Filter {number}',
|
||||
'notifications.attribute': 'Attribute',
|
||||
'notifications.operator': 'Operator',
|
||||
'notifications.value': 'Value',
|
||||
'notifications.values': 'Values (comma or newline separated)',
|
||||
'notifications.removeFilter': 'Remove filter',
|
||||
'notifications.removeFilterNumber': 'Remove filter {number}',
|
||||
'notifications.addFilter': 'Add filter',
|
||||
'notifications.enabledHelp':
|
||||
'Turn off this action to pause delivery while keeping its configuration.',
|
||||
'notifications.confirmDelete': 'Delete notification',
|
||||
'notifications.channel.discord': 'Discord',
|
||||
'notifications.channel.webhook': 'Webhook',
|
||||
'notifications.event.ticket_status_changed': 'Ticket status changed',
|
||||
'notifications.event.ticket_create': 'Ticket create',
|
||||
'notifications.event.ticket_assignee_changed': 'Assignee changed',
|
||||
'notifications.attribute.project.id': 'Project ID',
|
||||
'notifications.attribute.project.key': 'Project key',
|
||||
'notifications.attribute.project.name': 'Project name',
|
||||
'notifications.attribute.ticket.id': 'Ticket ID',
|
||||
'notifications.attribute.ticket.key': 'Ticket key',
|
||||
'notifications.attribute.ticket.title': 'Ticket title',
|
||||
'notifications.attribute.ticket.url': 'Ticket URL',
|
||||
'notifications.attribute.actor.id': 'Actor ID',
|
||||
'notifications.attribute.actor.name': 'Actor name',
|
||||
'notifications.attribute.assignee.from.id': 'From assignee ID',
|
||||
'notifications.attribute.assignee.from.name': 'From assignee name',
|
||||
'notifications.attribute.assignee.to.id': 'To assignee ID',
|
||||
'notifications.attribute.assignee.to.name': 'To assignee name',
|
||||
'notifications.attribute.status.from.id': 'From status ID',
|
||||
'notifications.attribute.status.from.label': 'From status label',
|
||||
'notifications.attribute.status.to.id': 'To status ID',
|
||||
'notifications.attribute.status.to.label': 'To status label',
|
||||
'notifications.operator.equals': 'Equals',
|
||||
'notifications.operator.not_equals': 'Not equals',
|
||||
'notifications.operator.exists': 'Exists',
|
||||
'notifications.operator.not_exists': 'Not exists',
|
||||
'notifications.operator.in': 'In list',
|
||||
'notifications.mention.everyone': '@everyone and @here',
|
||||
'notifications.mention.roles': 'Role mentions (@role)',
|
||||
'notifications.mention.users': 'User mentions (@username)',
|
||||
'notifications.variable.url':
|
||||
'Absolute ticket URL for the notification event.',
|
||||
'notifications.variable.ticket_key': 'Project ticket key such as CORE-42.',
|
||||
'notifications.variable.ticket_title':
|
||||
'Ticket title for the notification event.',
|
||||
'notifications.variable.project_name': 'Project name that owns the ticket.',
|
||||
'notifications.variable.status_from':
|
||||
'Origin status label for status moves; blank for ticket create and assignee changes.',
|
||||
'notifications.variable.status_to':
|
||||
'Destination or initial status label for the ticket event.',
|
||||
'notifications.variable.actor_name':
|
||||
'Actor name when the event has a known user.',
|
||||
'notifications.variable.assignee_from':
|
||||
'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.',
|
||||
'templates.description':
|
||||
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
|
||||
'templates.updated': 'Project templates updated.',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import type { NotificationActionModel } from '@/types/models/notification-action-model';
|
||||
|
||||
export type NotificationEditorTarget =
|
||||
| { action: 'create'; entry?: never }
|
||||
| { action: 'edit' | 'delete'; entry: NotificationActionModel };
|
||||
|
||||
export type NotificationFilterFormState = {
|
||||
id: number;
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
};
|
||||
Reference in New Issue
Block a user