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>
  );
}