fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s

Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 16:38:55 +02:00
1 parent f5b1964223
commit 10cbe9bf2d
310 files changed
+12177 -3746

No files matched your search

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonySelectField, LivingColonySelectField } from "./colony-select-field";
import { fetchColonies } from "@/lib/colonies/api";
import type { ColonyStatus } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
function colony(id: string, code: string, status: ColonyStatus) {
return { id, code, status, apiaryId: "apiary-1" };
}
const COLONIES = [
colony("c-active", "V-01", "ACTIVE"),
colony("c-weak", "V-02", "WEAK"),
colony("c-queenless", "V-03", "QUEENLESS"),
colony("c-conspicuous", "V-04", "CONSPICUOUS"),
colony("c-dissolved", "V-05", "DISSOLVED"),
colony("c-sold", "V-06", "SOLD"),
];
function withClient(ui: React.ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
function LivingHarness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<LivingColonySelectField triggerId="test-colony" value={value} onChange={setValue} />
);
}
function AllHarness({ initial }: { initial: string }) {
const [value, setValue] = useState(initial);
return <ColonySelectField id="test-edit-colony" value={value} onChange={setValue} />;
}
async function openOptions() {
await userEvent.click(screen.getByRole("combobox"));
return screen.findAllByRole("option");
}
describe("LivingColonySelectField", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never);
});
it("offers weak, queenless and conspicuous colonies - not only active ones", async () => {
withClient(<LivingHarness />);
const options = await openOptions();
expect(options.map((option) => option.textContent)).toEqual([
"V-01",
"V-02 (Schwach)",
"V-03 (Weisellos)",
"V-04 (Auffällig)",
]);
});
it("shows the chosen colony by its code, not by its internal id", async () => {
withClient(<LivingHarness />);
await openOptions();
await userEvent.click(screen.getByRole("option", { name: "V-03 (Weisellos)" }));
expect(screen.getByRole("combobox")).toHaveTextContent("V-03 (Weisellos)");
expect(screen.getByRole("combobox")).not.toHaveTextContent("c-queenless");
});
it("still names an ended colony handed in from a link, without offering it", async () => {
withClient(<LivingHarness initial="c-dissolved" />);
await waitFor(() =>
expect(screen.getByRole("combobox")).toHaveTextContent("V-05 (Aufgelöst)"),
);
const options = await openOptions();
expect(options.map((option) => option.textContent)).not.toContain("V-05 (Aufgelöst)");
});
it("says a colony is unavailable once loaded instead of loading forever", async () => {
withClient(<LivingHarness initial="c-unknown" />);
await waitFor(() =>
expect(screen.getByRole("combobox")).toHaveTextContent("Nicht mehr verfügbar"),
);
expect(screen.getByRole("combobox")).not.toHaveTextContent("c-unknown");
});
it("says so when the colonies could not be loaded instead of loading forever", async () => {
vi.mocked(fetchColonies).mockRejectedValue(new Error("offline"));
withClient(<LivingHarness />);
expect(await screen.findByText(/Völker konnten nicht geladen werden/)).toBeInTheDocument();
expect(screen.getByRole("combobox")).toHaveTextContent("Volk wählen");
});
it("explains what to do when there is no living colony yet", async () => {
vi.mocked(fetchColonies).mockResolvedValue([colony("c-sold", "V-06", "SOLD")] as never);
withClient(<LivingHarness />);
expect(
await screen.findByText(/Noch keine aktiven Völker/),
).toBeInTheDocument();
});
});
describe("ColonySelectField", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never);
});
it("is a regular form field: labelled, marked required and able to show an error", async () => {
withClient(
<ColonySelectField
id="edit-colony"
value="c-active"
onChange={() => undefined}
error="Bitte ein Volk auswählen."
/>,
);
const trigger = screen.getByLabelText(/^Volk/);
expect(trigger).toHaveAttribute("id", "edit-colony");
expect(trigger).toHaveAttribute("aria-invalid", "true");
expect(trigger).toHaveAccessibleDescription("Bitte ein Volk auswählen.");
expect(screen.getByText("Pflicht")).toBeInTheDocument();
expect(trigger).not.toHaveAttribute("data-field");
});
it("keeps ended colonies selectable and names their real status", async () => {
withClient(<AllHarness initial="c-sold" />);
await waitFor(() =>
expect(screen.getByRole("combobox")).toHaveTextContent("V-06 (Verkauft)"),
);
const options = await openOptions();
expect(options.map((option) => option.textContent)).toEqual([
"V-01",
"V-02 (Schwach)",
"V-03 (Weisellos)",
"V-04 (Auffällig)",
"V-05 (Aufgelöst)",
"V-06 (Verkauft)",
]);
});
});
@@ -2,20 +2,117 @@
import { useQuery } from "@tanstack/react-query";
import { fetchColonies } from "@/lib/colonies/api";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
const PLACEHOLDER = "Volk wählen";
const LOADING_PLACEHOLDER = "Völker werden geladen…";
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
function useColonies() {
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
}
interface ColonyChoicesProps {
id: string;
value: string;
onChange: (colonyId: string) => void;
/** The colonies on offer; `undefined` while loading. */
options: readonly Colony[] | undefined;
/** Every known colony, so a chosen one is named even if it is not offered. */
colonies: readonly Colony[] | undefined;
isLoading: boolean;
dataField?: string;
describedBy?: string;
invalid?: boolean;
}
/** The colony select: a RecordSelect named by code and status ("V-03 (Weisellos)"). */
function ColonyChoices({ colonies, options, isLoading, ...props }: ColonyChoicesProps) {
return (
<RecordSelect
{...props}
items={options}
labelItems={colonies}
getLabel={colonyOptionLabel}
placeholder={isLoading ? LOADING_PLACEHOLDER : PLACEHOLDER}
/>
);
}
interface LivingColonySelectFieldProps {
/** Id of the select trigger, used to focus it on a validation error. */
triggerId: string;
value: string;
onChange: (colonyId: string) => void;
error?: string | null;
label?: string;
/** A colony that must not be offered, e.g. the one another is merged into. */
excludeId?: string;
/** Shown when there is nothing to choose from. */
emptyHint?: string;
/** `data-field` of the trigger; the capture forms focus "colonyId" on errors. */
dataField?: string;
}
/**
* Picks a colony that still lives in the apiary: the one a new record
* (inspection, varroa count, feeding, treatment, swarm control check) is filed
* under, the colony a queen moves into, the colony merged into another.
* Weak, conspicuous and queenless colonies are included - they need the most
* attention - and none that has ended. A colony handed in by a link that has
* ended since is still named, just not offered.
*/
export function LivingColonySelectField({
triggerId,
value,
onChange,
error,
label = "Volk",
excludeId,
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
dataField = "colonyId",
}: LivingColonySelectFieldProps) {
const { data: colonies, isLoading, isError } = useColonies();
const living = colonies?.filter(
(colony) => isLivingColony(colony) && colony.id !== excludeId,
);
return (
<Field
id={triggerId}
label={label}
required
error={error}
hint={isError ? LOAD_ERROR_HINT : living?.length === 0 ? emptyHint : undefined}
>
{({ id, describedBy, invalid }) => (
<ColonyChoices
id={id}
value={value}
onChange={onChange}
options={living}
colonies={colonies}
isLoading={isLoading}
dataField={dataField}
describedBy={describedBy}
invalid={invalid}
/>
)}
</Field>
);
}
interface ColonySelectFieldProps {
id: string;
value: string;
onChange: (colonyId: string) => void;
label?: string;
error?: string | null;
dataField?: string;
}
/**
@@ -25,37 +122,41 @@ interface ColonySelectFieldProps {
* against the wrong colony is a data-entry error rather than a fact of
* beekeeping - and the only alternative repair would be deleting the record.
*
* Dissolved colonies stay in the list: a record already filed against one has
* Ended colonies stay in the list: a record already filed against one has
* to remain visible and selectable, otherwise opening the form would silently
* re-file it somewhere else.
*/
export function ColonySelectField({
id,
id: triggerId,
value,
onChange,
label = "Volk",
error,
dataField,
}: ColonySelectFieldProps) {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const { data: colonies, isLoading, isError } = useColonies();
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Select value={value} onValueChange={(next) => onChange(next ?? value)}>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
{(colonies ?? []).map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
{colony.status === "ACTIVE" ? "" : " (aufgelöst)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Field
id={triggerId}
label={label}
required
error={error}
hint={isError ? LOAD_ERROR_HINT : undefined}
>
{({ id, describedBy, invalid }) => (
<ColonyChoices
id={id}
value={value}
onChange={onChange}
options={colonies}
colonies={colonies}
isLoading={isLoading}
dataField={dataField}
describedBy={describedBy}
invalid={invalid}
/>
)}
</Field>
);
}
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChoiceGroup, choiceOptions, type ChoiceOption } from "./choice-group";
type Feed = "ausreichend" | "niedrig" | "kritisch";
const FEED_OPTIONS: ChoiceOption<Feed | null>[] = [
{ value: "ausreichend", label: "Ausreichend" },
{ value: "niedrig", label: "Niedrig" },
{ value: "kritisch", label: "Kritisch" },
{ value: null, label: "Nicht geprüft" },
];
function Harness({
initial = null,
clearable = false,
onChange,
}: {
initial?: Feed | null;
clearable?: boolean;
onChange?: (value: Feed | null) => void;
}) {
const [value, setValue] = useState<Feed | null>(initial);
return (
<ChoiceGroup
label="Futter"
options={FEED_OPTIONS}
value={value}
clearValue={clearable ? null : undefined}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
/>
);
}
describe("ChoiceGroup", () => {
it("is one named radio group with one large radio per option", () => {
render(<Harness />);
expect(screen.getByRole("radiogroup", { name: "Futter" })).toBeInTheDocument();
const radios = screen.getAllByRole("radio");
expect(radios.map((radio) => radio.textContent)).toEqual([
"Ausreichend",
"Niedrig",
"Kritisch",
"Nicht geprüft",
]);
for (const radio of radios) {
expect(radio.className).toContain("min-h-11");
}
});
it("marks the current value - null included - as checked", () => {
render(<Harness />);
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByRole("radio", { name: "Niedrig" })).toHaveAttribute(
"aria-checked",
"false",
);
});
it("selects with a single tap", async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
await userEvent.click(screen.getByRole("radio", { name: "Kritisch" }));
expect(onChange).toHaveBeenLastCalledWith("kritisch");
expect(screen.getByRole("radio", { name: "Kritisch" })).toHaveAttribute(
"aria-checked",
"true",
);
});
it("keeps a tap on the chosen option as is unless the group is clearable", async () => {
const onChange = vi.fn();
const { unmount } = render(<Harness initial="niedrig" onChange={onChange} />);
await userEvent.click(screen.getByRole("radio", { name: "Niedrig" }));
expect(onChange).toHaveBeenLastCalledWith("niedrig");
unmount();
render(<Harness initial="niedrig" clearable onChange={onChange} />);
await userEvent.click(screen.getByRole("radio", { name: "Niedrig" }));
expect(onChange).toHaveBeenLastCalledWith(null);
});
it("is reached with one tab stop and moved through with the arrow keys", async () => {
const onChange = vi.fn();
render(<Harness initial="kritisch" onChange={onChange} />);
await userEvent.tab();
expect(screen.getByRole("radio", { name: "Kritisch" })).toHaveFocus();
expect(
screen.getAllByRole("radio").filter((radio) => radio.tabIndex === 0),
).toHaveLength(1);
await userEvent.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus();
expect(onChange).toHaveBeenLastCalledWith(null);
await userEvent.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: "Ausreichend" })).toHaveFocus();
expect(onChange).toHaveBeenLastCalledWith("ausreichend");
await userEvent.keyboard("{ArrowLeft}");
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus();
await userEvent.keyboard("{Home}");
expect(screen.getByRole("radio", { name: "Ausreichend" })).toHaveFocus();
await userEvent.keyboard("{End}");
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus();
});
it("keeps the visible label inside the spoken name (WCAG 2.5.3)", () => {
render(
<ChoiceGroup
label="Sanftmut"
options={[{ value: 4, label: "4", srSuffix: "von 5" }]}
value={null}
onChange={() => undefined}
/>,
);
const radio = screen.getByRole("radio", { name: "4 von 5" });
expect(radio).not.toHaveAttribute("aria-label");
expect(radio).toHaveAccessibleName(expect.stringContaining("4"));
});
it("tells apart options whose values only look alike", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const onChange = vi.fn();
render(
<ChoiceGroup<string | boolean | null>
label="Gemischt"
options={[
{ value: "null", label: "Text null" },
{ value: null, label: "Nichts" },
{ value: "true", label: "Text true" },
{ value: true, label: "Ja" },
]}
value={null}
onChange={onChange}
/>,
);
expect(screen.getAllByRole("radio")).toHaveLength(4);
await userEvent.click(screen.getByRole("radio", { name: "Text null" }));
expect(onChange).toHaveBeenLastCalledWith("null");
await userEvent.click(screen.getByRole("radio", { name: "Ja" }));
expect(onChange).toHaveBeenLastCalledWith(true);
expect(
consoleError.mock.calls.some((call) => String(call[0]).includes("same key")),
).toBe(false);
consoleError.mockRestore();
});
it("makes the first option the tab stop while nothing is chosen", () => {
render(
<ChoiceGroup
label="Methode"
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
]}
value={""}
onChange={() => undefined}
/>,
);
expect(screen.getByRole("radio", { name: "A" })).toHaveAttribute("tabindex", "0");
expect(screen.getByRole("radio", { name: "B" })).toHaveAttribute("tabindex", "-1");
});
});
describe("choiceOptions", () => {
const MOOD_LABELS = { NONE: "Keine", LOW: "Leicht", HIGH: "Stark" } as const;
it("offers every value of a label map in its order - no cast needed", () => {
const options = choiceOptions(MOOD_LABELS);
expect(options).toEqual([
{ value: "NONE", label: "Keine" },
{ value: "LOW", label: "Leicht" },
{ value: "HIGH", label: "Stark" },
]);
});
it("offers only the given values, in the given order", () => {
expect(choiceOptions(MOOD_LABELS, ["HIGH", "NONE"])).toEqual([
{ value: "HIGH", label: "Stark" },
{ value: "NONE", label: "Keine" },
]);
});
});
@@ -0,0 +1,149 @@
"use client";
import { KeyboardEvent, useRef } from "react";
import { keysOf } from "@/lib/keys-of";
import { cn } from "@/lib/utils";
export type ChoiceValue = string | number | boolean | null;
export interface ChoiceOption<T extends ChoiceValue> {
value: T;
label: string;
/**
* Words only screen readers hear after the label, e.g. "von 5" for "4".
* Appended instead of an aria-label, so the spoken name always contains
* the visible label (voice control users say what they see).
*/
srSuffix?: string;
}
/** Distinct per value and type: "null" (text) and null must not collide. */
function optionKey(value: ChoiceValue): string {
return `${value === null ? "null" : typeof value}:${String(value)}`;
}
/**
* Options from a label map (an enum's labels), named like EnumSelect: all of
* its values in map order, or only `values` in that order. Typed from the
* map, so callers need no cast.
*/
export function choiceOptions<T extends string>(
labels: Readonly<Record<T, string>>,
values: readonly NoInfer<T>[] = keysOf(labels),
): ChoiceOption<T>[] {
return values.map((value) => ({ value, label: labels[value] }));
}
interface ChoiceGroupProps<T extends ChoiceValue> {
options: readonly ChoiceOption<T>[];
value: T;
onChange: (value: T) => void;
id?: string;
/** Id of a visible label; use `label` when there is none. */
labelledBy?: string;
label?: string;
describedBy?: string;
/** If set, tapping the chosen option again sets this value ("none"). */
clearValue?: T;
className?: string;
/** Extra classes for every option button, e.g. a larger font. */
optionClassName?: string;
}
const KEY_STEPS: Record<string, number> = {
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
};
function targetIndex(key: string, current: number, count: number): number | null {
if (key === "Home") return 0;
if (key === "End") return count - 1;
const step = KEY_STEPS[key];
if (step === undefined) return null;
return (current + step + count) % count;
}
/**
* One large button per option instead of a dropdown: a single tap with
* gloves, and every choice stays visible. A `null` option stands for "not
* answered" (e.g. "Nicht geprüft") and is shown muted when chosen, so the
* observations that were really made stand out.
*
* Accessible as a radio group: one tab stop, arrow keys move and select.
*/
export function ChoiceGroup<T extends ChoiceValue>({
options,
value,
onChange,
id,
labelledBy,
label,
describedBy,
clearValue,
className,
optionClassName,
}: ChoiceGroupProps<T>) {
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
const checkedIndex = options.findIndex((option) => option.value === value);
const tabStop = checkedIndex === -1 ? 0 : checkedIndex;
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
const next = targetIndex(event.key, index, options.length);
if (next === null) return;
event.preventDefault();
radioRefs.current[next]?.focus();
onChange(options[next].value);
}
return (
<div
id={id}
role="radiogroup"
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : label}
aria-describedby={describedBy}
className={cn("grid gap-2", className)}
>
{options.map((option, index) => {
const selected = index === checkedIndex;
const isUnanswered = option.value === null;
return (
<button
key={optionKey(option.value)}
ref={(element) => {
radioRefs.current[index] = element;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={index === tabStop ? 0 : -1}
onClick={() =>
onChange(selected && clearValue !== undefined ? clearValue : option.value)
}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn(
"min-h-11 rounded-lg border px-2 text-sm outline-none motion-safe:transition-colors",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
!selected && "border-border bg-background hover:bg-muted",
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
selected &&
!isUnanswered &&
"border-primary bg-primary font-medium text-primary-foreground",
optionClassName,
)}
>
{option.label}
{option.srSuffix ? (
<>
{" "}
<span className="sr-only">{option.srSuffix}</span>
</>
) : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EnumSelect } from "./enum-select";
type Mood = "NONE" | "LOW" | "HIGH";
const MOOD_LABELS: Record<Mood, string> = {
NONE: "Keine",
LOW: "Leicht",
HIGH: "Stark",
};
function Required({ onChange }: { onChange?: (value: Mood) => void }) {
const [value, setValue] = useState<Mood>("NONE");
return (
<>
<label htmlFor="mood">Schwarmstimmung</label>
<EnumSelect
id="mood"
describedBy="mood-hint"
invalid
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
labels={MOOD_LABELS}
/>
</>
);
}
function Filter({ onChange }: { onChange?: (value: Mood | "") => void }) {
const [value, setValue] = useState<Mood | "">("HIGH");
return (
<EnumSelect
ariaLabel="Nach Stimmung filtern"
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
values={["HIGH", "LOW"]}
labels={MOOD_LABELS}
noneLabel="Alle"
/>
);
}
async function optionTexts() {
await userEvent.click(screen.getByRole("combobox"));
return (await screen.findAllByRole("option")).map((option) => option.textContent);
}
describe("EnumSelect", () => {
it("shows the label of the value, offers every value and reports the typed choice", async () => {
const onChange = vi.fn();
render(<Required onChange={onChange} />);
expect(screen.getByRole("combobox", { name: "Schwarmstimmung" })).toHaveTextContent("Keine");
expect(await optionTexts()).toEqual(["Keine", "Leicht", "Stark"]);
await userEvent.click(screen.getByRole("option", { name: "Stark" }));
expect(onChange).toHaveBeenCalledWith("HIGH");
expect(screen.getByRole("combobox")).toHaveTextContent("Stark");
expect(screen.getByRole("combobox")).not.toHaveTextContent("HIGH");
});
it("passes the field's description and error state to the trigger", () => {
render(<Required />);
const trigger = screen.getByRole("combobox");
expect(trigger).toHaveAttribute("aria-describedby", "mood-hint");
expect(trigger).toHaveAttribute("aria-invalid", "true");
});
it("offers only the given values, in their order, after the 'none' choice", async () => {
render(<Filter />);
expect(await optionTexts()).toEqual(["Alle", "Stark", "Leicht"]);
});
it("reports the 'none' choice as an empty value and names it", async () => {
const onChange = vi.fn();
render(<Filter onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox", { name: "Nach Stimmung filtern" }));
await userEvent.click(await screen.findByRole("option", { name: "Alle" }));
expect(onChange).toHaveBeenCalledWith("");
expect(screen.getByRole("combobox")).toHaveTextContent("Alle");
});
});
describe("EnumSelect with decorated options", () => {
function Decorated({ onChange }: { onChange?: (value: Mood) => void }) {
const [value, setValue] = useState<Mood>("LOW");
return (
<>
<label htmlFor="mood-decorated">Schwarmstimmung</label>
<EnumSelect
id="mood-decorated"
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
labels={MOOD_LABELS}
renderOption={(mood) => (
<span data-testid={`badge-${mood}`} className={`tone-${mood}`}>
● {MOOD_LABELS[mood]}
</span>
)}
/>
</>
);
}
it("shows the decoration in the trigger and in every option row", async () => {
render(<Decorated />);
const trigger = screen.getByRole("combobox", { name: "Schwarmstimmung" });
expect(trigger.querySelector('[data-testid="badge-LOW"]')).not.toBeNull();
await userEvent.click(trigger);
for (const mood of ["NONE", "LOW", "HIGH"]) {
expect(
(await screen.findAllByTestId(`badge-${mood}`)).length,
).toBeGreaterThanOrEqual(1);
}
});
it("keeps the plain label as the spoken name, not the decoration", async () => {
const onChange = vi.fn();
render(<Decorated onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox", { name: "Schwarmstimmung" }));
const option = await screen.findByRole("option", { name: "Stark" });
await userEvent.click(option);
expect(onChange).toHaveBeenCalledWith("HIGH");
for (const badge of screen.getAllByTestId("badge-HIGH")) {
expect(badge.closest("[aria-hidden='true']")).not.toBeNull();
}
});
it("renders exactly as before without a decoration", async () => {
render(<Required />);
await userEvent.click(screen.getByRole("combobox", { name: "Schwarmstimmung" }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["Keine", "Leicht", "Stark"]);
});
});
@@ -0,0 +1,116 @@
"use client";
import type { ReactNode } from "react";
import { keysOf } from "@/lib/keys-of";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/** Base UI needs a real item value for the "none" choice; outside it is "". */
const NONE_VALUE = "__none__";
interface EnumSelectBaseProps<T extends string> {
/** Id of the trigger - pass the one from `Field` so its label points here. */
id?: string;
/** Accessible name when there is no visible label pointing at `id`. */
ariaLabel?: string;
/** How each value is named, in the list and in the trigger. */
labels: Record<T, string>;
/** The values on offer, in this order; all keys of `labels` by default. */
values?: readonly NoInfer<T>[];
/**
* Optional visual form of a value (e.g. a coloured badge), shown in the
* option rows and the trigger. Decoration only: screen readers still get
* the plain label from `labels`.
*/
renderOption?: (value: NoInfer<T>) => ReactNode;
describedBy?: string;
invalid?: boolean;
disabled?: boolean;
className?: string;
}
// T is inferred from `labels` only (NoInfer), so a "" value does not widen it.
type EnumSelectProps<T extends string> = EnumSelectBaseProps<T> &
(
| { noneLabel?: undefined; value: NoInfer<T>; onChange: (value: NoInfer<T>) => void }
| {
/**
* Offers an explicit first choice meaning "none" or "all" (e.g. "Alle
* Status"). It stands for the empty value "".
*/
noneLabel: string;
value: NoInfer<T> | "";
onChange: (value: NoInfer<T> | "") => void;
}
);
/** The label as text - or, with a decoration, the decoration for the eye and the label for screen readers. */
function OptionLabel({ label, decoration }: { label: string; decoration?: ReactNode }) {
if (decoration === undefined) return label;
return (
<>
<span aria-hidden="true" className="contents">
{decoration}
</span>
<span className="sr-only">{label}</span>
</>
);
}
/**
* A select over a fixed set of values (status, category, type ...), named by
* a label map. The change handler gets the typed value back, so callers need
* no `as` cast, and the trigger always shows the label - never the raw value.
* Counterpart of RecordSelect, which picks records by id.
*/
export function EnumSelect<T extends string>(props: EnumSelectProps<T>) {
const { id, ariaLabel, labels, renderOption, describedBy, invalid, disabled, className } = props;
const values = props.values ?? keysOf(labels);
function handleValueChange(next: unknown) {
if (props.noneLabel !== undefined && next === NONE_VALUE) {
props.onChange("");
return;
}
const chosen = values.find((value) => value === next);
if (chosen !== undefined) props.onChange(chosen);
}
const selectValue = props.value === "" ? NONE_VALUE : props.value;
const triggerLabel =
props.value === "" ? (
props.noneLabel
) : (
<OptionLabel label={labels[props.value]} decoration={renderOption?.(props.value)} />
);
return (
<Select value={selectValue} onValueChange={handleValueChange} disabled={disabled}>
<SelectTrigger
id={id}
aria-label={ariaLabel}
className={cn("h-11 w-full", className)}
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue>{triggerLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{props.noneLabel !== undefined ? (
<SelectItem value={NONE_VALUE}>{props.noneLabel}</SelectItem>
) : null}
{values.map((value) => (
<SelectItem key={value} value={value}>
<OptionLabel label={labels[value]} decoration={renderOption?.(value)} />
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { Field } from "./field";
describe("Field", () => {
it("labels a single control with label-for", () => {
render(<Field label="Datum">{({ id }) => <input id={id} type="date" />}</Field>);
expect(screen.getByLabelText(/Datum/)).toHaveAttribute("type", "date");
});
it("uses a given id for the control, e.g. a select trigger focused on errors", () => {
render(
<Field label="Volk" id="inspection-colony" required>
{({ id }) => <button id={id} type="button" />}
</Field>,
);
expect(screen.getByLabelText(/Volk/)).toHaveAttribute("id", "inspection-colony");
});
it("names a group of buttons without a label-for that points at a div", () => {
const { container } = render(
<Field label="Futterart" group hint="Eine Art antippen.">
{({ id, labelId, describedBy }) => (
<div id={id} role="radiogroup" aria-labelledby={labelId} aria-describedby={describedBy}>
<button type="button" role="radio" aria-checked="false">
Zuckerwasser
</button>
</div>
)}
</Field>,
);
expect(container.querySelector("label")).toBeNull();
const group = screen.getByRole("radiogroup", { name: /^Futterart/ });
expect(group).toHaveAccessibleDescription("Eine Art antippen.");
});
});
+16 -4
View File
@@ -8,6 +8,14 @@ interface FieldProps {
hint?: string;
error?: string | null;
className?: string;
/** Id for the control instead of a generated one. */
id?: string;
/**
* The child is a group of buttons (radio group, chips), not one form
* control. A <label for> may only point at a labelable element, so the
* caption becomes plain text the group references via `labelId`.
*/
group?: boolean;
/** Receives the ids the control must carry for label, hint and error. */
children: (ids: {
id: string;
@@ -28,18 +36,22 @@ export function Field({
hint,
error,
className,
id: idProp,
group = false,
children,
}: FieldProps) {
const id = useId();
const generatedId = useId();
const id = idProp ?? generatedId;
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
const Caption = group ? "span" : "label";
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<label
<Caption
id={`${id}-label`}
htmlFor={id}
htmlFor={group ? undefined : id}
className="flex items-baseline gap-2 text-sm font-medium"
>
{label}
@@ -51,7 +63,7 @@ export function Field({
>
{required ? "Pflicht" : "optional"}
</span>
</label>
</Caption>
{children({
id,
labelId: `${id}-label`,
@@ -1,6 +1,10 @@
"use client";
import { cn } from "@/lib/utils";
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = [1, 2, 3, 4, 5].map(
(rating) => ({ value: rating, label: String(rating), srSuffix: "von 5" }),
);
interface RatingScaleProps {
id: string;
@@ -30,35 +34,17 @@ export function RatingScale({
}: RatingScaleProps) {
return (
<div className="flex flex-col gap-1">
<div
<ChoiceGroup
id={id}
role="radiogroup"
aria-labelledby={labelledBy}
aria-describedby={describedBy}
className="grid grid-cols-5 gap-2"
>
{[1, 2, 3, 4, 5].map((option) => {
const selected = value === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
aria-label={`${option} von 5`}
onClick={() => onChange(selected ? null : option)}
className={cn(
"h-11 rounded-lg border text-base font-semibold motion-safe:transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option}
</button>
);
})}
</div>
labelledBy={labelledBy}
describedBy={describedBy}
options={RATING_OPTIONS}
value={value}
clearValue={null}
onChange={onChange}
className="grid-cols-5"
optionClassName="text-base font-semibold"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1 = {lowLabel}</span>
<span>5 = {highLabel}</span>
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RecordSelect } from "./record-select";
interface Apiary {
id: string;
name: string;
}
const APIARIES: Apiary[] = [
{ id: "cmg3xabc", name: "Obstwiese" },
{ id: "cmg3xdef", name: "Waldrand" },
];
function Harness({
initial = "",
loading = false,
noneLabel,
onChange,
}: {
initial?: string;
loading?: boolean;
noneLabel?: string;
onChange?: (value: string) => void;
}) {
const [value, setValue] = useState(initial);
return (
<RecordSelect
id="test-apiary"
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
items={loading ? undefined : APIARIES}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
noneLabel={noneLabel}
/>
);
}
describe("RecordSelect", () => {
it("names the chosen record in the trigger, not its internal id", async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
expect(onChange).toHaveBeenCalledWith("cmg3xdef");
expect(screen.getByRole("combobox")).toHaveTextContent("Waldrand");
expect(screen.getByRole("combobox")).not.toHaveTextContent("cmg3xdef");
});
it("names a record that was chosen before the form opened", () => {
render(<Harness initial="cmg3xabc" />);
expect(screen.getByRole("combobox")).toHaveTextContent("Obstwiese");
});
it("shows the placeholder while nothing is chosen", () => {
render(<Harness />);
expect(screen.getByRole("combobox")).toHaveTextContent("Bienenstand wählen");
});
it("says it is loading instead of showing the id while the records load", () => {
render(<Harness initial="cmg3xabc" loading />);
expect(screen.getByRole("combobox")).toHaveTextContent("Wird geladen…");
expect(screen.getByRole("combobox")).not.toHaveTextContent("cmg3xabc");
});
it("never shows the id of a record that is not in the list", () => {
render(<Harness initial="cmg3xgone" />);
expect(screen.getByRole("combobox")).toHaveTextContent("Nicht mehr verfügbar");
expect(screen.getByRole("combobox")).not.toHaveTextContent("cmg3xgone");
});
it("names a chosen record from a wider lookup list even if it is not offered", async () => {
const closed = { id: "cmg3xold", name: "Alter Stand (aufgelöst)" };
render(
<RecordSelect
id="test-apiary"
value="cmg3xold"
onChange={() => undefined}
items={APIARIES}
labelItems={[...APIARIES, closed]}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>,
);
expect(screen.getByRole("combobox")).toHaveTextContent("Alter Stand (aufgelöst)");
await userEvent.click(screen.getByRole("combobox"));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["Obstwiese", "Waldrand"]);
});
it("offers a 'none' choice first and names it while the value is empty", async () => {
render(<Harness noneLabel="Kein Bienenstand" />);
expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand");
await userEvent.click(screen.getByRole("combobox"));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Kein Bienenstand",
"Obstwiese",
"Waldrand",
]);
});
it("reports the 'none' choice as an empty value", async () => {
const onChange = vi.fn();
render(<Harness initial="cmg3xabc" noneLabel="Kein Bienenstand" onChange={onChange} />);
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Kein Bienenstand" }));
expect(onChange).toHaveBeenCalledWith("");
expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand");
});
});
@@ -0,0 +1,116 @@
"use client";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const LOADING_LABEL = "Wird geladen…";
const UNAVAILABLE_LABEL = "Nicht mehr verfügbar";
/** Base UI needs a real item value for the "none" choice; outside it is "". */
const NONE_VALUE = "__none__";
interface RecordSelectProps<T extends { id: string }> {
/** Id of the trigger - pass the one from `Field` so its label points here. */
id?: string;
/** Accessible name when there is no visible label pointing at `id`. */
ariaLabel?: string;
/** The chosen record's id, or "" for none / nothing chosen yet. */
value: string;
onChange: (value: string) => void;
/** The records on offer; `undefined` while they are still loading. */
items: readonly T[] | undefined;
/**
* Where the chosen record is looked up for the trigger, when that is a
* wider list than the offer - e.g. every colony, while only living ones can
* be picked, so a colony handed in by a link is still named. Defaults to
* `items`; load both from the same query so they arrive together.
*/
labelItems?: readonly T[] | undefined;
/** How a record is named, in the list and in the trigger. */
getLabel: (item: T) => string;
/** Shown while nothing is chosen (not used together with `noneLabel`). */
placeholder?: string;
/**
* Offers an explicit first choice meaning "none" (e.g. "Kein Produkt",
* "Alle Bienenstände"). It stands for the empty value "".
*/
noneLabel?: string;
/** `data-field` of the trigger, for focusing it on a validation error. */
dataField?: string;
describedBy?: string;
invalid?: boolean;
disabled?: boolean;
className?: string;
}
/**
* A select whose values are record ids (apiary, honey batch, container,
* role ...). The trigger always names the chosen record: without explicit
* children Base UI shows the raw value - an internal id nobody can read.
* Same approach as the colony picker (colony-select-field.tsx).
*/
export function RecordSelect<T extends { id: string }>({
id,
ariaLabel,
value,
onChange,
items,
labelItems = items,
getLabel,
placeholder,
noneLabel,
dataField,
describedBy,
invalid,
disabled,
className,
}: RecordSelectProps<T>) {
const selectValue = noneLabel !== undefined && value === "" ? NONE_VALUE : value;
return (
<Select
value={selectValue}
onValueChange={(next) => onChange(!next || next === NONE_VALUE ? "" : next)}
disabled={disabled}
>
<SelectTrigger
id={id}
aria-label={ariaLabel}
data-field={dataField}
className={cn("h-11 w-full", className)}
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue placeholder={placeholder}>
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{noneLabel !== undefined ? <SelectItem value={NONE_VALUE}>{noneLabel}</SelectItem> : null}
{(items ?? []).map((item) => (
<SelectItem key={item.id} value={item.id}>
{getLabel(item)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function triggerLabel<T extends { id: string }>({
value,
labelItems,
getLabel,
placeholder,
noneLabel,
}: Pick<RecordSelectProps<T>, "value" | "labelItems" | "getLabel" | "placeholder" | "noneLabel">) {
if (!value) return noneLabel ?? placeholder;
if (!labelItems) return LOADING_LABEL;
const chosen = labelItems.find((item) => item.id === value);
return chosen ? getLabel(chosen) : UNAVAILABLE_LABEL;
}
@@ -1,8 +1,16 @@
import { ReactNode } from "react";
/**
* Keeps the one primary action within thumb reach on a phone, however long
* the form is; on wider screens it simply ends the form.
* Keeps the one primary action within thumb reach, however long the form is:
* on a phone it sticks just above the bottom nav, on wider screens to the
* bottom of the window (clear of the iOS home indicator).
*
* `position: sticky` only works while no ancestor between this bar and the
* window is a scroll container (overflow auto/scroll/hidden).
*
* Buttons in the bar keep a 48px touch target on every breakpoint - the
* default button size shrinks to 32px from `sm` up (`sm:h-8`), which would
* otherwise win over the forms' `h-12`.
*/
export function StickySubmit({
children,
@@ -12,7 +20,7 @@ export function StickySubmit({
note?: ReactNode;
}) {
return (
<div className="sticky bottom-16 z-10 -mx-4 flex flex-col gap-2 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:p-0 sm:backdrop-blur-none">
<div className="sticky bottom-(--bottom-nav-height) z-10 -mx-4 flex flex-col gap-2 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur **:data-[slot=button]:min-h-12 sm:bottom-0 sm:pb-[calc(--spacing(3)+var(--safe-area-bottom))]">
{note}
{children}
</div>
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TriStateField } from "./tri-state-field";
function Harness({
initial,
onChange,
}: {
initial: boolean | null;
onChange: (value: boolean | null) => void;
}) {
const [value, setValue] = useState(initial);
return (
<TriStateField
label="Königin gesehen"
value={value}
onChange={(next) => {
setValue(next);
onChange(next);
}}
/>
);
}
describe("TriStateField", () => {
it("starts as 'Nicht geprüft' - an untouched observation is not a finding", () => {
render(<Harness initial={null} onChange={vi.fn()} />);
const group = screen.getByRole("radiogroup", { name: "Königin gesehen" });
expect(within(group).getAllByRole("radio").map((radio) => radio.textContent)).toEqual([
"Ja",
"Nein",
"Nicht geprüft",
]);
expect(within(group).getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute(
"aria-checked",
"true",
);
});
it("records yes, no and back to not checked", async () => {
const onChange = vi.fn();
render(<Harness initial={null} onChange={onChange} />);
await userEvent.click(screen.getByRole("radio", { name: "Ja" }));
expect(onChange).toHaveBeenLastCalledWith(true);
await userEvent.click(screen.getByRole("radio", { name: "Nein" }));
expect(onChange).toHaveBeenLastCalledWith(false);
await userEvent.click(screen.getByRole("radio", { name: "Nicht geprüft" }));
expect(onChange).toHaveBeenLastCalledWith(null);
});
it("shows a stored 'Nein' as such", () => {
render(<Harness initial={false} onChange={vi.fn()} />);
expect(screen.getByRole("radio", { name: "Nein" })).toHaveAttribute("aria-checked", "true");
});
});
@@ -0,0 +1,39 @@
"use client";
import { useId } from "react";
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
export const TRI_STATE_OPTIONS: readonly ChoiceOption<boolean | null>[] = [
{ value: true, label: "Ja" },
{ value: false, label: "Nein" },
{ value: null, label: "Nicht geprüft" },
];
interface TriStateFieldProps {
label: string;
value: boolean | null;
onChange: (value: boolean | null) => void;
}
/**
* A yes/no observation that may also not have been checked at all. Starts as
* "Nicht geprüft" (null), so only what the beekeeper actually looked at is
* saved as a finding - an untouched "no" would otherwise read as "seen: none".
*/
export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
const labelId = useId();
return (
<div className="flex flex-col gap-1.5">
<span id={labelId} className="text-sm font-medium">
{label}
</span>
<ChoiceGroup
labelledBy={labelId}
options={TRI_STATE_OPTIONS}
value={value}
onChange={onChange}
className="grid-cols-3"
/>
</div>
);
}
@@ -21,13 +21,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import {
Dialog,
DialogContent,
@@ -35,6 +29,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { emptyToNull } from "@/lib/forms/empty-to-null";
interface StockCardProps {
path: StockBatchPath;
@@ -238,7 +233,7 @@ function WithdrawDialog({
withdrawStock(path, id, {
quantity: Number(quantity),
reason,
notes: notes.trim() || undefined,
notes: emptyToNull(notes),
}),
onSuccess: () => {
done();
@@ -296,25 +291,12 @@ function WithdrawDialog({
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-reason">Grund</Label>
<Select
<EnumSelect
id="withdraw-reason"
value={reason}
onValueChange={(value) =>
setReason((value ?? reason) as WithdrawalReason)
}
>
<SelectTrigger id="withdraw-reason" className="w-full">
<SelectValue>{WITHDRAWAL_REASON_LABELS[reason]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(WITHDRAWAL_REASON_LABELS) as WithdrawalReason[]
).map((option) => (
<SelectItem key={option} value={option}>
{WITHDRAWAL_REASON_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setReason}
labels={WITHDRAWAL_REASON_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-notes">Notiz (optional)</Label>
@@ -12,13 +12,8 @@ import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchQueens } from "@/lib/queens/api";
import { fetchStorageContainers } from "@/lib/storage/api";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
interface RelatedOption {
id: string;
@@ -98,54 +93,47 @@ export function TaskRelatedField({
})),
GENERAL: [],
};
const options = optionsByType[relatedType];
const isLoadedByType: Record<TaskRelatedType, boolean> = {
COLONY: colonies.data !== undefined,
APIARY: apiaries.data !== undefined,
QUEEN: queens.data !== undefined,
HONEY_BATCH: honeyBatches.data !== undefined,
STORAGE: containers.data !== undefined,
GENERAL: true,
};
// undefined while loading, so the select says so instead of guessing.
const options = isLoadedByType[relatedType] ? optionsByType[relatedType] : undefined;
return (
<>
<div className="flex flex-col gap-2">
<Label htmlFor={`${idPrefix}-type`}>Bezug</Label>
<Select
<EnumSelect
id={`${idPrefix}-type`}
value={relatedType}
onValueChange={(value) => {
const next = (value ?? relatedType) as TaskRelatedType;
onChange={(next) => {
onRelatedTypeChange(next);
// The old id belongs to the old type's records, so it would point
// at nothing after the switch.
onRelatedIdChange("");
}}
>
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
<SelectValue>{TASK_RELATED_TYPE_LABELS[relatedType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{TASK_RELATED_TYPES.map((option) => (
<SelectItem key={option} value={option}>
{TASK_RELATED_TYPE_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
values={TASK_RELATED_TYPES}
labels={TASK_RELATED_TYPE_LABELS}
/>
</div>
{relatedType === "GENERAL" ? null : (
<div className="flex flex-col gap-2">
<Label htmlFor={`${idPrefix}-record`}>
{TASK_RELATED_TYPE_LABELS[relatedType]}
</Label>
<Select
<RecordSelect
id={`${idPrefix}-record`}
value={relatedId}
onValueChange={(value) => onRelatedIdChange(value ?? relatedId)}
>
<SelectTrigger id={`${idPrefix}-record`} className="w-full">
<SelectValue placeholder="Datensatz wählen" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={onRelatedIdChange}
items={options}
getLabel={(option) => option.label}
placeholder="Datensatz wählen"
/>
</div>
)}
</>
+4 -1
View File
@@ -8,12 +8,15 @@ function Card({
interactive = false,
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; interactive?: boolean }) {
// overflow-clip, not -hidden: both clip to the rounded corners, but hidden
// makes the card a scroll container that captures `position: sticky` - the
// StickySubmit save bar inside a form card would never stick.
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] transition-all duration-200 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
"group/card flex flex-col gap-(--card-spacing) overflow-clip rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] transition-all duration-200 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
interactive &&
"motion-safe:hover:-translate-y-0.5 hover:shadow-[var(--shadow-elevated)] hover:border-border",
className