Dialogs
All dialogs are hook-based, backed by NiceModal. New code must use one of three hooks
depending on the shape of the flow — the legacy imperative forwardRef + Dialog /
WarningDialog pattern is gone, do not reintroduce it.
| Hook |
Use for |
Signature |
useFormDialog |
Form + submit button |
open({ title, form: { id, submit }, mainAction, children, ... }) |
useCentralizedDialog |
Confirmation / warning (no form) |
open({ title, description, actionText, colorVariant, onAction }) |
useFormDialogOpeningDialog |
Edit form that can open a secondary destructive confirm |
same as useFormDialog + a nested open-other-dialog return |
The canonical pattern
Write a hook named use<Feature>Dialog that owns the form and returns
{ openDialog } (or an equivalent open<Feature>Dialog verb). Keep the dialog body
inline or in its own component. Consumers just call the hook — no ref, no rendered
dialog element.
// src/.../useFeatureDialog.tsx
const FEATURE_FORM_ID = 'feature-dialog-form'
export const useFeatureDialog = ({ onSave }: UseFeatureDialogProps) => {
const { translate } = useInternationalization()
const formDialog = useFormDialog()
const form = useAppForm({
defaultValues: DEFAULT_VALUES,
validationLogic: revalidateLogic(),
validators: { onDynamic: featureValidationSchema },
onSubmit: async ({ value }) => {
await onSave(value)
},
})
const openFeatureDialog = (values?: TFeature): void => {
form.reset({ ...DEFAULT_VALUES, ...values }, { keepDefaultValues: true })
formDialog.open({
title: translate('...'),
form: { id: FEATURE_FORM_ID, submit: form.handleSubmit },
onEntered: focusFirstInput,
children: <FeatureDialogContent form={form} />,
mainAction: (
<form.AppForm>
<form.SubmitButton dataTest="feature-dialog-save">
{translate('text_17295436903260tlyb1gp1i7')}
</form.SubmitButton>
</form.AppForm>
),
})
}
return { openFeatureDialog }
}
The consumer just calls the hook — no useRef, no dialog element in its JSX:
// ✅ Correct
const { openFeatureDialog } = useFeatureDialog({ onSave })
return <Button => openFeatureDialog(existingValue)}>Edit</Button>
// ❌ Wrong — phantom component + imperative ref (removed pattern)
const dialogRef = useRef<FeatureDialogRef>(null)
return (
<>
<Button => dialogRef.current?.openDialog()}>Edit</Button>
<FeatureDialog ref={dialogRef} />
</>
)
Notes:
- Confirmation-only flows (delete / revoke / danger prompts) use
useCentralizedDialog with colorVariant: 'danger' — no form, no mainAction,
just a single onAction callback.
useFormDialogOpeningDialog is only for the compound "edit + delete-from-within"
case; do not reach for it otherwise.
- Every dialog hook is registered globally in
src/core/overlays/registeredDialogs.ts —
no need to render the dialog element in the tree; NiceModal mounts it on open().
- Reference sites:
useCreateInviteDialog, useEditInviteRoleDialog,
useRevokeInviteDialog, useApplyTaxDialog, useAddEditSuccessRedirectUrlDialog.
Testing dialogs
Same rule as drawers: the dialog stack uses import.meta, unsupported by jest, so
every test touching a dialog must mock the module:
jest.mock('~/components/dialogs/FormDialog', () => ({
useFormDialog: () => ({ open: mockOpen, close: mockClose }),
}))
jest.mock('~/components/dialogs/CentralizedDialog', () => ({
useCentralizedDialog: () => ({ open: jest.fn(), close: jest.fn() }),
}))
- Testing a consumer → mock the
use<Feature>Dialog hook itself and assert
openDialog was called with the right seed.
- Testing the dialog itself → host the hook in a throwaway component to capture
open()'s payload, then render the captured children (with open mocked, the body
never mounts on its own). The same object exposes form.submit, onEntered, etc.,
so those are asserted directly rather than through the DOM.
1---2name: lago-dialogs3description: The three sanctioned dialog hooks in lago-front — useFormDialog, useCentralizedDialog and useFormDialogOpeningDialog, all NiceModal-backed — the removed forwardRef + Dialog pattern that must not come back, and the jest mock every dialog test needs. TRIGGER — read BEFORE writing the code whenever the task adds, edits or tests a dialog, modal, confirmation, warning or destructive prompt; whenever the diff mentions useFormDialog, useCentralizedDialog, useFormDialogOpeningDialog, WarningDialog, DialogRef or src/core/overlays/registeredDialogs.ts; and whenever a test renders a component that opens one, since jest cannot parse import.meta without the mock.4---56# Dialogs78All dialogs are hook-based, backed by NiceModal. New code must use one of three hooks9depending on the shape of the flow — the legacy imperative `forwardRef` + `Dialog` /10`WarningDialog` pattern is gone, do not reintroduce it.1112| Hook | Use for | Signature |13| ---- | ------- | --------- |14| `useFormDialog` | Form + submit button | `open({ title, form: { id, submit }, mainAction, children, ... })` |15| `useCentralizedDialog` | Confirmation / warning (no form) | `open({ title, description, actionText, colorVariant, onAction })` |16| `useFormDialogOpeningDialog` | Edit form that can open a secondary destructive confirm | same as `useFormDialog` + a nested `open-other-dialog` return |1718## The canonical pattern1920Write a hook named `use<Feature>Dialog` that owns the form and returns21`{ openDialog }` (or an equivalent `open<Feature>Dialog` verb). Keep the dialog body22inline or in its own component. Consumers just call the hook — no ref, no rendered23dialog element.2425```tsx26// src/.../useFeatureDialog.tsx27const FEATURE_FORM_ID = 'feature-dialog-form'2829export const useFeatureDialog = ({ onSave }: UseFeatureDialogProps) => {30 const { translate } = useInternationalization()31 const formDialog = useFormDialog()3233 const form = useAppForm({34 defaultValues: DEFAULT_VALUES,35 validationLogic: revalidateLogic(),36 validators: { onDynamic: featureValidationSchema },37 onSubmit: async ({ value }) => {38 await onSave(value)39 },40 })4142 const openFeatureDialog = (values?: TFeature): void => {43 form.reset({ ...DEFAULT_VALUES, ...values }, { keepDefaultValues: true })4445 formDialog.open({46 title: translate('...'),47 form: { id: FEATURE_FORM_ID, submit: form.handleSubmit },48 onEntered: focusFirstInput,49 children: <FeatureDialogContent form={form} />,50 mainAction: (51 <form.AppForm>52 <form.SubmitButton dataTest="feature-dialog-save">53 {translate('text_17295436903260tlyb1gp1i7')}54 </form.SubmitButton>55 </form.AppForm>56 ),57 })58 }5960 return { openFeatureDialog }61}62```6364The consumer just calls the hook — no `useRef`, no dialog element in its JSX:6566```tsx67// ✅ Correct68const { openFeatureDialog } = useFeatureDialog({ onSave })69return <Button onClick={() => openFeatureDialog(existingValue)}>Edit</Button>7071// ❌ Wrong — phantom component + imperative ref (removed pattern)72const dialogRef = useRef<FeatureDialogRef>(null)73return (74 <>75 <Button onClick={() => dialogRef.current?.openDialog()}>Edit</Button>76 <FeatureDialog ref={dialogRef} onSave={onSave} />77 </>78)79```8081Notes:8283- Confirmation-only flows (delete / revoke / danger prompts) use84 `useCentralizedDialog` with `colorVariant: 'danger'` — no form, no `mainAction`,85 just a single `onAction` callback.86- `useFormDialogOpeningDialog` is only for the compound "edit + delete-from-within"87 case; do not reach for it otherwise.88- Every dialog hook is registered globally in `src/core/overlays/registeredDialogs.ts` —89 no need to render the dialog element in the tree; NiceModal mounts it on `open()`.90- Reference sites: `useCreateInviteDialog`, `useEditInviteRoleDialog`,91 `useRevokeInviteDialog`, `useApplyTaxDialog`, `useAddEditSuccessRedirectUrlDialog`.9293## Testing dialogs9495Same rule as drawers: the dialog stack uses `import.meta`, unsupported by jest, so96**every** test touching a dialog must mock the module:9798```typescript99jest.mock('~/components/dialogs/FormDialog', () => ({100 useFormDialog: () => ({ open: mockOpen, close: mockClose }),101}))102jest.mock('~/components/dialogs/CentralizedDialog', () => ({103 useCentralizedDialog: () => ({ open: jest.fn(), close: jest.fn() }),104}))105```106107- **Testing a consumer** → mock the `use<Feature>Dialog` hook itself and assert108 `openDialog` was called with the right seed.109- **Testing the dialog itself** → host the hook in a throwaway component to capture110 `open()`'s payload, then render the captured `children` (with `open` mocked, the body111 never mounts on its own). The same object exposes `form.submit`, `onEntered`, etc.,112 so those are asserted directly rather than through the DOM.