.agents/skills/migrate-dialog/SKILL.md
Migrate a dialog component from the legacy imperative ref-based Dialog system to the new hook-based NiceModal dialog system (FormDialog, CentralizedDialog, or FormDialogOpeningDialog). This skill focuses only on the migration — testing is handled separately.
npx skillsauth add getlago/lago-front migrate-dialogInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Target dialog to migrate: $ARGUMENTS
Important: If no path was provided above (empty or missing), use the AskUserQuestion tool to ask the user for the path to the dialog they want to migrate before proceeding.
This skill guides the migration of dialog components from the legacy imperative ref-based system (forwardRef + useImperativeHandle + Dialog from design system) to the new hook-based NiceModal system (useFormDialog / useCentralizedDialog / useFormDialogOpeningDialog).
Note: This skill only handles the migration of the dialog and its parent component(s). Tests should be updated separately using a dedicated testing skill.
Before starting, gather context by reading these reference files:
src/components/dialogs/FormDialog.tsx - Dialog with form supportsrc/components/dialogs/CentralizedDialog.tsx - Simple action/confirmation dialogsrc/components/dialogs/FormDialogOpeningDialog.tsx - Form dialog that can also open a secondary CentralizedDialog (e.g., delete from within edit)src/components/dialogs/BaseDialog.tsx - Underlying dialog componentsrc/components/dialogs/types.ts - DialogResult, HookDialogReturnType, FormPropssrc/components/dialogs/const.ts - Dialog names and test IDssrc/core/dialogs/registeredDialogs.ts - NiceModal registrationsrc/pages/settings/teamAndSecurity/members/dialogs/CreateInviteDialog.tsx - Hook-based FormDialogsrc/pages/settings/teamAndSecurity/members/dialogs/EditInviteRoleDialog.tsx - Hook-based FormDialog (simpler)src/pages/settings/teamAndSecurity/members/dialogs/RevokeInviteDialog.tsx - Hook-based CentralizedDialogsrc/pages/settings/teamAndSecurity/members/dialogs/RevokeMembershipDialog.tsx - CentralizedDialog with conditional behaviorsrc/pages/settings/teamAndSecurity/authentication/dialogs/AddOktaDialog.tsx - Form dialog with optional secondary action (delete from within edit)openDialog, closeDialog)useState (typically localData)useAppForm, validation schema, onSubmit handleropenDialog(data)| Old Dialog Pattern | New Dialog Type | When to Use |
| ---------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
| Has form fields + submit button | useFormDialog | Dialog contains a form with validation |
| Has a single action button (confirm/copy/etc.) | useCentralizedDialog | Dialog is for confirmation or simple action |
| Uses useCentralizedDialog | Confirmation/warning dialogs with danger/info modes |
| Chains to another dialog after success | Both | Use FormDialog for the form, chain to CentralizedDialog |
| Form dialog + optional secondary action button (e.g., delete from edit) | useFormDialogOpeningDialog | Form dialog with a danger/action button that opens a CentralizedDialog |
Search for all places where the dialog is used:
grep -r "DialogName\|DialogNameRef" src/ --include="*.tsx" --include="*.ts"
Identify:
openDialog is called and what data is passedOld Pattern (imperative ref-based):
export interface MyDialogRef {
openDialog: (data: MyDialogData) => unknown
closeDialog: () => unknown
}
export const MyDialog = forwardRef<MyDialogRef>((_, ref) => {
const dialogRef = useRef<DialogRef>(null)
const [localData, setLocalData] = useState<MyDialogData | null>(null)
useImperativeHandle(ref, () => ({
openDialog: (data) => {
setLocalData(data)
dialogRef.current?.openDialog()
},
closeDialog: () => dialogRef.current?.closeDialog(),
}))
return (
<Dialog ref={dialogRef} title={...} actions={...} formId={...} formSubmit={...}>
{/* content using localData */}
</Dialog>
)
})
New Pattern (hook-based with FormDialog):
export const useMyDialog = () => {
const formDialog = useFormDialog()
const { translate } = useInternationalization()
// ... other hooks (mutations, etc.)
const dataRef = useRef<MyData | null>(null)
const successRef = useRef(false)
const form = useAppForm({
defaultValues: initialValues,
validationLogic: revalidateLogic(),
validators: { onDynamic: validationSchema },
onSubmit: async ({ value }) => {
const result = await myMutation({
variables: { input: { ...value, id: dataRef.current?.id as string } },
})
if (result.data?.myMutation) {
successRef.current = true
}
},
})
const handleSubmit = async (): Promise<DialogResult> => {
successRef.current = false
await form.handleSubmit()
if (!successRef.current) {
throw new Error('Submit failed')
}
return { reason: 'success' }
}
const openMyDialog = (data: MyData) => {
dataRef.current = data
form.reset()
// Set form values from data if editing
form.setFieldValue('fieldName', data.fieldValue || '')
formDialog
.open({
title: translate('...'),
children: (
<div className="...">
{/* Dialog content */}
</div>
),
closeOnError: false,
mainAction: (
<form.AppForm>
<form.SubmitButton>{translate('...')}</form.SubmitButton>
</form.AppForm>
),
form: {
id: MY_FORM_ID,
submit: handleSubmit,
},
})
.then((response) => {
if (response.reason === 'close') {
form.reset()
dataRef.current = null
}
})
}
return { openMyDialog }
}
New Pattern (hook-based with CentralizedDialog):
export const useMyDialog = () => {
const centralizedDialog = useCentralizedDialog()
const { translate } = useInternationalization()
const openMyDialog = (data: MyData) => {
centralizedDialog.open({
title: translate('...'),
description: translate('...'),
actionText: translate('...'),
colorVariant: 'danger', // or 'info'
onAction: async () => {
// Perform action (e.g., mutation, copy to clipboard)
await myMutation({ variables: { input: { id: data.id } } })
},
})
}
return { openMyDialog }
}
New Pattern (hook-based with FormDialogOpeningDialog):
Use this when a form dialog also needs a secondary action button (typically a danger button like "Delete") that opens a CentralizedDialog. This combines FormDialog behavior (form fields, validation, submit) with the ability to open another dialog from within.
export const useMyFormDialog = () => {
const formDialogOpeningDialog = useFormDialogOpeningDialog()
const { translate } = useInternationalization()
// ... other hooks (mutations for both form submit AND secondary action)
const dataRef = useRef<MyData | null>(null)
const successRef = useRef(false)
// Mutation for the form submit
const [updateItem] = useUpdateItemMutation({
onCompleted: (res) => {
if (!res.updateItem) return
successRef.current = true
dataRef.current?.callback?.(res.updateItem.id)
addToast({ severity: 'success', message: translate('...') })
},
})
// Mutation for the secondary action (e.g., delete)
const [deleteItem] = useDeleteItemMutation()
const form = useAppForm({
defaultValues: initialValues,
validationLogic: revalidateLogic(),
validators: { onDynamic: validationSchema },
onSubmit: async ({ value }) => {
await updateItem({
variables: { input: { ...value, id: dataRef.current?.id as string } },
})
},
})
const handleSubmit = async (): Promise<DialogResult> => {
successRef.current = false
await form.handleSubmit()
if (!successRef.current) {
throw new Error('Submit failed')
}
return { reason: 'success' }
}
const openMyFormDialog = (data: MyData) => {
dataRef.current = data
const isEdition = !!data.existingItem
form.reset()
if (data.existingItem) {
form.setFieldValue('fieldName', data.existingItem.fieldValue || '')
}
formDialogOpeningDialog
.open({
title: translate(isEdition ? '...' : '...'),
description: translate('...'),
children: (
<div className="...">
<form.AppField name="fieldName">
{(field) => <field.TextInputField label={translate('...')} />}
</form.AppField>
</div>
),
closeOnError: false,
mainAction: (
<form.AppForm>
<form.SubmitButton>{translate('...')}</form.SubmitButton>
</form.AppForm>
),
form: {
id: MY_FORM_ID,
submit: handleSubmit,
},
// Secondary action button (conditionally shown)
canOpenDialog: isEdition && !!data.deleteCallback && someCondition,
openDialogText: translate('...'), // e.g., "Delete integration"
otherDialogProps: {
title: translate('...'),
description: translate('...'),
colorVariant: 'danger',
actionText: translate('...'),
onAction: async () => {
const result = await deleteItem({
variables: { input: { id: data.existingItem?.id ?? '' } },
})
if (result.data?.deleteItem) {
data.deleteCallback?.()
addToast({ severity: 'success', message: translate('...') })
}
},
},
})
.then((response) => {
if (response.reason === 'close' || response.reason === 'open-other-dialog') {
form.reset()
dataRef.current = null
}
})
}
return { openMyFormDialog }
}
Key differences from FormDialog:
useFormDialogOpeningDialog() instead of useFormDialog()canOpenDialog, openDialogText, and otherDialogProps to the open callcanOpenDialog controls whether the secondary danger button is visibleotherDialogProps is a CentralizedDialogProps object (title, description, actionText, onAction, colorVariant).then() handler should also check for response.reason === 'open-other-dialog' for cleanupWhen to use FormDialogOpeningDialog vs FormDialog:
FormDialog when the dialog only has form fields and a submit buttonFormDialogOpeningDialog when the dialog has form fields AND a secondary action button (typically danger/delete) that should open a confirmation dialogHandling localData state:
useState to store data passed via openDialoguseRef to store data passed to the hook's open function. The ref is captured in closures for onSubmit and children.Handling form initial values when editing:
initialValues depended on localData state, re-rendered on state changeform.reset() then form.setFieldValue(...) before opening the dialog. The form values are set synchronously before formDialog.open() is called.Handling form submission:
handleSubmit called e.preventDefault() + form.handleSubmit(); dialog closed by calling dialogRef.current?.closeDialog() inside onSubmithandleSubmit returns a Promise<DialogResult>. Use a successRef to track whether the mutation succeeded. The dialog auto-closes on success (when the promise resolves). Throw an error to keep the dialog open on failure.Handling dialog close/cleanup:
onClose callback reset the form.then() on the promise returned by formDialog.open(). Check response.reason === 'close' to reset form and clear refs.Handling component props (like admins list):
<MyDialog admins={admins} />isDeletingLastAdmin) in the parent and pass them as part of the open function data.Remove:
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
import { Button } from '~/components/designSystem/Button'
import { Dialog, DialogRef } from '~/components/designSystem/Dialog'
import { WarningDialog } from '~/components/designSystem/WarningDialog'
Add (for FormDialog):
import { useRef } from 'react'
import { useFormDialog } from '~/components/dialogs/FormDialog'
import { DialogResult } from '~/components/dialogs/types'
Or (for CentralizedDialog):
import { useCentralizedDialog } from '~/components/dialogs/CentralizedDialog'
Or (for FormDialogOpeningDialog):
import { useRef } from 'react'
import { useFormDialogOpeningDialog } from '~/components/dialogs/FormDialogOpeningDialog'
import { DialogResult } from '~/components/dialogs/types'
Remove:
export interface MyDialogRef {
openDialog: (data: ...) => unknown
closeDialog: () => unknown
}
export const MyDialog = forwardRef<MyDialogRef>(...)
MyDialog.displayName = 'forwardRef'
Replace with:
export const useMyDialog = () => { ... }
Old usage:
import { MyDialog, MyDialogRef } from './dialogs/MyDialog'
const parentComponent = () => {
const myDialogRef = useRef<MyDialogRef>(null)
const handleAction = () => {
myDialogRef.current?.openDialog({ /* data */ })
}
return (
<>
{/* ... */}
<MyDialog ref={myDialogRef} />
</>
)
}
New usage:
import { useMyDialog } from './dialogs/MyDialog'
const parentComponent = () => {
const { openMyDialog } = useMyDialog()
const handleAction = () => {
openMyDialog(/* data */)
}
return (
<>
{/* ... */}
{/* No need to render MyDialog in JSX anymore */}
</>
)
}
Key changes in parent:
useRef<MyDialogRef> import and usage.current?.openDialog())<MyDialog ref={...} /> from JSX (the dialog is now rendered via NiceModal)useRef import from React if no longer needednpx tsc --noEmit
pnpm lint
Ensure there are no TypeScript errors or lint violations before considering the migration complete.
type DialogResult =
| { reason: 'close' }
| { reason: 'open-other-dialog'; otherDialog: Promise<DialogResult> }
| { reason: 'success'; params?: unknown }
| { reason: 'error'; error: Error }
type FormDialogProps = {
title: ReactNode
description?: ReactNode
headerContent?: ReactNode
children?: ReactNode
mainAction?: ReactNode
cancelOrCloseText?: 'close' | 'cancel'
closeOnError?: boolean
onError?: (error: Error) => void
form: FormProps // { id: string; submit: (e: React.FormEvent) => void }
}
type CentralizedDialogProps = {
title: ReactNode
description?: ReactNode
headerContent?: ReactNode
children?: ReactNode
onAction: () => DialogResult | Promise<DialogResult> | void | Promise<void>
actionText: string
colorVariant?: 'info' | 'danger'
disableOnContinue?: boolean
cancelOrCloseText?: 'close' | 'cancel'
closeOnError?: boolean
onError?: (error: Error) => void
}
type FormDialogOpeningDialogProps = FormDialogProps & {
canOpenDialog?: boolean // Controls visibility of the secondary action button
openDialogText: string // Label for the secondary action button (e.g., "Delete integration")
otherDialogProps: CentralizedDialogProps // Props passed to the CentralizedDialog that opens
}
openDialogforwardRef component to custom hook (useMyDialog)useState(localData) with useRef (for FormDialog/FormDialogOpeningDialog) or function parameter (for CentralizedDialog)Dialog/WarningDialog with useFormDialog(), useCentralizedDialog(), or useFormDialogOpeningDialog()handleSubmit returning Promise<DialogResult> (for FormDialog/FormDialogOpeningDialog).then() callback (for FormDialog/FormDialogOpeningDialog)forwardRef, DialogRef interface, displayName)canOpenDialog, openDialogText, and otherDialogPropsresponse.reason === 'open-other-dialog' in .then() for cleanupnpx tsc --noEmit)pnpm lint)Invoke this skill with:
/migrate-dialog <path-to-dialog>
Where <path-to-dialog> is the path to the existing imperative ref-based dialog file.
Example:
/migrate-dialog src/pages/settings/teamAndSecurity/members/dialogs/RevokeMembershipDialog.tsx
development
Migrate a React form from Formik to TanStack Form following project conventions. Use this skill when the user wants to migrate a form component from Formik to TanStack Form.
tools
# Make Tests Skill **Target:** `<PR_NUMBER | BRANCH_NAME>` > **Important:** If no argument was provided above (empty or missing), use the AskUserQuestion tool to ask the user what they want to create tests for. They can provide: > > - A PR number (format: `#123` or `123`) > - A branch name (local or remote, e.g., `feature/my-feature` or `origin/feature/my-feature`) ## Input Detection Determine the input type in this order: ### Step 1: Check if PR Number (Remote) If the argument is numeric
development
Create Cypress e2e tests for a specific feature. Accepts a feature name, PR number, or branch name. Navigates the codebase, adds data-test attributes if missing, writes happy-path tests following project conventions, and validates them with Cypress.
research
Execute git commit with conventional commit message analysis, intelligent staging, and message generation. Use when user asks to commit changes, create a git commit, or mentions "/commit". Supports: (1) Auto-detecting type and scope from changes, (2) Generating conventional commit messages from diff, (3) Interactive commit with optional type/scope/description overrides, (4) Intelligent file staging for logical grouping