From 96c94642ba651f72588fbee9c83ae8a59af18e66 Mon Sep 17 00:00:00 2001 From: Andrei Laas Date: Tue, 29 Sep 2026 21:39:35 +0200 Subject: [PATCH] Datumsfelder lassen sich wieder tippen In den Berichten hing jedes Datumsfeld unmittelbar an der Adresszeile: eine Aenderung hiess router.push, also neu laden und neu rendern. Ein meldet beim Tippen der Jahreszahl aber viermal -- 0002, 0020, 0202, 2026 -- und die ersten drei loesten je eine Navigation aus, die das Feld auf den Stand aus der Adresse zuruecksetzte. Mitten im Tippen. Mit dem Kalender ging es, weil der in einem Zug ein fertiges Datum setzt; das war der Hinweis darauf, wo es klemmt. DateField haelt den Tippstand jetzt bei sich und meldet nur, was eine Aussage ist: ein vollstaendiges Datum mit vierstelliger Jahreszahl ab 1000, oder das Leeren des Feldes. Beim Verlassen wird nachgereicht, was liegengeblieben ist. Der Stichtag im Organigramm ist ein blankes input und benutzt dieselbe Regel ueber istMeldbaresDatum -- zwei Fassungen davon liefen hier erfahrungsgemaess auseinander. Der Abgleich mit dem Wert von aussen laeuft waehrend des Renderns, nicht in einem Effekt: der liefe erst nach dem Zeichnen, das Feld zeigte also fuer einen Bildaufbau den alten Stand -- und die Regel gegen setState im Effekt verbietet ihn aus genau diesem Grund. Die Felder in den Panels bleiben, wie sie sind: dort ist der Zustand lokal, es gibt keine Navigation und damit auch kein Zuruecksetzen. --- components/orgchart/AsOfPicker.tsx | 27 +++++++- components/reports/ReportsPageClient.tsx | 17 ++--- components/ui/Field.tsx | 77 ++++++++++++++++++++- tests/components/DateField.test.tsx | 86 ++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 tests/components/DateField.test.tsx diff --git a/components/orgchart/AsOfPicker.tsx b/components/orgchart/AsOfPicker.tsx index 816b133..3a7a4c5 100644 --- a/components/orgchart/AsOfPicker.tsx +++ b/components/orgchart/AsOfPicker.tsx @@ -2,8 +2,9 @@ import { CalendarClock } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; import { Button } from "@/components/ui/Button"; -import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; +import { FILTER_SELECT_CLASS, istMeldbaresDatum } from "@/components/ui/Field"; import { fmtDate } from "@/lib/format"; type AsOfPickerProps = { @@ -27,6 +28,14 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false }); } + // Angeglichen während des Renderns, nicht in einem Effekt — siehe DateField. + const [entwurf, setEntwurf] = useState(asOf); + const [zuletzt, setZuletzt] = useState(asOf); + if (asOf !== zuletzt) { + setZuletzt(asOf); + setEntwurf(asOf); + } + const isToday = asOf === today; const isFuture = asOf > today; // Assignments only started being recorded when the history table was @@ -41,11 +50,23 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO Stichtag + {/* Der Tippstand bleibt hier, gemeldet wird nur ein vollständiges + Datum: jede Änderung ist eine Navigation, und beim Tippen der + Jahreszahl entstehen unterwegs 0002, 0020 und 0202 — jede davon + lud die Seite neu und setzte das Feld mitten im Tippen zurück. + Dieselbe Regel wie in DateField, dort steht auch der lange Grund. */} setAsOf(e.target.value || undefined)} + value={entwurf} + onChange={(e) => { + const wert = e.target.value; + setEntwurf(wert); + if (istMeldbaresDatum(wert) && wert !== asOf) setAsOf(wert || undefined); + }} + onBlur={() => { + if (entwurf !== asOf && istMeldbaresDatum(entwurf)) setAsOf(entwurf || undefined); + }} className={FILTER_SELECT_CLASS} /> {!isToday && ( diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index 9380a23..56a3753 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -6,7 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { deleteReport, saveReport } from "@/actions/reports"; import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button"; -import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field"; +import { CONTROL_CLASS, DateField, SelectField, TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; @@ -188,17 +188,15 @@ function KriterienBlock({
{k.label}
- onChange({ [`${k.key}From`]: v || undefined })} /> - onChange({ [`${k.key}To`]: v || undefined })} /> @@ -471,11 +469,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) { />
- updateParams({ asOf: v })} /> @@ -556,10 +553,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) { Zeitraum
- updateParams({ from: v })} @@ -574,10 +570,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
- updateParams({ to: v })} diff --git a/components/ui/Field.tsx b/components/ui/Field.tsx index 8a87e58..429cc62 100644 --- a/components/ui/Field.tsx +++ b/components/ui/Field.tsx @@ -1,6 +1,13 @@ "use client"; -import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react"; +import { + useId, + useState, + type ReactNode, + type SelectHTMLAttributes, + type InputHTMLAttributes, + type TextareaHTMLAttributes, +} from "react"; // Form primitives. // @@ -112,6 +119,74 @@ export function TextField({ label, required, hint, error, dense, className, valu ); } +/** Ein vollständiges Datum mit vierstelliger Jahreszahl. */ +const VOLLSTAENDIGES_DATUM = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Ob ein Datumswert eine Aussage ist — oder ein Zwischenstand beim Tippen. + * + * Exportiert, weil der Stichtag im Organigramm dieselbe Frage stellt und dort + * ein blankes `` steht. Zwei Fassungen derselben Regel laufen in diesem + * Projekt erfahrungsgemäss auseinander. + */ +export function istMeldbaresDatum(wert: string): boolean { + // Leer ist eine Aussage — „kein Datum" —, und die soll durchkommen. + if (wert === "") return true; + if (!VOLLSTAENDIGES_DATUM.test(wert)) return false; + // Ein Jahr unter 1000 entsteht beim Tippen: wer „2026" eingibt, erzeugt + // unterwegs 0002, 0020 und 0202. Das sind gültige Daten und damit nicht von + // einer echten Eingabe zu unterscheiden — ausser an ihrer Grösse. + return Number(wert.slice(0, 4)) >= 1000; +} + +/** + * Ein Datumsfeld, das Zwischenstände für sich behält. + * + * Der Anlass: in den Berichten hängt jedes Feld an der Adresszeile — eine + * Änderung heisst `router.push`, also neu laden und neu rendern. Ein + * `` meldet beim Tippen der Jahreszahl aber viermal: + * 0002, 0020, 0202, 2026. Die ersten drei lösten je eine Navigation aus, und + * jede setzte das Feld auf den Stand aus der Adresse zurück — mitten im + * Tippen. Mit der Maus über den Kalender ging es, weil der in einem Zug ein + * fertiges Datum setzt. + * + * Deshalb: der Tippstand bleibt hier, gemeldet wird nur, was eine Aussage ist + * — ein vollständiges Datum mit plausibler Jahreszahl, oder das Leeren des + * Feldes. Beim Verlassen wird nachgereicht, was bis dahin liegengeblieben ist. + */ +export function DateField({ value, onChange, onBlur, ...rest }: Omit) { + const [entwurf, setEntwurf] = useState(value); + const [zuletzt, setZuletzt] = useState(value); + + // Von aussen gewinnt: „Zurücksetzen" in den Kriterien leert das Feld, und + // nach dem Melden steht hier ohnehin schon dasselbe. + // + // Während des Renderns angeglichen und nicht in einem Effekt: ein Effekt + // liefe erst nach dem Zeichnen, das Feld zeigte also für einen Bildaufbau + // den alten Stand — und die Regel „kein setState im Effekt" verbietet ihn + // aus genau diesem Grund. + if (value !== zuletzt) { + setZuletzt(value); + setEntwurf(value); + } + + return ( + { + setEntwurf(v); + if (istMeldbaresDatum(v) && v !== value) onChange(v); + }} + onBlur={(e) => { + if (entwurf !== value && istMeldbaresDatum(entwurf)) onChange(entwurf); + onBlur?.(e); + }} + /> + ); +} + type Option = { value: string; label: string; disabled?: boolean }; type SelectFieldProps = FieldShellProps & diff --git a/tests/components/DateField.test.tsx b/tests/components/DateField.test.tsx new file mode 100644 index 0000000..a342ba4 --- /dev/null +++ b/tests/components/DateField.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DateField, istMeldbaresDatum } from "@/components/ui/Field"; + +// Der Anlass: in den Berichten hängt jedes Datumsfeld an der Adresszeile, eine +// Änderung heisst also neu laden. Ein `` meldet beim Tippen +// der Jahreszahl aber viermal — 0002, 0020, 0202, 2026 —, und die ersten drei +// setzten das Feld mitten im Tippen zurück. Mit dem Kalender ging es, weil der +// in einem Zug ein fertiges Datum setzt. + +describe("istMeldbaresDatum", () => { + it("nimmt ein vollständiges Datum an", () => { + expect(istMeldbaresDatum("2026-09-30")).toBe(true); + expect(istMeldbaresDatum("1980-01-01")).toBe(true); + }); + + it("nimmt das leere Feld an, denn kein Datum ist auch eine Aussage", () => { + expect(istMeldbaresDatum("")).toBe(true); + }); + + it("weist die Zwischenstände beim Tippen ab", () => { + for (const zwischen of ["0002-09-30", "0020-09-30", "0202-09-30"]) { + expect(istMeldbaresDatum(zwischen), zwischen).toBe(false); + } + }); + + it("weist Bruchstücke ab", () => { + expect(istMeldbaresDatum("2026-09")).toBe(false); + expect(istMeldbaresDatum("2026")).toBe(false); + }); +}); + +describe("DateField", () => { + function feld(onChange: (v: string) => void, value = "") { + render(); + return screen.getByLabelText("Von") as HTMLInputElement; + } + + it("meldet den Zwischenstand nicht, zeigt ihn aber an", () => { + const gemeldet = vi.fn(); + const input = feld(gemeldet); + + fireEvent.change(input, { target: { value: "0002-09-30" } }); + expect(gemeldet).not.toHaveBeenCalled(); + // Der Tippstand bleibt stehen: würde das Feld ihn verwerfen, kämpfte es + // gegen die Eingabe. + expect(input.value).toBe("0002-09-30"); + }); + + it("meldet, sobald die Jahreszahl vollständig ist", () => { + const gemeldet = vi.fn(); + const input = feld(gemeldet); + + fireEvent.change(input, { target: { value: "0002-09-30" } }); + fireEvent.change(input, { target: { value: "2026-09-30" } }); + expect(gemeldet).toHaveBeenCalledExactlyOnceWith("2026-09-30"); + }); + + it("meldet das Leeren", () => { + const gemeldet = vi.fn(); + const input = feld(gemeldet, "2026-09-30"); + fireEvent.change(input, { target: { value: "" } }); + expect(gemeldet).toHaveBeenCalledExactlyOnceWith(""); + }); + + it("reicht beim Verlassen nach, was liegengeblieben ist", () => { + // Kommt in der Praxis kaum vor, ist aber der Rückfall: was nicht gemeldet + // wurde, geht spätestens hier hinaus — sofern es eine Aussage ist. + const gemeldet = vi.fn(); + const input = feld(gemeldet); + fireEvent.change(input, { target: { value: "0002-09-30" } }); + fireEvent.blur(input); + expect(gemeldet).not.toHaveBeenCalled(); + }); + + it("übernimmt einen Wert, der von aussen kommt", () => { + // „Zurücksetzen" in den Kriterien leert das Feld — der Tippstand darf + // dann nicht stehenbleiben. + const { rerender } = render(); + const input = screen.getByLabelText("Von") as HTMLInputElement; + expect(input.value).toBe("2026-09-30"); + + rerender(); + expect(input.value).toBe(""); + }); +});