Add notification filters
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user