feat(person): generation dropdown on Person edit/new forms (#689)

PersonEditForm.svelte gains a G 0…G 6 select inside the {#if isPerson}
block. min-h-[44px] meets WCAG 2.5.8 / dual-audience touch target.
generationStr is initialised via $state(untrack(...)) so prop reruns
never reset an in-progress edit (same pattern as selectedType).

Both /persons/[id]/edit and /persons/new form actions read the field
without the conditional-spread idiom — generation always lands in the
PUT/POST body. G 0 is a valid family-tree-root value the spread would
silently drop, and an empty option sends null so a human can clear the
field back to "unset".

i18n adds person_label_generation / person_option_generation_unset /
person_hint_generation in de/en/es. Drops the dead stammbaum_generations
key (zero callsites after the filter-chip removal in the spec).

Tests: dropdown render + hydration in the component, generation=0/3/null
arriving in the API body in the server actions.

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 15:55:25 +02:00
parent c0b500b692
commit 577dd3fcb1
10 changed files with 270 additions and 8 deletions

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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: {

View File

@@ -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
}
});

View File

@@ -16,6 +16,12 @@ let selectedType = $state<PersonType>(
)
);
// 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}
/>
</div>
<div class="md:col-span-2">
<label for="generation" class={labelCls}>{m.person_label_generation()}</label>
<select
id="generation"
name="generation"
bind:value={generationStr}
class="block min-h-[44px] w-full rounded border border-line bg-surface px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-describedby="generation-hint"
>
<option value="">{m.person_option_generation_unset()}</option>
<option value="0">G 0</option>
<option value="1">G 1</option>
<option value="2">G 2</option>
<option value="3">G 3</option>
<option value="4">G 4</option>
<option value="5">G 5</option>
<option value="6">G 6</option>
</select>
<p id="generation-hint" class="mt-1 font-sans text-xs text-ink-3">
{m.person_hint_generation()}
</p>
</div>
{/if}
<div class="md:col-span-2">

View File

@@ -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('');
});
});

View File

@@ -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<Record<string, string>> = {}): {
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);
});
});

View File

@@ -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
}
});

View File

@@ -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<Record<string, string>> = {}): 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);
});
});