Add notification filters

This commit is contained in:
2026-04-24 20:02:22 +00:00
parent 5f74ddd964
commit c54df44ebe
24 changed files with 1288 additions and 7 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { reactive } from 'vue';
import { computed, reactive } from 'vue';
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
@@ -28,18 +28,45 @@ import type {
BreadcrumbItem,
ProjectNotificationSettingsPageProps,
} from '@/types';
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
import type { NotificationActionModel } from '@/types/models/notification-action-model';
type NotificationFilterFormState = {
attribute: string;
id: string;
operator: string;
value: string;
};
type NotificationFilterPayload = {
attribute: string;
operator: string;
value: string | string[] | null;
};
const props = defineProps<ProjectNotificationSettingsPageProps>();
const defaultCreateChannel =
props.channelOptions.find((option) => option.value === 'webhook')?.value ??
props.channelOptions[0]?.value ??
'';
const defaultFilterAttribute = props.filterAttributeOptions[0]?.value ?? '';
const defaultFilterOperator =
props.filterOperatorOptions.find((option) => option.value === 'equals')
?.value ??
props.filterOperatorOptions[0]?.value ??
'';
const createChannelState = reactive<{ value: string }>({
value: defaultCreateChannel,
});
const createFilterState = reactive<{ filters: NotificationFilterFormState[] }>({
filters: [],
});
const editChannelState = reactive<Record<number, string>>({});
const editFilterState = reactive<Record<number, NotificationFilterFormState[]>>(
{},
);
const timestampFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
@@ -60,8 +87,106 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
let filterStateCounter = 0;
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
const parseInFilterValues = (value: string): string[] =>
value
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const supportsFilterValue = (operator: string): boolean =>
!['exists', 'not_exists'].includes(operator);
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
const createFilter = (): NotificationFilterFormState => ({
attribute: defaultFilterAttribute,
id: nextFilterStateId(),
operator: defaultFilterOperator,
value: '',
});
const toFilterFormState = (
filter: NotificationActionFilterModel,
): NotificationFilterFormState => ({
attribute: filter.attribute,
id: nextFilterStateId(),
operator: filter.operator,
value: Array.isArray(filter.value)
? filter.value.join(', ')
: (filter.value ?? ''),
});
const serializeFilters = (
filters: NotificationFilterFormState[],
): NotificationFilterPayload[] =>
filters.map((filter) => ({
attribute: filter.attribute,
operator: filter.operator,
value: supportsArrayFilterValue(filter.operator)
? parseInFilterValues(filter.value)
: supportsFilterValue(filter.operator)
? filter.value.trim()
: null,
}));
const notificationFilterError = (
errors: Record<string, string>,
): string | undefined => {
if (errors.filters) {
return errors.filters;
}
const nestedErrorKey = Object.keys(errors).find((key) =>
key.startsWith('filters.'),
);
return nestedErrorKey === undefined ? undefined : errors[nestedErrorKey];
};
const addCreateFilter = (): void => {
createFilterState.filters.push(createFilter());
};
const removeCreateFilter = (index: number): void => {
createFilterState.filters.splice(index, 1);
};
const resetCreateFilters = (): void => {
createFilterState.filters = [];
};
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
editFilterState[notificationActionId] ?? [];
const addEditFilter = (notificationActionId: number): void => {
editFilterState[notificationActionId] = [
...editFilters(notificationActionId),
createFilter(),
];
};
const removeEditFilter = (notificationActionId: number, index: number): void => {
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
(_, candidateIndex) => candidateIndex !== index,
);
};
const createSerializedFilters = computed<string>(() =>
JSON.stringify(serializeFilters(createFilterState.filters)),
);
const editSerializedFilters = (notificationActionId: number): string =>
JSON.stringify(serializeFilters(editFilters(notificationActionId)));
props.notificationActions.forEach((notificationAction) => {
editChannelState[notificationAction.id] = notificationAction.channel;
editFilterState[notificationAction.id] = (
notificationAction.filters ?? []
).map(toFilterFormState);
});
const selectedChannel = (notificationAction: NotificationActionModel): string =>
@@ -167,7 +292,8 @@ const notificationActionTimestamp = (
reset-on-success
@success="
createChannelState.value =
defaultCreateChannel
defaultCreateChannel;
resetCreateFilters();
"
class="space-y-4"
v-slot="{ errors, processing }"
@@ -285,6 +411,148 @@ const notificationActionTimestamp = (
/>
</div>
<div class="space-y-3 rounded-lg border p-4">
<div class="space-y-1">
<Label>Notification filters</Label>
<p
class="text-muted-foreground text-sm"
>
Optional rules that must match the
ticket payload before this
notification is dispatched.
</p>
</div>
<input
type="hidden"
name="filters"
:value="createSerializedFilters"
/>
<div
v-if="createFilterState.filters.length === 0"
class="text-muted-foreground text-sm"
>
No filters configured. This action runs
for every matching event.
</div>
<div
v-for="(
filter,
filterIndex
) in createFilterState.filters"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
>
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<Label
:for="`create-filter-attribute-${filter.id}`"
>
Attribute
</Label>
<select
:id="`create-filter-attribute-${filter.id}`"
v-model="filter.attribute"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
>
{{
filterAttributeOption.label
}}
</option>
</select>
</div>
<div class="grid gap-2">
<Label
:for="`create-filter-operator-${filter.id}`"
>
Operator
</Label>
<select
:id="`create-filter-operator-${filter.id}`"
v-model="filter.operator"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
>
{{
filterOperatorOption.label
}}
</option>
</select>
</div>
</div>
<div
v-if="supportsFilterValue(filter.operator)"
class="grid gap-2"
>
<Label
:for="`create-filter-value-${filter.id}`"
>
{{
supportsArrayFilterValue(
filter.operator,
)
? 'Values (comma or newline separated)'
: 'Value'
}}
</Label>
<textarea
:id="`create-filter-value-${filter.id}`"
v-model="filter.value"
rows="2"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
:placeholder="
supportsArrayFilterValue(
filter.operator,
)
? 'CORE-1, CORE-2'
: 'Doing'
"
/>
</div>
<div class="flex justify-end">
<Button
type="button"
variant="outline"
@click="
removeCreateFilter(
filterIndex,
)
"
>
Remove filter
</Button>
</div>
</div>
<div class="flex justify-start">
<Button
type="button"
variant="outline"
@click="addCreateFilter"
>
Add filter
</Button>
</div>
<InputError
:message="notificationFilterError(errors)"
/>
</div>
<div class="grid gap-2">
<Label
for="create-notification-enabled"
@@ -573,6 +841,159 @@ const notificationActionTimestamp = (
/>
</div>
<div class="space-y-3 rounded-lg border p-4">
<div class="space-y-1">
<Label>Notification filters</Label>
<p
class="text-muted-foreground text-sm"
>
Optional rules that must all match
this event payload before delivery.
</p>
</div>
<input
type="hidden"
name="filters"
:value="
editSerializedFilters(
notificationAction.id,
)
"
/>
<div
v-if="
editFilters(notificationAction.id)
.length === 0
"
class="text-muted-foreground text-sm"
>
No filters configured. This action runs
for every matching event.
</div>
<div
v-for="(
filter,
filterIndex
) in editFilters(notificationAction.id)"
:key="filter.id"
class="grid gap-3 rounded-md border p-3"
>
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<Label
:for="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
>
Attribute
</Label>
<select
:id="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
v-model="filter.attribute"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterAttributeOption in filterAttributeOptions"
:key="filterAttributeOption.value"
:value="filterAttributeOption.value"
>
{{
filterAttributeOption.label
}}
</option>
</select>
</div>
<div class="grid gap-2">
<Label
:for="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
>
Operator
</Label>
<select
:id="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
v-model="filter.operator"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="filterOperatorOption in filterOperatorOptions"
:key="filterOperatorOption.value"
:value="filterOperatorOption.value"
>
{{
filterOperatorOption.label
}}
</option>
</select>
</div>
</div>
<div
v-if="supportsFilterValue(filter.operator)"
class="grid gap-2"
>
<Label
:for="`notification-filter-value-${notificationAction.id}-${filter.id}`"
>
{{
supportsArrayFilterValue(
filter.operator,
)
? 'Values (comma or newline separated)'
: 'Value'
}}
</Label>
<textarea
:id="`notification-filter-value-${notificationAction.id}-${filter.id}`"
v-model="filter.value"
rows="2"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
:placeholder="
supportsArrayFilterValue(
filter.operator,
)
? 'CORE-1, CORE-2'
: 'Doing'
"
/>
</div>
<div class="flex justify-end">
<Button
type="button"
variant="outline"
@click="
removeEditFilter(
notificationAction.id,
filterIndex,
)
"
>
Remove filter
</Button>
</div>
</div>
<div class="flex justify-start">
<Button
type="button"
variant="outline"
@click="
addEditFilter(
notificationAction.id,
)
"
>
Add filter
</Button>
</div>
<InputError
:message="notificationFilterError(errors)"
/>
</div>
<div
class="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between"
>