diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 0ac0a807..f0535639 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -130,6 +130,29 @@ "persons_search_placeholder": "Namen suchen...", "persons_empty_heading": "Keine Personen gefunden.", "persons_empty_text": "Versuchen Sie einen anderen Suchbegriff.", + "persons_empty_filtered": "Keine Personen für diese Filter.", + "persons_filter_group_label": "Filter", + "persons_filter_type_person": "Person", + "persons_filter_type_group": "Gruppe", + "persons_filter_type_institution": "Institution", + "persons_filter_family_only": "Nur Familie", + "persons_filter_has_documents": "Mit Dokumenten", + "persons_toggle_show_all": "Alle anzeigen", + "persons_toggle_needs_review": "Zu prüfen ({count})", + "person_badge_unconfirmed": "unbestätigt", + "persons_review_heading": "Personen prüfen", + "persons_review_intro": "Vom Import erzeugte, noch nicht bestätigte Personen. Zusammenführen, umbenennen, bestätigen oder löschen.", + "persons_review_action_merge": "Zusammenführen", + "persons_review_action_rename": "Umbenennen", + "persons_review_action_confirm": "Bestätigen", + "persons_review_action_delete": "Löschen", + "persons_review_action_cancel": "Abbrechen", + "persons_review_action_save": "Speichern", + "persons_review_empty": "Keine Personen zu prüfen.", + "persons_review_delete_confirm_title": "Person löschen", + "persons_review_delete_confirm_text": "Diese Person wird endgültig gelöscht. Dokumentverweise bleiben erhalten, verlieren aber diese Person.", + "persons_review_delete_confirm_button": "Person löschen", + "persons_review_merge_label": "Mit welcher Person zusammenführen?", "persons_new_heading": "Neue Person", "persons_section_details": "Angaben zur Person", "person_edit_heading": "Person bearbeiten", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 269e95d3..c0eb58ff 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -130,6 +130,29 @@ "persons_search_placeholder": "Search names...", "persons_empty_heading": "No persons found.", "persons_empty_text": "Try a different search term.", + "persons_empty_filtered": "No persons match these filters.", + "persons_filter_group_label": "Filter", + "persons_filter_type_person": "Person", + "persons_filter_type_group": "Group", + "persons_filter_type_institution": "Institution", + "persons_filter_family_only": "Family only", + "persons_filter_has_documents": "With documents", + "persons_toggle_show_all": "Show all", + "persons_toggle_needs_review": "Needs review ({count})", + "person_badge_unconfirmed": "unconfirmed", + "persons_review_heading": "Review persons", + "persons_review_intro": "Import-generated persons not yet confirmed. Merge, rename, confirm or delete.", + "persons_review_action_merge": "Merge", + "persons_review_action_rename": "Rename", + "persons_review_action_confirm": "Confirm", + "persons_review_action_delete": "Delete", + "persons_review_action_cancel": "Cancel", + "persons_review_action_save": "Save", + "persons_review_empty": "No persons to review.", + "persons_review_delete_confirm_title": "Delete person", + "persons_review_delete_confirm_text": "This person will be permanently deleted. Document references are kept but lose this person.", + "persons_review_delete_confirm_button": "Delete person", + "persons_review_merge_label": "Merge into which person?", "persons_new_heading": "New person", "persons_section_details": "Person details", "person_edit_heading": "Edit person", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1cbd3eda..4c6dbc3c 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -130,6 +130,29 @@ "persons_search_placeholder": "Buscar nombres...", "persons_empty_heading": "No se encontraron personas.", "persons_empty_text": "Pruebe con otro término de búsqueda.", + "persons_empty_filtered": "Ninguna persona coincide con estos filtros.", + "persons_filter_group_label": "Filtro", + "persons_filter_type_person": "Persona", + "persons_filter_type_group": "Grupo", + "persons_filter_type_institution": "Institución", + "persons_filter_family_only": "Solo familia", + "persons_filter_has_documents": "Con documentos", + "persons_toggle_show_all": "Mostrar todo", + "persons_toggle_needs_review": "Por revisar ({count})", + "person_badge_unconfirmed": "sin confirmar", + "persons_review_heading": "Revisar personas", + "persons_review_intro": "Personas generadas por la importación aún sin confirmar. Fusionar, renombrar, confirmar o eliminar.", + "persons_review_action_merge": "Fusionar", + "persons_review_action_rename": "Renombrar", + "persons_review_action_confirm": "Confirmar", + "persons_review_action_delete": "Eliminar", + "persons_review_action_cancel": "Cancelar", + "persons_review_action_save": "Guardar", + "persons_review_empty": "No hay personas por revisar.", + "persons_review_delete_confirm_title": "Eliminar persona", + "persons_review_delete_confirm_text": "Esta persona se eliminará de forma permanente. Las referencias de documentos se conservan pero pierden a esta persona.", + "persons_review_delete_confirm_button": "Eliminar persona", + "persons_review_merge_label": "¿Fusionar con qué persona?", "persons_new_heading": "Nueva persona", "persons_section_details": "Datos de la persona", "person_edit_heading": "Editar persona", diff --git a/frontend/src/lib/person/PersonCard.svelte b/frontend/src/lib/person/PersonCard.svelte new file mode 100644 index 00000000..feb609ad --- /dev/null +++ b/frontend/src/lib/person/PersonCard.svelte @@ -0,0 +1,146 @@ + + + +
+ +
+ {#if showGlyph} + {#if person.personType === 'INSTITUTION'} + + + + {:else if person.personType === 'GROUP'} + + + + {:else} + + + {/if} + {:else} + {initials} + {/if} +
+ + +

+ {person.displayName} +

+ + {#if isUnconfirmed} + + + + {m.person_badge_unconfirmed()} + + {:else if person.personType && person.personType !== 'PERSON'} + + {/if} + + + {#if person.alias} +

„{person.alias}"

+ {/if} + + + {#if person.birthYear || person.deathYear} +

+ {formatLifeDateRange(person.birthYear, person.deathYear)} +

+ {/if} + + + {#if documentCount > 0} + + {documentCount === 1 + ? m.person_card_doc_count_one() + : m.person_card_doc_count_many({ count: documentCount })} + + {/if} +
+
diff --git a/frontend/src/lib/person/PersonCard.svelte.test.ts b/frontend/src/lib/person/PersonCard.svelte.test.ts new file mode 100644 index 00000000..8b50875e --- /dev/null +++ b/frontend/src/lib/person/PersonCard.svelte.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonCard from './PersonCard.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['PersonSummaryDTO']; + +const makePerson = (overrides: Partial = {}): Person => ({ + id: 'p-1', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt', + personType: 'PERSON', + familyMember: false, + provisional: false, + documentCount: 0, + ...overrides +}); + +afterEach(cleanup); + +describe('PersonCard — confirmed person', () => { + it('renders the display name', async () => { + render(PersonCard, { props: { person: makePerson() } }); + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + }); + + it('does not show an unconfirmed badge for a confirmed person', async () => { + render(PersonCard, { props: { person: makePerson() } }); + await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument(); + }); +}); + +describe('PersonCard — unconfirmed / malformed (regression: null-lastName crash)', () => { + it('renders without throwing when lastName is null', async () => { + // Before the fix, `lastName[0]` threw at render for a null lastName. + const person = makePerson({ + lastName: null as unknown as string, + displayName: '?', + provisional: true + }); + render(PersonCard, { props: { person } }); + // No throw + the placeholder avatar (an ) is present, never a "?" initial. + await expect.element(page.getByText('unbestätigt')).toBeVisible(); + }); + + it('shows an unbestätigt badge for a provisional person', async () => { + render(PersonCard, { props: { person: makePerson({ provisional: true }) } }); + await expect.element(page.getByText('unbestätigt')).toBeVisible(); + }); + + it('shows a placeholder (no "?" initial) for a "?" name', async () => { + render(PersonCard, { + props: { person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' }) } + }); + await expect.element(page.getByText('unbestätigt')).toBeVisible(); + }); + + it('treats an UNKNOWN type as unconfirmed', async () => { + render(PersonCard, { + props: { person: makePerson({ personType: 'UNKNOWN', displayName: 'Unklar' }) } + }); + await expect.element(page.getByText('unbestätigt')).toBeVisible(); + }); +}); diff --git a/frontend/src/lib/person/PersonFilterBar.svelte b/frontend/src/lib/person/PersonFilterBar.svelte new file mode 100644 index 00000000..686dffe9 --- /dev/null +++ b/frontend/src/lib/person/PersonFilterBar.svelte @@ -0,0 +1,160 @@ + + +
+ +
+ {#each typeChips as chip (chip.value)} + {@const active = type === chip.value} + + {/each} + + + + +
+ + + {#if canWrite} + + {/if} +
diff --git a/frontend/src/lib/person/PersonFilterBar.svelte.test.ts b/frontend/src/lib/person/PersonFilterBar.svelte.test.ts new file mode 100644 index 00000000..f1069f90 --- /dev/null +++ b/frontend/src/lib/person/PersonFilterBar.svelte.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page as browserPage } from 'vitest/browser'; +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import PersonFilterBar from './PersonFilterBar.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } })); + +const gotoMock = vi.mocked(goto); + +function setUrl(search: string) { + (page as unknown as { url: URL }).url = new URL(`http://localhost/persons${search}`); +} + +beforeEach(() => { + gotoMock.mockClear(); + setUrl(''); +}); +afterEach(cleanup); + +const baseProps = { + type: undefined, + familyOnly: false, + hasDocuments: false, + review: false, + needsReviewCount: 12, + canWrite: true +}; + +describe('PersonFilterBar — chip activation', () => { + it('activating a type chip sets type and resets page', async () => { + setUrl('?page=3&q=Anna'); + render(PersonFilterBar, { props: baseProps }); + + await browserPage.getByRole('button', { name: 'Institution' }).click(); + + expect(gotoMock).toHaveBeenCalled(); + const target = gotoMock.mock.calls.at(-1)![0] as string; + const url = new URL(target, 'http://localhost'); + expect(url.searchParams.get('type')).toBe('INSTITUTION'); + expect(url.searchParams.get('q')).toBe('Anna'); // preserved + expect(url.searchParams.get('page')).toBeNull(); // reset + }); + + it('activating "Nur Familie" sets familyOnly=true', async () => { + render(PersonFilterBar, { props: baseProps }); + + await browserPage.getByRole('button', { name: 'Nur Familie' }).click(); + + const target = gotoMock.mock.calls.at(-1)![0] as string; + const url = new URL(target, 'http://localhost'); + expect(url.searchParams.get('familyOnly')).toBe('true'); + }); + + it('deactivating an already-active chip removes the param', async () => { + render(PersonFilterBar, { props: { ...baseProps, hasDocuments: true } }); + + await browserPage.getByRole('button', { name: 'Mit Dokumenten' }).click(); + + const target = gotoMock.mock.calls.at(-1)![0] as string; + const url = new URL(target, 'http://localhost'); + expect(url.searchParams.get('hasDocuments')).toBeNull(); + }); +}); + +describe('PersonFilterBar — review toggle', () => { + it('renders a switch with the needs-review count in its accessible name', async () => { + render(PersonFilterBar, { props: baseProps }); + await expect + .element(browserPage.getByRole('switch', { name: /Zu prüfen \(12\)/ })) + .toBeVisible(); + }); + + it('is hidden for users without write permission', async () => { + render(PersonFilterBar, { props: { ...baseProps, canWrite: false } }); + await expect.element(browserPage.getByRole('switch')).not.toBeInTheDocument(); + }); + + it('toggling on sets review=true', async () => { + render(PersonFilterBar, { props: baseProps }); + + await browserPage.getByRole('switch').click(); + + const target = gotoMock.mock.calls.at(-1)![0] as string; + const url = new URL(target, 'http://localhost'); + expect(url.searchParams.get('review')).toBe('true'); + }); +}); diff --git a/frontend/src/routes/persons/+page.server.ts b/frontend/src/routes/persons/+page.server.ts index 62a1f8cf..071208e2 100644 --- a/frontend/src/routes/persons/+page.server.ts +++ b/frontend/src/routes/persons/+page.server.ts @@ -2,8 +2,23 @@ import { error } from '@sveltejs/kit'; import { createApiClient } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; +const PAGE_SIZE = 50; + +type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP'; + +function parseType(raw: string | null): PersonType | undefined { + return raw === 'PERSON' || raw === 'INSTITUTION' || raw === 'GROUP' ? raw : undefined; +} + export async function load({ url, fetch, locals }) { const q = url.searchParams.get('q') || ''; + const page = Math.max(0, Number.parseInt(url.searchParams.get('page') ?? '0', 10) || 0); + const review = + url.searchParams.get('review') === '1' || url.searchParams.get('review') === 'true'; + const type = parseType(url.searchParams.get('type')); + const familyOnly = url.searchParams.get('familyOnly') === 'true'; + const hasDocuments = url.searchParams.get('hasDocuments') === 'true'; + const api = createApiClient(fetch); const canWrite = @@ -11,15 +26,32 @@ export async function load({ url, fetch, locals }) { g.permissions.includes('WRITE_ALL') ) ?? false; - const [personsResult, statsResult] = await Promise.all([ - api.GET('/api/persons', { params: { query: { q: q || undefined } } }), - api.GET('/api/stats', {}) + const filters = { + q: q || undefined, + type, + familyOnly: familyOnly || undefined, + hasDocuments: hasDocuments || undefined, + review: review || undefined, + page, + size: PAGE_SIZE + }; + + // The "Zu prüfen (N)" link count is the totalElements of a provisional-only query. A size=1 + // page keeps the extra request cheap — we only need the count, not the rows. + const [personsResult, statsResult, reviewCountResult] = await Promise.all([ + api.GET('/api/persons', { params: { query: filters } }), + api.GET('/api/stats', {}), + canWrite + ? api.GET('/api/persons', { params: { query: { provisional: true, review: true, size: 1 } } }) + : Promise.resolve(null) ]); if (!personsResult.response.ok) { throw error(personsResult.response.status, getErrorMessage(undefined)); } + const result = personsResult.data!; + const stats = statsResult.response.ok ? { totalPersons: statsResult.data!.totalPersons ?? 0, @@ -27,5 +59,21 @@ export async function load({ url, fetch, locals }) { } : { totalPersons: 0, totalDocuments: 0 }; - return { persons: personsResult.data!, stats, q, canWrite }; + const needsReviewCount = + reviewCountResult && reviewCountResult.response.ok + ? (reviewCountResult.data!.totalElements ?? 0) + : 0; + + return { + persons: result.items, + totalElements: result.totalElements, + totalPages: result.totalPages, + pageNumber: result.pageNumber, + pageSize: result.pageSize, + filters: { type, familyOnly, hasDocuments, review }, + needsReviewCount, + stats, + q, + canWrite + }; } diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte index 5522d3ad..06080c83 100644 --- a/frontend/src/routes/persons/+page.svelte +++ b/frontend/src/routes/persons/+page.svelte @@ -1,9 +1,12 @@ @@ -32,7 +54,7 @@ function handleSearch() {
-
+

{m.page_title_persons()}

diff --git a/frontend/src/routes/persons/page.server.spec.ts b/frontend/src/routes/persons/page.server.spec.ts new file mode 100644 index 00000000..814fe2c8 --- /dev/null +++ b/frontend/src/routes/persons/page.server.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/shared/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +function makeUrl(params: Record = {}) { + const url = new URL('http://localhost/persons'); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + return url; +} + +/** Invokes the loader with a minimal event; the partial event is cast to satisfy the type. */ +function runLoad(url: URL, user: unknown) { + return load({ + url, + fetch: vi.fn() as unknown as typeof fetch, + request: new Request('http://localhost/persons'), + locals: { user } as App.Locals + } as unknown as Parameters[0]); +} + +/** Mock the typed client. /api/persons returns a paged envelope; /api/stats returns counts. */ +function mockApi() { + const personsResult = { + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }; + // Loose `...args` signature (matching the documents loader spec) so call tuples aren't + // narrowed to length 1 — the test inspects calls[i][1].params.query. + const get = vi.fn((...args: unknown[]) => { + if (args[0] === '/api/stats') { + return Promise.resolve({ + response: { ok: true, status: 200 }, + data: { totalPersons: 7, totalDocuments: 3 } + }); + } + return Promise.resolve(personsResult); + }); + vi.mocked(createApiClient).mockReturnValue({ GET: get } as unknown as ReturnType< + typeof createApiClient + >); + return get; +} + +const writer = { groups: [{ permissions: ['READ_ALL', 'WRITE_ALL'] }] }; +const reader = { groups: [{ permissions: ['READ_ALL'] }] }; + +type GetCall = [string, { params: { query: Record } }]; + +/** Find the GET call to a path, optionally narrowing by a query predicate. */ +function findCall( + get: ReturnType, + path: string, + matchQuery?: (q: Record) => boolean +): GetCall | undefined { + return (get.mock.calls as unknown as GetCall[]).find( + (c) => c[0] === path && (!matchQuery || matchQuery(c[1].params.query)) + ); +} + +describe('persons page load — reader default', () => { + it('does NOT pass review when no review param is present (clean reader default)', async () => { + const get = mockApi(); + + await runLoad(makeUrl(), reader); + + const personsCall = findCall(get, '/api/persons'); + expect(personsCall?.[1].params.query.review).toBeUndefined(); + }); + + it('passes review=true when review=1 is in the URL', async () => { + const get = mockApi(); + + await runLoad(makeUrl({ review: '1' }), reader); + + const personsCall = findCall(get, '/api/persons'); + expect(personsCall?.[1].params.query.review).toBe(true); + }); +}); + +describe('persons page load — filter forwarding', () => { + it('forwards type, familyOnly, hasDocuments and page to the API', async () => { + const get = mockApi(); + + await runLoad( + makeUrl({ type: 'INSTITUTION', familyOnly: 'true', hasDocuments: 'true', page: '2' }), + reader + ); + + const personsCall = findCall(get, '/api/persons'); + expect(personsCall?.[1].params.query).toMatchObject({ + type: 'INSTITUTION', + familyOnly: true, + hasDocuments: true, + page: 2, + size: 50 + }); + }); + + it('clamps a negative page to 0', async () => { + const get = mockApi(); + + await runLoad(makeUrl({ page: '-5' }), reader); + + const personsCall = findCall(get, '/api/persons'); + expect(personsCall?.[1].params.query.page).toBe(0); + }); +}); + +describe('persons page load — needsReviewCount', () => { + it('fires a provisional count request for writers', async () => { + const get = mockApi(); + + await runLoad(makeUrl(), writer); + + const provisionalCall = findCall(get, '/api/persons', (query) => query.provisional === true); + expect(provisionalCall).toBeDefined(); + }); + + it('does not fire a provisional count request for read-only users', async () => { + const get = mockApi(); + + const result = await runLoad(makeUrl(), reader); + + const provisionalCall = findCall(get, '/api/persons', (query) => query.provisional === true); + expect(provisionalCall).toBeUndefined(); + expect(result.needsReviewCount).toBe(0); + expect(result.canWrite).toBe(false); + }); +}); diff --git a/frontend/src/routes/persons/page.svelte.spec.ts b/frontend/src/routes/persons/page.svelte.spec.ts index 19697f80..f20c22fe 100644 --- a/frontend/src/routes/persons/page.svelte.spec.ts +++ b/frontend/src/routes/persons/page.svelte.spec.ts @@ -6,6 +6,7 @@ import Page from './+page.svelte'; const tick = () => new Promise((r) => setTimeout(r, 0)); vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } })); const makePerson = (overrides = {}) => ({ id: '1', @@ -13,6 +14,8 @@ const makePerson = (overrides = {}) => ({ lastName: 'Mustermann', displayName: 'Max Mustermann', documentCount: 0, + provisional: false, + personType: 'PERSON', ...overrides }); @@ -24,7 +27,13 @@ const emptyData = { canBlogWrite: false, q: '', persons: [], - stats: defaultStats + stats: defaultStats, + totalElements: 0, + totalPages: 0, + pageNumber: 0, + pageSize: 50, + filters: { type: undefined, familyOnly: false, hasDocuments: false, review: false }, + needsReviewCount: 0 }; const dataWithPersons = { ...emptyData, diff --git a/frontend/src/routes/persons/page.svelte.test.ts b/frontend/src/routes/persons/page.svelte.test.ts index 71d90e6f..8a1cea20 100644 --- a/frontend/src/routes/persons/page.svelte.test.ts +++ b/frontend/src/routes/persons/page.svelte.test.ts @@ -16,6 +16,8 @@ vi.mock('$app/navigation', () => ({ onNavigate: () => () => {} })); +vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } })); + const { default: PersonsListPage } = await import('./+page.svelte'); afterEach(cleanup); @@ -31,8 +33,15 @@ const baseData = (overrides: Record = {}) => ({ birthYear?: number; deathYear?: number; documentCount?: number; + provisional?: boolean; }>, stats: { totalPersons: 0, totalDocuments: 0 }, + totalElements: 0, + totalPages: 0, + pageNumber: 0, + pageSize: 50, + filters: { type: undefined, familyOnly: false, hasDocuments: false, review: false }, + needsReviewCount: 0, canWrite: false, q: '', ...overrides