diff --git a/frontend/messages/de.json b/frontend/messages/de.json index dcd08246..c6659b13 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -522,6 +522,7 @@ "notification_filter_unread": "Ungelesen", "notification_filter_mention": "Erwähnung", "notification_filter_reply": "Antwort", + "notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.", "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", "notification_load_more": "Ältere laden", "notification_empty_history": "Keine Benachrichtigungen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f915633e..6991e87e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -522,6 +522,7 @@ "notification_filter_unread": "Unread", "notification_filter_mention": "Mention", "notification_filter_reply": "Reply", + "notification_error_generic": "Action failed. Please try again.", "notification_mark_all_read_aria": "Mark all notifications as read", "notification_load_more": "Load older", "notification_empty_history": "No notifications", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1f1dec02..d77c4389 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -522,6 +522,7 @@ "notification_filter_unread": "No leídas", "notification_filter_mention": "Mención", "notification_filter_reply": "Respuesta", + "notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.", "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", "notification_load_more": "Cargar anteriores", "notification_empty_history": "Sin notificaciones", diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte b/frontend/src/lib/activity/ChronikFuerDichBox.svelte index 9cb94c24..ac893171 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte @@ -1,4 +1,5 @@ + {#if errorMessage} + {errorMessage} + {/if} {#if unread.length === 0} - { + errorMessage = null; + optimisticMarkAllRead(); + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + await update({ reset: false, invalidateAll: false }); + } + }; + }} > - {m.chronik_mark_all_read()} - + + {m.chronik_mark_all_read()} + + @@ -89,7 +109,7 @@ function href(n: NotificationItem): string { aria-hidden="true" class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent" > - {n.type === 'MENTION' ? '@' : '\u21A9'} + {n.type === 'MENTION' ? '@' : '↩'} @@ -100,25 +120,40 @@ function href(n: NotificationItem): string { - onMarkRead(n)} - class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none" + { + errorMessage = null; + optimisticMarkRead(n.id); + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + await update({ reset: false, invalidateAll: false }); + } + }; + }} > - + - - - + + + + + {/each} diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts index f491dd06..8ffd6713 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts @@ -5,7 +5,36 @@ import { page, userEvent } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte'; -afterEach(cleanup); +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + +vi.mock('$app/forms', () => ({ + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { + e.preventDefault(); + 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(); + mockFormResult.type = 'success'; +}); function notif(partial: Partial): NotificationItem { return { @@ -26,8 +55,8 @@ describe('ChronikFuerDichBox', () => { it('renders inbox-zero state when there are no unread items', async () => { render(ChronikFuerDichBox, { unread: [], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); expect(zero).not.toBeNull(); @@ -37,8 +66,8 @@ describe('ChronikFuerDichBox', () => { it('links to the archived mentions in the inbox-zero state', async () => { render(ChronikFuerDichBox, { unread: [], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); expect(link).not.toBeNull(); @@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => { it('renders the count badge with correct total when unread exists', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'a' }), notif({ id: 'b' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); await expect.element(page.getByText('2 neu')).toBeInTheDocument(); }); @@ -56,8 +85,8 @@ describe('ChronikFuerDichBox', () => { it('count badge has aria-live=polite when unread exists', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'a' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); // Wait for render await expect.element(page.getByText('1 neu')).toBeInTheDocument(); @@ -69,8 +98,8 @@ describe('ChronikFuerDichBox', () => { it('does not render the "Alle gelesen" button when there are no unread items', async () => { render(ChronikFuerDichBox, { unread: [], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); @@ -80,38 +109,38 @@ describe('ChronikFuerDichBox', () => { it('renders the "Alle gelesen" button when unread exists', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'a' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); }); - it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => { - const onMarkAllRead = vi.fn(); + it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => { + const optimisticMarkAllRead = vi.fn(); render(ChronikFuerDichBox, { unread: [notif({ id: 'a' })], - onMarkRead: vi.fn(), - onMarkAllRead + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead }); await userEvent.click(page.getByText('Alle gelesen')); - expect(onMarkAllRead).toHaveBeenCalledTimes(1); + expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1); }); - it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => { - const onMarkRead = vi.fn(); + it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => { + const optimisticMarkRead = vi.fn(); const n = notif({ id: 'xyz' }); render(ChronikFuerDichBox, { unread: [n], - onMarkRead, - onMarkAllRead: vi.fn() + optimisticMarkRead, + optimisticMarkAllRead: vi.fn() }); const dismiss = document.querySelector( '[data-testid="chronik-fuerdich-dismiss"]' ) as HTMLButtonElement | null; expect(dismiss).not.toBeNull(); dismiss?.click(); - expect(onMarkRead).toHaveBeenCalledTimes(1); - expect(onMarkRead.mock.calls[0][0]).toEqual(n); + expect(optimisticMarkRead).toHaveBeenCalledTimes(1); + expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz'); }); it('mention row href includes both commentId and annotationId when annotationId is present', async () => { @@ -124,8 +153,8 @@ describe('ChronikFuerDichBox', () => { annotationId: 'annot-9' }) ], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const link = document.querySelector( 'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' @@ -136,8 +165,8 @@ describe('ChronikFuerDichBox', () => { it('Dismiss button is a sibling of the document link, never nested inside ', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'x' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]'); expect(dismiss).not.toBeNull(); @@ -145,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 6b887a86..2ee844e3 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts @@ -4,7 +4,36 @@ import { page } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications'; -afterEach(cleanup); +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + +vi.mock('$app/forms', () => ({ + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { + e.preventDefault(); + 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(); + mockFormResult.type = 'success'; +}); const mention = (overrides: Partial = {}): NotificationItem => ({ id: 'n-1', @@ -22,7 +51,7 @@ const mention = (overrides: Partial = {}): NotificationItem => describe('ChronikFuerDichBox', () => { it('renders the inbox-zero state when there are no unread', async () => { render(ChronikFuerDichBox, { - props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} } + props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} } }); await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); @@ -34,8 +63,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -62,8 +91,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention({ actorName: 'Bertha' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -76,8 +105,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention({ type: 'REPLY', actorName: 'Carl' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -86,11 +115,11 @@ describe('ChronikFuerDichBox', () => { .toBeVisible(); }); - it('calls onMarkRead with the notification when its dismiss button is clicked', async () => { - const onMarkRead = vi.fn(); + it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => { + const optimisticMarkRead = vi.fn(); const item = mention({ id: 'n-7' }); render(ChronikFuerDichBox, { - props: { unread: [item], onMarkRead, onMarkAllRead: () => {} } + props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} } }); const dismiss = document.querySelector( @@ -98,35 +127,55 @@ describe('ChronikFuerDichBox', () => { ) as HTMLElement; dismiss.click(); - expect(onMarkRead).toHaveBeenCalledWith(item); + expect(optimisticMarkRead).toHaveBeenCalledWith('n-7'); }); - it('calls onMarkAllRead when the mark-all-read button is clicked', async () => { - const onMarkAllRead = vi.fn(); + it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => { + const optimisticMarkAllRead = vi.fn(); render(ChronikFuerDichBox, { props: { unread: [mention()], - onMarkRead: () => {}, - onMarkAllRead + optimisticMarkRead: () => {}, + optimisticMarkAllRead } }); const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; btn.click(); - expect(onMarkAllRead).toHaveBeenCalledOnce(); + expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); }); it('builds a deep-link href to the comment for each notification', async () => { render(ChronikFuerDichBox, { props: { unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); 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(); + }); }); diff --git a/frontend/src/lib/notification/NotificationBell.svelte b/frontend/src/lib/notification/NotificationBell.svelte index 3648f95f..3a04bb35 100644 --- a/frontend/src/lib/notification/NotificationBell.svelte +++ b/frontend/src/lib/notification/NotificationBell.svelte @@ -1,10 +1,8 @@
{errorMessage}
@@ -100,25 +120,40 @@ function href(n: NotificationItem): string {