Forms¶
Unified form recipe for
@bricks/bricksoffice-projectsand@bricks-common/bo. The idtlt validator is the single source of truth for shape, required-ness, and the visual red asterisk onFormLabel. 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
formValidatorper form, declared at top of file.type FormValues = typeof formValidator.T— never hand-written. - No
rulesprop onFormField. RHF silently disablesrules.validate/rules.requiredwhen a resolver is set (verified:feedback_rhf_resolver_ignores_rules_validate). Schema drives shape + required;enforceFormRulesdrives runtime cross-field checks. - No
<FormLabel required>insideFormField. Schema drives the asterisk;requiredis the escape hatch for bare<Label>only. - Runtime rules ⇒
enforceFormRulesfrom@bricks-common/bo/design-system, called fromhandleSubmit. Wrapper namedvalidateForm, invoked once at the top, bails onfalse. Error renders on submit, no live-while-typing. - Conditional fields ⇒
discriminatedUnionfrom@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 + BOdiscriminatedUnion. Read fields-to-preserve viaform.getValues(...)BEFOREreset. Bind the discriminator control toform.watch(parent.discriminator), notfield.value. defaultValuesinline — don't extractconst DEFAULT_VALUESunless 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 uuidsubmissionId, ignores re-submits while pending. SendsubmissionIdto the API for idempotency keys when relevant. useFormSubmission()context — descendants read{ isPending }.resetOnSuccess(defaulttrue) — callsform.reset()after resolved promise.validatorprop — feedsFormLabel'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,nullon pass.fieldtyped asFieldPath<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>skipsresetOnSuccesswhenformState.errorsis 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 centsparseDecimalInput→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¶
- ❌
rulesprop onFormField(silently disabled by the resolver) — use schema +enforceFormRules. - ❌
<FormItem>/<FormLabel>/<FormControl>outside<FormField>— for read-only displays useReadOnlyField(<ReadOnlyField id label value />). - ❌
form.setValue('parent', newVariant)to swap a discriminated-union variant — useform.reset(...). - ❌ Naming the per-form wrapper anything other than
validateForm. - ❌
notEmptyStringfor numeric / date / percent fields — use the typed-string primitive. - ❌ Introducing zod (or another validator lib) to BO / consumers.