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

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