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