Deleting and correcting a history entry stopped at the present: anything not yet effective stayed put. That was not a principle, it was a missing link. A planned change lives as a payload in pending_org_changes, and nothing tied it to the history row — only a person and a date, and the data already holds an Eintritt and a Vertragsänderung sharing one. So employee_history now carries pending_id, set by change_employee_data when it schedules something. One planned change can carry two history rows: Stammdaten and Vertrag are kept apart but scheduled together. Taking one back therefore strips only that group's fields from the payload, and cancels the operation only when nothing is left. Correcting one rewrites its group and the effective date, and touches no employee data — the change has not happened yet. An entry stays on its side of the present. Pulling a planned change into today, or pushing an effective one into the future, would mean adjusting the employee record and the pending payload in opposite directions; that is what the real operations are for. Existing rows were linked where exactly one running operation matched the person and date and no other row had claimed it. All five of them matched. Anything ambiguous would have kept the old refusal, which now says the actual reason. The edit dialog surfaced a bug in useDialogFocus that predates it: the effect depended on the identity of onClose, which almost every caller rebuilds on render, so it re-ran after each keystroke and its cleanup pulled focus back to whatever opened the dialog. Any dialog with a text field would have accepted one character. It never showed because until now no dialog kept its own state next to its own onClose. Rehearsed against real data: a two-row planned change corrected, one row taken back with the operation continuing on the rest, the second taken back with the operation cancelled, and both refusals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.4 KiB
TypeScript
157 lines
5.4 KiB
TypeScript
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { useState } from "react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { TextField } from "@/components/ui/Field";
|
|
import { Modal } from "@/components/ui/Modal";
|
|
import { SlideOver } from "@/components/ui/SlideOver";
|
|
|
|
function ModalHarness({ onClose = () => {} }: { onClose?: () => void }) {
|
|
const [first, setFirst] = useState("");
|
|
const [second, setSecond] = useState("");
|
|
return (
|
|
<Modal
|
|
open
|
|
onClose={onClose}
|
|
title="Testdialog"
|
|
footer={
|
|
<Button onClick={() => {}}>Speichern</Button>
|
|
}
|
|
>
|
|
<TextField label="Erstes Feld" value={first} onChange={setFirst} />
|
|
<TextField label="Zweites Feld" value={second} onChange={setSecond} />
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Ein Dialog, dessen onClose bei jedem Rendern neu entsteht — und dessen
|
|
// Zustand im selben Bauteil liegt, sodass jeder Tastendruck neu rendert. Das
|
|
// ist der Normalfall für ein Formular im Dialog.
|
|
function DialogMitEigenemZustand() {
|
|
const [offen, setOffen] = useState(true);
|
|
const [wert, setWert] = useState("");
|
|
if (!offen) return <p>zu</p>;
|
|
return (
|
|
<Modal open onClose={() => setOffen(false)} title="Tippen" footer={<Button>Speichern</Button>}>
|
|
<TextField label="Feld" value={wert} onChange={setWert} />
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
describe("Modal focus management", () => {
|
|
it("lässt mehr als ein Zeichen tippen", async () => {
|
|
// Der Fokus-Wächter hing an der Identität von onClose. Die entsteht bei
|
|
// fast jedem Aufrufer bei jedem Rendern neu, also lief der Effekt nach
|
|
// jedem Tastendruck erneut — und sein Aufräumen holte den Fokus dorthin
|
|
// zurück, wo er vor dem Öffnen war. Nach dem ersten Zeichen ging die
|
|
// Eingabe ins Leere.
|
|
render(<DialogMitEigenemZustand />);
|
|
const feld = screen.getByLabelText("Feld");
|
|
await userEvent.type(feld, "Bahnhofstrasse");
|
|
expect(feld).toHaveValue("Bahnhofstrasse");
|
|
});
|
|
|
|
it("names the dialog from its visible heading", () => {
|
|
render(<ModalHarness />);
|
|
expect(screen.getByRole("dialog", { name: "Testdialog" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("moves focus into the dialog on open", () => {
|
|
render(<ModalHarness />);
|
|
// Not the page behind it: without this, the first Tab would start from
|
|
// the top of the document.
|
|
expect(document.body.contains(document.activeElement)).toBe(true);
|
|
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
|
});
|
|
|
|
it("keeps Tab inside the dialog and wraps at the end", async () => {
|
|
render(<ModalHarness />);
|
|
const dialog = screen.getByRole("dialog");
|
|
for (let i = 0; i < 8; i++) {
|
|
await userEvent.tab();
|
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("wraps backwards on Shift+Tab", async () => {
|
|
render(<ModalHarness />);
|
|
const dialog = screen.getByRole("dialog");
|
|
for (let i = 0; i < 8; i++) {
|
|
await userEvent.tab({ shift: true });
|
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("closes on Escape", async () => {
|
|
const onClose = vi.fn();
|
|
render(<ModalHarness onClose={onClose} />);
|
|
await userEvent.keyboard("{Escape}");
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns focus to the element that opened it", async () => {
|
|
function Toggle() {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<>
|
|
<button onClick={() => setOpen(true)}>Öffnen</button>
|
|
<Modal open={open} onClose={() => setOpen(false)} title="Testdialog">
|
|
<p>Inhalt</p>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
render(<Toggle />);
|
|
const trigger = screen.getByRole("button", { name: "Öffnen" });
|
|
await userEvent.click(trigger);
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
await userEvent.keyboard("{Escape}");
|
|
expect(trigger).toHaveFocus();
|
|
});
|
|
});
|
|
|
|
describe("SlideOver", () => {
|
|
it("marks a closed panel inert", () => {
|
|
// It stays mounted for the slide transition, so without `inert` its
|
|
// fields stay in the tab order behind the page — aria-hidden does not
|
|
// remove them.
|
|
//
|
|
// Asserted on the attribute rather than by tabbing: jsdom does not
|
|
// implement inert at all ("inert" in HTMLElement.prototype === false),
|
|
// so a focus-based assertion here would be testing jsdom, not this
|
|
// component. Browsers enforce it.
|
|
const { container } = render(
|
|
<SlideOver open={false} onClose={() => {}} title="Geschlossen">
|
|
<button>Im Panel</button>
|
|
</SlideOver>
|
|
);
|
|
expect(container.firstElementChild).toHaveAttribute("inert");
|
|
});
|
|
|
|
it("is not inert while open", () => {
|
|
const { container } = render(
|
|
<SlideOver open onClose={() => {}} title="Offen">
|
|
<button>Im Panel</button>
|
|
</SlideOver>
|
|
);
|
|
expect(container.firstElementChild).not.toHaveAttribute("inert");
|
|
});
|
|
|
|
it("traps focus once open", async () => {
|
|
render(
|
|
<>
|
|
<button>Dahinter</button>
|
|
<SlideOver open onClose={() => {}} title="Offen" footer={<Button>Speichern</Button>}>
|
|
<button>Im Panel</button>
|
|
</SlideOver>
|
|
</>
|
|
);
|
|
const dialog = screen.getByRole("dialog", { name: "Offen" });
|
|
for (let i = 0; i < 6; i++) {
|
|
await userEvent.tab();
|
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
}
|
|
});
|
|
});
|