From f2bb58e294c17176e2397f58769c18470fbf0eba Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 07:06:58 +0200 Subject: [PATCH] fix(chronik): surface action failures in ChronikFuerDichBox with accessible error banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add $state errorMessage + role=alert banner to ChronikFuerDichBox. Both enhance callbacks now inspect result.type and set the error message on 'failure' or 'error'; errorMessage is cleared on each new submit attempt. Upgrade both test files to the mockFormResult pattern (via vi.hoisted) so the result callback is exercised. Add a failing-action test in each file that asserts role=alert appears after a form submit with type='failure'. Fix bare Function cast → explicit typed cast to satisfy @typescript-eslint/no-unsafe-function-type. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/activity/ChronikFuerDichBox.svelte | 17 ++++++- .../ChronikFuerDichBox.svelte.spec.ts | 44 ++++++++++++++++-- .../ChronikFuerDichBox.svelte.test.ts | 46 +++++++++++++++++-- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte b/frontend/src/lib/activity/ChronikFuerDichBox.svelte index fccf5892..18c2edff 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte @@ -13,6 +13,8 @@ interface Props { const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props(); +let errorMessage: string | null = $state(null); + function verb(type: NotificationItem['type'], actor: string): string { return type === 'REPLY' ? m.notification_type_reply({ actor }) @@ -25,6 +27,9 @@ function href(n: NotificationItem): string {
+ {#if errorMessage} + + {/if} {#if unread.length === 0}
{ + errorMessage = null; optimisticMarkAllRead(); - return async ({ update }) => { + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + } await update({ reset: false, invalidateAll: false }); }; }} @@ -115,8 +124,12 @@ function href(n: NotificationItem): string { action="/aktivitaeten?/dismiss-notification" method="POST" use:enhance={() => { + errorMessage = null; optimisticMarkRead(n.id); - return async ({ update }) => { + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + } await update({ reset: false, invalidateAll: false }); }; }} diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts index 44099b40..8ffd6713 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts @@ -5,18 +5,36 @@ import { page, userEvent } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte'; +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + vi.mock('$app/forms', () => ({ - enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) { - const handler = (e: Event) => { + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { e.preventDefault(); - submit?.({ formData: new FormData(node) } as never); + const cb = submit?.({ formData: new FormData(node) } as never); + if (typeof cb === 'function') { + await ( + cb as (o: { result: typeof mockFormResult; update: () => Promise }) => Promise + )({ result: mockFormResult, update: async () => {} }); + } }; node.addEventListener('submit', handler); return { destroy: () => node.removeEventListener('submit', handler) }; } })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + mockFormResult.type = 'success'; +}); function notif(partial: Partial): NotificationItem { return { @@ -156,4 +174,22 @@ describe('ChronikFuerDichBox', () => { // Prevents the senior-audience tap-drag bug flagged by Leonie. expect(dismiss?.closest('a')).toBeNull(); }); + + it('shows an accessible error banner when the dismiss action returns a failure', async () => { + mockFormResult.type = 'failure'; + render(ChronikFuerDichBox, { + unread: [notif({ id: 'err-1' })], + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() + }); + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLButtonElement | null; + expect(dismiss).not.toBeNull(); + dismiss?.click(); + // Allow microtask queue to flush + await new Promise((r) => setTimeout(r, 0)); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); }); diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts index dfc271f6..2ee844e3 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts @@ -4,18 +4,36 @@ import { page } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications'; +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + vi.mock('$app/forms', () => ({ - enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) { - const handler = (e: Event) => { + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { e.preventDefault(); - submit?.({ formData: new FormData(node) } as never); + const cb = submit?.({ formData: new FormData(node) } as never); + if (typeof cb === 'function') { + await ( + cb as (o: { result: typeof mockFormResult; update: () => Promise }) => Promise + )({ result: mockFormResult, update: async () => {} }); + } }; node.addEventListener('submit', handler); return { destroy: () => node.removeEventListener('submit', handler) }; } })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + mockFormResult.type = 'success'; +}); const mention = (overrides: Partial = {}): NotificationItem => ({ id: 'n-1', @@ -140,4 +158,24 @@ describe('ChronikFuerDichBox', () => { const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; expect(link.getAttribute('href')).toContain('doc-x'); }); + + it('shows an accessible error banner when the dismiss action returns a failure', async () => { + mockFormResult.type = 'failure'; + render(ChronikFuerDichBox, { + props: { + unread: [mention({ id: 'err-1' })], + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} + } + }); + + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLElement; + dismiss.click(); + // Allow microtask queue to flush + await new Promise((r) => setTimeout(r, 0)); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); });