Aller au contenu

Forms

Unified form recipe for TanStack bricksoffice-* consumers and @bricks-common/bo. The idtlt validator is the single source of truth for shape, required-ness, and the visual red asterisk on FormLabel.

We move money (€500k+ transfers). Every form looks identical so the next contributor follows one recipe with no decisions.

Hard rules

  • One formValidator per form, declared at top of file. type FormValues = typeof formValidator.T — never hand-written.
  • No rules prop on FormField. RHF silently disables rules.validate / rules.required when a resolver is set (verified: feedback_rhf_resolver_ignores_rules_validate). Schema drives shape + required; enforceFormRules drives runtime cross-field checks.
  • No <FormLabel required> inside FormField. Schema drives the asterisk; required is the escape hatch for bare <Label> only.
  • Runtime rules ⇒ enforceFormRules from @bricks-common/bo/design-system, called from handleSubmit. Wrapper named validateForm, invoked once at the top, bails on false. Error renders on submit, no live-while-typing.
  • FormRichTextEditor empty check ⇒ enforceFormRules (strip tags + &nbsp;). TipTap serializes empty as <p></p>, so notEmptyString still passes.
  • A <form> nested inside a host form must stopPropagation() on submit, not just preventDefault(). React bubbles submit up the React tree — through portals too — so the host handleSubmit fires and the modal saves on a mere "Appliquer" (canonical: LinkPopover / ImagePopover toolbar popovers).
  • Conditional fields ⇒ discriminatedUnion from @bricks-common/bo/design-system (not direct from idtlt — the BO wrapper attaches the runtime variant metadata the asterisk walker needs).
  • Variant swap ⇒ form.reset({ parent: newVariant, ...preserved }). form.setValue('parent', newVariant) does NOT propagate to nested subscribers in RHF 7.66.1 + BO discriminatedUnion. Read fields-to-preserve via form.getValues(...) BEFORE reset. Bind the discriminator control to form.watch(parent.discriminator), not field.value.
  • defaultValues inline — don't extract const DEFAULT_VALUES unless reused 3+ times.
  • Validators are idonttrustlikethat, never zod. Don't introduce a parallel validation system.
  • Read / edit card ⇒ DetailEditableCard from @bricks-common/bo/design-system. One card = one <Form>; read mode = fieldset disabled (same look as ReadOnlyField); « Modifier » → fields enabled + « Annuler » / « Enregistrer » footer; fields lock while the save is in flight; a resolved onSubmit with no field error returns to read mode and rebases isDirty (a runtime rule that setError'd keeps the card in edit mode, like Form). The card owns useSubmitWithError (inline Alert on business error) — consumers pass form, validator, onSubmit, and editDisabledReason when editing is blocked (button stays visible, disabled, with tooltip). Single-field rows: FormTextField.

The <Form> wrapper

<Form> (from @bricks-common/bo/design-system) wraps RHF's FormProvider and adds:

  • Idempotent submit via useIdempotentSubmit — generates a uuid submissionId, ignores re-submits while pending. Send submissionId to the API for idempotency keys when relevant.
  • useFormSubmission() context — descendants read { isPending }.
  • resetOnSuccess (default true) — calls form.reset() after resolved promise.
  • validator prop — feeds FormLabel's asterisk auto-detection (walks the schema at the current field path).

onSubmit: (values, { submissionId }) => Promise<void>.

idtltResolver lives at design-system/forms/resolver.ts. Field-level error codes (required, invalid_email, wrong_format, …) translate via src/utils/translateValidationError.ts. Copy in BO's messages/fr.json under validation.*.

Flat fields — canonical recipe

const formValidator = object({
  lwMandateId: positiveIntegerString,
  description: notEmptyString,
  highlighted: boolean,
  optionalNote: optionalPercentageString,
})
type FormValues = typeof formValidator.T

const form = useForm<FormValues>({
  resolver: idtltResolver(formValidator),
  defaultValues: { lwMandateId: '', description: '', highlighted: false, optionalNote: '' },
  mode: 'onChange',
})

const handleSubmit = withGlobalErrorHandling(async (values) => {
  await mutation.mutateAsync({
    lemonwayMandateId: PositiveInteger(Number.parseInt(values.lwMandateId, 10)),
    description: values.description,
    highlighted: values.highlighted,
  })
  notifySuccess(m['...']())
})

<Form form={form} onSubmit={handleSubmit} validator={formValidator}>
  <FormField name="lwMandateId" render={({ field }) => (
    <FormItem>
      <FormLabel>{m['mandate_label']()}</FormLabel>     {/* auto-shows red * */}
      <FormControl><Input type="number" step="1" min="1" {...field} /></FormControl>
      <FormMessage />
    </FormItem>
  )} />
</Form>

Conditional fields — discriminated unions

const wireMode = object({ mode: literal('wire'), dayOfMonth: positiveIntegerString })
const directDebitMode = object({
  mode: literal('direct-debit'),
  dayOfMonth: positiveIntegerString,
  lwMandateId: positiveIntegerString,
})
const paymentModeValidator = discriminatedUnion('mode', wireMode, directDebitMode)

const handleModeChange = (next: string) => {
  // Read survivors BEFORE reset
  const dayOfMonth = form.getValues('paymentMode.dayOfMonth')
  const constructionBudgetEur = form.getValues('constructionBudgetEur')
  form.reset({
    paymentMode: next === 'wire'
      ? { mode: 'wire', dayOfMonth }
      : { mode: 'direct-debit', dayOfMonth, lwMandateId: '' },
    constructionBudgetEur,
  })
}

DS useFormField() walks the validator at the current path, narrows to the active variant via the discriminator, reports isRequired correctly. No JSX override.

Runtime rules — enforceFormRules

Schema (idtlt) handles per-field shape + required. For runtime cross-field constraints (amount <= remaining, duration > current, day <= 31), call enforceFormRules at the top of handleSubmit.

import { enforceFormRules, type FormRule } from '@bricks-common/bo/design-system'

const validateForm = (values: FormValues) =>
  enforceFormRules(values, form, [
    (v) => parseEurosInputToCents(v.capitalLoanedAmountEur) > totalFundedCents
      ? { field: 'capitalLoanedAmountEur', message: m['…validation_capital_at_most_funded']() }
      : null,
  ])

const handleSubmit = withGlobalErrorHandling(async (values: FormValues) => {
  if (!validateForm(values)) return
  // ... mutation + success
})
  • Per-form wrapper always named validateForm — grep-friendly, reads at the call-site (if (!validateForm(values)) return).
  • Rules return { field, message } on violation, null on pass. field typed as FieldPath<FormValues> — dot-paths work.
  • Short-circuits on first violation. Order most-impactful first.
  • Sets form.setError(field, { type: 'form-rule', message }) — distinguishes from schema errors.
  • <Form> skips resetOnSuccess when formState.errors is non-empty, so a failed rule doesn't wipe input.

Reusable primitives — reach for these first

Typed-string primitives (from @bricks-common/bo/design-system)

Each validates BOTH that something was typed AND that the format parses — so the submit handler's branded constructor (PositiveInteger, Cents, YearMonthDayDate) is safe. Pair each with the matching Input type/inputMode.

Primitive Accepts Use for Input attrs
positiveIntegerString "1", "42" day of month, mandate ID, bank account ID, duration type="number" step="1" min="1"
optionalPositiveIntegerString undefined / "" / valid positive int optional Lemonway paymentId (no asterisk) type="number" step="1" min="1"
nonNegativeIntegerString "0", "42" counts that may be zero type="number" step="1" min="0"
eurAmountString "12", "12,34", "12.34" (> 0) positive money type="number" step="0.01" min="0"
nonNegativeEurAmountString same, >= 0 construction budget, escrow same
percentageString "3", "3,1234" (>= 0, 4 decimals) rate, fees type="number" step="0.01" min="0"
optionalPercentageString undefined / "" / valid optional rate (no asterisk) same
yearMonthDayString "YYYY-MM-DD" required dates DatePicker
optionalYearMonthDayString optional "YYYY-MM-DD" optional dates DatePicker
optionalNonNegativeEurAmountString undefined / "" / valid >= 0 optional money (share capital) type="number" step="0.01" min="0"
optionalSirenString undefined / "" / 9 digits, spaces tolerated SIREN (submit strips the spaces) inputMode="numeric"
optionalLeiString undefined / "" / 20 alphanumerics, any case LEI (submit uppercases) plain Input

Calendar defaults to locale={fr}. A calendar day persisted as ISO datetime must store noon UTC (YYYY-MM-DDT12:00:00.000Z) — dayjs(date).startOf('day').toISOString() is browser-local and shifts the day west of Paris, including investors. Read a Date into the picker with dayjs.utc(date).format('YYYY-MM-DD'), persist with dayjs.utc(yearMonthDay).hour(12).toDate() — not Date#toISOString() / new Date(isoString).

Other primitives

Primitive From Use for
notEmptyString @bricks-common/api-communication required non-empty free-text
uuid @bricks-common/api-communication UUID
object, literal, boolean, array(...) idonttrustlikethat form roots / variants / checkboxes (auto-detected → no asterisk) / arrays
FileUpload @bricks-common/bo/design-system image/file picker; form value is the CDN URL (string) or string[] if multiple. Inject upload: (file) => Promise<string> — DS never calls the API. Disable submit via onUploadingChange while a batch is in flight
CountrySelect @bricks-common/bo/design-system country field; value = lowercase alpha-2 code (fr, what the financing portal stores), French names from i18n-iso-countries, spreads field like DatePicker. Read a legacy label (« France ») with toCountryCode from @bricks-common/bo/core

Decimal parsing

Both in @bricks-common/bo/core, both accept , or .. Never value.replace(',', '.') inline.

  • parseEurosInputToCents → integer cents
  • parseDecimalInput → number

Typed-confirmation fields

Use ConfirmationFormField + confirmationWord primitive — schema rejects anything other than VALIDER, submit button stays disabled until exact match. No runtime rule needed.

Reusing API payload validators

Import from @bricks-common/api-communication-bricksoffice when the form field IS the API field. Don't use payload-level validators with branded types (cents, positiveInteger) for form schemas — those validate already-parsed values; the form holds strings.

Submit handler — branded constructors at the boundary

Brand at the call site (Cents(parseEurosInputToCents(values.amountEur)), PositiveInteger(Number.parseInt(values.dayOfMonth, 10)), YearMonthDayDate(values.date)). mutateAsync's parameter type enforces branding — forget and TS fails. Error handling: data-fetching.

Canonical examples

  • Multi-field + runtime rules — .../components/ProjectDrawer/tabs/EcheancierTab/SimulationForm/index.tsx
  • Discriminated union — .../modals/EcheancierCreateConfirmModal.tsx
  • Discriminated union driven by Checkbox — .../modals/ProjectFundsManagementModal/TransferFundsToPdpForm.tsx

Known A11y gap

The red * is aria-hidden. aria-required is NOT yet threaded through FormControl — AT users rely on validation messages, not the visual marker. Follow-up: thread schema-derived isRequired into FormControl.

What NOT to do

  • ❌ rules prop on FormField (silently disabled by the resolver) — use schema + enforceFormRules.
  • ❌ <FormItem> / <FormLabel> / <FormControl> outside <FormField> — for read-only displays use ReadOnlyField (<ReadOnlyField id label value />).
  • ❌ form.setValue('parent', newVariant) to swap a discriminated-union variant — use form.reset(...).
  • ❌ Naming the per-form wrapper anything other than validateForm.
  • ❌ notEmptyString for numeric / date / percent fields — use the typed-string primitive.
  • ❌ Hand-rolled Modifier / Annuler / Enregistrer toggles (useState + inline inputs, BO Invest ProfileTab style) — use DetailEditableCard.
  • ❌ Introducing zod (or another validator lib) to BO / consumers.