Compare commits
11 Commits
ba307e991b
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5239f515f | ||
|
|
f2bb58e294 | ||
|
|
2adb98895d | ||
|
|
6049dcadd3 | ||
|
|
7fe8842b57 | ||
|
|
f9340366d1 | ||
|
|
af84ffc379 | ||
|
|
23439e581a | ||
|
|
2c6b59d0c7 | ||
|
|
c0a7408ef4 | ||
|
|
9d283c4500 |
@@ -106,31 +106,6 @@ export default defineConfig(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Forbid test fixtures (*.test-fixture.svelte) from being imported by
|
|
||||||
// production code. Tree-shaking keeps them out of the production bundle
|
|
||||||
// today (no route reaches them), but a lint rule makes the boundary
|
|
||||||
// explicit so an accidental autocomplete import in a route or component
|
|
||||||
// fails fast. Test files (*.spec.ts / *.test.ts) and the fixtures
|
|
||||||
// themselves are exempt — see the next block. Nora #2 on PR #629
|
|
||||||
// round 3.
|
|
||||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js', '**/*.ts'],
|
|
||||||
ignores: ['**/*.spec.ts', '**/*.test.ts', '**/*.test-fixture.svelte'],
|
|
||||||
rules: {
|
|
||||||
'no-restricted-imports': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: ['**/*.test-fixture.svelte'],
|
|
||||||
message:
|
|
||||||
'Test fixtures (*.test-fixture.svelte) are test-only — do not import from production code. Tracked by #637.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
plugins: { boundaries },
|
plugins: { boundaries },
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -445,12 +445,8 @@
|
|||||||
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||||
"person_mention_loading": "Lade Person…",
|
"person_mention_loading": "Lade Person…",
|
||||||
"person_mention_popup_empty": "Keine Personen gefunden",
|
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||||
"person_mention_search_label": "Person suchen",
|
|
||||||
"person_mention_search_prompt": "Namen eingeben…",
|
|
||||||
"person_mention_btn_label": "Person verlinken",
|
"person_mention_btn_label": "Person verlinken",
|
||||||
"person_mention_create_new": "Neue Person anlegen",
|
"person_mention_create_new": "Neue Person anlegen",
|
||||||
"person_mention_results_count_singular": "1 Person gefunden",
|
|
||||||
"person_mention_results_count_plural": "{count} Personen gefunden",
|
|
||||||
"transcription_editor_aria_label": "Transkriptionstext",
|
"transcription_editor_aria_label": "Transkriptionstext",
|
||||||
"person_born_name_prefix": "geb.",
|
"person_born_name_prefix": "geb.",
|
||||||
"page_title_home": "Archiv",
|
"page_title_home": "Archiv",
|
||||||
@@ -526,6 +522,7 @@
|
|||||||
"notification_filter_unread": "Ungelesen",
|
"notification_filter_unread": "Ungelesen",
|
||||||
"notification_filter_mention": "Erwähnung",
|
"notification_filter_mention": "Erwähnung",
|
||||||
"notification_filter_reply": "Antwort",
|
"notification_filter_reply": "Antwort",
|
||||||
|
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
||||||
"notification_load_more": "Ältere laden",
|
"notification_load_more": "Ältere laden",
|
||||||
"notification_empty_history": "Keine Benachrichtigungen",
|
"notification_empty_history": "Keine Benachrichtigungen",
|
||||||
|
|||||||
@@ -445,12 +445,8 @@
|
|||||||
"person_mention_load_error": "Could not load person.",
|
"person_mention_load_error": "Could not load person.",
|
||||||
"person_mention_loading": "Loading person…",
|
"person_mention_loading": "Loading person…",
|
||||||
"person_mention_popup_empty": "No persons found",
|
"person_mention_popup_empty": "No persons found",
|
||||||
"person_mention_search_label": "Search for a person",
|
|
||||||
"person_mention_search_prompt": "Enter a name…",
|
|
||||||
"person_mention_btn_label": "Link person",
|
"person_mention_btn_label": "Link person",
|
||||||
"person_mention_create_new": "Create new person",
|
"person_mention_create_new": "Create new person",
|
||||||
"person_mention_results_count_singular": "1 person found",
|
|
||||||
"person_mention_results_count_plural": "{count} persons found",
|
|
||||||
"transcription_editor_aria_label": "Transcription text",
|
"transcription_editor_aria_label": "Transcription text",
|
||||||
"person_born_name_prefix": "née",
|
"person_born_name_prefix": "née",
|
||||||
"page_title_home": "Archive",
|
"page_title_home": "Archive",
|
||||||
@@ -526,6 +522,7 @@
|
|||||||
"notification_filter_unread": "Unread",
|
"notification_filter_unread": "Unread",
|
||||||
"notification_filter_mention": "Mention",
|
"notification_filter_mention": "Mention",
|
||||||
"notification_filter_reply": "Reply",
|
"notification_filter_reply": "Reply",
|
||||||
|
"notification_error_generic": "Action failed. Please try again.",
|
||||||
"notification_mark_all_read_aria": "Mark all notifications as read",
|
"notification_mark_all_read_aria": "Mark all notifications as read",
|
||||||
"notification_load_more": "Load older",
|
"notification_load_more": "Load older",
|
||||||
"notification_empty_history": "No notifications",
|
"notification_empty_history": "No notifications",
|
||||||
|
|||||||
@@ -445,12 +445,8 @@
|
|||||||
"person_mention_load_error": "No se pudo cargar la persona.",
|
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||||
"person_mention_loading": "Cargando persona…",
|
"person_mention_loading": "Cargando persona…",
|
||||||
"person_mention_popup_empty": "No se encontraron personas",
|
"person_mention_popup_empty": "No se encontraron personas",
|
||||||
"person_mention_search_label": "Buscar persona",
|
|
||||||
"person_mention_search_prompt": "Escribe un nombre…",
|
|
||||||
"person_mention_btn_label": "Vincular persona",
|
"person_mention_btn_label": "Vincular persona",
|
||||||
"person_mention_create_new": "Crear nueva persona",
|
"person_mention_create_new": "Crear nueva persona",
|
||||||
"person_mention_results_count_singular": "1 persona encontrada",
|
|
||||||
"person_mention_results_count_plural": "{count} personas encontradas",
|
|
||||||
"transcription_editor_aria_label": "Texto de transcripción",
|
"transcription_editor_aria_label": "Texto de transcripción",
|
||||||
"person_born_name_prefix": "n.",
|
"person_born_name_prefix": "n.",
|
||||||
"page_title_home": "Archivo",
|
"page_title_home": "Archivo",
|
||||||
@@ -526,6 +522,7 @@
|
|||||||
"notification_filter_unread": "No leídas",
|
"notification_filter_unread": "No leídas",
|
||||||
"notification_filter_mention": "Mención",
|
"notification_filter_mention": "Mención",
|
||||||
"notification_filter_reply": "Respuesta",
|
"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_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
|
||||||
"notification_load_more": "Cargar anteriores",
|
"notification_load_more": "Cargar anteriores",
|
||||||
"notification_empty_history": "Sin notificaciones",
|
"notification_empty_history": "Sin notificaciones",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { relativeTime } from '$lib/shared/utils/time';
|
import { relativeTime } from '$lib/shared/utils/time';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
@@ -6,11 +7,13 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
unread: NotificationItem[];
|
unread: NotificationItem[];
|
||||||
onMarkRead: (n: NotificationItem) => void;
|
optimisticMarkRead: (id: string) => void;
|
||||||
onMarkAllRead: () => void;
|
optimisticMarkAllRead: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { unread, onMarkRead, onMarkAllRead }: 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'
|
||||||
@@ -24,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
|
||||||
@@ -66,14 +72,28 @@ function href(n: NotificationItem): string {
|
|||||||
{m.chronik_for_you_count({ count: unread.length })}
|
{m.chronik_for_you_count({ count: unread.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<form
|
||||||
type="button"
|
action="/aktivitaeten?/mark-all-read"
|
||||||
data-testid="chronik-mark-all-read"
|
method="POST"
|
||||||
onclick={onMarkAllRead}
|
use:enhance={() => {
|
||||||
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
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()}
|
<button
|
||||||
</button>
|
type="submit"
|
||||||
|
data-testid="chronik-mark-all-read"
|
||||||
|
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.chronik_mark_all_read()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul role="list" class="flex flex-col gap-2">
|
<ul role="list" class="flex flex-col gap-2">
|
||||||
@@ -89,7 +109,7 @@ function href(n: NotificationItem): string {
|
|||||||
aria-hidden="true"
|
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"
|
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' ? '@' : '↩'}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-sans text-sm leading-snug text-ink">
|
<p class="font-sans text-sm leading-snug text-ink">
|
||||||
@@ -100,25 +120,40 @@ function href(n: NotificationItem): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<form
|
||||||
type="button"
|
action="/aktivitaeten?/dismiss-notification"
|
||||||
data-testid="chronik-fuerdich-dismiss"
|
method="POST"
|
||||||
aria-label={m.chronik_mark_read_aria()}
|
use:enhance={() => {
|
||||||
onclick={() => onMarkRead(n)}
|
errorMessage = null;
|
||||||
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"
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<input type="hidden" name="notificationId" value={n.id} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<button
|
||||||
class="h-4 w-4"
|
type="submit"
|
||||||
fill="none"
|
data-testid="chronik-fuerdich-dismiss"
|
||||||
viewBox="0 0 24 24"
|
aria-label={m.chronik_mark_read_aria()}
|
||||||
stroke="currentColor"
|
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"
|
||||||
stroke-width="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<svg
|
||||||
</svg>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</button>
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,7 +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';
|
||||||
|
|
||||||
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<string, unknown> };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) {
|
||||||
|
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<void> }) => Promise<void>
|
||||||
|
)({ result: mockFormResult, update: async () => {} });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockFormResult.type = 'success';
|
||||||
|
});
|
||||||
|
|
||||||
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
||||||
return {
|
return {
|
||||||
@@ -26,8 +55,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders inbox-zero state when there are no unread items', async () => {
|
it('renders inbox-zero state when there are no unread items', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
||||||
expect(zero).not.toBeNull();
|
expect(zero).not.toBeNull();
|
||||||
@@ -37,8 +66,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('links to the archived mentions in the inbox-zero state', async () => {
|
it('links to the archived mentions in the inbox-zero state', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders the count badge with correct total when unread exists', async () => {
|
it('renders the count badge with correct total when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
|
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 () => {
|
it('count badge has aria-live=polite when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
// Wait for render
|
// Wait for render
|
||||||
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
|
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 () => {
|
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
||||||
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
|
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 () => {
|
it('renders the "Alle gelesen" button when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
|
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
|
||||||
const onMarkAllRead = vi.fn();
|
const optimisticMarkAllRead = vi.fn();
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead
|
optimisticMarkAllRead
|
||||||
});
|
});
|
||||||
await userEvent.click(page.getByText('Alle gelesen'));
|
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 () => {
|
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => {
|
||||||
const onMarkRead = vi.fn();
|
const optimisticMarkRead = vi.fn();
|
||||||
const n = notif({ id: 'xyz' });
|
const n = notif({ id: 'xyz' });
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [n],
|
unread: [n],
|
||||||
onMarkRead,
|
optimisticMarkRead,
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const dismiss = document.querySelector(
|
const dismiss = document.querySelector(
|
||||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(dismiss).not.toBeNull();
|
expect(dismiss).not.toBeNull();
|
||||||
dismiss?.click();
|
dismiss?.click();
|
||||||
expect(onMarkRead).toHaveBeenCalledTimes(1);
|
expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
|
||||||
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
|
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
||||||
@@ -124,8 +153,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
annotationId: 'annot-9'
|
annotationId: 'annot-9'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const link = document.querySelector(
|
const link = document.querySelector(
|
||||||
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
'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 <a>', async () => {
|
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'x' })],
|
unread: [notif({ id: 'x' })],
|
||||||
onMarkRead: vi.fn(),
|
optimisticMarkRead: vi.fn(),
|
||||||
onMarkAllRead: vi.fn()
|
optimisticMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
||||||
expect(dismiss).not.toBeNull();
|
expect(dismiss).not.toBeNull();
|
||||||
@@ -145,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,7 +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';
|
||||||
|
|
||||||
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<string, unknown> };
|
||||||
|
update: () => Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) {
|
||||||
|
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<void> }) => Promise<void>
|
||||||
|
)({ result: mockFormResult, update: async () => {} });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockFormResult.type = 'success';
|
||||||
|
});
|
||||||
|
|
||||||
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||||
id: 'n-1',
|
id: 'n-1',
|
||||||
@@ -22,7 +51,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
|
|||||||
describe('ChronikFuerDichBox', () => {
|
describe('ChronikFuerDichBox', () => {
|
||||||
it('renders the inbox-zero state when there are no unread', async () => {
|
it('renders the inbox-zero state when there are no unread', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
|
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
||||||
@@ -34,8 +63,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,8 +91,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ actorName: 'Bertha' })],
|
unread: [mention({ actorName: 'Bertha' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,8 +105,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,11 +115,11 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
|
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
|
||||||
const onMarkRead = vi.fn();
|
const optimisticMarkRead = vi.fn();
|
||||||
const item = mention({ id: 'n-7' });
|
const item = mention({ id: 'n-7' });
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
|
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
const dismiss = document.querySelector(
|
const dismiss = document.querySelector(
|
||||||
@@ -98,35 +127,55 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
dismiss.click();
|
dismiss.click();
|
||||||
|
|
||||||
expect(onMarkRead).toHaveBeenCalledWith(item);
|
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||||
const onMarkAllRead = vi.fn();
|
const optimisticMarkAllRead = vi.fn();
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention()],
|
unread: [mention()],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead
|
optimisticMarkAllRead
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
||||||
btn.click();
|
btn.click();
|
||||||
|
|
||||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds a deep-link href to the comment for each notification', async () => {
|
it('builds a deep-link href to the comment for each notification', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {}
|
optimisticMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import TranscriptionBlockHost from './TranscriptionBlock.test-fixture.svelte';
|
import TranscriptionBlockHost from './TranscriptionBlock.test-host.svelte';
|
||||||
import type { ConfirmService } from '$lib/shared/services/confirm.svelte.js';
|
import type { ConfirmService } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
import { notificationStore } from '$lib/notification/notifications.svelte';
|
import { notificationStore } from '$lib/notification/notifications.svelte';
|
||||||
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
|
||||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -30,17 +28,6 @@ function closeDropdown() {
|
|||||||
bellButtonEl?.focus();
|
bellButtonEl?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
|
||||||
await stream.markRead(notification);
|
|
||||||
const url = buildCommentHref(
|
|
||||||
notification.documentId,
|
|
||||||
notification.referenceId,
|
|
||||||
notification.annotationId
|
|
||||||
);
|
|
||||||
closeDropdown();
|
|
||||||
goto(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape' && open) {
|
if (event.key === 'Escape' && open) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -113,8 +100,8 @@ onDestroy(() => {
|
|||||||
{#if open}
|
{#if open}
|
||||||
<NotificationDropdown
|
<NotificationDropdown
|
||||||
notifications={stream.notifications}
|
notifications={stream.notifications}
|
||||||
onMarkRead={handleMarkRead}
|
optimisticMarkRead={stream.optimisticMarkRead}
|
||||||
onMarkAllRead={stream.markAllRead}
|
optimisticMarkAllRead={stream.optimisticMarkAllRead}
|
||||||
onClose={closeDropdown}
|
onClose={closeDropdown}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import type { NotificationItem } from '$lib/notification/notifications';
|
import type { NotificationItem } from '$lib/notification/notifications';
|
||||||
import NotificationBell from './NotificationBell.svelte';
|
import NotificationBell from './NotificationBell.svelte';
|
||||||
|
|
||||||
const gotoMock = vi.hoisted(() => vi.fn());
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
||||||
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit?.({ formData: new FormData(node) } as never);
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
||||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
||||||
|
|
||||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||||
@@ -17,18 +25,17 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
|
|||||||
get unreadCount() {
|
get unreadCount() {
|
||||||
return mockNotificationList.value.length;
|
return mockNotificationList.value.length;
|
||||||
},
|
},
|
||||||
markRead: mockMarkRead,
|
optimisticMarkRead: vi.fn(),
|
||||||
|
optimisticMarkAllRead: vi.fn(),
|
||||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn()
|
||||||
markAllRead: vi.fn()
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
gotoMock.mockClear();
|
vi.clearAllMocks();
|
||||||
mockMarkRead.mockClear();
|
|
||||||
mockNotificationList.value = [];
|
mockNotificationList.value = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,16 +52,6 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openDropdownAndClickFirstNotification() {
|
|
||||||
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
||||||
bellButton.click();
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
|
|
||||||
notifButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('NotificationBell — cursor and tooltip', () => {
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
it('bell button has cursor-pointer class', async () => {
|
it('bell button has cursor-pointer class', async () => {
|
||||||
render(NotificationBell);
|
render(NotificationBell);
|
||||||
@@ -82,29 +79,3 @@ describe('NotificationBell — cursor and tooltip', () => {
|
|||||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
|
||||||
render(NotificationBell);
|
|
||||||
|
|
||||||
await openDropdownAndClickFirstNotification();
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(gotoMock).toHaveBeenCalledWith(
|
|
||||||
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
|
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: null })];
|
|
||||||
render(NotificationBell);
|
|
||||||
|
|
||||||
await openDropdownAndClickFirstNotification();
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { relativeTime } from '$lib/shared/utils/time';
|
import { relativeTime } from '$lib/shared/utils/time';
|
||||||
|
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
notifications: NotificationItem[];
|
notifications: NotificationItem[];
|
||||||
onMarkRead: (notification: NotificationItem) => void;
|
optimisticMarkRead: (id: string) => void;
|
||||||
onMarkAllRead: () => void;
|
optimisticMarkAllRead: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
|
||||||
function handleViewAll() {
|
function handleViewAll() {
|
||||||
onClose(); // close first — avoids stale dropdown during navigation transition
|
onClose(); // close first — avoids stale dropdown during navigation transition
|
||||||
@@ -31,16 +35,35 @@ function handleViewAll() {
|
|||||||
{m.notification_bell_label()}
|
{m.notification_bell_label()}
|
||||||
</span>
|
</span>
|
||||||
{#if notifications.length > 0}
|
{#if notifications.length > 0}
|
||||||
<button
|
<form
|
||||||
type="button"
|
action="/aktivitaeten?/mark-all-read"
|
||||||
onclick={onMarkAllRead}
|
method="POST"
|
||||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
|
optimisticMarkAllRead();
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure' || result.type === 'error') {
|
||||||
|
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
|
||||||
|
await update({ reset: false, invalidateAll: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{m.notification_mark_all_read()}
|
<button
|
||||||
</button>
|
type="submit"
|
||||||
|
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.notification_mark_all_read()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
|
||||||
|
{#if errorMessage}
|
||||||
|
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Notification list -->
|
<!-- Notification list -->
|
||||||
{#if notifications.length === 0}
|
{#if notifications.length === 0}
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
@@ -66,67 +89,93 @@ function handleViewAll() {
|
|||||||
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||||
{#each notifications as notification (notification.id)}
|
{#each notifications as notification (notification.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<form
|
||||||
type="button"
|
action="/aktivitaeten?/dismiss-notification"
|
||||||
onclick={() => onMarkRead(notification)}
|
method="POST"
|
||||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
class="contents"
|
||||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
use:enhance={() => {
|
||||||
|
errorMessage = null;
|
||||||
|
optimisticMarkRead(notification.id);
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure' || result.type === 'error') {
|
||||||
|
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
|
||||||
|
await update({ reset: false, invalidateAll: false });
|
||||||
|
} else {
|
||||||
|
// Navigate away — no need to update the store since we're leaving the page
|
||||||
|
onClose();
|
||||||
|
goto(
|
||||||
|
buildCommentHref(
|
||||||
|
notification.documentId,
|
||||||
|
notification.referenceId,
|
||||||
|
notification.annotationId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<!-- Type icon -->
|
<input type="hidden" name="notificationId" value={notification.id} />
|
||||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
<button
|
||||||
{#if notification.type === 'REPLY'}
|
type="submit"
|
||||||
<!-- Reply icon -->
|
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas
|
||||||
<svg
|
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
>
|
||||||
class="h-4 w-4"
|
<!-- Type icon -->
|
||||||
fill="none"
|
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||||
viewBox="0 0 24 24"
|
{#if notification.type === 'REPLY'}
|
||||||
stroke="currentColor"
|
<!-- Reply icon -->
|
||||||
stroke-width="2"
|
<svg
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path
|
class="h-4 w-4"
|
||||||
stroke-linecap="round"
|
fill="none"
|
||||||
stroke-linejoin="round"
|
viewBox="0 0 24 24"
|
||||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
stroke="currentColor"
|
||||||
/>
|
stroke-width="2"
|
||||||
</svg>
|
>
|
||||||
{:else}
|
<path
|
||||||
<!-- Mention icon -->
|
stroke-linecap="round"
|
||||||
<svg
|
stroke-linejoin="round"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||||
class="h-4 w-4"
|
/>
|
||||||
fill="none"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
{:else}
|
||||||
stroke="currentColor"
|
<!-- Mention icon -->
|
||||||
stroke-width="2"
|
<svg
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path
|
class="h-4 w-4"
|
||||||
stroke-linecap="round"
|
fill="none"
|
||||||
stroke-linejoin="round"
|
viewBox="0 0 24 24"
|
||||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
stroke="currentColor"
|
||||||
/>
|
stroke-width="2"
|
||||||
</svg>
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Text + time -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm leading-snug text-ink">
|
||||||
|
{notification.type === 'REPLY'
|
||||||
|
? m.notification_type_reply({ actor: notification.actorName })
|
||||||
|
: m.notification_type_mention({ actor: notification.actorName })}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unread dot -->
|
||||||
|
{#if !notification.read}
|
||||||
|
<span
|
||||||
|
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||||
|
aria-label={m.notification_unread()}
|
||||||
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</button>
|
||||||
|
</form>
|
||||||
<!-- Text + time -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm leading-snug text-ink">
|
|
||||||
{notification.type === 'REPLY'
|
|
||||||
? m.notification_type_reply({ actor: notification.actorName })
|
|
||||||
: m.notification_type_mention({ actor: notification.actorName })}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unread dot -->
|
|
||||||
{#if !notification.read}
|
|
||||||
<span
|
|
||||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
|
||||||
aria-label={m.notification_unread()}
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -6,9 +6,38 @@ import NotificationDropdown from './NotificationDropdown.svelte';
|
|||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
// Configurable result for the enhance mock — tests that need failure set
|
||||||
|
// mockFormResult.type = 'failure' before clicking.
|
||||||
|
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||||
|
|
||||||
|
// Invoke the SubmitFunction and always call the returned result callback with
|
||||||
|
// mockFormResult so tests can exercise both success and failure branches.
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance(
|
||||||
|
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();
|
||||||
|
const cb = submit?.({ formData: new FormData(node) } as never);
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
await cb({ result: mockFormResult, update: async () => {} } as never);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.addEventListener('submit', handler);
|
||||||
|
return { destroy: () => node.removeEventListener('submit', handler) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockFormResult.type = 'success'; // reset to default after each test
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@@ -29,8 +58,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -42,8 +71,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -55,8 +84,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -70,8 +99,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification()],
|
notifications: [makeNotification()],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -83,8 +112,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,8 +127,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -116,8 +145,8 @@ describe('NotificationDropdown', () => {
|
|||||||
makeNotification({ id: 'n1', read: false }),
|
makeNotification({ id: 'n1', read: false }),
|
||||||
makeNotification({ id: 'n2', read: true })
|
makeNotification({ id: 'n2', read: true })
|
||||||
],
|
],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -126,37 +155,100 @@ describe('NotificationDropdown', () => {
|
|||||||
expect(unreadDots.length).toBe(1);
|
expect(unreadDots.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkRead with the notification when an item is clicked', async () => {
|
it('each notification row is wrapped in a form posting to the dismiss action', async () => {
|
||||||
const onMarkRead = vi.fn();
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification({ id: 'n42' })],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form?.getAttribute('method')).toBe('POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification({ id: 'n42' })],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.querySelector<HTMLInputElement>(
|
||||||
|
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
|
||||||
|
);
|
||||||
|
expect(input?.value).toBe('n42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
|
||||||
|
const optimisticMarkRead = vi.fn();
|
||||||
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
|
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [n],
|
notifications: [n],
|
||||||
onMarkRead,
|
optimisticMarkRead,
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
||||||
|
|
||||||
expect(onMarkRead).toHaveBeenCalledWith(n);
|
expect(optimisticMarkRead).toHaveBeenCalledWith('n42');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
it('the mark-all-read control is a form posting to the mark-all-read action', async () => {
|
||||||
const onMarkAllRead = vi.fn();
|
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification()],
|
notifications: [makeNotification()],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead,
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form?.getAttribute('method')).toBe('POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
|
||||||
|
const optimisticMarkAllRead = vi.fn();
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification()],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead,
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
||||||
|
|
||||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
|
||||||
|
mockFormResult.type = 'failure';
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [makeNotification()],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
||||||
|
|
||||||
|
const alert = document.querySelector('[role="alert"]');
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onClose when the view-all button is clicked', async () => {
|
it('calls onClose when the view-all button is clicked', async () => {
|
||||||
@@ -164,8 +256,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose
|
onClose
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -179,8 +271,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -193,12 +285,15 @@ describe('NotificationDropdown', () => {
|
|||||||
it('calls onClose before navigating to /aktivitaeten', async () => {
|
it('calls onClose before navigating to /aktivitaeten', async () => {
|
||||||
const callOrder: string[] = [];
|
const callOrder: string[] = [];
|
||||||
const onClose = vi.fn(() => callOrder.push('close'));
|
const onClose = vi.fn(() => callOrder.push('close'));
|
||||||
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
|
vi.mocked(goto).mockImplementation(() => {
|
||||||
|
callOrder.push('goto');
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose
|
onClose
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -212,8 +307,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -225,8 +320,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -242,14 +337,78 @@ describe('NotificationDropdown', () => {
|
|||||||
makeNotification({ id: 'n1', actorName: 'First' }),
|
makeNotification({ id: 'n1', actorName: 'First' }),
|
||||||
makeNotification({ id: 'n2', actorName: 'Second' })
|
makeNotification({ id: 'n2', actorName: 'Second' })
|
||||||
],
|
],
|
||||||
onMarkRead: () => {},
|
optimisticMarkRead: () => {},
|
||||||
onMarkAllRead: () => {},
|
optimisticMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = document.querySelectorAll('button[type="button"]');
|
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
|
||||||
// At least 2 items + mark-all button
|
expect(forms.length).toBe(2);
|
||||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
});
|
||||||
|
|
||||||
|
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const n = makeNotification({
|
||||||
|
id: 'n42',
|
||||||
|
documentId: 'd1',
|
||||||
|
referenceId: 'c1',
|
||||||
|
annotationId: null,
|
||||||
|
actorName: 'Anna'
|
||||||
|
});
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [n],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
|
||||||
|
mockFormResult.type = 'failure';
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [n],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
|
||||||
|
const n = makeNotification({
|
||||||
|
id: 'n55',
|
||||||
|
documentId: 'd1',
|
||||||
|
referenceId: 'c1',
|
||||||
|
annotationId: 'a1',
|
||||||
|
actorName: 'Eva'
|
||||||
|
});
|
||||||
|
render(NotificationDropdown, {
|
||||||
|
props: {
|
||||||
|
notifications: [n],
|
||||||
|
optimisticMarkRead: () => {},
|
||||||
|
optimisticMarkAllRead: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
|
||||||
|
|
||||||
|
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,12 +108,46 @@ describe('notificationStore (singleton)', () => {
|
|||||||
expect(notificationStore.unreadCount).toBe(1);
|
expect(notificationStore.unreadCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('markAllRead resets unreadCount', async () => {
|
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
||||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
notificationStore.init();
|
||||||
await notificationStore.markAllRead();
|
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||||
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
notificationStore.optimisticMarkRead('sse-1');
|
||||||
|
|
||||||
|
expect(notificationStore.notifications[0].read).toBe(true);
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
const notification = makeNotification({ id: 'sse-1', read: true });
|
||||||
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
|
||||||
|
notificationStore.optimisticMarkRead('sse-1');
|
||||||
|
|
||||||
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
lastEventSource!.simulate(
|
||||||
|
'notification',
|
||||||
|
JSON.stringify(makeNotification({ id: 'n1', read: false }))
|
||||||
|
);
|
||||||
|
lastEventSource!.simulate(
|
||||||
|
'notification',
|
||||||
|
JSON.stringify(makeNotification({ id: 'n2', read: false }))
|
||||||
|
);
|
||||||
|
mockFetch.mockReset();
|
||||||
|
|
||||||
|
notificationStore.optimisticMarkAllRead();
|
||||||
|
|
||||||
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
|
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,28 +35,19 @@ async function fetchUnreadCount(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(notification: NotificationItem): Promise<void> {
|
function optimisticMarkRead(id: string): void {
|
||||||
if (!notification.read) {
|
const notification = notifications.find((n) => n.id === id);
|
||||||
try {
|
if (notification && !notification.read) {
|
||||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
notification.read = true;
|
||||||
notification.read = true;
|
unreadCount = Math.max(0, unreadCount - 1);
|
||||||
unreadCount = Math.max(0, unreadCount - 1);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark notification as read', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllRead(): Promise<void> {
|
function optimisticMarkAllRead(): void {
|
||||||
try {
|
for (const n of notifications) {
|
||||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
n.read = true;
|
||||||
for (const n of notifications) {
|
|
||||||
n.read = true;
|
|
||||||
}
|
|
||||||
unreadCount = 0;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark all notifications as read', e);
|
|
||||||
}
|
}
|
||||||
|
unreadCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(): void {
|
function init(): void {
|
||||||
@@ -123,8 +114,8 @@ export const notificationStore = {
|
|||||||
},
|
},
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
markRead,
|
optimisticMarkRead,
|
||||||
markAllRead,
|
optimisticMarkAllRead,
|
||||||
init,
|
init,
|
||||||
destroy
|
destroy
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,7 @@
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
|
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
|
||||||
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
// Layered defence cap on the @mention search query length (CWE-400
|
|
||||||
// amplification). The <input maxlength> attribute below caps direct
|
|
||||||
// user edits, but the editor-mirror path (Tiptap contenteditable -> mirror
|
|
||||||
// $effect -> searchQuery) is not covered by `maxlength` since the
|
|
||||||
// contenteditable has no such enforcement. Clipping at the mirror keeps
|
|
||||||
// the cap honest from both paths. Tracked server-side separately.
|
|
||||||
// Nora #1 on PR #629. Hoisted to mentionConstants.ts so the host editor
|
|
||||||
// (PersonMentionEditor) can clip the inserted displayName to the same cap
|
|
||||||
// — see Felix #3 on PR #629.
|
|
||||||
import { MAX_QUERY_LENGTH } from './mentionConstants';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
@@ -28,46 +17,7 @@ type DropdownState = {
|
|||||||
clientRect: (() => DOMRect | null) | null;
|
clientRect: (() => DOMRect | null) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { model }: { model: DropdownState } = $props();
|
||||||
model,
|
|
||||||
editorQuery = '',
|
|
||||||
onSearch = () => {}
|
|
||||||
}: {
|
|
||||||
model: DropdownState;
|
|
||||||
/** Text typed after `@` in the host editor. Mirrors into the search input
|
|
||||||
* until the user takes manual ownership by typing into the input itself. */
|
|
||||||
editorQuery?: string;
|
|
||||||
onSearch?: (query: string) => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
|
||||||
let userHasEdited = $state(false);
|
|
||||||
|
|
||||||
// Intent-revealing alias used by both the persistent aria-live announcer and
|
|
||||||
// the visible empty-state copy. Folding the duplicated rule into one $derived
|
|
||||||
// keeps the two branches in lockstep. Felix #3 on PR #629 round 4.
|
|
||||||
const isQueryEmpty = $derived(searchQuery.trim() === '');
|
|
||||||
|
|
||||||
// Mirror the editor's typed text until the user takes ownership.
|
|
||||||
//
|
|
||||||
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
|
|
||||||
// `bind:value` on the <input> below, so it needs to be a mutable `$state`.
|
|
||||||
// A `$derived` would be read-only and would clobber direct user edits on
|
|
||||||
// every editor keystroke. The `userHasEdited` latch pins ownership once the
|
|
||||||
// user types into the input. Felix #1 on PR #629.
|
|
||||||
$effect(() => {
|
|
||||||
if (!userHasEdited) {
|
|
||||||
searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fire onSearch whenever the effective query changes — covers both the
|
|
||||||
// editor mirror and direct input edits. This is the only place onSearch
|
|
||||||
// fires; when the dropdown is unmounted, the effect is disposed and no
|
|
||||||
// further fetches occur.
|
|
||||||
$effect(() => {
|
|
||||||
onSearch(searchQuery);
|
|
||||||
});
|
|
||||||
|
|
||||||
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
||||||
// reset when `items` changes (so it never points past the end of a new list).
|
// reset when `items` changes (so it never points past the end of a new list).
|
||||||
@@ -162,70 +112,16 @@ function selectItem(item: Person) {
|
|||||||
unauthenticated users.
|
unauthenticated users.
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="fixed z-50 w-72 max-w-[calc(100vw-1rem)] overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={m.person_mention_btn_label()}
|
aria-label={m.person_mention_btn_label()}
|
||||||
style:top={position.top}
|
style:top={position.top}
|
||||||
style:bottom={position.bottom}
|
style:bottom={position.bottom}
|
||||||
style:left={position.left}
|
style:left={position.left}
|
||||||
>
|
>
|
||||||
<div class="border-b border-line px-3 py-2">
|
|
||||||
<label class="sr-only" for="mention-search">{m.person_mention_search_label()}</label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
class="h-5 w-5 shrink-0 text-ink-2"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="7" />
|
|
||||||
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
id="mention-search"
|
|
||||||
type="search"
|
|
||||||
data-test-search-input
|
|
||||||
maxlength={MAX_QUERY_LENGTH}
|
|
||||||
class="min-h-[44px] w-full bg-transparent font-sans text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
|
||||||
placeholder={m.person_mention_search_prompt()}
|
|
||||||
bind:value={searchQuery}
|
|
||||||
oninput={() => {
|
|
||||||
userHasEdited = true;
|
|
||||||
}}
|
|
||||||
onmousedown={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--
|
|
||||||
Persistent aria-live region — lives ABOVE the conditional branches so the
|
|
||||||
element never unmounts when items transition between empty and populated.
|
|
||||||
VoiceOver in particular swallows announcements from freshly-mounted live
|
|
||||||
regions, and the previous (conditional-inside) markup silently dropped
|
|
||||||
the "N persons found" announcement when results populated. Leonie #3 on
|
|
||||||
PR #629 round 3.
|
|
||||||
-->
|
|
||||||
<p class="sr-only" aria-live="polite">
|
|
||||||
{#if model.items.length === 0}
|
|
||||||
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
|
||||||
{:else if model.items.length === 1}
|
|
||||||
{m.person_mention_results_count_singular()}
|
|
||||||
{:else}
|
|
||||||
{m.person_mention_results_count_plural({ count: model.items.length })}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{#if model.items.length === 0}
|
{#if model.items.length === 0}
|
||||||
<!--
|
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||||
Visible empty-state copy — visual-only. The persistent sr-only <p>
|
{m.person_mention_popup_empty()}
|
||||||
above is the sole AT announcer; this one is hidden from screen readers
|
|
||||||
via aria-hidden="true" so VoiceOver does not double-announce
|
|
||||||
(NVDA de-dups, VoiceOver does not). Leonie S-2 on PR #629 round 4.
|
|
||||||
Do NOT add an aria-live attribute here — that would re-introduce
|
|
||||||
the duplicate announcement.
|
|
||||||
-->
|
|
||||||
<p aria-hidden="true" class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
|
||||||
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
|
||||||
</p>
|
</p>
|
||||||
<!--
|
<!--
|
||||||
Empty-state escape hatch — without it the transcriber has to close
|
Empty-state escape hatch — without it the transcriber has to close
|
||||||
@@ -236,7 +132,7 @@ function selectItem(item: Person) {
|
|||||||
<a
|
<a
|
||||||
href="/persons/new"
|
href="/persons/new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener"
|
||||||
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
||||||
onmousedown={(e) => e.preventDefault()}
|
onmousedown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,37 +1,22 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
|
||||||
import MentionDropdown from './MentionDropdown.svelte';
|
import MentionDropdown from './MentionDropdown.svelte';
|
||||||
import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const makePerson = (id: string, name: string, overrides: Partial<Person> = {}): Person => {
|
const makePerson = (id: string, name: string, overrides: Record<string, unknown> = {}) => ({
|
||||||
const parts = name.split(' ');
|
id,
|
||||||
return {
|
firstName: name.split(' ')[0] ?? null,
|
||||||
id,
|
lastName: name.split(' ').slice(1).join(' ') || name,
|
||||||
firstName: parts[0],
|
displayName: name,
|
||||||
lastName: parts.slice(1).join(' ') || name,
|
birthYear: null as number | null,
|
||||||
displayName: name,
|
deathYear: null as number | null,
|
||||||
personType: 'PERSON',
|
...overrides
|
||||||
familyMember: false,
|
});
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type DropdownState = {
|
const baseModel = (overrides: Record<string, unknown> = {}) => ({
|
||||||
items: Person[];
|
items: [] as ReturnType<typeof makePerson>[],
|
||||||
command: (item: Person) => void;
|
|
||||||
clientRect: (() => DOMRect | null) | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseModel = (overrides: Partial<DropdownState> = {}): DropdownState => ({
|
|
||||||
items: [],
|
|
||||||
command: vi.fn(),
|
command: vi.fn(),
|
||||||
clientRect: () => new DOMRect(100, 100, 0, 24),
|
clientRect: () => new DOMRect(100, 100, 0, 24),
|
||||||
...overrides
|
...overrides
|
||||||
@@ -44,32 +29,14 @@ describe('MentionDropdown', () => {
|
|||||||
await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible();
|
await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the "enter a name" prompt when the search field is empty', async () => {
|
it('renders the empty placeholder when items is empty', async () => {
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
render(MentionDropdown, { props: { model: baseModel() } });
|
||||||
|
|
||||||
// Scope to the visible empty-state <p> (text-ink-3) — the persistent
|
await expect.element(page.getByText('Keine Personen gefunden')).toBeVisible();
|
||||||
// sr-only aria-live region above also contains the same prompt copy.
|
|
||||||
const visibleEmptyP = document.querySelector(
|
|
||||||
'[role="listbox"] p.text-ink-3'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(visibleEmptyP).not.toBeNull();
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_popup_empty());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows "no persons found" when the search has a query but the list is empty', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
|
|
||||||
|
|
||||||
const visibleEmptyP = document.querySelector(
|
|
||||||
'[role="listbox"] p.text-ink-3'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(visibleEmptyP).not.toBeNull();
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_search_prompt());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the create-new escape hatch link in the empty state', async () => {
|
it('shows the create-new escape hatch link in the empty state', async () => {
|
||||||
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'unknown' } });
|
render(MentionDropdown, { props: { model: baseModel() } });
|
||||||
|
|
||||||
const link = (await page
|
const link = (await page
|
||||||
.getByRole('link', { name: /neue person anlegen/i })
|
.getByRole('link', { name: /neue person anlegen/i })
|
||||||
@@ -77,7 +44,6 @@ describe('MentionDropdown', () => {
|
|||||||
expect(link.href).toContain('/persons/new');
|
expect(link.href).toContain('/persons/new');
|
||||||
expect(link.target).toBe('_blank');
|
expect(link.target).toBe('_blank');
|
||||||
expect(link.rel).toContain('noopener');
|
expect(link.rel).toContain('noopener');
|
||||||
expect(link.rel).toContain('noreferrer');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders one option per item when populated', async () => {
|
it('renders one option per item when populated', async () => {
|
||||||
@@ -138,315 +104,3 @@ describe('MentionDropdown', () => {
|
|||||||
expect(dropdown.style.left).toBe('123px');
|
expect(dropdown.style.left).toBe('123px');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Search input — Issue #380 ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('MentionDropdown — search input', () => {
|
|
||||||
it('renders a search input pre-filled with the editorQuery prop', async () => {
|
|
||||||
render(MentionDropdown, {
|
|
||||||
props: { model: baseModel(), editorQuery: 'WdG' }
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes a data-test-search-input attribute for E2E selectors', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
|
||||||
|
|
||||||
const input = document.querySelector('[data-test-search-input]');
|
|
||||||
expect(input).not.toBeNull();
|
|
||||||
expect((input as HTMLInputElement).type).toBe('search');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('search input wrapper meets the 44px touch target (WCAG 2.2 AA)', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
|
||||||
|
|
||||||
const input = document.querySelector('[data-test-search-input]') as HTMLElement;
|
|
||||||
expect(input).not.toBeNull();
|
|
||||||
expect(input.className).toContain('min-h-[44px]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a persistent aria-live="polite" region (does not remount on items transition; Leonie #3 on PR #629)', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
|
||||||
|
|
||||||
const listbox = document.querySelector('[role="listbox"]');
|
|
||||||
expect(listbox).not.toBeNull();
|
|
||||||
const live = listbox!.querySelector('p[aria-live="polite"]');
|
|
||||||
expect(live).not.toBeNull();
|
|
||||||
// Empty + empty-query → "Namen eingeben…" prompt
|
|
||||||
expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('announces the result count in the persistent live region when items populate (Leonie #3 on PR #629)', async () => {
|
|
||||||
render(MentionDropdown, {
|
|
||||||
props: {
|
|
||||||
model: baseModel({
|
|
||||||
items: [
|
|
||||||
makePerson('p1', 'Anna Schmidt'),
|
|
||||||
makePerson('p2', 'Bert Meier'),
|
|
||||||
makePerson('p3', 'Carl Vogel')
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const listbox = document.querySelector('[role="listbox"]');
|
|
||||||
expect(listbox).not.toBeNull();
|
|
||||||
const live = listbox!.querySelector('p[aria-live="polite"]');
|
|
||||||
expect(live).not.toBeNull();
|
|
||||||
// Populated → "3 Personen gefunden" (plural)
|
|
||||||
expect(live!.textContent ?? '').toContain('3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('announces the singular form when exactly one item is present (Sara #4 on PR #629)', async () => {
|
|
||||||
render(MentionDropdown, {
|
|
||||||
props: {
|
|
||||||
model: baseModel({
|
|
||||||
items: [makePerson('p1', 'Anna Schmidt')]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const listbox = document.querySelector('[role="listbox"]');
|
|
||||||
expect(listbox).not.toBeNull();
|
|
||||||
const live = listbox!.querySelector('p[aria-live="polite"]');
|
|
||||||
expect(live).not.toBeNull();
|
|
||||||
// Singular branch — "1 Person gefunden" / "1 person found" / "1 persona encontrada"
|
|
||||||
// (locale-dependent; resolved via the Paraglide message helper).
|
|
||||||
expect(live!.textContent ?? '').toContain(m.person_mention_results_count_singular());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the visible empty-state copy without its own aria-live and hides it from AT (Leonie #3 on PR #629 round 3; Leonie S-2 round 4)', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
|
|
||||||
|
|
||||||
// Visible empty-state <p> exists with the empty-result copy ...
|
|
||||||
const empty = document.querySelector('p.text-ink-3') as HTMLElement | null;
|
|
||||||
expect(empty).not.toBeNull();
|
|
||||||
expect(empty!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
|
||||||
// ... but it must NOT carry its own aria-live (the persistent sr-only
|
|
||||||
// region above the conditional is the announcer now).
|
|
||||||
expect(empty!.hasAttribute('aria-live')).toBe(false);
|
|
||||||
// ... and it MUST be hidden from screen readers via aria-hidden="true"
|
|
||||||
// so VoiceOver does not double-announce (the persistent sr-only region
|
|
||||||
// is the sole AT source of truth). Leonie S-2 on PR #629 round 4.
|
|
||||||
expect(empty!.getAttribute('aria-hidden')).toBe('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
|
||||||
|
|
||||||
const icon = document.querySelector('[data-test-search-input]')
|
|
||||||
?.previousElementSibling as SVGElement | null;
|
|
||||||
expect(icon).not.toBeNull();
|
|
||||||
expect(icon!.tagName.toLowerCase()).toBe('svg');
|
|
||||||
expect(icon!.getAttribute('class') ?? '').toContain('h-5');
|
|
||||||
expect(icon!.getAttribute('class') ?? '').toContain('w-5');
|
|
||||||
expect(icon!.getAttribute('class') ?? '').toContain('text-ink-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps the search input at maxlength=100 (CWE-400 amplification — Nora on PR #629)', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
|
||||||
|
|
||||||
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
|
|
||||||
expect(input).not.toBeNull();
|
|
||||||
expect(input.maxLength).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => {
|
|
||||||
const longQuery = 'A'.repeat(200);
|
|
||||||
render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } });
|
|
||||||
|
|
||||||
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
|
|
||||||
expect(input).not.toBeNull();
|
|
||||||
expect(input.value.length).toBe(100);
|
|
||||||
expect(input.value).toBe('A'.repeat(100));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
|
||||||
|
|
||||||
const listbox = document.querySelector('[role="listbox"]') as HTMLElement;
|
|
||||||
expect(listbox).not.toBeNull();
|
|
||||||
expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006)', async () => {
|
|
||||||
render(MentionDropdown, { props: { model: baseModel() } });
|
|
||||||
|
|
||||||
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
|
|
||||||
expect(input).not.toBeNull();
|
|
||||||
expect(input.className).toContain('text-base');
|
|
||||||
expect(input.className).not.toContain('text-sm');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invokes onSearch with the current value whenever the user types', async () => {
|
|
||||||
const onSearch = vi.fn();
|
|
||||||
render(MentionDropdown, { props: { model: baseModel(), onSearch } });
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('searchbox'), 'Walter');
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(onSearch).toHaveBeenCalled();
|
|
||||||
expect(onSearch).toHaveBeenLastCalledWith('Walter');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the user-edited search value when editorQuery changes after the takeover (Felix on PR #629)', async () => {
|
|
||||||
let setEditorQuery!: (q: string) => void;
|
|
||||||
render(MentionDropdownFixture, {
|
|
||||||
model: baseModel(),
|
|
||||||
initialEditorQuery: 'WdG',
|
|
||||||
onReady: (s: (q: string) => void) => {
|
|
||||||
setEditorQuery = s;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
|
|
||||||
|
|
||||||
await page.getByRole('searchbox').fill('Walter');
|
|
||||||
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
|
|
||||||
|
|
||||||
setEditorQuery('WdGruyter');
|
|
||||||
// Flush pending Svelte reactivity so any (non-)update from the mirror
|
|
||||||
// $effect has landed before we assert. expect.element already polls, so
|
|
||||||
// no fixed-timeout fallback is needed. Sara on PR #629 round 3.
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── ArrowDown via exported onKeyDown (Sara #3 on PR #629) ──────────────────
|
|
||||||
//
|
|
||||||
// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level
|
|
||||||
// and forwards them to the dropdown via its exported onKeyDown(event) function
|
|
||||||
// — the dropdown itself has no DOM keydown listener. This test exercises the
|
|
||||||
// same export so a regression in highlightedIndex/selection logic is caught
|
|
||||||
// at the unit level. The full E2E focus-chain test is deferred to a separate
|
|
||||||
// issue (Playwright).
|
|
||||||
//
|
|
||||||
// These unit tests directly invoke the exported `onKeyDown` to pin its
|
|
||||||
// behaviour in isolation. They do NOT exercise the Tiptap forwarding
|
|
||||||
// chain (PersonMentionEditor.suggestion.render() returning { onKeyDown })
|
|
||||||
// — that integration is covered by the 'ArrowDown moves the highlight'
|
|
||||||
// test in PersonMentionEditor.svelte.spec.ts. Sara on PR #629 round 3.
|
|
||||||
|
|
||||||
describe('MentionDropdown — onKeyDown forwarding', () => {
|
|
||||||
// flushSync ensures Svelte reactivity propagation completes before
|
|
||||||
// asserting (uniform across all four key tests so the next reader
|
|
||||||
// doesn't have to figure out why some are wrapped and others aren't).
|
|
||||||
// Felix #1 suggestion on PR #629 round 3.
|
|
||||||
|
|
||||||
it('ArrowDown advances aria-selected to the next option in the listbox', async () => {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.appendChild(container);
|
|
||||||
const instance = mount(MentionDropdown, {
|
|
||||||
target: container,
|
|
||||||
props: {
|
|
||||||
model: baseModel({
|
|
||||||
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
||||||
|
|
||||||
// First option starts highlighted.
|
|
||||||
const first = container.querySelector('[data-test-person-id="p1"]') as HTMLElement;
|
|
||||||
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
|
|
||||||
expect(first.getAttribute('aria-selected')).toBe('true');
|
|
||||||
expect(second.getAttribute('aria-selected')).toBe('false');
|
|
||||||
|
|
||||||
let consumed = false;
|
|
||||||
flushSync(() => {
|
|
||||||
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
||||||
});
|
|
||||||
expect(consumed).toBe(true);
|
|
||||||
|
|
||||||
expect(first.getAttribute('aria-selected')).toBe('false');
|
|
||||||
expect(second.getAttribute('aria-selected')).toBe('true');
|
|
||||||
} finally {
|
|
||||||
unmount(instance);
|
|
||||||
container.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ArrowUp wraps from the first option to the last', async () => {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.appendChild(container);
|
|
||||||
const instance = mount(MentionDropdown, {
|
|
||||||
target: container,
|
|
||||||
props: {
|
|
||||||
model: baseModel({
|
|
||||||
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
||||||
|
|
||||||
let consumed = false;
|
|
||||||
flushSync(() => {
|
|
||||||
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
|
|
||||||
});
|
|
||||||
expect(consumed).toBe(true);
|
|
||||||
|
|
||||||
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
|
|
||||||
expect(second.getAttribute('aria-selected')).toBe('true');
|
|
||||||
} finally {
|
|
||||||
unmount(instance);
|
|
||||||
container.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Enter invokes model.command with the currently highlighted item', async () => {
|
|
||||||
const command = vi.fn();
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.appendChild(container);
|
|
||||||
const instance = mount(MentionDropdown, {
|
|
||||||
target: container,
|
|
||||||
props: {
|
|
||||||
model: baseModel({
|
|
||||||
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')],
|
|
||||||
command
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
||||||
|
|
||||||
let consumed = false;
|
|
||||||
flushSync(() => {
|
|
||||||
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
||||||
});
|
|
||||||
expect(consumed).toBe(true);
|
|
||||||
expect(command).toHaveBeenCalledTimes(1);
|
|
||||||
expect(command.mock.calls[0][0].id).toBe('p1');
|
|
||||||
} finally {
|
|
||||||
unmount(instance);
|
|
||||||
container.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Escape returns false so the suggestion plugin can handle it', async () => {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
document.body.appendChild(container);
|
|
||||||
const instance = mount(MentionDropdown, {
|
|
||||||
target: container,
|
|
||||||
props: {
|
|
||||||
model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] })
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
|
|
||||||
let consumed = true;
|
|
||||||
flushSync(() => {
|
|
||||||
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
||||||
});
|
|
||||||
expect(consumed).toBe(false);
|
|
||||||
} finally {
|
|
||||||
unmount(instance);
|
|
||||||
container.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import MentionDropdown from './MentionDropdown.svelte';
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
|
||||||
type DropdownState = {
|
|
||||||
items: Person[];
|
|
||||||
command: (item: Person) => void;
|
|
||||||
clientRect: (() => DOMRect | null) | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
model: DropdownState;
|
|
||||||
initialEditorQuery: string;
|
|
||||||
/** Test hook: receives a setter for editorQuery so the test can mutate it. */
|
|
||||||
onReady?: (setEditorQuery: (q: string) => void) => void;
|
|
||||||
onSearch?: (q: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { model, initialEditorQuery, onReady, onSearch = () => {} }: Props = $props();
|
|
||||||
|
|
||||||
let editorQuery = $state(untrack(() => initialEditorQuery));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
onReady?.((q) => {
|
|
||||||
editorQuery = q;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MentionDropdown model={model} editorQuery={editorQuery} onSearch={onSearch} />
|
|
||||||
@@ -7,9 +7,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import type { PersonMention } from '$lib/shared/types';
|
import type { PersonMention } from '$lib/shared/types';
|
||||||
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
||||||
import { debounce } from '$lib/shared/utils/debounce';
|
|
||||||
import MentionDropdown from './MentionDropdown.svelte';
|
import MentionDropdown from './MentionDropdown.svelte';
|
||||||
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
@@ -35,13 +33,6 @@ let {
|
|||||||
|
|
||||||
let editorEl: HTMLDivElement;
|
let editorEl: HTMLDivElement;
|
||||||
let editor: Editor | null = null;
|
let editor: Editor | null = null;
|
||||||
// Hoisted so onDestroy can guarantee the imperatively-mounted dropdown is
|
|
||||||
// torn down even if Tiptap's suggestion plugin onExit didn't fire (e.g. when
|
|
||||||
// the host component is unmounted while the dropdown is still open).
|
|
||||||
let mountedDropdown: object | null = null;
|
|
||||||
// Hoisted so onDestroy can cancel any pending fetch — otherwise a trailing
|
|
||||||
// debounced search can fire after the editor is gone and pollute later tests.
|
|
||||||
let cancelPendingSearch: (() => void) | null = null;
|
|
||||||
|
|
||||||
// Single reactive state object shared with MentionDropdown. Mutating these
|
// Single reactive state object shared with MentionDropdown. Mutating these
|
||||||
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
||||||
@@ -51,12 +42,10 @@ let dropdownState = $state<{
|
|||||||
items: Person[];
|
items: Person[];
|
||||||
command: (item: Person) => void;
|
command: (item: Person) => void;
|
||||||
clientRect: (() => DOMRect | null) | null;
|
clientRect: (() => DOMRect | null) | null;
|
||||||
editorQuery: string;
|
|
||||||
}>({
|
}>({
|
||||||
items: [],
|
items: [],
|
||||||
command: () => {},
|
command: () => {},
|
||||||
clientRect: null,
|
clientRect: null
|
||||||
editorQuery: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type DropdownExports = {
|
type DropdownExports = {
|
||||||
@@ -149,13 +138,16 @@ onMount(() => {
|
|||||||
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
||||||
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Tiptap's suggestion plugin requires an `items()` callback to keep
|
items: async ({ query }: { query: string }) => {
|
||||||
// the dropdown alive, but the actual fetch is owned by `runSearch`
|
if (!query) return [];
|
||||||
// below — routed through the dropdown's search input via the
|
try {
|
||||||
// debounced `onSearch` channel. Returning `[]` here keeps Tiptap
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
||||||
// happy without firing a duplicate per-keystroke fetch.
|
if (!res.ok) return [];
|
||||||
// Markus #5616 / Felix / Nora / Sara on PR #629.
|
return ((await res.json()) as Person[]).slice(0, 5);
|
||||||
items: async () => [],
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
||||||
command({ editor: ed, range, props }) {
|
command({ editor: ed, range, props }) {
|
||||||
const p = props as unknown as { personId: string; displayName: string };
|
const p = props as unknown as { personId: string; displayName: string };
|
||||||
@@ -173,6 +165,7 @@ onMount(() => {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
render() {
|
render() {
|
||||||
|
let component: object | null = null;
|
||||||
let exports: DropdownExports | null = null;
|
let exports: DropdownExports | null = null;
|
||||||
|
|
||||||
// Tiptap's SuggestionProps types `command` against the default
|
// Tiptap's SuggestionProps types `command` against the default
|
||||||
@@ -185,84 +178,25 @@ onMount(() => {
|
|||||||
clientRect?: (() => DOMRect | null) | null;
|
clientRect?: (() => DOMRect | null) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Request-token guard: every onSearch invocation bumps `requestId`;
|
|
||||||
// runSearch captures the id active when its fetch starts and discards
|
|
||||||
// the response if a newer onSearch has fired since. Without this, a
|
|
||||||
// late response can repopulate the dropdown after the user cleared
|
|
||||||
// the search input. Sara on PR #629.
|
|
||||||
let requestId = 0;
|
|
||||||
const runSearch = async (query: string) => {
|
|
||||||
const id = requestId;
|
|
||||||
try {
|
|
||||||
// Defensive client-side cap — server-side enforcement is tracked
|
|
||||||
// separately. Markus on PR #629.
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/persons?q=${encodeURIComponent(query)}&limit=${SEARCH_RESULT_LIMIT}`
|
|
||||||
);
|
|
||||||
if (id !== requestId) return;
|
|
||||||
if (!res.ok) {
|
|
||||||
dropdownState.items = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as Person[];
|
|
||||||
if (id !== requestId) return;
|
|
||||||
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT);
|
|
||||||
} catch {
|
|
||||||
if (id !== requestId) return;
|
|
||||||
dropdownState.items = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
|
|
||||||
cancelPendingSearch = () => debouncedSearch.cancel();
|
|
||||||
const onSearch = (query: string) => {
|
|
||||||
requestId++;
|
|
||||||
if (query.trim() === '') {
|
|
||||||
debouncedSearch.cancel();
|
|
||||||
dropdownState.items = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debouncedSearch(query);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateState = (renderProps: LooseRenderProps) => {
|
const updateState = (renderProps: LooseRenderProps) => {
|
||||||
// Clip once here so both the inserted displayName and the
|
dropdownState.items = renderProps.items as Person[];
|
||||||
// dropdown's editor-mirror see the same value. The dropdown
|
|
||||||
// already clips the mirror (Nora #1 CWE-400), but without
|
|
||||||
// clipping at the command boundary an unclipped query would
|
|
||||||
// still flow through as the inserted displayName — visible
|
|
||||||
// UI divergence between "what I searched" and "what was
|
|
||||||
// inserted". Felix #3 on PR #629.
|
|
||||||
const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH);
|
|
||||||
// AC-1: pass typed query as displayName, not person.displayName
|
// AC-1: pass typed query as displayName, not person.displayName
|
||||||
dropdownState.command = (item: Person) =>
|
dropdownState.command = (item: Person) =>
|
||||||
renderProps.command({
|
renderProps.command({
|
||||||
personId: item.id,
|
personId: item.id,
|
||||||
displayName: clippedQuery
|
displayName: renderProps.query
|
||||||
});
|
});
|
||||||
dropdownState.clientRect = renderProps.clientRect ?? null;
|
dropdownState.clientRect = renderProps.clientRect ?? null;
|
||||||
dropdownState.editorQuery = clippedQuery;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart(renderProps) {
|
onStart(renderProps) {
|
||||||
const loose = renderProps as unknown as LooseRenderProps;
|
updateState(renderProps as unknown as LooseRenderProps);
|
||||||
updateState(loose);
|
|
||||||
// MentionDropdown reads `editorQuery` off the shared state
|
|
||||||
// proxy via its `editorQuery` prop binding below — this is
|
|
||||||
// the same pattern as `model.items`. We do not pass it as a
|
|
||||||
// separate prop because Svelte 5's mount() does not expose
|
|
||||||
// settable prop accessors, so we route through the proxy.
|
|
||||||
const mounted = mount(MentionDropdown, {
|
const mounted = mount(MentionDropdown, {
|
||||||
target: document.body,
|
target: document.body,
|
||||||
props: {
|
props: { model: dropdownState }
|
||||||
model: dropdownState,
|
|
||||||
get editorQuery() {
|
|
||||||
return dropdownState.editorQuery;
|
|
||||||
},
|
|
||||||
onSearch
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
mountedDropdown = mounted as object;
|
component = mounted as object;
|
||||||
exports = mounted as unknown as DropdownExports;
|
exports = mounted as unknown as DropdownExports;
|
||||||
},
|
},
|
||||||
onUpdate(renderProps) {
|
onUpdate(renderProps) {
|
||||||
@@ -274,16 +208,9 @@ onMount(() => {
|
|||||||
return exports?.onKeyDown(event) ?? false;
|
return exports?.onKeyDown(event) ?? false;
|
||||||
},
|
},
|
||||||
onExit() {
|
onExit() {
|
||||||
// Cancel any pending debounce so a closed dropdown's trailing
|
if (component) {
|
||||||
// runSearch cannot fire against the *next* dropdown's state.
|
unmount(component);
|
||||||
// The hoisted `cancelPendingSearch` would be overwritten by
|
component = null;
|
||||||
// the next render()'s onStart before the trailing call fires,
|
|
||||||
// so we cancel locally via the closure-scoped debouncedSearch.
|
|
||||||
// Felix #1 on PR #629.
|
|
||||||
debouncedSearch.cancel();
|
|
||||||
if (mountedDropdown) {
|
|
||||||
unmount(mountedDropdown);
|
|
||||||
mountedDropdown = null;
|
|
||||||
exports = null;
|
exports = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,15 +253,7 @@ onMount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
cancelPendingSearch?.();
|
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
// Tiptap suggestion onExit usually unmounts the dropdown, but if the host
|
|
||||||
// component is destroyed while a suggestion is active the dropdown can
|
|
||||||
// outlive the editor — clean it up explicitly.
|
|
||||||
if (mountedDropdown) {
|
|
||||||
unmount(mountedDropdown);
|
|
||||||
mountedDropdown = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the data-placeholder attribute in sync with actual emptiness so the
|
// Keep the data-placeholder attribute in sync with actual emptiness so the
|
||||||
|
|||||||
@@ -8,45 +8,29 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import { tick } from 'svelte';
|
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
|
||||||
import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte';
|
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
// Single source of truth for the debounce window — imported from the shared
|
|
||||||
// module so the test cannot drift from production. Sara on PR #629 round 3.
|
|
||||||
import { SEARCH_DEBOUNCE_MS } from './mentionConstants';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
type PersonMention = components['schemas']['PersonMention'];
|
type PersonMention = components['schemas']['PersonMention'];
|
||||||
|
|
||||||
/**
|
|
||||||
* Headroom above SEARCH_DEBOUNCE_MS for the debounce-window wait
|
|
||||||
* assertions in this file. 350 ms is calibrated against CI-runner jitter
|
|
||||||
* we observed pre-#629; dropping it below ~200 ms reintroduces flake.
|
|
||||||
* See PR #629 round-2 review comment #10935 (Sara).
|
|
||||||
*/
|
|
||||||
const POST_DEBOUNCE_SLACK_MS = 350;
|
|
||||||
|
|
||||||
const AUGUSTE: Person = {
|
const AUGUSTE: Person = {
|
||||||
id: 'p-aug',
|
id: 'p-aug',
|
||||||
firstName: 'Auguste',
|
firstName: 'Auguste',
|
||||||
lastName: 'Raddatz',
|
lastName: 'Raddatz',
|
||||||
displayName: 'Auguste Raddatz',
|
displayName: 'Auguste Raddatz',
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false,
|
|
||||||
birthYear: 1882,
|
birthYear: 1882,
|
||||||
deathYear: 1944
|
deathYear: 1944
|
||||||
};
|
} as unknown as Person;
|
||||||
|
|
||||||
const ANNA: Person = {
|
const ANNA: Person = {
|
||||||
id: 'p-anna',
|
id: 'p-anna',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Schmidt',
|
lastName: 'Schmidt',
|
||||||
displayName: 'Anna Schmidt',
|
displayName: 'Anna Schmidt',
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false,
|
|
||||||
birthYear: 1860
|
birthYear: 1860
|
||||||
};
|
} as unknown as Person;
|
||||||
|
|
||||||
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -141,20 +125,6 @@ describe('PersonMentionEditor — typeahead', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows life dates next to the name in the dropdown', async () => {
|
it('shows life dates next to the name in the dropdown', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
renderHost();
|
renderHost();
|
||||||
@@ -172,15 +142,8 @@ describe('PersonMentionEditor — typeahead', () => {
|
|||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||||
|
|
||||||
// The visible empty-state <p> (text-ink-3) shows the copy. The persistent
|
await vi.waitFor(async () => {
|
||||||
// sr-only aria-live region also contains the same copy, so we scope to the
|
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||||
// visible element to avoid a multi-match resolution in expect.element.
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const visibleEmptyP = document.querySelector(
|
|
||||||
'[role="listbox"] p.text-ink-3'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(visibleEmptyP).not.toBeNull();
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,254 +161,6 @@ describe('PersonMentionEditor — typeahead', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── AC-2/3: search input drives the person fetch (debounced) ───────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
|
|
||||||
it('editing the search input fires a debounced fetch with the new query', async () => {
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
// Open the dropdown so the search input is reachable.
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchesBeforeSearch = fetchMock.mock.calls.length;
|
|
||||||
|
|
||||||
// `fill` simulates a single input event with the final value — sidesteps
|
|
||||||
// per-keystroke timing of userEvent.type so the test can deterministically
|
|
||||||
// assert that one input event collapses into one debounced fetch.
|
|
||||||
await page.getByRole('searchbox').fill('Walter');
|
|
||||||
|
|
||||||
await vi.waitFor(
|
|
||||||
() => {
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter'));
|
|
||||||
},
|
|
||||||
{ timeout: 1000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch;
|
|
||||||
expect(fetchesAfterSearch).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fires exactly one /api/persons fetch when the user searches for Walter (regression guard)', async () => {
|
|
||||||
// Regression guard: a previous version of PersonMentionEditor had a
|
|
||||||
// duplicated `items()` callback in the Tiptap suggestion config that
|
|
||||||
// fetched per-keystroke in addition to the debounced search-input fetch
|
|
||||||
// (Markus & Felix round-1). To catch that regression, we must NOT
|
|
||||||
// subtract any baseline — every fetch from render onwards counts.
|
|
||||||
// Sara on PR #629 round 3.
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
// Open the dropdown, then drive the search input via fill() — sidesteps
|
|
||||||
// per-keystroke timing of userEvent.type that Sara flagged round 2.
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
|
||||||
});
|
|
||||||
await page.getByRole('searchbox').fill('Walter');
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
|
||||||
|
|
||||||
// No baseline subtraction — count ALL /api/persons fetches since render.
|
|
||||||
// If the legacy per-keystroke items() callback returns, typing `@` alone
|
|
||||||
// would already produce one fetch and `fill('Walter')` another, breaking
|
|
||||||
// this assertion.
|
|
||||||
const personsFetches = fetchMock.mock.calls.filter(
|
|
||||||
([url]) => typeof url === 'string' && url.startsWith('/api/persons')
|
|
||||||
);
|
|
||||||
expect(personsFetches.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clearing the search input clears the list without firing a fetch', async () => {
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchesBeforeClear = fetchMock.mock.calls.length;
|
|
||||||
|
|
||||||
await userEvent.clear(page.getByRole('searchbox'));
|
|
||||||
|
|
||||||
// Negative assertion: wait past the debounce window to confirm no
|
|
||||||
// trailing fetch was scheduled. Removing this wait would mask a
|
|
||||||
// re-introduction of the keystroke-driven items() fetch.
|
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
|
||||||
|
|
||||||
expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear);
|
|
||||||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Whitespace-only query (Elicit AC-4 ambiguity on PR #629) ───────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — whitespace-only query', () => {
|
|
||||||
it('keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@ ');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
|
||||||
|
|
||||||
// Scope to the visible empty-state <p> (text-ink-3) — the persistent
|
|
||||||
// sr-only aria-live region above contains the same copy.
|
|
||||||
const visibleEmptyP = document.querySelector(
|
|
||||||
'[role="listbox"] p.text-ink-3'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(visibleEmptyP).not.toBeNull();
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Stale-response race (Sara on PR #629) ───────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — stale-response race', () => {
|
|
||||||
it('discards a stale response that resolves after the search has been cleared', async () => {
|
|
||||||
let resolveFetch!: (v: { ok: boolean; json: () => Promise<Person[]> }) => void;
|
|
||||||
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<Person[]> }>((r) => {
|
|
||||||
resolveFetch = r;
|
|
||||||
});
|
|
||||||
const fetchMock = vi.fn().mockReturnValue(pendingResponse);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
// Open the dropdown and let the debounce fire so a fetch is in flight.
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the search input *before* the fetch resolves.
|
|
||||||
await userEvent.clear(page.getByRole('searchbox'));
|
|
||||||
await expect.element(page.getByRole('searchbox')).toHaveValue('');
|
|
||||||
|
|
||||||
// The stale fetch now resolves with persons. The dropdown must stay empty.
|
|
||||||
resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) });
|
|
||||||
// Flush pending Svelte reactivity so any (non-)update from the stale
|
|
||||||
// fetch resolution has landed before we assert. expect.element already
|
|
||||||
// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4.
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Server failure characterization (Sara #2 on PR #629) ───────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — server failure', () => {
|
|
||||||
it('on 500 response keeps the dropdown open with the empty-state copy (silent failure pinned; distinct error UX tracked separately)', async () => {
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ ok: false, status: 500, json: vi.fn().mockResolvedValue({}) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
|
||||||
|
|
||||||
// Pins current silent-failure behaviour. The day someone implements a
|
|
||||||
// distinct error UX (toast / "Suche fehlgeschlagen" copy), this test
|
|
||||||
// goes red and forces them to update the assertion. Scope to the
|
|
||||||
// visible <p> (text-ink-3) — the persistent sr-only live region
|
|
||||||
// above contains the same copy.
|
|
||||||
const visibleEmptyP = document.querySelector(
|
|
||||||
'[role="listbox"] p.text-ink-3'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(visibleEmptyP).not.toBeNull();
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => {
|
|
||||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('NetworkError'));
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
|
||||||
|
|
||||||
const visibleEmptyP = document.querySelector(
|
|
||||||
'[role="listbox"] p.text-ink-3'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
expect(visibleEmptyP).not.toBeNull();
|
|
||||||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── onExit cancels pending debounce (Felix #1 on PR #629) ───────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — onExit cancels pending debounce', () => {
|
|
||||||
it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
// Open the dropdown by typing @ + a query in the editor.
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@A');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for any in-flight fetch from opening the dropdown to settle.
|
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
|
||||||
const fetchesBeforeEscape = fetchMock.mock.calls.length;
|
|
||||||
|
|
||||||
// Trigger a new debounced search (queues runSearch after 150 ms), then
|
|
||||||
// immediately Escape *while focus is back in the editor* so Tiptap's
|
|
||||||
// suggestion-plugin Escape handler fires onExit before the debounce.
|
|
||||||
// Without onExit cancelling the pending debounce, runSearch executes
|
|
||||||
// against the now-unmounted dropdown's state.
|
|
||||||
await page.getByRole('searchbox').fill('Walter');
|
|
||||||
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
|
|
||||||
(page.getByRole('textbox').element() as HTMLElement).focus();
|
|
||||||
await userEvent.keyboard('{Escape}');
|
|
||||||
|
|
||||||
// Wait past the debounce window. If onExit did not cancel the pending
|
|
||||||
// debounce, a fetch with q=Walter would still fire here.
|
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
|
||||||
|
|
||||||
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
|
|
||||||
const walterFetches = newFetches.filter(
|
|
||||||
([url]) => typeof url === 'string' && url.includes('q=Walter')
|
|
||||||
);
|
|
||||||
expect(walterFetches.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
|
||||||
it('prefills the dropdown search input with the text typed after @', async () => {
|
|
||||||
mockFetchEmpty();
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@WdG');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||||
@@ -514,39 +229,6 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clips the inserted displayName to MAX_QUERY_LENGTH=100 chars (Felix #3 on PR #629)', async () => {
|
|
||||||
// CWE-400 amplification: the dropdown clips its search input + mirror at
|
|
||||||
// 100 chars (Nora #1), but the host editor was passing the unclipped
|
|
||||||
// renderProps.query straight through to displayName — so a 105-char
|
|
||||||
// @-suffix in the editor could insert a 105-char displayName into the
|
|
||||||
// sidecar even though the dropdown only searched the first 100.
|
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
// Type @ + 105 'A' chars in the contenteditable. The renderProps.query
|
|
||||||
// fed into the command callback derives from the editor text after `@`,
|
|
||||||
// not the dropdown's searchbox — so we must drive the editor.
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@' + 'A'.repeat(105));
|
|
||||||
|
|
||||||
// The mocked /api/persons returns AUGUSTE for any query — wait for it.
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
const option = (await page
|
|
||||||
.getByRole('option', { name: /Auguste Raddatz/ })
|
|
||||||
.element()) as HTMLElement;
|
|
||||||
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
|
||||||
// Tight assertion: input is 105 chars, cap is exactly 100. Using
|
|
||||||
// `toHaveLength(100)` discriminates "clip works" from "clip works
|
|
||||||
// AND nothing weakened it to e.g. 95". Sara on PR #629 round 4.
|
|
||||||
expect(host.snapshot.mentionedPersons[0].displayName).toHaveLength(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
const host = renderHost({
|
const host = renderHost({
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
/** Shared knobs for the @mention typeahead. Single source of truth for
|
|
||||||
* the dropdown component and the host editor — keeps the layered length
|
|
||||||
* cap and the debounce window consistent across both files. */
|
|
||||||
export const MAX_QUERY_LENGTH = 100;
|
|
||||||
export const SEARCH_DEBOUNCE_MS = 150;
|
|
||||||
/** Defensive client-side cap on the result list. Single consumer today
|
|
||||||
* (PersonMentionEditor), kept here for symmetry with the other limit
|
|
||||||
* knobs so the @mention configuration lives in one place. Felix #1 on
|
|
||||||
* PR #629 round 4. */
|
|
||||||
export const SEARCH_RESULT_LIMIT = 5;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import TestHost from './confirm.test-fixture.svelte';
|
import TestHost from './confirm.test-host.svelte';
|
||||||
import type { ConfirmService } from './confirm.svelte.js';
|
import type { ConfirmService } from './confirm.svelte.js';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Returns a debounced version of fn that delays invocation until after
|
* Returns a debounced version of fn that delays invocation until after
|
||||||
* `delay` ms have elapsed since the last call. The returned function
|
* `delay` ms have elapsed since the last call.
|
||||||
* exposes a `cancel()` method that DROPS (does not flush) the pending
|
|
||||||
* trailing invocation — essential when the host context (a destroyed
|
|
||||||
* component, an unmounted editor) shouldn't fire the trailing call.
|
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function debounce<T extends (...args: any[]) => void>(
|
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
||||||
fn: T,
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
delay: number
|
return ((...args: Parameters<T>) => {
|
||||||
): T & { cancel: () => void } {
|
clearTimeout(timer);
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const wrapped = ((...args: Parameters<T>) => {
|
|
||||||
if (timer !== undefined) clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => fn(...args), delay);
|
timer = setTimeout(() => fn(...args), delay);
|
||||||
}) as T & { cancel: () => void };
|
}) as T;
|
||||||
wrapped.cancel = () => {
|
|
||||||
if (timer !== undefined) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return wrapped;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components, operations } from '$lib/generated/api';
|
import type { components, operations } from '$lib/generated/api';
|
||||||
|
|
||||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||||
@@ -65,3 +67,31 @@ export async function load({ fetch, url }) {
|
|||||||
loadError
|
loadError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
'dismiss-notification': async ({ request, fetch }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const raw = data.get('notificationId');
|
||||||
|
const notificationId = typeof raw === 'string' ? raw : null;
|
||||||
|
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.PATCH('/api/notifications/{id}/read', {
|
||||||
|
params: { path: { id: notificationId } }
|
||||||
|
});
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
'mark-all-read': async ({ fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.POST('/api/notifications/read-all');
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -76,14 +76,6 @@ async function onFilterChange(v: FilterValue) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onMarkRead(n: NotificationItem) {
|
|
||||||
await notificationStore.markRead(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onMarkAllRead() {
|
|
||||||
await notificationStore.markAllRead();
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
||||||
|
|
||||||
const isEmpty = $derived(displayFeed.length === 0);
|
const isEmpty = $derived(displayFeed.length === 0);
|
||||||
@@ -108,7 +100,11 @@ function retry() {
|
|||||||
{#if data.loadError === 'activity'}
|
{#if data.loadError === 'activity'}
|
||||||
<ChronikErrorCard onRetry={retry} />
|
<ChronikErrorCard onRetry={retry} />
|
||||||
{:else}
|
{:else}
|
||||||
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
<ChronikFuerDichBox
|
||||||
|
unread={unread}
|
||||||
|
optimisticMarkRead={notificationStore.optimisticMarkRead}
|
||||||
|
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { load } from './+page.server';
|
import { load, actions } from './+page.server';
|
||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
GET: vi.fn()
|
GET: vi.fn(),
|
||||||
|
PATCH: vi.fn(),
|
||||||
|
POST: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({
|
||||||
@@ -173,3 +175,84 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
|||||||
expect(call[1].params.query.kinds).toHaveLength(2);
|
expect(call[1].params.query.kinds).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function makeActionEvent(formData: FormData): any {
|
||||||
|
return {
|
||||||
|
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
|
||||||
|
fetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('aktivitaeten/actions — dismiss-notification', () => {
|
||||||
|
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
|
||||||
|
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
expect(mockApi.PATCH).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
|
||||||
|
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('notificationId', 'n-abc');
|
||||||
|
|
||||||
|
await actions['dismiss-notification'](makeActionEvent(fd));
|
||||||
|
|
||||||
|
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
|
||||||
|
params: { path: { id: 'n-abc' } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { success: true } when the API responds ok', async () => {
|
||||||
|
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('notificationId', 'n-abc');
|
||||||
|
|
||||||
|
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
||||||
|
mockApi.PATCH.mockResolvedValue({
|
||||||
|
response: { ok: false, status: 403 },
|
||||||
|
error: { code: 'NOTIFICATION_NOT_FOUND' }
|
||||||
|
});
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('notificationId', 'n-abc');
|
||||||
|
|
||||||
|
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aktivitaeten/actions — mark-all-read', () => {
|
||||||
|
it('calls POST /api/notifications/read-all', async () => {
|
||||||
|
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
||||||
|
|
||||||
|
await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { success: true } when the API responds ok', async () => {
|
||||||
|
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
||||||
|
|
||||||
|
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
||||||
|
mockApi.POST.mockResolvedValue({
|
||||||
|
response: { ok: false, status: 500 },
|
||||||
|
error: { code: 'INTERNAL_ERROR' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user