.agents/skills/extract-section-to-drawer/SKILL.md
Extract a Formik form section into a TanStack Form drawer with Zod validation, following the plan form migration pattern. Use this skill when the user wants to extract a plan form section (or similar Formik section) into an independent TanStack Form drawer.
npx skillsauth add getlago/lago-front extract-section-to-drawerInstall 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 section to extract: $ARGUMENTS
Important: If no path was provided above (empty or missing), use the AskUserQuestion tool to ask the user for the path to the section component they want to extract into a drawer.
This skill extracts a Formik-based form section into an independent TanStack Form inside a ref-based Drawer, bridging data back to the parent Formik form via a callback. Both the existing Formik section and the new drawer coexist during migration.
Before starting, read these files to understand existing patterns:
src/hooks/forms/useAppForm.ts — registered field componentssrc/hooks/forms/formContext.ts — useFieldContext for TanStack fieldssrc/pages/settings/teamAndSecurity/members/dialogs/CreateInviteDialog.tsxsrc/components/designSystem/Drawer.tsx — ref-based DrawerRef with openDrawer/closeDrawersrc/components/plans/drawers/SubscriptionFeeDrawer.tsx — first completed extractionsrc/components/plans/SubscriptionFeeSection.tsx — section that hosts its drawer internallysrc/contexts/PlanFormContext.tsx — shared read-only context for currency/intervalsrc/components/form/TextInput/TextInputFieldForTanstack.tsxsrc/components/form/AmountInput/AmountInputFieldForTanstack.tsxsrc/components/form/Switch/SwitchFieldForTanstack.tsxsrc/components/form/Radio/RadioGroupFieldForTanstack.tsxCreate an interface for the drawer's form values. The field names must match the Formik field names exactly so the parent can spread them back with setValues:
interface SectionNameFormValues {
fieldA: string
fieldB: boolean
fieldC: number | null
}
Critical rule: The keys in this type must be a subset of the parent Formik form's type (PlanFormInput or equivalent). This enables the spread pattern:
formikProps.setValues({ ...formikProps.values, ...drawerValues })
Define a Zod schema that validates exactly the form values shape:
const sectionNameSchema = z.object({
fieldA: z.string().min(1, 'text_translationKeyForError'),
fieldB: z.boolean(),
fieldC: z.number().positive().nullable(),
})
Important:
z.enum(MyEnum) for GraphQL/TS string enums. z.nativeEnum() is deprecated in Zod v4.useInternationalization.Create the drawer file at src/components/plans/drawers/SectionNameDrawer.tsx following this structure:
import { revalidateLogic, useStore } from '@tanstack/react-form'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { z } from 'zod'
import { Button } from '~/components/designSystem/Button'
import { Drawer, DrawerRef } from '~/components/designSystem/Drawer'
// ... other imports
export interface SectionNameFormValues { /* ... */ }
const sectionNameSchema = z.object({ /* ... */ })
const DEFAULT_VALUES: SectionNameFormValues = { /* ... */ }
const SECTION_NAME_FORM_ID = 'section-name-drawer-form'
export interface SectionNameDrawerRef {
openDrawer: (values: SectionNameFormValues) => void
closeDrawer: () => void
}
interface SectionNameDrawerProps {
onSave: (values: SectionNameFormValues) => void
}
export const SectionNameDrawer = forwardRef<SectionNameDrawerRef, SectionNameDrawerProps>(
({ onSave }, ref) => {
const drawerRef = useRef<DrawerRef>(null)
const form = useAppForm({
defaultValues: DEFAULT_VALUES,
validationLogic: revalidateLogic(),
validators: { onDynamic: sectionNameSchema },
onSubmit: async ({ value }) => {
onSave(value)
drawerRef.current?.closeDrawer()
},
})
useImperativeHandle(ref, () => ({
openDrawer: (values) => {
// keepDefaultValues: true prevents the React adapter's formApi.update()
// from overwriting values — it compares opts.defaultValues against
// this.options.defaultValues, and since we keep DEFAULT_VALUES as both,
// they match and no overwrite occurs. isDirty starts as false because
// reset() clears all field meta.
form.reset(values, { keepDefaultValues: true })
drawerRef.current?.openDrawer()
},
closeDrawer: () => drawerRef.current?.closeDrawer(),
}))
// IMPORTANT: subscribe to isDirty via useStore — reading form.state.isDirty
// directly is a passive read that does NOT trigger re-renders.
const isDirty = useStore(form.store, (state) => state.isDirty)
const handleFormSubmit = (event: React.FormEvent) => {
event.preventDefault()
form.handleSubmit()
}
return (
<Drawer
ref={drawerRef}
title="..."
showCloseWarningDialog={isDirty}
onClose={() => form.reset()}
stickyBottomBar={({ closeDrawer }) => (
<div className="flex justify-end gap-3">
<Button variant="quaternary" onClick={closeDrawer}>Cancel</Button>
{/* form.SubmitButton uses type="submit" which requires being inside <form>.
In drawers, stickyBottomBar is outside <form> in the DOM, so use
form.Subscribe + programmatic form.handleSubmit() instead. */}
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit })}>
{({ canSubmit }) => (
<Button disabled={!canSubmit} onClick={() => form.handleSubmit()}>
Save
</Button>
)}
</form.Subscribe>
</div>
)}
>
<form id={SECTION_NAME_FORM_ID} onSubmit={handleFormSubmit}>
{/* Hidden submit button enables Enter-key submission.
The visible SubmitButton is in stickyBottomBar, outside the <form> in the DOM. */}
<button type="submit" hidden aria-hidden="true" />
{/* form.AppField for each field */}
</form>
</Drawer>
)
},
)
<form> wrapper: Always wrap drawer content in a <form> element with an id and onSubmit handler. Add a <button type="submit" hidden aria-hidden="true" /> inside the form — the visible SubmitButton is in stickyBottomBar (outside <form> in the DOM), so the hidden button is needed for Enter-key submission to work.onSubmit on useAppForm: The save logic lives in the form's onSubmit config, not in a manual handler. This ensures Zod validation runs via form.handleSubmit() before saving.form.SubmitButton uses type="submit" which only works inside a <form> element. In drawers, the stickyBottomBar is rendered outside the <form> in the DOM, so use form.Subscribe + form.handleSubmit() instead: <form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit })}>{({ canSubmit }) => (<Button disabled={!canSubmit} onClick={() => form.handleSubmit()}>Save</Button>)}</form.Subscribe>. This gives you the same canSubmit gating as SubmitButton with programmatic submission.reset(values, { keepDefaultValues: true }): On openDrawer, reset the form to the provided values while keeping DEFAULT_VALUES as the internal defaultValues. This prevents the React adapter's formApi.update() from overwriting values on re-render, without needing a useState workaround.openDrawer(values) accepts initial values from Formik, not just openDrawer()useStore(form.store, (state) => state.isDirty) — reading form.state.isDirty directly is a passive read that does NOT trigger re-renders.onClose={() => form.reset()} cleans up when drawer closesThe drawer lives inside the section component, not at the page level:
useRef<SectionNameDrawerRef> inside the section componentonDrawerSave prop (required, not optional) to the section's props interfaceSelector component) that calls drawerRef.current?.openDrawer(currentValues)<SectionNameDrawer ref={drawerRef} onSave={onDrawerSave} /> inside the sectioninterface SectionProps {
formikProps: FormikProps<PlanFormInput>
onDrawerSave: (values: SectionNameFormValues) => void
// ... other existing props
}
Critical rule for sections that render items in a loop (e.g., fixed charges, usage charges, where each charge is an accordion item):
The drawer must be rendered once at the section level, NOT inside each loop item. Only the drawerRef is passed down to each item as a prop. This prevents N drawer instances in the DOM (one per item).
// ✅ Correct: ONE drawer at the section level, ref passed to items
const ChargesSection = ({ formikProps, onDrawerSave }: ChargesSectionProps) => {
const drawerRef = useRef<ChargeDrawerRef>(null)
return (
<>
{formikProps.values.charges.map((charge, index) => (
<ChargeAccordionItem
key={charge.id}
charge={charge}
drawerRef={drawerRef} // ← pass only the ref
/>
))}
<ChargeDrawer ref={drawerRef} onSave={onDrawerSave} />
</>
)
}
// Each item calls drawerRef.current?.openDrawer(itsValues)
const ChargeAccordionItem = ({ charge, drawerRef }: ChargeAccordionItemProps) => {
return (
<Selector
onClick={() => drawerRef.current?.openDrawer(charge)}
// ...
/>
)
}
// ❌ Wrong: drawer rendered inside each loop item = N drawers in DOM
{formikProps.values.charges.map((charge, index) => (
<ChargeAccordionItem key={charge.id} charge={charge}>
<ChargeDrawer onSave={onDrawerSave} /> {/* duplicated per item! */}
</ChargeAccordionItem>
))}
In the parent page (e.g., CreatePlan.tsx, CreateSubscription.tsx):
onDrawerSave using the spread pattern:<SectionComponent
formikProps={formikProps}
onDrawerSave={(values) => {
formikProps.setValues({ ...formikProps.values, ...values })
}}
/>
PlanFormProvider wraps the section (provides currency/interval to the drawer):<PlanFormProvider
currency={formikProps.values.amountCurrency || CurrencyEnum.Usd}
interval={formikProps.values.interval || PlanInterval.Monthly}
>
{/* ... section components ... */}
</PlanFormProvider>
If the section uses a Formik field component that doesn't have a TanStack equivalent yet:
src/hooks/forms/useAppForm.ts for the fieldComponents map*ForTanstack.tsx file following the pattern of existing ones (e.g., SwitchFieldForTanstack.tsx)useAppForm.tsThe TanStack field pattern:
const FieldName = (props: Omit<OriginalProps, 'name' | 'value' | 'onChange'>) => {
const field = useFieldContext<FieldType>()
return (
<OriginalComponent
{...props}
name={field.name}
value={field.state.value}
onChange={(value) => field.handleChange(value)}
/>
)
}
Never manually create translation keys. Always use the script to generate properly formatted keys:
pnpm translations:add <count>
This generates <count> keys with the format text_<timestamp><random> and appends them with empty values to translations/base.json. Then fill in the values for each generated key.
For example, if you need 2 new strings ("Pricing settings" and "Open drawer"):
pnpm translations:add 2translations/base.json"text_1771963033466...": "Pricing settings"translate('text_1771963033466...')After completing the extraction:
pnpm tsc --noEmit — zero errorspnpm eslint on changed files — zero new errorsonDrawerSave and is wrapped with PlanFormProvider<form> wrapper: Always wrap drawer content in a <form> element. Without it, form.handleSubmit() won't trigger the validation → submit lifecycle, and Zod validation is never enforced.form.handleSubmit(): Never read form.state.values directly and pass to onSave. Always use form.handleSubmit() which runs validation first, then calls the onSubmit handler only if valid.useState for defaultValues: Don't use useState to track defaultValues. Use form.reset(values, { keepDefaultValues: true }) instead — this prevents the React adapter's formApi.update() from overwriting values while keeping isDirty correct.z.nativeEnum() usage: Deprecated in Zod v4. Use z.enum(MyEnum) for GraphQL/TS string enums.onDrawerSave: Keep it required. If a parent doesn't need the drawer, it still passes a handler. This avoids conditional rendering bugs.usePlanFormContext() for currency/interval. Every parent that renders the section must be wrapped.useAppForm is imported from ~/hooks/forms/useAppform (lowercase 'f').validationLogic: revalidateLogic() from @tanstack/react-form.drawerRef to each item. Never render a drawer inside each loop iteration.pnpm translations:add <count> to generate them with the correct format.*ForTanstack.tsx) wire up error state via useStore(field.store, (state) => state.meta.errors) and getErrorToDisplay(). If the underlying component accepts an error prop, it must be connected.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.
development
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.
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.