Drawers
Three generations coexist in the codebase. Only the hook pattern is allowed in new
code — the other two are migration debt, and their presence is not permission to copy
them.
| Generation |
Shape |
Status |
use<Feature>Drawer() hook returning { openDrawer }, built on useFormDrawer / useDrawer (NiceModal) |
no ref, no rendered element |
✅ canonical |
useFormDrawer wrapped in a forwardRef + useImperativeHandle component that return null |
parent holds a ref |
⚠️ legacy, migrate on touch |
~/components/designSystem/Drawer + DrawerRef (openDrawer/closeDrawer) |
parent holds a ref to a rendered <Drawer> |
⛔ legacy, never for new code |
The canonical pattern
Write a hook named use<Feature>Drawer that owns the form and returns { openDrawer }.
Keep the drawer body in its own component. Use useFormDrawer for drawers with a form
and a save button, useDrawer (CentralizedDrawer) for read-only or non-form content.
// src/.../useFeatureDrawer.tsx
const FEATURE_FORM_ID = 'feature-drawer-form'
export const useFeatureDrawer = ({ onSave }: UseFeatureDrawerProps): UseFeatureDrawerReturn => {
const { translate } = useInternationalization()
const drawer = useFormDrawer()
const form = useAppForm({
defaultValues: DEFAULT_VALUES,
validationLogic: revalidateLogic(),
validators: { onDynamic: featureValidationSchema },
onSubmit: async ({ value }) => {
await onSave(value)
drawer.close()
},
})
// Seed + open in one step: no values = create, values = edit
const openDrawer = (values?: TFeature): void => {
form.reset({ ...DEFAULT_VALUES, ...values }, { keepDefaultValues: true })
drawer.open({
title: translate('...'),
form: { id: FEATURE_FORM_ID, submit: form.handleSubmit },
closeOnSubmitSuccess: false,
shouldPromptOnClose: () => form.state.isDirty,
onClose: () => form.reset(),
onEntered: focusFirstInput,
children: <FeatureDrawerContent form={form} />,
mainAction: (
<form.AppForm>
<form.SubmitButton dataTest="feature-drawer-save">
{translate('text_17295436903260tlyb1gp1i7')}
</form.SubmitButton>
</form.AppForm>
),
})
}
return { openDrawer }
}
The consumer just calls the hook — no useRef, no drawer element in its JSX:
// ✅ Correct
const { openDrawer } = useFeatureDrawer({ onSave })
return <Button => openDrawer(existingValue)}>Edit</Button>
// ❌ Wrong — phantom component + imperative ref
const drawerRef = useRef<FeatureDrawerRef>(null)
return (
<>
<Button => drawerRef.current?.openDrawer()}>Edit</Button>
<FeatureDrawer ref={drawerRef} />
</>
)
Notes:
drawer.close() belongs inside the hook (in onSubmit); do not expose a closeDrawer
unless a consumer actually calls it. In practice they never do.
shouldPromptOnClose: () => form.state.isDirty gives the unsaved-changes prompt;
closeOnSubmitSuccess: false lets onSubmit decide when to close.
- Drawer-local draft: the value only reaches the parent form in
onSave, so cancelling
must not mutate parent state.
- Reference sites:
usePlanSettingsDrawer, useSubscriptionInformationDrawer,
useCreditsDrawer, useRecurringRuleDrawer.
Testing drawers
The drawer stack uses import.meta, unsupported by jest, so every test touching a
drawer must mock the module:
jest.mock('~/components/drawers/useDrawer', () => ({
useDrawer: () => ({ open: jest.fn(), close: jest.fn() }),
useFormDrawer: () => ({ open: mockOpen, close: mockClose }),
}))
1---2name: lago-drawers3description: The only sanctioned drawer pattern in lago-front — a use<Feature>Drawer hook built on useFormDrawer / useDrawer returning { openDrawer } — plus the two legacy generations that must never be copied and the jest mock every drawer test needs. TRIGGER — read BEFORE writing the code whenever the task creates, edits, opens or tests a drawer, side panel or slide-over; whenever the diff mentions useFormDrawer, useDrawer, DrawerRef, ~/components/designSystem/Drawer, ~/components/drawers/useDrawer, openDrawer, closeDrawer, shouldPromptOnClose or closeOnSubmitSuccess; and whenever a test renders a component that opens one, since jest cannot parse import.meta without the mock.4---56# Drawers78Three generations coexist in the codebase. **Only the hook pattern is allowed in new9code** — the other two are migration debt, and their presence is not permission to copy10them.1112| Generation | Shape | Status |13| ---------- | ----- | ------ |14| `use<Feature>Drawer()` hook returning `{ openDrawer }`, built on `useFormDrawer` / `useDrawer` (NiceModal) | no ref, no rendered element | ✅ **canonical** |15| `useFormDrawer` wrapped in a `forwardRef` + `useImperativeHandle` component that `return null` | parent holds a ref | ⚠️ legacy, migrate on touch |16| `~/components/designSystem/Drawer` + `DrawerRef` (`openDrawer`/`closeDrawer`) | parent holds a ref to a rendered `<Drawer>` | ⛔ legacy, never for new code |1718## The canonical pattern1920Write a hook named `use<Feature>Drawer` that owns the form and returns `{ openDrawer }`.21Keep the drawer body in its own component. Use `useFormDrawer` for drawers with a form22and a save button, `useDrawer` (CentralizedDrawer) for read-only or non-form content.2324```tsx25// src/.../useFeatureDrawer.tsx26const FEATURE_FORM_ID = 'feature-drawer-form'2728export const useFeatureDrawer = ({ onSave }: UseFeatureDrawerProps): UseFeatureDrawerReturn => {29 const { translate } = useInternationalization()30 const drawer = useFormDrawer()3132 const form = useAppForm({33 defaultValues: DEFAULT_VALUES,34 validationLogic: revalidateLogic(),35 validators: { onDynamic: featureValidationSchema },36 onSubmit: async ({ value }) => {37 await onSave(value)38 drawer.close()39 },40 })4142 // Seed + open in one step: no values = create, values = edit43 const openDrawer = (values?: TFeature): void => {44 form.reset({ ...DEFAULT_VALUES, ...values }, { keepDefaultValues: true })4546 drawer.open({47 title: translate('...'),48 form: { id: FEATURE_FORM_ID, submit: form.handleSubmit },49 closeOnSubmitSuccess: false,50 shouldPromptOnClose: () => form.state.isDirty,51 onClose: () => form.reset(),52 onEntered: focusFirstInput,53 children: <FeatureDrawerContent form={form} />,54 mainAction: (55 <form.AppForm>56 <form.SubmitButton dataTest="feature-drawer-save">57 {translate('text_17295436903260tlyb1gp1i7')}58 </form.SubmitButton>59 </form.AppForm>60 ),61 })62 }6364 return { openDrawer }65}66```6768The consumer just calls the hook — no `useRef`, no drawer element in its JSX:6970```tsx71// ✅ Correct72const { openDrawer } = useFeatureDrawer({ onSave })73return <Button onClick={() => openDrawer(existingValue)}>Edit</Button>7475// ❌ Wrong — phantom component + imperative ref76const drawerRef = useRef<FeatureDrawerRef>(null)77return (78 <>79 <Button onClick={() => drawerRef.current?.openDrawer()}>Edit</Button>80 <FeatureDrawer ref={drawerRef} onSave={onSave} />81 </>82)83```8485Notes:8687- `drawer.close()` belongs inside the hook (in `onSubmit`); do not expose a `closeDrawer`88 unless a consumer actually calls it. In practice they never do.89- `shouldPromptOnClose: () => form.state.isDirty` gives the unsaved-changes prompt;90 `closeOnSubmitSuccess: false` lets `onSubmit` decide when to close.91- Drawer-local draft: the value only reaches the parent form in `onSave`, so cancelling92 must not mutate parent state.93- Reference sites: `usePlanSettingsDrawer`, `useSubscriptionInformationDrawer`,94 `useCreditsDrawer`, `useRecurringRuleDrawer`.9596## Testing drawers9798The drawer stack uses `import.meta`, unsupported by jest, so **every** test touching a99drawer must mock the module:100101```typescript102jest.mock('~/components/drawers/useDrawer', () => ({103 useDrawer: () => ({ open: jest.fn(), close: jest.fn() }),104 useFormDrawer: () => ({ open: mockOpen, close: mockClose }),105}))106```107108- **Testing a consumer** → mock the `use<Feature>Drawer` hook itself and assert109 `openDrawer` was called with the right seed:110 ```typescript111 useFeatureDrawer: (props) => {112 capturedProps.current = props113 return { openDrawer: mockOpenDrawer }114 }115 ```116- **Testing the drawer itself** → host the hook in a throwaway component to capture117 `openDrawer`, call it, then render the captured `children` (with `open` mocked, the body118 never mounts on its own):119 ```tsx120 const opened = mockOpen.mock.calls.at(-1)?.[0]121 render(<>{opened.children}</>)122 ```123 The same object exposes `form.submit`, `shouldPromptOnClose`, `onClose` and `onEntered`,124 so those are asserted directly rather than through the DOM.