-
-
-
- Create notification
-
- Add a project-scoped notification action for
- supported ticket events.
-
-
-
-
-
-
-
-
-
-
- Template variables
-
- Discord templates can reference the following
- fixed variables.
-
-
-
-
-
-
-
- {{ templateVariable.value }}
-
-
-
- {{ templateVariable.description }}
-
-
-
-
-
-
-
-
-
- No notifications configured
-
- Create your first webhook or Discord action to
- start sending project notifications later in
- this release.
-
-
-
-
-
-
+
+
+ {{ t('notifications.actions') }}
+ ({{ notificationActions.length }})
-
-
-
- {{ notificationAction.name }}
-
-
- {{
- notificationAction.event_type_label
- }}
-
-
- {{ notificationAction.channel_label }}
-
+
+
+ {{
+ t('notifications.create')
+ }}
+
+
+
+
+
+ {{ t('notifications.empty') }}
+
+
+ {{ t('notifications.emptyHelp') }}
+
+
+
+
+
+
+
+ {{ entry.name }}
+
+
{{
+ t(
+ entry.enabled
+ ? 'notifications.enabled'
+ : 'notifications.disabled',
+ )
+ }}
+ {{
+ notificationLabel(
+ t,
+ 'channel',
+ entry.channel,
+ entry.channel_label,
+ )
+ }}
+ {{
+ notificationLabel(
+ t,
+ 'event',
+ entry.event_type,
+ entry.event_type_label,
+ )
+ }}
- {{
- notificationAction.enabled
- ? 'Enabled'
- : 'Disabled'
- }}
-
-
- {{
- notificationActionTimestamp(
- notificationAction,
+
+
+
+
+
+
+ {{ t('notifications.delivery') }}
+
+
+ {{
+ t(
+ entry.has_delivery_url
+ ? 'notifications.urlSaved'
+ : 'notifications.urlMissing',
)
}}
-
+
-
-
-
-
-
-
-
- Name
-
-
-
-
-
-
-
- Delivery URL
-
-
-
- {{
- notificationAction.has_delivery_url
- ? 'A delivery URL is already stored. Leaving this blank keeps the existing secret.'
- : 'No delivery URL is stored yet. Enter a secret URL to enable delivery later.'
- }}
-
-
-
-
-
-
- Event type
-
-
-
- {{ eventTypeOption.label }}
-
-
-
-
-
-
-
- Channel
-
-
-
- {{ channelOption.label }}
-
-
-
-
-
-
-
-
Allowed mentions
-
-
-
-
- {{ allowedMentionOption.label }}
-
-
-
-
- {{
- selectedChannel(
- notificationAction,
- ) === 'discord'
- ? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.'
- : 'Allowed mentions are only used when the channel is Discord.'
- }}
-
-
-
-
-
-
- Discord message template
-
-
-
- {{
- selectedChannel(
- notificationAction,
- ) === 'discord'
- ? 'Available for Discord actions only. Use the fixed variables listed in the sidebar.'
- : 'Discord message templates are only used when the channel is Discord.'
- }}
-
-
-
-
-
-
-
Notification filters
-
- Optional rules that must all match
- this event payload before delivery.
-
-
-
-
-
-
- No filters configured. This action runs
- for every matching event.
-
-
-
-
-
-
- Attribute
-
-
-
- {{
- filterAttributeOption.label
- }}
-
-
-
-
-
-
- Operator
-
-
-
- {{
- filterOperatorOption.label
- }}
-
-
-
-
-
-
-
- {{
- supportsArrayFilterValue(
- filter.operator,
- )
- ? 'Values (comma or newline separated)'
- : 'Value'
- }}
-
-
-
-
-
-
- Remove filter
-
-
-
-
-
-
- Add filter
-
-
-
-
-
-
-
-
-
-
-
- Enabled
-
-
-
-
-
- Save changes
-
-
-
-
-
-
- Delete notification
-
-
-
-
-
-
-
+
+
+ {{ t('notifications.filters') }}
+
+
+ {{
+ entry.filters.length
+ ? t('notifications.filterCount', {
+ count: entry.filters.length,
+ })
+ : t('notifications.noFiltersShort')
+ }}
+
+
+
+
+ {{ timestamp(entry) }}
+
+
+
+
+
+
diff --git a/resources/js/types/project-notifications.ts b/resources/js/types/project-notifications.ts
new file mode 100644
index 0000000..6039b00
--- /dev/null
+++ b/resources/js/types/project-notifications.ts
@@ -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;
+};
diff --git a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php
index f48b384..929524a 100644
--- a/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php
+++ b/tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php
@@ -430,4 +430,66 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->missing('notificationActions.0.delivery_url'),
);
}
+
+ public function test_editor_accepts_array_filters_and_can_clear_discord_mentions(): void
+ {
+ $user = User::factory()->create();
+ $project = Project::factory()->create();
+ $notificationAction = NotificationAction::factory()->discord()->for($project)->create([
+ 'channel_configuration' => ['allowed_mentions' => ['users']],
+ 'delivery_url' => 'https://discord.com/api/webhooks/123456/original-secret',
+ ]);
+ $this->syncProjectPermissions($project, $user, ['edit']);
+
+ $this->actingAs($user)
+ ->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
+ 'name' => 'Paused alerts',
+ 'event_type' => NotificationEventType::TicketAssigneeChanged->value,
+ 'channel' => NotificationChannel::Discord->value,
+ 'enabled' => false,
+ 'delivery_url' => null,
+ 'message_template' => '$ticket_key: $url',
+ 'channel_configuration' => ['allowed_mentions' => []],
+ 'filters' => [
+ ['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
+ ['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
+ ],
+ ])
+ ->assertSessionHasNoErrors()
+ ->assertRedirect(route('projects.settings.notifications', $project));
+
+ $notificationAction->refresh();
+ $this->assertFalse($notificationAction->enabled);
+ $this->assertSame(['allowed_mentions' => []], $notificationAction->channel_configuration);
+ $this->assertSame('https://discord.com/api/webhooks/123456/original-secret', $notificationAction->delivery_url);
+ $this->assertSame([
+ ['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
+ ['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
+ ], $notificationAction->filters);
+ }
+
+ public function test_invalid_editor_update_does_not_change_saved_configuration(): void
+ {
+ $user = User::factory()->create();
+ $project = Project::factory()->create();
+ $notificationAction = NotificationAction::factory()->discord()->for($project)->create();
+ $original = $notificationAction->fresh()->getRawOriginal();
+ $this->syncProjectPermissions($project, $user, ['edit']);
+
+ $this->actingAs($user)
+ ->from(route('projects.settings.notifications', $project))
+ ->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
+ 'name' => 'Invalid update',
+ 'event_type' => NotificationEventType::TicketCreate->value,
+ 'channel' => NotificationChannel::Discord->value,
+ 'enabled' => false,
+ 'delivery_url' => 'invalid-url',
+ 'message_template' => '',
+ 'channel_configuration' => ['allowed_mentions' => ['unsupported']],
+ 'filters' => [['attribute' => 'ticket.key', 'operator' => 'in', 'value' => []]],
+ ])
+ ->assertSessionHasErrors(['delivery_url', 'message_template', 'channel_configuration.allowed_mentions.0', 'filters.0.value']);
+
+ $this->assertSame($original, $notificationAction->fresh()->getRawOriginal());
+ }
}
diff --git a/tests/Feature/Projects/ProjectNotificationSettingsUiTest.php b/tests/Feature/Projects/ProjectNotificationSettingsUiTest.php
new file mode 100644
index 0000000..577429d
--- /dev/null
+++ b/tests/Feature/Projects/ProjectNotificationSettingsUiTest.php
@@ -0,0 +1,19 @@
+setTimeout(60);
+ $process->run();
+
+ $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
+ $this->assertStringContainsString('Notification settings checks passed.', $process->getOutput());
+ }
+}
diff --git a/tests/Fixtures/project-notification-settings-browser.mjs b/tests/Fixtures/project-notification-settings-browser.mjs
new file mode 100644
index 0000000..2da0715
--- /dev/null
+++ b/tests/Fixtures/project-notification-settings-browser.mjs
@@ -0,0 +1,310 @@
+import assert from 'node:assert/strict';
+import process from 'node:process';
+const { chromium } = await import(
+ process.env.PLAYWRIGHT_MODULE ?? 'playwright'
+);
+const browser = await chromium.launch({ channel: 'chrome', headless: true });
+const base = 'http://127.0.0.1:4195';
+const context = await browser.newContext({
+ viewport: { width: 1440, height: 1000 },
+});
+const page = await context.newPage();
+page.setDefaultTimeout(10000);
+const errors = [];
+page.on('pageerror', (error) => errors.push(error.message));
+const requests = [];
+page.on('request', (request) => {
+ if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
+ requests.push(request);
+ }
+});
+const button = (name) => page.getByRole('button', { name, exact: true });
+const dialog = () => page.getByRole('dialog');
+const result = (value) =>
+ context.addCookies([
+ { name: 'notification_test_result', value, url: base },
+ ]);
+const visit = async (query = '?reset') => {
+ await result('success');
+ await page.goto(base + '/projects/1/settings/notifications' + query);
+ await page
+ .getByRole('heading', { name: 'Notifications', exact: true })
+ .waitFor();
+};
+const closed = () => dialog().waitFor({ state: 'hidden' });
+const state = (value) =>
+ dialog().locator(`[data-form-state="${value}"]`).waitFor();
+const noOverflow = async () =>
+ assert.equal(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ true,
+ );
+const submit = async (label, method, path, expected) => {
+ const sent = page.waitForRequest((request) => request.method() === method);
+ await dialog().getByRole('button', { name: label, exact: true }).click();
+ const request = await sent;
+ assert.equal(new URL(request.url()).pathname, path);
+ assert.deepEqual(request.postDataJSON(), expected);
+};
+try {
+ for (const width of [1440, 768, 390, 320]) {
+ await page.setViewportSize({ width, height: 1000 });
+ for (const theme of ['light', 'dark']) {
+ await page.emulateMedia({ colorScheme: theme });
+ await visit();
+ await noOverflow();
+ const trigger = button('Edit Release alerts');
+ await trigger.focus();
+ await page.keyboard.press('Enter');
+ await dialog().waitFor();
+ assert.equal(
+ await page.getByLabel('Name', { exact: true }).inputValue(),
+ 'Release alerts',
+ );
+ const secret = page.getByLabel('Replace delivery URL (optional)');
+ assert.equal(await secret.inputValue(), '');
+ assert.equal(await secret.getAttribute('type'), 'password');
+ assert.equal(
+ await page
+ .getByLabel('Values (comma or newline separated)')
+ .inputValue(),
+ 'CORE-1, CORE-2',
+ );
+ assert.equal(
+ await page
+ .getByLabel('User mentions (@username)', { exact: true })
+ .isChecked(),
+ true,
+ );
+ await page
+ .getByText('Available template variables', { exact: true })
+ .click();
+ await dialog().getByText('$url', { exact: true }).waitFor();
+ await noOverflow();
+ assert.equal(
+ await dialog().evaluate(
+ (el) => el.scrollWidth <= el.clientWidth + 1,
+ ),
+ true,
+ );
+ if (
+ process.env.NOTIFICATION_SCREENSHOTS &&
+ [1440, 390].includes(width)
+ ) {
+ await page.screenshot({
+ path: `${process.env.NOTIFICATION_SCREENSHOTS}/kan45-${width}-${theme}.png`,
+ });
+ }
+ await page.keyboard.press('Escape');
+ await closed();
+ assert.equal(
+ await trigger.evaluate((el) => el === document.activeElement),
+ true,
+ );
+ }
+ }
+ await page.setViewportSize({ width: 1280, height: 1000 });
+ await visit();
+ await button('Add notification').click();
+ assert.equal(
+ await page.getByLabel('Channel', { exact: true }).inputValue(),
+ 'webhook',
+ );
+ assert.equal(
+ await page
+ .getByLabel('Discord message template', { exact: true })
+ .count(),
+ 0,
+ );
+ await page.getByLabel('Name', { exact: true }).fill('New webhook');
+ await page
+ .getByLabel('Delivery URL', { exact: true })
+ .fill('https://hooks.example.test/new-secret');
+ await page
+ .getByLabel('Trigger', { exact: true })
+ .selectOption('ticket_assignee_changed');
+ await page.getByLabel('Enabled', { exact: true }).uncheck();
+ await button('Add filter').click();
+ await page
+ .getByLabel('Attribute', { exact: true })
+ .selectOption('actor.name');
+ await page.getByLabel('Operator', { exact: true }).selectOption('exists');
+ assert.equal(await page.getByLabel('Value', { exact: true }).count(), 0);
+ const payload = {
+ name: 'New webhook',
+ event_type: 'ticket_assignee_changed',
+ channel: 'webhook',
+ enabled: false,
+ delivery_url: 'https://hooks.example.test/new-secret',
+ channel_configuration: {},
+ message_template: null,
+ filters: [{ attribute: 'actor.name', operator: 'exists', value: null }],
+ };
+ await submit('Save', 'POST', '/projects/1/settings/notifications', payload);
+ await closed();
+ await button('Edit New webhook').waitFor();
+ assert.equal(
+ await page
+ .locator('body')
+ .textContent()
+ .then((text) => text.includes('new-secret')),
+ false,
+ );
+ await button('Edit New webhook').click();
+ assert.equal(
+ await page.getByLabel('Replace delivery URL (optional)').inputValue(),
+ '',
+ );
+ await button('Remove filter 1').click();
+ await page.getByLabel('Channel', { exact: true }).selectOption('discord');
+ await page
+ .getByRole('textbox', { name: 'Discord message template', exact: true })
+ .fill('$ticket_key: $url');
+ await page.getByLabel('Role mentions (@role)', { exact: true }).check();
+ await button('Add filter').click();
+ await page
+ .getByLabel('Attribute', { exact: true })
+ .selectOption('ticket.key');
+ await page.getByLabel('Operator', { exact: true }).selectOption('in');
+ await page
+ .getByLabel('Values (comma or newline separated)')
+ .fill(' CORE-1,\n CORE-3, ');
+ const edited = {
+ ...payload,
+ channel: 'discord',
+ delivery_url: null,
+ channel_configuration: { allowed_mentions: ['roles'] },
+ message_template: '$ticket_key: $url',
+ filters: [
+ {
+ attribute: 'ticket.key',
+ operator: 'in',
+ value: ['CORE-1', 'CORE-3'],
+ },
+ ],
+ };
+ await result('validation');
+ await submit(
+ 'Save',
+ 'PATCH',
+ '/projects/1/settings/notifications/100',
+ edited,
+ );
+ await state('validation');
+ await page.getByText('Enter a valid URL.', { exact: true }).waitFor();
+ await page
+ .getByText('Choose at least one ticket.', { exact: true })
+ .waitFor();
+ await page
+ .getByText('This mention type is not supported.', { exact: true })
+ .waitFor();
+ assert.equal(
+ await page.getByLabel('Name', { exact: true }).inputValue(),
+ 'New webhook',
+ );
+ await result('server');
+ await submit(
+ 'Save',
+ 'PATCH',
+ '/projects/1/settings/notifications/100',
+ edited,
+ );
+ await state('server');
+ await result('forbidden');
+ await submit(
+ 'Try again',
+ 'PATCH',
+ '/projects/1/settings/notifications/100',
+ edited,
+ );
+ await state('server');
+ await result('success');
+ await submit(
+ 'Try again',
+ 'PATCH',
+ '/projects/1/settings/notifications/100',
+ edited,
+ );
+ await closed();
+ await button('Edit New webhook').click();
+ await page.getByLabel('Role mentions (@role)', { exact: true }).uncheck();
+ await button('Remove filter 1').click();
+ await page
+ .getByLabel('Replace delivery URL (optional)')
+ .fill('https://hooks.example.test/replacement');
+ await page.route('**/notifications/100', (route) => route.abort('failed'));
+ await button('Save').click();
+ await state('network');
+ await page.unroute('**/notifications/100');
+ await submit(
+ 'Try again',
+ 'PATCH',
+ '/projects/1/settings/notifications/100',
+ {
+ ...edited,
+ delivery_url: 'https://hooks.example.test/replacement',
+ channel_configuration: { allowed_mentions: [] },
+ filters: [],
+ },
+ );
+ await closed();
+ await button('Edit New webhook').click();
+ await page.getByLabel('Channel', { exact: true }).selectOption('webhook');
+ await submit('Save', 'PATCH', '/projects/1/settings/notifications/100', {
+ ...edited,
+ channel: 'webhook',
+ channel_configuration: {},
+ message_template: null,
+ filters: [],
+ });
+ await closed();
+ // Dismissing a dirty dialog asks before clearing a secret; reopening starts clean.
+ await button('Edit New webhook').click();
+ await page
+ .getByLabel('Replace delivery URL (optional)')
+ .fill('discard-this-secret');
+ page.once('dialog', (prompt) => prompt.dismiss());
+ await button('Cancel').click();
+ assert.equal(await dialog().isVisible(), true);
+ page.once('dialog', (prompt) => prompt.accept());
+ await button('Cancel').click();
+ await closed();
+ await button('Edit Release alerts').click();
+ assert.equal(
+ await page.getByLabel('Replace delivery URL (optional)').inputValue(),
+ '',
+ );
+ assert.equal(
+ await page.getByLabel('Name', { exact: true }).inputValue(),
+ 'Release alerts',
+ );
+ await button('Cancel').click();
+ await closed();
+ const before = requests.length;
+ await button('Delete New webhook').click();
+ await dialog()
+ .getByText(/This cannot be undone/)
+ .waitFor();
+ await button('Cancel').click();
+ await closed();
+ assert.equal(requests.length, before);
+ await button('Delete New webhook').click();
+ await submit(
+ 'Delete notification',
+ 'DELETE',
+ '/projects/1/settings/notifications/100',
+ {},
+ );
+ await closed();
+ assert.equal(await button('Edit New webhook').count(), 0);
+ await visit('?reset&empty');
+ await page.getByRole('heading', { name: 'No notifications yet' }).waitFor();
+ assert.deepEqual(errors, []);
+ console.log(
+ 'Notification browser checks passed: CRUD, filters, channels, secrets, validation, retries, keyboard, responsive layouts and themes.',
+ );
+} finally {
+ await browser.close();
+}
diff --git a/tests/Fixtures/project-notification-settings-preview.mjs b/tests/Fixtures/project-notification-settings-preview.mjs
new file mode 100644
index 0000000..89270a9
--- /dev/null
+++ b/tests/Fixtures/project-notification-settings-preview.mjs
@@ -0,0 +1,19 @@
+import { createApp, h } from 'vue';
+import { createInertiaApp } from '@inertiajs/vue3';
+import '../../resources/css/app.css';
+import Notifications from '../../resources/js/pages/projects/settings/Notifications.vue';
+import { createAppearance } from '../../resources/js/composables/useAppearance';
+import { createLocalization } from '../../resources/js/lib/localization';
+
+createInertiaApp({
+ resolve: (name) =>
+ name === 'Notifications'
+ ? Notifications
+ : { render: () => h('h1', 'Destination reached') },
+ setup: ({ el, App, props, plugin }) =>
+ createApp({ render: () => h(App, props) })
+ .use(plugin)
+ .use(createLocalization())
+ .use(createAppearance())
+ .mount(el),
+});
diff --git a/tests/Fixtures/project-notification-settings.mjs b/tests/Fixtures/project-notification-settings.mjs
new file mode 100644
index 0000000..2ffbd30
--- /dev/null
+++ b/tests/Fixtures/project-notification-settings.mjs
@@ -0,0 +1,363 @@
+import assert from 'node:assert/strict';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+import vue from '@vitejs/plugin-vue';
+import tailwind from '@tailwindcss/vite';
+import { createServer } from 'vite';
+import { createSSRApp, h } from 'vue';
+import { renderToString } from 'vue/server-renderer';
+import { createInertiaApp } from '@inertiajs/vue3';
+
+const serve = process.argv.includes('--serve');
+const server = await createServer({
+ configFile: false,
+ cacheDir: 'node_modules/.vite/project-notification-settings-fixture',
+ plugins: [vue(), ...(serve ? [tailwind()] : [])],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
+ },
+ },
+ server: serve
+ ? { host: '127.0.0.1', port: 4195, strictPort: true, hmr: false }
+ : { middlewareMode: true, hmr: false, watch: null },
+ appType: 'custom',
+});
+const options = (values) => values.map((value) => ({ value, label: value }));
+function initial() {
+ return {
+ project: { id: 1, key: 'CORE', name: 'Core platform' },
+ status: null,
+ templateState: null,
+ channelOptions: options(['discord', 'webhook']),
+ eventTypeOptions: options([
+ 'ticket_status_changed',
+ 'ticket_create',
+ 'ticket_assignee_changed',
+ ]),
+ filterAttributeOptions: options([
+ 'project.id',
+ 'ticket.key',
+ 'status.to.label',
+ 'actor.name',
+ ]),
+ filterOperatorOptions: options([
+ 'equals',
+ 'not_equals',
+ 'exists',
+ 'not_exists',
+ 'in',
+ ]),
+ discordAllowedMentionOptions: options(['everyone', 'roles', 'users']),
+ templateVariables: [
+ { value: '$ticket_key', description: 'Ticket key' },
+ { value: '$url', description: 'Ticket URL' },
+ ],
+ notificationActions: [
+ {
+ id: 10,
+ project_id: 1,
+ name: 'Release alerts',
+ enabled: true,
+ event_type: 'ticket_status_changed',
+ event_type_label: 'Ticket status changed',
+ channel: 'discord',
+ channel_label: 'Discord',
+ channel_configuration: { allowed_mentions: ['users'] },
+ has_delivery_url: true,
+ message_template: '$ticket_key moved to $status_to: $url',
+ filters: [
+ {
+ attribute: 'ticket.key',
+ operator: 'in',
+ value: ['CORE-1', 'CORE-2'],
+ },
+ ],
+ created_at: '2026-09-01T09:00:00Z',
+ updated_at: '2026-09-02T12:00:00Z',
+ },
+ {
+ id: 20,
+ project_id: 1,
+ name: 'Audit webhook',
+ enabled: false,
+ event_type: 'ticket_create',
+ event_type_label: 'Ticket create',
+ channel: 'webhook',
+ channel_label: 'Webhook',
+ channel_configuration: {},
+ has_delivery_url: false,
+ message_template: null,
+ filters: [],
+ created_at: null,
+ updated_at: null,
+ },
+ ],
+ };
+}
+function makePage(
+ extra = {},
+ url = '/projects/1/settings/notifications',
+ component = 'Notifications',
+) {
+ return {
+ component,
+ url,
+ version: null,
+ clearHistory: false,
+ encryptHistory: false,
+ props: {
+ name: 'KanbanGit',
+ errors: {},
+ sidebarOpen: true,
+ appearance: 'system',
+ auth: {
+ user: {
+ id: 1,
+ name: 'Alex Morgan',
+ email: 'alex@example.test',
+ avatar: null,
+ avatar_color: '#10B981',
+ avatar_is_generated: true,
+ },
+ permissions: {
+ globalAdmin: false,
+ projectCreate: false,
+ canManageTeams: false,
+ },
+ },
+ navigation: {
+ project: {
+ id: 1,
+ name: 'Core platform',
+ key: 'CORE',
+ canManage: true,
+ },
+ },
+ ...initial(),
+ ...extra,
+ },
+ };
+}
+if (serve) {
+ let data = initial();
+ let nextId = 100;
+ server.middlewares.use(async (request, response, next) => {
+ if (!request.url.startsWith('/projects')) {
+ return next();
+ }
+ const url = new URL(request.url, 'http://localhost');
+ const mutation = request.method !== 'GET';
+ if (!mutation && url.searchParams.has('reset')) {
+ data = initial();
+ nextId = 100;
+ }
+ let errors = {};
+ if (mutation) {
+ let body = '';
+ for await (const chunk of request) {
+ body += chunk;
+ }
+ const submitted = JSON.parse(body || '{}');
+ const result =
+ request.headers.cookie?.match(
+ /notification_test_result=(\w+)/,
+ )?.[1] ?? 'success';
+ if (result === 'server' || result === 'forbidden') {
+ response.statusCode = result === 'server' ? 500 : 403;
+ response.end('Unavailable');
+ return;
+ }
+ if (result === 'validation') {
+ errors = {
+ delivery_url: 'Enter a valid URL.',
+ 'filters.0.value': 'Choose at least one ticket.',
+ 'channel_configuration.allowed_mentions.0':
+ 'This mention type is not supported.',
+ };
+ } else {
+ const id = Number(url.pathname.split('/').at(-1));
+ if (request.method === 'DELETE') {
+ data.notificationActions = data.notificationActions.filter(
+ (entry) => entry.id !== id,
+ );
+ } else {
+ const { delivery_url, ...safe } = submitted;
+ if (request.method === 'POST') {
+ data.notificationActions.push({
+ id: nextId++,
+ project_id: 1,
+ ...safe,
+ has_delivery_url: !!delivery_url,
+ created_at: '2026-09-15T10:00:00Z',
+ updated_at: null,
+ });
+ } else {
+ const entry = data.notificationActions.find(
+ (entry) => entry.id === id,
+ );
+ Object.assign(entry, safe, {
+ has_delivery_url:
+ entry.has_delivery_url || !!delivery_url,
+ });
+ }
+ }
+ }
+ data.status = Object.keys(errors).length ? null : 'Updated';
+ }
+ if (url.searchParams.has('empty')) {
+ data.notificationActions = [];
+ }
+ const page = makePage({ ...data, errors });
+ response.setHeader('Vary', 'X-Inertia');
+ if (request.headers['x-inertia']) {
+ response.setHeader('X-Inertia', 'true');
+ response.setHeader('Content-Type', 'application/json');
+ response.end(JSON.stringify(page));
+ return;
+ }
+ const escaped = JSON.stringify(page)
+ .replaceAll('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll('<', '<');
+ response.setHeader('Content-Type', 'text/html');
+ response.end(
+ await server.transformIndexHtml(
+ request.url,
+ '
',
+ ),
+ );
+ });
+ await server.listen();
+ console.log('Notification settings fixture ready.');
+} else {
+ try {
+ const { createLocalization } = await server.ssrLoadModule(
+ '/resources/js/lib/localization.ts',
+ );
+ const { createAppearance } = await server.ssrLoadModule(
+ '/resources/js/composables/useAppearance.ts',
+ );
+ const { en } = await server.ssrLoadModule(
+ '/resources/js/locales/en.ts',
+ );
+ const component = (
+ await server.ssrLoadModule(
+ '/resources/js/pages/projects/settings/Notifications.vue',
+ )
+ ).default;
+ const render = async (extra = {}, locale = createLocalization()) =>
+ (
+ await createInertiaApp({
+ page: makePage(extra),
+ resolve: () => component,
+ render: renderToString,
+ setup: ({ App, props, plugin }) =>
+ createSSRApp({ render: () => h(App, props) })
+ .use(plugin)
+ .use(locale)
+ .use(createAppearance()),
+ })
+ ).body;
+ const html = await render();
+ assert.match(html, /Notifications/);
+ assert.match(html, /aria-current="page"/);
+ assert.match(html, /Release alerts/);
+ assert.match(html, /Audit webhook/);
+ assert.match(html, /Secret URL saved/);
+ assert.match(html, /No delivery URL saved/);
+ assert.match(html, /1 filter · all must match/);
+ assert.match(html, /Disabled/);
+ assert.match(html, /Ticket status changed/);
+ assert.match(html, /No saved changes yet/);
+ assert.match(
+ await render({ notificationActions: [] }),
+ /No notifications yet/,
+ );
+ assert.match(
+ await render({ status: 'Saved' }),
+ /Notification settings saved/,
+ );
+ assert.match(
+ await render(
+ {},
+ createLocalization(
+ { locale: 'de' },
+ {
+ de: {
+ ...en,
+ 'notifications.title': 'Benachrichtigungen',
+ },
+ },
+ ),
+ ),
+ /Benachrichtigungen/,
+ );
+ const { serializeNotificationFilters } = await server.ssrLoadModule(
+ '/resources/js/lib/notification-filters.ts',
+ );
+ assert.deepEqual(
+ serializeNotificationFilters([
+ {
+ id: 1,
+ attribute: 'ticket.key',
+ operator: 'in',
+ value: ' CORE-1, \nCORE-2, ',
+ },
+ {
+ id: 2,
+ attribute: 'actor.name',
+ operator: 'exists',
+ value: 'ignored',
+ },
+ {
+ id: 3,
+ attribute: 'actor.name',
+ operator: 'not_exists',
+ value: 'ignored',
+ },
+ {
+ id: 4,
+ attribute: 'status.to.label',
+ operator: 'equals',
+ value: ' Done ',
+ },
+ {
+ id: 5,
+ attribute: 'status.to.label',
+ operator: 'not_equals',
+ value: ' Doing ',
+ },
+ ]),
+ [
+ {
+ attribute: 'ticket.key',
+ operator: 'in',
+ value: ['CORE-1', 'CORE-2'],
+ },
+ { attribute: 'actor.name', operator: 'exists', value: null },
+ {
+ attribute: 'actor.name',
+ operator: 'not_exists',
+ value: null,
+ },
+ {
+ attribute: 'status.to.label',
+ operator: 'equals',
+ value: 'Done',
+ },
+ {
+ attribute: 'status.to.label',
+ operator: 'not_equals',
+ value: 'Doing',
+ },
+ ],
+ );
+ assert.deepEqual(serializeNotificationFilters([]), []);
+ console.log('Notification settings checks passed.');
+ } finally {
+ await server.close();
+ }
+}