feat(person): date + precision controls on person new/edit forms
New PersonLifeDateField (German date input + hidden ISO + DAY/MONTH/YEAR precision select, min-h-44px, sm: side-by-side) used for birth and death in both forms. Legacy APPROX precision seeds the select as YEAR so an untouched save never claims DAY. Server actions send date+precision pairs or omit both; obsolete year i18n keys removed, 9 form keys added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -174,12 +174,18 @@
|
|||||||
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
|
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
|
||||||
"person_label_notes": "Notizen",
|
"person_label_notes": "Notizen",
|
||||||
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
||||||
"person_label_birth_year": "Geburtsjahr",
|
"person_label_birth_date": "Geburtsdatum",
|
||||||
"person_label_death_year": "Todesjahr",
|
"person_label_death_date": "Sterbedatum",
|
||||||
|
"person_label_birth_date_precision": "Genauigkeit",
|
||||||
|
"person_label_death_date_precision": "Genauigkeit",
|
||||||
|
"person_precision_hint": "Wie genau ist dieses Datum bekannt?",
|
||||||
|
"person_precision_day": "Genaues Datum (Tag)",
|
||||||
|
"person_precision_month": "Monat bekannt",
|
||||||
|
"person_precision_year": "Nur Jahreszahl",
|
||||||
|
"person_date_placeholder_hint": "Leer lassen, wenn unbekannt",
|
||||||
"person_label_generation": "Generation",
|
"person_label_generation": "Generation",
|
||||||
"person_option_generation_unset": "(keine)",
|
"person_option_generation_unset": "(keine)",
|
||||||
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
|
"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_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||||
"person_docs_heading": "Gesendete Dokumente",
|
"person_docs_heading": "Gesendete Dokumente",
|
||||||
|
|||||||
@@ -174,12 +174,18 @@
|
|||||||
"person_merge_warning": "Warning: This action cannot be undone.",
|
"person_merge_warning": "Warning: This action cannot be undone.",
|
||||||
"person_label_notes": "Notes",
|
"person_label_notes": "Notes",
|
||||||
"person_placeholder_notes": "Biographical notes, remarks…",
|
"person_placeholder_notes": "Biographical notes, remarks…",
|
||||||
"person_label_birth_year": "Birth year",
|
"person_label_birth_date": "Date of birth",
|
||||||
"person_label_death_year": "Death year",
|
"person_label_death_date": "Date of death",
|
||||||
|
"person_label_birth_date_precision": "Precision",
|
||||||
|
"person_label_death_date_precision": "Precision",
|
||||||
|
"person_precision_hint": "How precisely is this date known?",
|
||||||
|
"person_precision_day": "Exact date (day)",
|
||||||
|
"person_precision_month": "Month known",
|
||||||
|
"person_precision_year": "Year only",
|
||||||
|
"person_date_placeholder_hint": "Leave empty if unknown",
|
||||||
"person_label_generation": "Generation",
|
"person_label_generation": "Generation",
|
||||||
"person_option_generation_unset": "(none)",
|
"person_option_generation_unset": "(none)",
|
||||||
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
|
"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_year_error": "Please enter a four-digit year",
|
||||||
"person_years_error_order": "Birth year must be before death year",
|
"person_years_error_order": "Birth year must be before death year",
|
||||||
"person_docs_heading": "Sent documents",
|
"person_docs_heading": "Sent documents",
|
||||||
|
|||||||
@@ -174,12 +174,18 @@
|
|||||||
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
|
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
|
||||||
"person_label_notes": "Notas",
|
"person_label_notes": "Notas",
|
||||||
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
||||||
"person_label_birth_year": "Año de nacimiento",
|
"person_label_birth_date": "Fecha de nacimiento",
|
||||||
"person_label_death_year": "Año de fallecimiento",
|
"person_label_death_date": "Fecha de defunción",
|
||||||
|
"person_label_birth_date_precision": "Precisión",
|
||||||
|
"person_label_death_date_precision": "Precisión",
|
||||||
|
"person_precision_hint": "¿Con qué precisión se conoce esta fecha?",
|
||||||
|
"person_precision_day": "Fecha exacta (día)",
|
||||||
|
"person_precision_month": "Mes conocido",
|
||||||
|
"person_precision_year": "Solo año",
|
||||||
|
"person_date_placeholder_hint": "Dejar vacío si es desconocido",
|
||||||
"person_label_generation": "Generación",
|
"person_label_generation": "Generación",
|
||||||
"person_option_generation_unset": "(ninguna)",
|
"person_option_generation_unset": "(ninguna)",
|
||||||
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
|
"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_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",
|
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||||
"person_docs_heading": "Documentos enviados",
|
"person_docs_heading": "Documentos enviados",
|
||||||
|
|||||||
96
frontend/src/lib/person/PersonLifeDateField.svelte
Normal file
96
frontend/src/lib/person/PersonLifeDateField.svelte
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
|
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
|
||||||
|
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
|
||||||
|
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
||||||
|
{ value: 'DAY', label: m.person_precision_day },
|
||||||
|
{ value: 'MONTH', label: m.person_precision_month },
|
||||||
|
{ value: 'YEAR', label: m.person_precision_year }
|
||||||
|
];
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
legend,
|
||||||
|
precisionLabel,
|
||||||
|
initialIso = '',
|
||||||
|
initialPrecision = null
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
legend: string;
|
||||||
|
precisionLabel: string;
|
||||||
|
initialIso?: string | null;
|
||||||
|
initialPrecision?: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let display = $state('');
|
||||||
|
let iso = $state('');
|
||||||
|
let precision = $state<DatePrecision>('DAY');
|
||||||
|
|
||||||
|
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
|
||||||
|
// stomp the user's in-progress edit.
|
||||||
|
onMount(() => {
|
||||||
|
if (initialIso) {
|
||||||
|
iso = initialIso;
|
||||||
|
display = isoToGerman(initialIso);
|
||||||
|
}
|
||||||
|
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
|
||||||
|
if (offered) {
|
||||||
|
precision = initialPrecision as DatePrecision;
|
||||||
|
} else if (initialIso) {
|
||||||
|
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
|
||||||
|
// untouched save does not silently claim DAY precision for the stored date.
|
||||||
|
precision = 'YEAR';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const result = handleGermanDateInput(e);
|
||||||
|
display = result.display;
|
||||||
|
iso = result.iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlCls =
|
||||||
|
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{legend}
|
||||||
|
</legend>
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="TT.MM.JJJJ"
|
||||||
|
maxlength="10"
|
||||||
|
aria-label={legend}
|
||||||
|
value={display}
|
||||||
|
oninput={handleInput}
|
||||||
|
class={controlCls}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name={name} value={iso} />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<select
|
||||||
|
id="{name}Precision"
|
||||||
|
name="{name}Precision"
|
||||||
|
aria-label="{legend}: {precisionLabel}"
|
||||||
|
bind:value={precision}
|
||||||
|
class="{controlCls} bg-surface"
|
||||||
|
>
|
||||||
|
{#each PERSON_DATE_PRECISIONS as p (p.value)}
|
||||||
|
<option value={p.value}>{p.label()}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-sans text-xs text-ink-3">
|
||||||
|
{m.person_precision_hint()} · {m.person_date_placeholder_hint()}
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
@@ -9,8 +9,10 @@ export type PersonFormData = {
|
|||||||
firstName?: string | null;
|
firstName?: string | null;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
birthYear?: number | null;
|
birthDate?: string | null;
|
||||||
deathYear?: number | null;
|
birthDatePrecision?: string | null;
|
||||||
|
deathDate?: string | null;
|
||||||
|
deathDatePrecision?: string | null;
|
||||||
generation?: number | null;
|
generation?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
import {
|
import {
|
||||||
normalizePersonType,
|
normalizePersonType,
|
||||||
validatePersonFields,
|
validatePersonFields,
|
||||||
@@ -47,10 +48,17 @@ export const actions = {
|
|||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||||
const birthYearStr = formData.get('birthYear')?.toString().trim();
|
// Empty date input → omit date AND precision: the backend normalises the
|
||||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
// absent pair to null/UNKNOWN, and a lone precision would fail the
|
||||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
// coherence check (INVALID_DATE_PRECISION).
|
||||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
const birthDate = formData.get('birthDate')?.toString().trim() || undefined;
|
||||||
|
const birthDatePrecision = birthDate
|
||||||
|
? (formData.get('birthDatePrecision')?.toString() as DatePrecision)
|
||||||
|
: undefined;
|
||||||
|
const deathDate = formData.get('deathDate')?.toString().trim() || undefined;
|
||||||
|
const deathDatePrecision = deathDate
|
||||||
|
? (formData.get('deathDatePrecision')?.toString() as DatePrecision)
|
||||||
|
: undefined;
|
||||||
// Must NOT use the conditional-spread idiom for generation: G 0 is a
|
// 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
|
// valid family-tree-root value. The key always travels in the body so
|
||||||
// an explicit clear (empty option) reaches the backend as null.
|
// an explicit clear (empty option) reaches the backend as null.
|
||||||
@@ -73,8 +81,8 @@ export const actions = {
|
|||||||
lastName,
|
lastName,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
...(birthYear ? { birthYear } : {}),
|
...(birthDate ? { birthDate, birthDatePrecision } : {}),
|
||||||
...(deathYear ? { deathYear } : {}),
|
...(deathDate ? { deathDate, deathDatePrecision } : {}),
|
||||||
generation
|
generation
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PersonLifeDateField from '$lib/person/PersonLifeDateField.svelte';
|
||||||
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
|
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
|
||||||
import {
|
import {
|
||||||
PERSON_TYPES as TYPES,
|
PERSON_TYPES as TYPES,
|
||||||
@@ -88,32 +89,20 @@ const inputCls =
|
|||||||
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
||||||
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
|
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<PersonLifeDateField
|
||||||
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
name="birthDate"
|
||||||
<input
|
legend={m.person_label_birth_date()}
|
||||||
id="birthYear"
|
precisionLabel={m.person_label_birth_date_precision()}
|
||||||
name="birthYear"
|
initialIso={person.birthDate ?? ''}
|
||||||
type="number"
|
initialPrecision={person.birthDatePrecision ?? null}
|
||||||
min="1"
|
/>
|
||||||
max="2100"
|
<PersonLifeDateField
|
||||||
placeholder={m.person_placeholder_year()}
|
name="deathDate"
|
||||||
value={person.birthYear ?? ''}
|
legend={m.person_label_death_date()}
|
||||||
class={inputCls}
|
precisionLabel={m.person_label_death_date_precision()}
|
||||||
/>
|
initialIso={person.deathDate ?? ''}
|
||||||
</div>
|
initialPrecision={person.deathDatePrecision ?? null}
|
||||||
<div>
|
/>
|
||||||
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
|
||||||
<input
|
|
||||||
id="deathYear"
|
|
||||||
name="deathYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
value={person.deathYear ?? ''}
|
|
||||||
class={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="generation" class={labelCls}>{m.person_label_generation()}</label>
|
<label for="generation" class={labelCls}>{m.person_label_generation()}</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ const personPersonal = {
|
|||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Schmidt',
|
lastName: 'Schmidt',
|
||||||
alias: 'Anni',
|
alias: 'Anni',
|
||||||
birthYear: 1899 as number | null,
|
birthDate: '1899-03-14' as string | null,
|
||||||
deathYear: 1972 as number | null,
|
birthDatePrecision: 'DAY' as string | null,
|
||||||
|
deathDate: '1972-01-01' as string | null,
|
||||||
|
deathDatePrecision: 'YEAR' as string | null,
|
||||||
notes: 'Wohnte in Berlin.'
|
notes: 'Wohnte in Berlin.'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,8 +26,10 @@ const personInstitution = {
|
|||||||
firstName: null,
|
firstName: null,
|
||||||
lastName: 'Acme GmbH',
|
lastName: 'Acme GmbH',
|
||||||
alias: null,
|
alias: null,
|
||||||
birthYear: null,
|
birthDate: null,
|
||||||
deathYear: null,
|
birthDatePrecision: null,
|
||||||
|
deathDate: null,
|
||||||
|
deathDatePrecision: null,
|
||||||
notes: null
|
notes: null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,14 +46,14 @@ describe('PersonEditForm', () => {
|
|||||||
await expect.element(page.getByLabelText(/titel/i)).toBeVisible();
|
await expect.element(page.getByLabelText(/titel/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the firstName / title / alias / year fields for INSTITUTION', async () => {
|
it('hides the firstName / title / alias / life-date fields for INSTITUTION', async () => {
|
||||||
render(PersonEditForm, { props: { person: personInstitution } });
|
render(PersonEditForm, { props: { person: personInstitution } });
|
||||||
|
|
||||||
await expect.element(page.getByLabelText(/vorname/i)).not.toBeInTheDocument();
|
await expect.element(page.getByLabelText(/vorname/i)).not.toBeInTheDocument();
|
||||||
await expect.element(page.getByLabelText(/^titel$/i)).not.toBeInTheDocument();
|
await expect.element(page.getByLabelText(/^titel$/i)).not.toBeInTheDocument();
|
||||||
await expect.element(page.getByLabelText(/rufname/i)).not.toBeInTheDocument();
|
await expect.element(page.getByLabelText(/rufname/i)).not.toBeInTheDocument();
|
||||||
await expect.element(page.getByLabelText(/geburtsjahr/i)).not.toBeInTheDocument();
|
await expect.element(page.getByLabelText(/^geburtsdatum$/i)).not.toBeInTheDocument();
|
||||||
await expect.element(page.getByLabelText(/todesjahr/i)).not.toBeInTheDocument();
|
await expect.element(page.getByLabelText(/^sterbedatum$/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the "Nachname" label for PERSON', async () => {
|
it('uses the "Nachname" label for PERSON', async () => {
|
||||||
@@ -77,13 +81,63 @@ describe('PersonEditForm', () => {
|
|||||||
expect(title.value).toBe('Frau Dr.');
|
expect(title.value).toBe('Frau Dr.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders birthYear and deathYear inputs with prior values', async () => {
|
it('renders an existing DAY-precision birth date as dd.mm.yyyy in the date input', async () => {
|
||||||
render(PersonEditForm, { props: { person: personPersonal } });
|
render(PersonEditForm, { props: { person: personPersonal } });
|
||||||
|
|
||||||
const birthYear = (await page.getByLabelText(/geburtsjahr/i).element()) as HTMLInputElement;
|
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
|
||||||
const deathYear = (await page.getByLabelText(/todesjahr/i).element()) as HTMLInputElement;
|
expect(birthInput.value).toBe('14.03.1899');
|
||||||
expect(birthYear.value).toBe('1899');
|
});
|
||||||
expect(deathYear.value).toBe('1972');
|
|
||||||
|
it('submits the ISO date via the hidden birthDate input', async () => {
|
||||||
|
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
|
||||||
|
|
||||||
|
const hidden = container.querySelector('input[type="hidden"][name="birthDate"]');
|
||||||
|
expect((hidden as HTMLInputElement).value).toBe('1899-03-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only DAY / MONTH / YEAR precisions for life dates', async () => {
|
||||||
|
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
|
||||||
|
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[name="birthDatePrecision"]'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const values = Array.from(select.options).map((o) => o.value);
|
||||||
|
expect(values).toEqual(['DAY', 'MONTH', 'YEAR']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates the precision selects from the person prop', async () => {
|
||||||
|
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
|
||||||
|
|
||||||
|
const birth = container.querySelector('select[name="birthDatePrecision"]') as HTMLSelectElement;
|
||||||
|
const death = container.querySelector('select[name="deathDatePrecision"]') as HTMLSelectElement;
|
||||||
|
expect(birth.value).toBe('DAY');
|
||||||
|
expect(death.value).toBe('YEAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds a non-form precision (APPROX legacy import) as YEAR instead of DAY', async () => {
|
||||||
|
const { container } = render(PersonEditForm, {
|
||||||
|
props: {
|
||||||
|
person: { ...personPersonal, birthDate: '1899-01-01', birthDatePrecision: 'APPROX' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const birth = container.querySelector('select[name="birthDatePrecision"]') as HTMLSelectElement;
|
||||||
|
expect(birth.value).toBe('YEAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks the date input and precision select without overflow at 320px', async () => {
|
||||||
|
await page.viewport(320, 640);
|
||||||
|
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
|
||||||
|
|
||||||
|
const input = container.querySelector('#birthDate') as HTMLElement;
|
||||||
|
const select = container.querySelector('select[name="birthDatePrecision"]') as HTMLElement;
|
||||||
|
expect(input.getBoundingClientRect().right).toBeLessThanOrEqual(320);
|
||||||
|
expect(select.getBoundingClientRect().right).toBeLessThanOrEqual(320);
|
||||||
|
// Stacked, not side by side: the select starts below the input.
|
||||||
|
expect(select.getBoundingClientRect().top).toBeGreaterThanOrEqual(
|
||||||
|
input.getBoundingClientRect().bottom
|
||||||
|
);
|
||||||
|
await page.viewport(1280, 720);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the notes textarea pre-filled with prior content', async () => {
|
it('renders the notes textarea pre-filled with prior content', async () => {
|
||||||
@@ -103,15 +157,23 @@ describe('PersonEditForm', () => {
|
|||||||
|
|
||||||
it('renders empty inputs when nullable fields are null', async () => {
|
it('renders empty inputs when nullable fields are null', async () => {
|
||||||
render(PersonEditForm, {
|
render(PersonEditForm, {
|
||||||
props: { person: { ...personPersonal, title: null, alias: null, birthYear: null } }
|
props: {
|
||||||
|
person: {
|
||||||
|
...personPersonal,
|
||||||
|
title: null,
|
||||||
|
alias: null,
|
||||||
|
birthDate: null,
|
||||||
|
birthDatePrecision: null
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = (await page.getByLabelText(/^titel/i).element()) as HTMLInputElement;
|
const title = (await page.getByLabelText(/^titel/i).element()) as HTMLInputElement;
|
||||||
const alias = (await page.getByLabelText(/rufname/i).element()) as HTMLInputElement;
|
const alias = (await page.getByLabelText(/rufname/i).element()) as HTMLInputElement;
|
||||||
const birthYear = (await page.getByLabelText(/geburtsjahr/i).element()) as HTMLInputElement;
|
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
|
||||||
expect(title.value).toBe('');
|
expect(title.value).toBe('');
|
||||||
expect(alias.value).toBe('');
|
expect(alias.value).toBe('');
|
||||||
expect(birthYear.value).toBe('');
|
expect(birthInput.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── generation dropdown (#689) ─────────────────────────────────────────────
|
// ─── generation dropdown (#689) ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
import {
|
import {
|
||||||
normalizePersonType,
|
normalizePersonType,
|
||||||
validatePersonFields,
|
validatePersonFields,
|
||||||
@@ -23,8 +24,17 @@ export const actions = {
|
|||||||
const firstName = formData.get('firstName')?.toString().trim();
|
const firstName = formData.get('firstName')?.toString().trim();
|
||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
const birthYearStr = formData.get('birthYear')?.toString().trim();
|
// Empty date input → omit date AND precision: the backend normalises the
|
||||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
// absent pair to null/UNKNOWN, and a lone precision would fail the
|
||||||
|
// coherence check (INVALID_DATE_PRECISION).
|
||||||
|
const birthDate = formData.get('birthDate')?.toString().trim() || undefined;
|
||||||
|
const birthDatePrecision = birthDate
|
||||||
|
? (formData.get('birthDatePrecision')?.toString() as DatePrecision)
|
||||||
|
: undefined;
|
||||||
|
const deathDate = formData.get('deathDate')?.toString().trim() || undefined;
|
||||||
|
const deathDatePrecision = deathDate
|
||||||
|
? (formData.get('deathDatePrecision')?.toString() as DatePrecision)
|
||||||
|
: undefined;
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||||
// Must NOT use the conditional-spread idiom for generation: G 0 is a
|
// 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
|
// valid family-tree-root value. Always travels in the body so an
|
||||||
@@ -45,9 +55,6 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
|
||||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.POST('/api/persons', {
|
const result = await api.POST('/api/persons', {
|
||||||
body: {
|
body: {
|
||||||
@@ -56,8 +63,8 @@ export const actions = {
|
|||||||
...(firstName ? { firstName } : {}),
|
...(firstName ? { firstName } : {}),
|
||||||
lastName: lastName!,
|
lastName: lastName!,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(birthYear ? { birthYear } : {}),
|
...(birthDate ? { birthDate, birthDatePrecision } : {}),
|
||||||
...(deathYear ? { deathYear } : {}),
|
...(deathDate ? { deathDate, deathDatePrecision } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
generation
|
generation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||||
|
import PersonLifeDateField from '$lib/person/PersonLifeDateField.svelte';
|
||||||
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
|
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
|
||||||
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person/person-validation';
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person/person-validation';
|
||||||
|
|
||||||
@@ -102,30 +103,16 @@ const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
|
|||||||
class={inputCls}
|
class={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<PersonLifeDateField
|
||||||
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
name="birthDate"
|
||||||
<input
|
legend={m.person_label_birth_date()}
|
||||||
id="birthYear"
|
precisionLabel={m.person_label_birth_date_precision()}
|
||||||
name="birthYear"
|
/>
|
||||||
type="number"
|
<PersonLifeDateField
|
||||||
min="1"
|
name="deathDate"
|
||||||
max="2100"
|
legend={m.person_label_death_date()}
|
||||||
placeholder={m.person_placeholder_year()}
|
precisionLabel={m.person_label_death_date_precision()}
|
||||||
class={inputCls}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
|
||||||
<input
|
|
||||||
id="deathYear"
|
|
||||||
name="deathYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
class={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user