Aller au contenu

Forms

Unified form recipe for @bricks/bricksoffice-projects and @bricks-common/bo. The idtlt validator is the single source of truth for shape, required-ness, and the visual red asterisk on FormLabel. Keep in sync: changing a form pattern, primitive, validator, or the <Form> wrapper → update this file in the same PR.

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.
  • 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.

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

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

Decimal parsing

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

  • parseEurosInputToCents → integer cents
  • parseDecimalInputnumber

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.
  • ❌ Introducing zod (or another validator lib) to BO / consumers.