feat(t5): Wächter vollständig, Hinweis Sonnenmodus, Nacharbeiten (T5a A10)
- Baukasten-Wächter: jede Regel prüft eigene Beispiele (muss fangen, muss ignorieren), veraltete Ausnahmen und Ordner-Ausschlüsse schlagen fehl; neue Regeln für text-primary ohne -ink, dark:, Schrift unter 13 px (Tailwind, SVG, CSS, style, *_SIZE-Konstanten) und Verlaufs-Hintergründe. Aufgeteilt in baukasten-guard, touch-target-guard und einen Scan-Helfer. - Hinweis „Neu: Sonnenmodus für draußen“ auf dem Dashboard, einmal je Gerät, „Ausprobieren“ schaltet live um; Fokus geht danach zur Überschrift. - Varroa-Diagramm: Beschriftungen als HTML mit fester Größe statt SVG-Text. - Medikamente zeigen die Tabs auch leer; Zahlen in Tabs nicht beim Laden oder Fehler; inaktive Tabs mit Hover für die Maus; TabPicker (Auswahl unter 640 px) für Automatisierung und Statistik; Rollen-Dialoge breit genug für die Rechte-Matrix. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
355a353671
commit
427969d8b9
42 files changed
+1053
-400
No files matched your search
@@ -43,4 +43,14 @@ describe("ApiariesPage", () => {
|
||||
expect(await screen.findByText("Heimstand")).toBeInTheDocument();
|
||||
expect(screen.getByText("Garten")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the tabs without a count when the first load failed", async () => {
|
||||
vi.mocked(fetchApiaries).mockRejectedValueOnce(new Error("kaputt"));
|
||||
renderWithClient(<ApiariesPage />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Aktiv" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/\(0\)/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -84,8 +84,8 @@ export default function ApiariesPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
activeCount={countOf(apiariesQuery.data, activeApiaries)}
|
||||
historyCount={countOf(apiariesQuery.data, historyApiaries)}
|
||||
toolbar={
|
||||
<Input
|
||||
className="w-48"
|
||||
|
||||
@@ -208,7 +208,8 @@ describe("ColonyVarroaCard trend", () => {
|
||||
it("labels the date axis and explains the lines in a legend", async () => {
|
||||
renderCard();
|
||||
const chart = await screen.findByRole("img");
|
||||
const axis = within(chart as unknown as HTMLElement);
|
||||
// The dates sit in an HTML row under the chart (the img names them in its description).
|
||||
const axis = within(chart.parentElement?.parentElement as HTMLElement);
|
||||
expect(axis.getByText("01.06.2026")).toBeInTheDocument();
|
||||
expect(axis.getByText("15.06.2026")).toBeInTheDocument();
|
||||
await screen.findByText("Rote Grenze");
|
||||
@@ -217,4 +218,17 @@ describe("ColonyVarroaCard trend", () => {
|
||||
expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument();
|
||||
expect(within(legend).getByText("Gelbe Grenze")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("chart labels are HTML text at a fixed 13 px, not SVG text that shrinks with a narrow chart", async () => {
|
||||
const { container } = renderCard();
|
||||
await screen.findByText("Grenze 10/Tag");
|
||||
// SVG font sizes are user units: in a 300-unit viewBox on a 360 px phone 13 units are ~12 px.
|
||||
expect(container.querySelectorAll("svg text")).toHaveLength(0);
|
||||
for (const text of ["Grenze 10/Tag", "01.06.2026", "15.06.2026"]) {
|
||||
const label = screen.getByText(text);
|
||||
expect(label.closest("svg"), text).toBeNull();
|
||||
expect(label.className.split(/\s+/), text).toContain("text-xs");
|
||||
expect(label.closest("[aria-hidden='true']"), text).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,12 @@ import { VarroaEditDialog } from "./varroa-edit-dialog";
|
||||
const CHART_WIDTH = 300;
|
||||
const CHART_HEIGHT = 136;
|
||||
const PAD_X = 12;
|
||||
const PAD_TOP = 12;
|
||||
const PAD_BOTTOM = 28;
|
||||
// Room for the HTML limit label (~22 px) above the highest point.
|
||||
const PAD_TOP = 24;
|
||||
const PAD_BOTTOM = 12;
|
||||
const MARKER_RADIUS = 4;
|
||||
const LABEL_GAP = 4;
|
||||
const AXIS_LABEL_SIZE = 10;
|
||||
/** Horizontal inset of the HTML labels, matching PAD_X of the viewBox. */
|
||||
const PAD_X_PERCENT = `${(PAD_X / CHART_WIDTH) * 100}%`;
|
||||
|
||||
const MAX_BAND_MONTHS = 24;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
@@ -203,78 +204,70 @@ function VarroaTrendChart({
|
||||
const monthly = monthLimitsText(limits, range);
|
||||
|
||||
const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2;
|
||||
const labelY = CHART_HEIGHT - LABEL_GAP;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
className="w-full text-primary-ink"
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descId}
|
||||
>
|
||||
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
|
||||
<desc id={descId}>
|
||||
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
|
||||
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
|
||||
}`}
|
||||
</desc>
|
||||
{redPath || yellowPath ? (
|
||||
<g className="text-muted-foreground" fill="none" stroke="currentColor">
|
||||
{yellowPath ? (
|
||||
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
|
||||
) : null}
|
||||
{redPath ? (
|
||||
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
|
||||
) : null}
|
||||
</g>
|
||||
) : null}
|
||||
{/* Labels are HTML at text-xs: SVG text scales with the viewBox and drops below 13 px. */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
className="w-full text-primary-ink"
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descId}
|
||||
>
|
||||
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
|
||||
<desc id={descId}>
|
||||
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
|
||||
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
|
||||
}`}
|
||||
</desc>
|
||||
{redPath || yellowPath ? (
|
||||
<g className="text-muted-foreground" fill="none" stroke="currentColor">
|
||||
{yellowPath ? (
|
||||
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
|
||||
) : null}
|
||||
{redPath ? (
|
||||
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
|
||||
) : null}
|
||||
</g>
|
||||
) : null}
|
||||
<line
|
||||
x1={PAD_X}
|
||||
x2={CHART_WIDTH - PAD_X}
|
||||
y1={axisY}
|
||||
y2={axisY}
|
||||
stroke="currentColor"
|
||||
strokeWidth={0.5}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
|
||||
{points.map((p) => (
|
||||
<circle
|
||||
key={p.id}
|
||||
cx={xOf(p.time)}
|
||||
cy={yOf(p.rate)}
|
||||
r={MARKER_RADIUS}
|
||||
fill="currentColor"
|
||||
className="stroke-background"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{lastLimit ? (
|
||||
<text
|
||||
x={CHART_WIDTH - PAD_X}
|
||||
y={yOf(lastLimit.redAbove) - LABEL_GAP}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
className="fill-muted-foreground"
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -translate-y-full pb-1 text-xs text-muted-foreground"
|
||||
style={{ top: `${(yOf(lastLimit.redAbove) / CHART_HEIGHT) * 100}%`, right: PAD_X_PERCENT }}
|
||||
>
|
||||
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
|
||||
</text>
|
||||
</span>
|
||||
) : null}
|
||||
<line
|
||||
x1={PAD_X}
|
||||
x2={CHART_WIDTH - PAD_X}
|
||||
y1={axisY}
|
||||
y2={axisY}
|
||||
stroke="currentColor"
|
||||
strokeWidth={0.5}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<text x={PAD_X} y={labelY} fontSize={AXIS_LABEL_SIZE} className="fill-muted-foreground">
|
||||
{formatDate(first.time)}
|
||||
</text>
|
||||
<text
|
||||
x={CHART_WIDTH - PAD_X}
|
||||
y={labelY}
|
||||
textAnchor="end"
|
||||
fontSize={AXIS_LABEL_SIZE}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
{formatDate(last.time)}
|
||||
</text>
|
||||
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
|
||||
{points.map((p) => (
|
||||
<circle
|
||||
key={p.id}
|
||||
cx={xOf(p.time)}
|
||||
cy={yOf(p.rate)}
|
||||
r={MARKER_RADIUS}
|
||||
fill="currentColor"
|
||||
className="stroke-background"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<div aria-hidden="true" className="flex justify-between gap-2" style={{ paddingInline: PAD_X_PERCENT }}>
|
||||
<span className="text-xs text-muted-foreground">{formatDate(first.time)}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDate(last.time)}</span>
|
||||
</div>
|
||||
<ChartLegend hasRed={redPath !== ""} hasYellow={yellowPath !== ""} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -108,8 +108,8 @@ export default function ColoniesPage() {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
activeCount={countOf(coloniesQuery.data, activeColonies)}
|
||||
historyCount={countOf(coloniesQuery.data, historyColonies)}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
|
||||
@@ -288,4 +288,34 @@ describe("DashboardPage – Heute", () => {
|
||||
expect(header.getAttribute("style")).toBeNull();
|
||||
expect(container.innerHTML).not.toMatch(/text-white|--gradient/);
|
||||
});
|
||||
|
||||
it("the dashboard shows no white text on honey", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(
|
||||
todayFixture({
|
||||
nextStep: { ...stepFixture({ title: "Durchsicht A1" }), colonyId: "c1", colonyCode: "A1" },
|
||||
}),
|
||||
);
|
||||
const { container } = renderPage();
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary");
|
||||
const honey = Array.from(container.querySelectorAll<HTMLElement>(".bg-primary"));
|
||||
expect(honey.length).toBeGreaterThan(0);
|
||||
for (const element of honey) {
|
||||
// Text on honey is the dark ink of --primary-foreground, never white (spec 4.4, 9.4:1).
|
||||
expect(element.className, element.textContent ?? "").toMatch(/(^|\s)text-primary-foreground(\s|$)/);
|
||||
}
|
||||
expect(container.innerHTML).not.toMatch(/(^|[\s"])text-white\b/);
|
||||
});
|
||||
|
||||
it("shows the one-time sun notice on the start page (spec 15.4)", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||
renderPage();
|
||||
const notice = await screen.findByRole("complementary", {
|
||||
name: "Neu: Sonnenmodus für draußen – im Benutzermenü.",
|
||||
});
|
||||
expect(within(notice).getByRole("button", { name: "Ausprobieren" })).toBeInTheDocument();
|
||||
// Closing hands the focus to the greeting, not to <body>.
|
||||
await userEvent.click(within(notice).getByRole("button", { name: "Schließen" }));
|
||||
expect(document.activeElement).toBe(screen.getByRole("heading", { level: 1 }));
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
import { tasksForToday } from "@/lib/tasks/today";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AppearanceNotice } from "@/components/appearance-notice";
|
||||
import { StandNote } from "@/components/offline/stand-note";
|
||||
import { ComplianceLine } from "@/components/today/compliance-line";
|
||||
import { TodayPanel } from "@/components/today/today-panel";
|
||||
@@ -157,7 +158,8 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{/* tabIndex -1: the sun notice hands the focus here when it closes. */}
|
||||
<h1 tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{greeting(now)}
|
||||
{firstName ? `, ${firstName}` : ""}
|
||||
</h1>
|
||||
@@ -167,6 +169,7 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</header>
|
||||
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
|
||||
<AppearanceNotice />
|
||||
|
||||
{autopilotError && !isAutopilotSilent ? (
|
||||
<div
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -102,8 +102,8 @@ export default function HoneyBatchesPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeBatches.length}
|
||||
historyCount={historyBatches.length}
|
||||
activeCount={countOf(honeyBatchesQuery.data, activeBatches)}
|
||||
historyCount={countOf(honeyBatchesQuery.data, historyBatches)}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
@@ -88,6 +88,18 @@ describe("MedicinesPage", () => {
|
||||
expect(screen.getByText("Oxalsäure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("an empty catalogue keeps the Aktiv (0) / Historie (0) tabs like every other list", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
|
||||
renderPage();
|
||||
|
||||
const active = await screen.findByRole("tab", { name: "Aktiv (0)" });
|
||||
expect(active).toHaveAttribute("aria-selected", "true");
|
||||
expect(await within(screen.getByRole("tabpanel")).findByText("Aus deinen Behandlungen übernehmen?")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Historie (0)" }));
|
||||
expect(within(screen.getByRole("tabpanel")).getByText("Keine deaktivierten Mittel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchMedicines } from "@/lib/medicines/api";
|
||||
import type { MedicineView } from "@/lib/medicines/types";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
@@ -35,9 +35,19 @@ export default function MedicinesPage() {
|
||||
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
||||
: "";
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const listOf = (bucket: EntityLifecycleTab) => (
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() => bucketList(bucket)}
|
||||
</QueryState>
|
||||
);
|
||||
|
||||
const bucketList = (bucket: EntityLifecycleTab) => {
|
||||
const shown = bucket === "active" ? active : inactive;
|
||||
if (shown.length > 0) return <MedicineGrid medicines={shown} />;
|
||||
// An empty catalogue starts from the beekeeper's own treatments.
|
||||
if (bucket === "active" && medicines.length === 0) {
|
||||
return <MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />;
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
@@ -63,27 +73,19 @@ export default function MedicinesPage() {
|
||||
/>
|
||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() =>
|
||||
medicines.length === 0 ? (
|
||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||
) : (
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
toolbar={
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{countText}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={countOf(medicinesQuery.data, active)}
|
||||
historyCount={countOf(medicinesQuery.data, inactive)}
|
||||
toolbar={
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{countText}
|
||||
</p>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
||||
@@ -76,8 +76,8 @@ export default function ProductsPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
activeCount={countOf(productsQuery.data, activeProducts)}
|
||||
historyCount={countOf(productsQuery.data, historyProducts)}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
|
||||
export default function QueensPage() {
|
||||
@@ -68,8 +68,8 @@ export default function QueensPage() {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
activeCount={countOf(queensQuery.data, activeQueens)}
|
||||
historyCount={countOf(queensQuery.data, historyQueens)}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { Suspense, type ComponentType } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { TabPicker } from "@/components/tab-picker";
|
||||
import { WebhooksTab } from "./webhooks-tab";
|
||||
import { TaskTemplatesTab } from "./task-templates-tab";
|
||||
import { LabelTemplatesTab } from "./label-templates-tab";
|
||||
@@ -32,6 +33,9 @@ const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
|
||||
{ value: "webhooks", label: "Webhooks", Content: WebhooksTab },
|
||||
];
|
||||
|
||||
const TAB_VALUES = TABS.map((entry) => entry.value);
|
||||
const TAB_LABELS = Object.fromEntries(TABS.map((entry) => [entry.value, entry.label])) as Record<Tab, string>;
|
||||
|
||||
function isTab(value: unknown): value is Tab {
|
||||
return TABS.some((tab) => tab.value === value);
|
||||
}
|
||||
@@ -60,13 +64,14 @@ function AutomationPageContent() {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Bereiche der Automatisierung">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabPicker
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
labels={TAB_LABELS}
|
||||
values={TAB_VALUES}
|
||||
listLabel="Bereiche der Automatisierung"
|
||||
selectLabel="Bereich der Automatisierung wählen"
|
||||
/>
|
||||
{TABS.map(({ value, Content }) => (
|
||||
<TabsContent key={value} value={value}>
|
||||
<Content />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
|
||||
import { EditTaskTemplateDialog } from "./edit-task-template-dialog";
|
||||
|
||||
@@ -116,8 +116,8 @@ export function TaskTemplatesTab() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
activeCount={countOf(templates, activeTemplates)}
|
||||
historyCount={countOf(templates, historyTemplates)}
|
||||
toolbar={<CreateTaskTemplateDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
|
||||
@@ -43,6 +43,23 @@ describe("both settings pages use Tabs directly", () => {
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
|
||||
});
|
||||
|
||||
it("Automatisierung: on a phone a select replaces the seven tabs and keeps the ?tab= contract", async () => {
|
||||
search = "tab=api-keys";
|
||||
const user = userEvent.setup();
|
||||
render(<AutomationPage />);
|
||||
|
||||
// Below sm the tab bar would wrap into five rows; the same choice is a select there.
|
||||
const picker = screen.getByRole("combobox", { name: "Bereich der Automatisierung wählen" });
|
||||
expect(picker.className.split(/\s+/)).toContain("sm:hidden");
|
||||
expect(picker).toHaveTextContent("API-Schlüssel");
|
||||
const list = screen.getByRole("tablist", { hidden: true });
|
||||
expect(list.className.split(/\s+/)).toEqual(expect.arrayContaining(["hidden", "sm:inline-flex"]));
|
||||
|
||||
await user.click(picker);
|
||||
await user.click(await screen.findByRole("option", { name: "Webhooks" }));
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
|
||||
});
|
||||
|
||||
it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => {
|
||||
search = "tab=unknown";
|
||||
render(<AutomationPage />);
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CreateRoleDialog() {
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setOpen(true)}>+ Neue Rolle</Button>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Rolle</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -115,7 +115,7 @@ interface EditRoleDialogProps {
|
||||
export function EditRoleDialog({ role, onClose }: EditRoleDialogProps) {
|
||||
return (
|
||||
<Dialog open={role !== null} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rolle bearbeiten{role ? `: ${role.name}` : ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -45,4 +47,13 @@ describe("PermissionMatrix", () => {
|
||||
expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x"));
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["create-role-dialog.tsx", "edit-role-dialog.tsx"])(
|
||||
"%s gives the matrix the wide dialog on a wide screen (sm:max-w-2xl beats the 384 px default)",
|
||||
(file) => {
|
||||
const text = readFileSync(join(__dirname, file), "utf8");
|
||||
const content = /<DialogContent className="([^"]*)"/.exec(text)?.[1] ?? "";
|
||||
expect(content.split(/\s+/)).toContain("sm:max-w-2xl");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { CreateRoleDialog } from "./create-role-dialog";
|
||||
import { EditRoleDialog } from "./edit-role-dialog";
|
||||
|
||||
@@ -160,8 +160,8 @@ export function RolesTab() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
activeCount={countOf(roles, activeRoles)}
|
||||
historyCount={countOf(roles, historyRoles)}
|
||||
toolbar={<CreateRoleDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
|
||||
@@ -63,4 +63,11 @@ describe("StatisticsPage", () => {
|
||||
expect(list).toHaveAttribute("data-variant", "segment");
|
||||
expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("on a phone the select says what it picks", () => {
|
||||
vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
|
||||
renderWithClient(<StatisticsPage />);
|
||||
expect(screen.getByRole("combobox", { name: "Auswertung wählen" })).toHaveTextContent("Völker");
|
||||
expect(screen.getByRole("tablist", { hidden: true })).toHaveAccessibleName("Auswertungen");
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,8 @@ import { StatBarList } from "@/components/stat-bar-list";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { TabPicker } from "@/components/tab-picker";
|
||||
import { keysOf } from "@/lib/keys-of";
|
||||
|
||||
const TAB_LABELS = {
|
||||
@@ -345,19 +345,6 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
/** On phones the tab bar does not fit - the same choice as a select. */
|
||||
function TabPicker({ value, onChange }: { value: StatsTab; onChange: (value: StatsTab) => void }) {
|
||||
return (
|
||||
<EnumSelect
|
||||
ariaLabel="Bereich wählen"
|
||||
labels={TAB_LABELS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="sm:hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [tab, setTab] = useState<StatsTab>("colonies");
|
||||
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||
@@ -375,14 +362,14 @@ export default function StatisticsPage() {
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={(next) => setTab(toStatsTab(next) ?? tab)}>
|
||||
<TabPicker value={tab} onChange={setTab} />
|
||||
<TabsList className="hidden sm:inline-flex">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry} value={entry}>
|
||||
{TAB_LABELS[entry]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabPicker
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
labels={TAB_LABELS}
|
||||
values={TABS}
|
||||
listLabel="Auswertungen"
|
||||
selectLabel="Auswertung wählen"
|
||||
/>
|
||||
<TabsContent value="colonies">
|
||||
<ColonyStatsPanel />
|
||||
</TabsContent>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
|
||||
import { CreateStockItemDialog } from "./create-stock-item-dialog";
|
||||
@@ -65,8 +65,8 @@ export default function StoragePage() {
|
||||
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
|
||||
value={containerTab}
|
||||
onValueChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
activeCount={countOf(containersQuery.data, activeContainers)}
|
||||
historyCount={countOf(containersQuery.data, historyContainers)}
|
||||
>
|
||||
{{ active: containersOf("active"), history: containersOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { TaskEditDialog } from "./task-edit-dialog";
|
||||
|
||||
@@ -136,8 +136,8 @@ export default function TasksPage() {
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
activeCount={countOf(tasks, activeTasks)}
|
||||
historyCount={countOf(tasks, historyTasks)}
|
||||
toolbar={
|
||||
<>
|
||||
<EnumSelect
|
||||
|
||||
@@ -82,7 +82,7 @@ function Stat({ icon: Icon, label, value }: StatProps) {
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center">
|
||||
<Icon className="size-4 text-primary-ink" />
|
||||
<span className="text-sm font-semibold">{value}</span>
|
||||
<span className="text-[0.7rem] text-muted-foreground">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user