| name | admin-form-ui |
| description | Enforce correct form UI patterns when creating or modifying forms in packages/admin. Use when writing form fields, edit drawers, create modals, or any form-based UI in the admin package. Covers Form.Field pattern, labels, errors, hints, grids, submit guards, drawer/modal structure. |
Admin Form UI Patterns
Use this skill when:
- creating new form fields in the admin
- building edit forms in RouteDrawer
- building create forms in RouteFocusModal
- adding fields to existing forms
- reviewing form code for UI consistency
Not for: creating tabbed wizard forms (use admin-tab-ui), creating pages/sections (use admin-page-ui).
Before introducing new custom field wrappers, overlays, selectors, or interactive form primitives, first apply medusa-ui-conformance.
Read next (as needed):
references/form-field-patterns.md — exact code examples for every field type
references/drawer-modal-patterns.md — RouteDrawer and RouteFocusModal form structure
Core Rule
NEVER use raw Controller from react-hook-form or raw Label from @medusajs/ui in admin forms. Always use the Form.* compound components.
Form Field Pattern (mandatory)
Every form field MUST follow this structure:
<Form.Field
control={form.control}
name="field_name"
render={({ field }) => (
<Form.Item>
<Form.Label>{t("scope.fields.field_name.label")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
Hard Rules (DO NOT)
- Do NOT use raw
Controller — always use Form.Field (wraps Controller with context).
- Do NOT use
<Label> from @medusajs/ui — use <Form.Label> (supports optional, tooltip, accessibility).
- Do NOT manually render errors (
fieldState.error && <span>) — use <Form.ErrorMessage /> (auto-reads form state).
- Do NOT mark required fields with
* in text — omit optional prop (absence = required). Use <Form.Label optional> for optional fields.
- Do NOT use hardcoded strings — use
t("...") from useTranslation() for all user-visible text.
- Do NOT use custom
<div className="flex flex-col gap-y-2"> wrappers around fields — use <Form.Item> (renders flex flex-col space-y-2).
- Do NOT skip
data-testid on form fields and buttons.
- Do NOT skip
<Form.ErrorMessage /> — include it even if you think validation won't fail.
- Do NOT use
window.confirm — use usePrompt() for confirmations.
- Do NOT use raw
<form> — use <KeyboundForm> for Ctrl/Cmd+Enter support.
- Do NOT skip
isPending guard on submit — both button isLoading AND keyboard submit must check it.
- Do NOT hand-roll custom input components — ALWAYS check
packages/admin/src/components/inputs/ and the Field Types Reference table below FIRST. If a component exists (HandleInput, ChipInput, SwitchBox, etc.), use it. Never create a custom wrapper for something that already has a reusable component.
- Do NOT call
useRouteModal() outside of RouteDrawer or RouteFocusModal — it requires the provider. Split into outer shell (RouteDrawer + data fetch) and inner form component (useRouteModal + form logic). See RouteDrawer Form Structure below.
Form.Label Features
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Label optional>{t("fields.subtitle")}</Form.Label>
<Form.Label tooltip={t("fields.handle.tooltip")}>{t("fields.handle")}</Form.Label>
<Form.Label optional tooltip={t("fields.sku.tooltip")}>{t("fields.sku")}</Form.Label>
Grid Layouts
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field ... />
<Form.Field ... />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Form.Field ... />
<Form.Field ... />
<Form.Field ... />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Form.Label optional>{t("fields.shipping_profile.label")}</Form.Label>
<Form.Hint><Trans i18nKey="fields.shipping_profile.hint" /></Form.Hint>
</div>
<Form.Field control={form.control} name="shipping_profile_id" render={...} />
</div>
Form.Hint Usage
<Form.Label optional>{t("fields.sales_channels.label")}</Form.Label>
<Form.Hint><Trans i18nKey="fields.sales_channels.hint" /></Form.Hint>
<div className="flex items-start justify-between gap-x-4">
<div className="flex flex-col">
<Form.Label>{t("fields.options.label")}</Form.Label>
<Form.Hint>{t("fields.options.hint")}</Form.Hint>
</div>
<Button size="small" variant="secondary" type="button" onClick={handleAdd}>
{t("actions.add")}
</Button>
</div>
Submit Pattern
const { mutateAsync, isPending } = useMutation()
const handleSubmit = form.handleSubmit(async (data) => {
if (isRegionsPending) return
await mutateAsync(data, {
onSuccess: () => {
toast.success(t("scope.successToast"))
handleSuccess()
},
})
})
return (
<KeyboundForm onSubmit={handleSubmit}>
{/* ... form content ... */}
<Button type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</KeyboundForm>
)
RouteDrawer Form Structure
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("scope.edit.header")}</Heading>
</RouteDrawer.Header>
<RouteDrawer.Form form={form}>
<KeyboundForm onSubmit={handleSubmit}>
<RouteDrawer.Body>
{/* Form fields here */}
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary">{t("actions.cancel")}</Button>
</RouteDrawer.Close>
<Button type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
</RouteDrawer>
RouteFocusModal Form Structure
<RouteFocusModal>
<RouteFocusModal.Form form={form}>
<KeyboundForm onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex flex-1 flex-col items-center overflow-y-auto py-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("scope.create.header")}</Heading>
<Text size="small">{t("scope.create.hint")}</Text>
</div>
{/* Form fields (grids) */}
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary">{t("actions.cancel")}</Button>
</RouteFocusModal.Close>
<Button type="submit" isLoading={isPending}>
{t("actions.create")}
</Button>
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
</RouteFocusModal.Form>
</RouteFocusModal>
Field Types Reference
| Field Type | Component | Import |
|---|
| Text input | <Input {...field} /> | @medusajs/ui |
| Textarea | <Textarea {...field} /> | @medusajs/ui |
| Select/Combobox | <Combobox {...field} options={...} /> | local |
| Switch | <SwitchBox control={form.control} name="..." label="..." description="..." /> | local |
| Number | <Input type="number" {...field} onChange={...} /> | @medusajs/ui |
| Handle/Slug | <HandleInput {...field} /> | local |
| Chips (tags) | <ChipInput {...field} variant="contrast" /> | local |
| File upload | <FileUpload /> | local |
Imports Checklist
import { Form } from "@components/common/form"
import { KeyboundForm } from "@components/utilities/keybound-form"
import { SwitchBox } from "@components/common/switch-box"
import { HandleInput } from "@components/inputs/handle-input"
import { Input, Textarea, Heading, Text, Button } from "@medusajs/ui"
import { RouteDrawer, useRouteModal } from "@components/modals"
import { RouteFocusModal } from "@components/modals"
import { useTranslation, Trans } from "react-i18next"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"