fix(chronik): surface action failures in ChronikFuerDichBox with accessible error banner
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m35s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m17s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m35s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m17s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ interface Props {
|
|||||||
|
|
||||||
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
|
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
|
||||||
|
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
function verb(type: NotificationItem['type'], actor: string): string {
|
function verb(type: NotificationItem['type'], actor: string): string {
|
||||||
return type === 'REPLY'
|
return type === 'REPLY'
|
||||||
? m.notification_type_reply({ actor })
|
? m.notification_type_reply({ actor })
|
||||||
@@ -25,6 +27,9 @@ function href(n: NotificationItem): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="rounded-sm border border-line bg-surface p-5">
|
<section class="rounded-sm border border-line bg-surface p-5">
|
||||||
|
{#if errorMessage}
|
||||||
|
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
{#if unread.length === 0}
|
{#if unread.length === 0}
|
||||||
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
<svg
|
<svg
|
||||||
@@ -71,8 +76,12 @@ function href(n: NotificationItem): string {
|
|||||||
action="/aktivitaeten?/mark-all-read"
|
action="/aktivitaeten?/mark-all-read"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
optimisticMarkAllRead();
|
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 });
|
await update({ reset: false, invalidateAll: false });
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
@@ -115,8 +124,12 @@ function href(n: NotificationItem): string {
|
|||||||
action="/aktivitaeten?/dismiss-notification"
|
action="/aktivitaeten?/dismiss-notification"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
optimisticMarkRead(n.id);
|
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 });
|
await update({ reset: false, invalidateAll: false });
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,18 +5,36 @@ import { page, userEvent } from 'vitest/browser';
|
|||||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
|
|
||||||
|
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({
|
vi.mock('$app/forms', () => ({
|
||||||
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
|
enhance(
|
||||||
const handler = (e: Event) => {
|
node: HTMLFormElement,
|
||||||
|
submit?: (opts: {
|
||||||
|
formData: FormData;
|
||||||
|
}) => (opts: {
|
||||||
|
result: { type: string; data?: Record<string, unknown> };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) {
|
||||||
|
const handler = async (e: Event) => {
|
||||||
e.preventDefault();
|
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<void> }) => Promise<void>
|
||||||
|
)({ result: mockFormResult, update: async () => {} });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
node.addEventListener('submit', handler);
|
node.addEventListener('submit', handler);
|
||||||
return { destroy: () => node.removeEventListener('submit', handler) };
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockFormResult.type = 'success';
|
||||||
|
});
|
||||||
|
|
||||||
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
||||||
return {
|
return {
|
||||||
@@ -156,4 +174,22 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
||||||
expect(dismiss?.closest('a')).toBeNull();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,18 +4,36 @@ import { page } from 'vitest/browser';
|
|||||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications';
|
import type { NotificationItem } from '$lib/notification/notifications';
|
||||||
|
|
||||||
|
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({
|
vi.mock('$app/forms', () => ({
|
||||||
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
|
enhance(
|
||||||
const handler = (e: Event) => {
|
node: HTMLFormElement,
|
||||||
|
submit?: (opts: {
|
||||||
|
formData: FormData;
|
||||||
|
}) => (opts: {
|
||||||
|
result: { type: string; data?: Record<string, unknown> };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) {
|
||||||
|
const handler = async (e: Event) => {
|
||||||
e.preventDefault();
|
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<void> }) => Promise<void>
|
||||||
|
)({ result: mockFormResult, update: async () => {} });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
node.addEventListener('submit', handler);
|
node.addEventListener('submit', handler);
|
||||||
return { destroy: () => node.removeEventListener('submit', handler) };
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockFormResult.type = 'success';
|
||||||
|
});
|
||||||
|
|
||||||
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||||
id: 'n-1',
|
id: 'n-1',
|
||||||
@@ -140,4 +158,24 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
||||||
expect(link.getAttribute('href')).toContain('doc-x');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user