Sixty days and all three kinds was a guess, and it was the only one on offer. Payroll cares about next month; the person filling a vacancy cares about entries and nothing else. The card now takes a period and a set of kinds. The choice lives in the address rather than in the browser, because it has to: the page is built on the server, and ninety days pulls in rows that were never loaded at sixty. Filtering client-side would silently cap the answer at whatever the first query happened to fetch. It also means a filtered overview can be sent to someone and opened again the same way. Deselecting every kind returns to all of them. An empty card is not an answer to a question nobody asked, and the way back would otherwise be one click further than the way in. The default period and the full set are absent from the URL instead of written into it, so a shared link carries only what was actually chosen. Anything the address cannot be trusted to hold is rejected: an unknown period falls back to sixty rather than reaching the query, which would otherwise be an invitation to ask for ten years of rows through a link. Eight rows still, with a count of what did not fit underneath — this is an overview, and the employee list is where lists belong. Not verified in a browser: the built-in preview has no company sign-in, so the page redirects to the login before it renders. Types, lint and 386 tests pass, and the filter's behaviour is covered directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
4.2 KiB
TypeScript
99 lines
4.2 KiB
TypeScript
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
|
|
|
// Die Auswahl muss in die Adresse, nicht in den Browser-Zustand: die
|
|
// Übersicht wird auf dem Server gebaut, und ein längerer Zeitraum bringt
|
|
// Zeilen ins Spiel, die im Browser gar nicht liegen. Geprüft wird deshalb
|
|
// genau das — was in der Adresse landet.
|
|
|
|
const replace = vi.fn();
|
|
let suchString = "";
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ replace }),
|
|
usePathname: () => "/",
|
|
useSearchParams: () => new URLSearchParams(suchString),
|
|
}));
|
|
|
|
const ALLE = ["hire", "exit", "return"] as const;
|
|
|
|
beforeEach(() => {
|
|
replace.mockClear();
|
|
suchString = "";
|
|
});
|
|
|
|
describe("AnstehendFilter", () => {
|
|
it("schreibt einen gewählten Zeitraum in die Adresse", async () => {
|
|
const user = userEvent.setup();
|
|
render(<AnstehendFilter zeitraum={60} arten={[...ALLE]} />);
|
|
await user.click(screen.getByRole("button", { name: "90 Tage" }));
|
|
expect(replace).toHaveBeenCalledWith("/?tage=90", { scroll: false });
|
|
});
|
|
|
|
it("lässt den Standard aus der Adresse verschwinden statt ihn hineinzuschreiben", async () => {
|
|
// Sonst trüge jede geteilte Adresse einen Filter, den niemand gesetzt hat.
|
|
const user = userEvent.setup();
|
|
suchString = "tage=90";
|
|
render(<AnstehendFilter zeitraum={90} arten={[...ALLE]} />);
|
|
await user.click(screen.getByRole("button", { name: "60 Tage" }));
|
|
expect(replace).toHaveBeenCalledWith("/", { scroll: false });
|
|
});
|
|
|
|
it("nimmt eine abgewählte Art aus der Auswahl", async () => {
|
|
const user = userEvent.setup();
|
|
render(<AnstehendFilter zeitraum={60} arten={[...ALLE]} />);
|
|
await user.click(screen.getByRole("button", { name: "Austritt" }));
|
|
expect(replace).toHaveBeenCalledWith("/?arten=hire%2Creturn", { scroll: false });
|
|
});
|
|
|
|
it("nimmt eine wieder hinzugewählte Art auf", async () => {
|
|
const user = userEvent.setup();
|
|
suchString = "arten=hire";
|
|
render(<AnstehendFilter zeitraum={60} arten={["hire"]} />);
|
|
await user.click(screen.getByRole("button", { name: "Rückkehr" }));
|
|
expect(replace).toHaveBeenCalledWith("/?arten=hire%2Creturn", { scroll: false });
|
|
});
|
|
|
|
it("führt die letzte abgewählte Art auf alle zurück, nicht auf keine", async () => {
|
|
// Eine leere Karte ist keine Antwort, und der Weg zurück wäre sonst ein
|
|
// Klick zu weit.
|
|
const user = userEvent.setup();
|
|
suchString = "arten=hire";
|
|
render(<AnstehendFilter zeitraum={60} arten={["hire"]} />);
|
|
await user.click(screen.getByRole("button", { name: "Eintritt" }));
|
|
expect(replace).toHaveBeenCalledWith("/", { scroll: false });
|
|
});
|
|
|
|
it("lässt vollständige Auswahl gar nicht erst in der Adresse stehen", async () => {
|
|
const user = userEvent.setup();
|
|
suchString = "arten=hire,exit";
|
|
render(<AnstehendFilter zeitraum={60} arten={["hire", "exit"]} />);
|
|
await user.click(screen.getByRole("button", { name: "Rückkehr" }));
|
|
expect(replace).toHaveBeenCalledWith("/", { scroll: false });
|
|
});
|
|
|
|
it("behält andere Angaben in der Adresse bei", async () => {
|
|
const user = userEvent.setup();
|
|
suchString = "arten=hire";
|
|
render(<AnstehendFilter zeitraum={60} arten={["hire"]} />);
|
|
await user.click(screen.getByRole("button", { name: "30 Tage" }));
|
|
expect(replace).toHaveBeenCalledWith("/?arten=hire&tage=30", { scroll: false });
|
|
});
|
|
|
|
it("zeigt am Knopf an, was gerade gewählt ist", () => {
|
|
render(<AnstehendFilter zeitraum={60} arten={["hire"]} />);
|
|
expect(screen.getByRole("button", { name: "Eintritt" })).toHaveAttribute("aria-pressed", "true");
|
|
expect(screen.getByRole("button", { name: "Austritt" })).toHaveAttribute("aria-pressed", "false");
|
|
});
|
|
|
|
it("springt beim Filtern nicht an den Seitenanfang", async () => {
|
|
// Die Karte steht in der unteren Hälfte; dorthin sieht gerade, wer klickt.
|
|
const user = userEvent.setup();
|
|
render(<AnstehendFilter zeitraum={60} arten={[...ALLE]} />);
|
|
await user.click(screen.getByRole("button", { name: "30 Tage" }));
|
|
expect(replace.mock.calls[0][1]).toEqual({ scroll: false });
|
|
});
|
|
});
|