dodil/erp-ui
src/components/form/Form.tsx
/**
* Forms — fields described as data, a dialog that submits them, and the API's refusal shown in place.
*
* Every value is a STRING while it is being edited (an amount is exact text, a date is "2026-10-17"), and
* the screen turns them into the request body. `FormDialog` keeps itself open while the request runs and
* shows the reason when the API refuses — the person keeps what they typed. Required fields are checked
* before anything is sent; the rules that matter are the server's, and its words are what the person reads.
*/
import { Button, Callout, Dialog, Flex, Select, Text, TextArea, TextField } from "@radix-ui/themes";
import { useId, useState, type FormEvent, type ReactNode } from "react";
import { MoneyInput, parseMoneyInput } from "./MoneyInput";
export type FieldKind = "text" | "email" | "textarea" | "select" | "date" | "integer" | "money";
export interface FieldSpec {
name: string;
label: string;
kind?: FieldKind;
required?: boolean;
options?: { value: string; label: string }[];
/** money: the currency shown beside the amount. */
currency?: string;
hint?: string;
placeholder?: string;
defaultValue?: string;
}
export type FormValues = Record<string, string>;
/** Problems before sending: required fields, and the shape of integers and amounts. Empty = fine. */
export function validate(fields: FieldSpec[], values: FormValues): Record<string, string> {
const problems: Record<string, string> = {};
for (const f of fields) {
const v = (values[f.name] ?? "").trim();
if (f.required && v === "") problems[f.name] = `${f.label} is required`;
else if (v !== "" && f.kind === "integer" && !/^-?\d+$/.test(v)) problems[f.name] = `${f.label} must be a whole number`;
else if (v !== "" && f.kind === "money" && parseMoneyInput(v) === null) problems[f.name] = `${f.label} must be an amount`;
else if (v !== "" && f.kind === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) problems[f.name] = `${f.label} must be an email address`;
}
return problems;
}
export function FormField({ field, value, onChange, error }: {
field: FieldSpec; value: string; onChange: (v: string) => void; error?: string;
}) {
const id = useId();
const kind = field.kind ?? "text";
const note = error ?? field.hint;
const described = note ? `${id}-note` : undefined;
if (kind === "money") {
return (
<Flex direction="column" gap="1">
<MoneyInput label={field.label} value={value} required={field.required} currency={field.currency}
onChange={(_parsed, text) => onChange(text)} hint={error ? undefined : field.hint} />
{error && <Text size="1" color="red">{error}</Text>}
</Flex>
);
}
return (
<Flex direction="column" gap="1">
<Text as="label" size="2" weight="medium" htmlFor={id}>{field.label}{field.required ? " *" : ""}</Text>
{kind === "textarea" ? (
<TextArea id={id} value={value} placeholder={field.placeholder} onChange={(e) => onChange(e.target.value)}
aria-invalid={!!error || undefined} aria-describedby={described} />
) : kind === "select" ? (
<Select.Root value={value || undefined} onValueChange={onChange}>
<Select.Trigger id={id} placeholder={field.placeholder ?? "Choose…"} aria-describedby={described} />
<Select.Content>
{(field.options ?? []).map((o) => <Select.Item key={o.value} value={o.value}>{o.label}</Select.Item>)}
</Select.Content>
</Select.Root>
) : (
<TextField.Root id={id} value={value} placeholder={field.placeholder}
type={kind === "date" ? "date" : kind === "email" ? "email" : "text"}
inputMode={kind === "integer" ? "numeric" : undefined}
onChange={(e) => onChange(e.target.value)}
aria-invalid={!!error || undefined} aria-describedby={described} />
)}
{note && <Text id={described} size="1" color={error ? "red" : "gray"}>{note}</Text>}
</Flex>
);
}
function initial(fields: FieldSpec[]): FormValues {
return Object.fromEntries(fields.map((f) => [f.name, f.defaultValue ?? ""]));
}
export interface FormDialogProps {
/** The button that opens it. Omit it and pass `open` to open the dialog from code (a card dropped on a column). */
trigger?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
title: string;
description?: ReactNode;
fields: FieldSpec[];
submitLabel: string;
/** Throw to keep the dialog open with the error shown. Resolve to close it. */
onSubmit: (values: FormValues) => Promise<unknown> | unknown;
}
export function FormDialog({ trigger, open: controlled, onOpenChange, title, description, fields, submitLabel, onSubmit }: FormDialogProps) {
const [values, setValues] = useState<FormValues>(() => initial(fields));
const [problems, setProblems] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [own, setOwn] = useState(false);
const open = controlled ?? own;
const setOpen = (o: boolean) => { setOwn(o); onOpenChange?.(o); };
const [seen, setSeen] = useState(open);
if (open !== seen) { // opened from outside: start from the defaults, like a click would
setSeen(open);
if (open) { setValues(initial(fields)); setProblems({}); setError(null); }
}
const submit = async (e: FormEvent) => {
e.preventDefault();
const found = validate(fields, values);
setProblems(found);
if (Object.keys(found).length) return;
setBusy(true);
setError(null);
try {
const normalised = Object.fromEntries(fields.map((f) => {
const v = (values[f.name] ?? "").trim();
return [f.name, f.kind === "money" && v !== "" ? parseMoneyInput(v)! : v];
}));
await onSubmit(normalised);
setOpen(false);
setValues(initial(fields));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<Dialog.Root open={open} onOpenChange={(o) => { setOpen(o); setSeen(o); if (o) { setValues(initial(fields)); setProblems({}); setError(null); } }}>
{trigger && <Dialog.Trigger>{trigger}</Dialog.Trigger>}
<Dialog.Content maxWidth="520px">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description size="2" mb="3" color="gray">{description ?? "Fields marked * are required."}</Dialog.Description>
<form onSubmit={submit} noValidate>
<Flex direction="column" gap="3">
{fields.map((f) => (
<FormField key={f.name} field={f} value={values[f.name] ?? ""}
error={problems[f.name]}
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))} />
))}
{error && <Callout.Root color="red" role="alert" size="1"><Callout.Text>{error}</Callout.Text></Callout.Root>}
<Flex gap="3" justify="end" mt="2">
<Dialog.Close><Button type="button" variant="soft" color="gray">Cancel</Button></Dialog.Close>
<Button type="submit" loading={busy}>{submitLabel}</Button>
</Flex>
</Flex>
</form>
</Dialog.Content>
</Dialog.Root>
);
}