diff --git a/frontend/messages/de.json b/frontend/messages/de.json index d791b094..00cc1488 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -175,6 +175,9 @@ "person_placeholder_notes": "Biographische Hinweise, Besonderheiten…", "person_label_birth_year": "Geburtsjahr", "person_label_death_year": "Todesjahr", + "person_label_generation": "Generation", + "person_option_generation_unset": "(keine)", + "person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)", "person_placeholder_year": "z.B. 1923", "person_year_error": "Bitte eine vierstellige Jahreszahl eingeben", "person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen", @@ -1103,7 +1106,6 @@ "stammbaum_relationships_heading": "Stammbaum & Beziehungen", "stammbaum_zoom_in": "Vergrößern", "stammbaum_zoom_out": "Verkleinern", - "stammbaum_generations": "Generationen", "relation_error_duplicate": "Diese Beziehung gibt es bereits.", "relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.", "relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 84b7cd3b..baa0d1b4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -175,6 +175,9 @@ "person_placeholder_notes": "Biographical notes, remarks…", "person_label_birth_year": "Birth year", "person_label_death_year": "Death year", + "person_label_generation": "Generation", + "person_option_generation_unset": "(none)", + "person_hint_generation": "Generation within the family (G 0 = oldest generation)", "person_placeholder_year": "e.g. 1923", "person_year_error": "Please enter a four-digit year", "person_years_error_order": "Birth year must be before death year", @@ -1103,7 +1106,6 @@ "stammbaum_relationships_heading": "Family tree & relationships", "stammbaum_zoom_in": "Zoom in", "stammbaum_zoom_out": "Zoom out", - "stammbaum_generations": "Generations", "relation_error_duplicate": "This relationship already exists.", "relation_error_circular": "This relationship would form a cycle.", "relation_error_self": "A person cannot be related to themselves.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 00a74688..a2128407 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -175,6 +175,9 @@ "person_placeholder_notes": "Notas biográficas, observaciones…", "person_label_birth_year": "Año de nacimiento", "person_label_death_year": "Año de fallecimiento", + "person_label_generation": "Generación", + "person_option_generation_unset": "(ninguna)", + "person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)", "person_placeholder_year": "p.ej. 1923", "person_year_error": "Introduzca un año de cuatro dígitos", "person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento", @@ -1103,7 +1106,6 @@ "stammbaum_relationships_heading": "Árbol genealógico & relaciones", "stammbaum_zoom_in": "Acercar", "stammbaum_zoom_out": "Alejar", - "stammbaum_generations": "Generaciones", "relation_error_duplicate": "Esta relación ya existe.", "relation_error_circular": "Esta relación crearía un ciclo.", "relation_error_self": "Una persona no puede estar relacionada consigo misma.", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index af38b2fa..5b059dd5 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1667,7 +1667,7 @@ export interface components { /** Format: int32 */ deathYear?: number; /** Format: int32 */ - generation?: number; + generation?: number | null; }; Person: { /** Format: uuid */ @@ -1684,7 +1684,7 @@ export interface components { /** Format: int32 */ deathYear?: number; /** Format: int32 */ - generation?: number; + generation?: number | null; familyMember: boolean; sourceRef?: string; provisional: boolean; @@ -2290,7 +2290,7 @@ export interface components { /** Format: int32 */ deathYear?: number; /** Format: int32 */ - generation?: number; + generation?: number | null; familyMember: boolean; }; InferredRelationshipDTO: { diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index a48d68ec..91461f78 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.server.ts +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -51,6 +51,12 @@ export const actions = { const deathYearStr = formData.get('deathYear')?.toString().trim(); const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined; const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined; + // Must NOT use the conditional-spread idiom for generation: G 0 is a + // valid family-tree-root value. The key always travels in the body so + // an explicit clear (empty option) reaches the backend as null. + const generationRaw = formData.get('generation'); + const generation = + generationRaw == null || generationRaw.toString() === '' ? null : Number(generationRaw); const validationKey = validatePersonFields(personType, firstName, lastName); if (validationKey) { @@ -68,7 +74,8 @@ export const actions = { ...(alias ? { alias } : {}), ...(notes ? { notes } : {}), ...(birthYear ? { birthYear } : {}), - ...(deathYear ? { deathYear } : {}) + ...(deathYear ? { deathYear } : {}), + generation } }); diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte index 45a30330..3a746550 100644 --- a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte +++ b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte @@ -16,6 +16,12 @@ let selectedType = $state( ) ); +// Match the selectedType initialiser pattern: untrack so a subsequent prop +// update (e.g. load() rerun) does not reset the user's in-progress edit. +let generationStr = $state( + untrack(() => (person.generation == null ? '' : String(person.generation))) +); + const isPerson = $derived(selectedType === 'PERSON'); const lastNameLabel = $derived( selectedType === 'INSTITUTION' || selectedType === 'GROUP' @@ -108,6 +114,28 @@ const inputCls = class={inputCls} /> +
+ + +

+ {m.person_hint_generation()} +

+
{/if}
diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte.test.ts b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte.test.ts index 2179001b..c20c586e 100644 --- a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte.test.ts +++ b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte.test.ts @@ -113,4 +113,48 @@ describe('PersonEditForm', () => { expect(alias.value).toBe(''); expect(birthYear.value).toBe(''); }); + + // ─── generation dropdown (#689) ───────────────────────────────────────────── + + it('renders the generation select with G 0…G 6 options when personType is PERSON', async () => { + render(PersonEditForm, { props: { person: personPersonal } }); + + const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement; + const labels = Array.from(select.options).map((o) => o.label.trim()); + expect(labels).toEqual( + expect.arrayContaining(['G 0', 'G 1', 'G 2', 'G 3', 'G 4', 'G 5', 'G 6']) + ); + }); + + it('hides the generation select for INSTITUTION', async () => { + render(PersonEditForm, { props: { person: personInstitution } }); + + await expect.element(page.getByLabelText(/^generation$/i)).not.toBeInTheDocument(); + }); + + it('hydrates the generation select from person.generation', async () => { + render(PersonEditForm, { + props: { + person: { ...personPersonal, generation: 3 } as typeof personPersonal & { + generation: number; + } + } + }); + + const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement; + expect(select.value).toBe('3'); + }); + + it('hydrates the generation select to "" when person.generation is null', async () => { + render(PersonEditForm, { + props: { + person: { ...personPersonal, generation: null } as typeof personPersonal & { + generation: number | null; + } + } + }); + + const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement; + expect(select.value).toBe(''); + }); }); diff --git a/frontend/src/routes/persons/[id]/edit/page.server.spec.ts b/frontend/src/routes/persons/[id]/edit/page.server.spec.ts new file mode 100644 index 00000000..7955e538 --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/page.server.spec.ts @@ -0,0 +1,99 @@ +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 { createApiClient } from '$lib/shared/api.server'; +import { actions } from './+page.server'; + +const mockFetch = vi.fn() as unknown as typeof fetch; + +beforeEach(() => vi.clearAllMocks()); + +function makeFormData(overrides: Partial> = {}): { + request: Request; + redirectThrown: () => unknown; +} { + const fd = new FormData(); + fd.set('personType', 'PERSON'); + fd.set('firstName', 'Hans'); + fd.set('lastName', 'Müller'); + for (const [k, v] of Object.entries(overrides)) { + if (v == null) fd.delete(k); + else fd.set(k, v); + } + return { + request: new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd }), + redirectThrown: () => {} + }; +} + +describe('persons/[id]/edit update action — generation (#689)', () => { + it('always includes generation in the PUT body — even when value is 0', async () => { + const put = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } }); + vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType< + typeof createApiClient + >); + + const { request } = makeFormData({ generation: '0' }); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any); + } catch { + // redirect throws on success — ignore + } + + expect(put).toHaveBeenCalledTimes(1); + const body = put.mock.calls[0][1].body; + expect(body).toHaveProperty('generation', 0); + }); + + it('sends generation: null when the dropdown is cleared (empty option)', async () => { + const put = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } }); + vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType< + typeof createApiClient + >); + + const { request } = makeFormData({ generation: '' }); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any); + } catch { + // redirect throws on success — ignore + } + + expect(put).toHaveBeenCalledTimes(1); + const body = put.mock.calls[0][1].body; + expect(body).toHaveProperty('generation', null); + }); + + it('sends generation: 3 when the dropdown carries G 3', async () => { + const put = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } }); + vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType< + typeof createApiClient + >); + + const { request } = makeFormData({ generation: '3' }); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any); + } catch { + // redirect throws on success — ignore + } + + expect(put).toHaveBeenCalledTimes(1); + const body = put.mock.calls[0][1].body; + expect(body).toHaveProperty('generation', 3); + }); +}); diff --git a/frontend/src/routes/persons/new/+page.server.ts b/frontend/src/routes/persons/new/+page.server.ts index 3231627e..efc557b7 100644 --- a/frontend/src/routes/persons/new/+page.server.ts +++ b/frontend/src/routes/persons/new/+page.server.ts @@ -26,6 +26,12 @@ export const actions = { const birthYearStr = formData.get('birthYear')?.toString().trim(); const deathYearStr = formData.get('deathYear')?.toString().trim(); const notes = formData.get('notes')?.toString().trim() || undefined; + // Must NOT use the conditional-spread idiom for generation: G 0 is a + // valid family-tree-root value. Always travels in the body so an + // explicit clear (empty option) reaches the backend as null. + const generationRaw = formData.get('generation'); + const generation = + generationRaw == null || generationRaw.toString() === '' ? null : Number(generationRaw); const validationKey = validatePersonFields(personType, firstName, lastName); if (validationKey) { @@ -52,7 +58,8 @@ export const actions = { ...(alias ? { alias } : {}), ...(birthYear ? { birthYear } : {}), ...(deathYear ? { deathYear } : {}), - ...(notes ? { notes } : {}) + ...(notes ? { notes } : {}), + generation } }); diff --git a/frontend/src/routes/persons/new/page.server.spec.ts b/frontend/src/routes/persons/new/page.server.spec.ts new file mode 100644 index 00000000..c3cd71da --- /dev/null +++ b/frontend/src/routes/persons/new/page.server.spec.ts @@ -0,0 +1,71 @@ +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 { createApiClient } from '$lib/shared/api.server'; +import { actions } from './+page.server'; + +const mockFetch = vi.fn() as unknown as typeof fetch; + +beforeEach(() => vi.clearAllMocks()); + +function buildRequest(overrides: Partial> = {}): Request { + const fd = new FormData(); + fd.set('personType', 'PERSON'); + fd.set('firstName', 'Hans'); + fd.set('lastName', 'Müller'); + for (const [k, v] of Object.entries(overrides)) { + if (v == null) fd.delete(k); + else fd.set(k, v); + } + return new Request('http://localhost/persons/new', { method: 'POST', body: fd }); +} + +describe('persons/new create action — generation (#689)', () => { + it('always includes generation in the POST body — even when value is 0', async () => { + const post = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p-new' } }); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType< + typeof createApiClient + >); + + const request = buildRequest({ generation: '0' }); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await actions.default({ request, fetch: mockFetch } as any); + } catch { + // redirect throws on success — ignore + } + + expect(post).toHaveBeenCalledTimes(1); + const body = post.mock.calls[0][1].body; + expect(body).toHaveProperty('generation', 0); + }); + + it('sends generation: null when the dropdown is left unset', async () => { + const post = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p-new' } }); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType< + typeof createApiClient + >); + + const request = buildRequest({ generation: '' }); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await actions.default({ request, fetch: mockFetch } as any); + } catch { + // redirect throws on success — ignore + } + + expect(post).toHaveBeenCalledTimes(1); + const body = post.mock.calls[0][1].body; + expect(body).toHaveProperty('generation', null); + }); +});