From 754af513126351b33ecb99f1fd6318c7ed318b80 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Thu, 11 Dec 2025 18:03:30 +0100 Subject: [PATCH] chore: form patterns cursor rules (#41225) * chore(studio): integrate cursor rules with form patterns * chore(studio): update FormLayout inner styling * chore(studio): align actions form field to the right * chore(design-system): wrap each form field in a CardContent --- .cursor/rules/studio-ui.mdc | 199 +++++++++++++++--- .../example/form-patterns-pagelayout.tsx | 114 +++++----- .../Auth/PerformanceSettingsForm.tsx | 1 - .../ProtectionAuthSettingsForm.tsx | 2 +- .../OrganizationDetailsForm.tsx | 13 +- .../Organization/SecuritySettings.tsx | 22 +- .../src/form/Layout/FormLayout.tsx | 3 +- 7 files changed, 237 insertions(+), 117 deletions(-) diff --git a/.cursor/rules/studio-ui.mdc b/.cursor/rules/studio-ui.mdc index 391978294b4..754cca1520b 100644 --- a/.cursor/rules/studio-ui.mdc +++ b/.cursor/rules/studio-ui.mdc @@ -125,63 +125,106 @@ export const MyPageComponent = () => ( ## Forms -- Build forms with `react-hook-form` + `zod`. -- Use our `_Shadcn_` form primitives from `ui` and prefer `FormItemLayout` with layout="flex-row-reverse" for most controls (see `apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx`). -- Keep imports from `ui` with `_Shadcn_` suffixes. -- Forms should generally be wrapped in a Card unless specified -- If the submit button is outside the form, add a new variable named formId outside the component, and set it as property id on the form element and formId on the button. +Forms in Supabase Studio should follow consistent patterns to ensure a cohesive user experience across settings pages and side panels. -### Example (single field) +### Core Principles + +- Build forms with `react-hook-form` + `zod` +- Always use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription` +- Always wrap form inputs with `FormControl_Shadcn_` to ensure proper form integration +- Keep imports from `ui` with `_Shadcn_` suffixes +- Handle dirty state: Show cancel buttons and disable save buttons based on `form.formState.isDirty` +- Show loading states on submit buttons using the `loading` prop +- If the submit button is outside the form, add a `formId` variable outside the component, set it as `id` on the form element and `form` prop on the button + +### Layout Selection + +- **Page layouts**: Use `FormItemLayout` with `layout="flex-row-reverse"` for horizontal alignment. Forms should be wrapped in a `Card` with each form field in its own `CardContent`, and `CardFooter` for actions. The layout automatically handles consistent input widths (50% on md, 40% on xl, min-w-100). +- **Side panels (wide)**: Use `FormItemLayout` with `layout="horizontal"`. Use `SheetSection` to wrap each field group. +- **Side panels (narrow, size="sm" or below)**: Use `FormItemLayout` with `layout="vertical"` + +### Page Layout Form Example ```tsx import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import * as z from 'zod' -import { Button, Form_Shadcn_, FormField_Shadcn_, FormControl_Shadcn_, Input_Shadcn_ } from 'ui' +import { + Button, + Card, + CardContent, + CardFooter, + Form_Shadcn_, + FormField_Shadcn_, + FormControl_Shadcn_, + Input_Shadcn_, + Switch, +} from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -const profileSchema = z.object({ - username: z.string().min(2, 'Username must be at least 2 characters'), +const formSchema = z.object({ + name: z.string().min(1, 'Name is required'), + enableFeature: z.boolean(), }) -const formId = `profile-form` - -export function ProfileForm() { - const form = useForm>({ - resolver: zodResolver(profileSchema), - defaultValues: { username: '' }, +export function SettingsForm() { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { name: '', enableFeature: false }, mode: 'onSubmit', reValidateMode: 'onBlur', }) - function onSubmit(values: z.infer) { - // handle values + function onSubmit(values: z.infer) { + // handle mutation with onSuccess/onError toast } return ( -
+ - + ( - + )} /> - - + )} + @@ -192,6 +235,106 @@ export function ProfileForm() { } ``` +### Side Panel Form Example + +```tsx +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import * as z from 'zod' + +import { + Button, + Form_Shadcn_, + FormField_Shadcn_, + FormControl_Shadcn_, + Input_Shadcn_, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required'), +}) + +const formId = 'sidepanel-form' + +export function CreateResourcePanel() { + const [open, setOpen] = useState(false) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { name: '' }, + }) + + function onSubmit(values: z.infer) { + // handle mutation + setOpen(false) + } + + return ( + + + + Create Resource + + + + + ( + + + + + + )} + /> + + + + + + + + + + ) +} +``` + +### Common Form Field Types + +- **Text Input**: `Input_Shadcn_` with `placeholder` +- **Password Input**: `Input_Shadcn_` with `type="password"` +- **Number Input**: `Input_Shadcn_` with `type="number"` and `onChange={(e) => field.onChange(Number(e.target.value))}` +- **Input with Units**: Wrap `Input_Shadcn_` with `PrePostTab` component: `` +- **Textarea**: `Textarea` component with `rows` and `className="resize-none"` +- **Switch**: `Switch` with `checked={field.value} onCheckedChange={field.onChange}` +- **Checkbox**: `Checkbox_Shadcn_` with label, use multiple for checkbox groups +- **Select**: `Select_Shadcn_` with `SelectTrigger_Shadcn_`, `SelectContent_Shadcn_`, `SelectItem_Shadcn_` +- **Multi-Select**: Use `MultiSelector` from `ui-patterns/multi-select` +- **Radio Group**: `RadioGroupStacked` with `RadioGroupStackedItem` for stacked options with descriptions +- **Date Picker**: `Calendar` inside `Popover_Shadcn_` with a trigger button +- **Copyable Input**: Use `Input` from `ui-patterns/DataInputs/Input` with `copy` and `readOnly` props +- **Field Array**: Use `useFieldArray` from `react-hook-form` for dynamic add/remove fields +- **Action Field**: Use `FormItemLayout` without form control, just buttons for navigation or performable actions. Wrap buttons in a div with `justify-end` to align them to the right + ## Cards - Use cards when needing to group related pieces of information @@ -203,6 +346,11 @@ export function ProfileForm() { ## Sheets - Use a sheet when needing to reveal more complicated forms or information relating to an object and context switching away to a new page would be disruptive e.g. we list auth providers, clicking an auth provider opens a sheet with information about that provider and a form to enable, user can close sheet to go back to providers list +- Use `SheetContent` with `size="lg"` for forms that need horizontal layout +- Use `SheetHeader`, `SheetTitle`, `SheetSection`, and `SheetFooter` for consistent structure +- Place submit/cancel buttons in `SheetFooter` +- For forms in sheets, use `FormItemLayout` with `layout="horizontal"` for wider panels or `layout="vertical"` for narrow panels (size="sm" or below) +- See the Forms section for a complete side panel form example ## React Query @@ -223,7 +371,6 @@ export function ProfileForm() { ```jsx import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from 'ui' - ;A list of your recent invoices. diff --git a/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx index 71cf2850274..1dead4a7f73 100644 --- a/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx +++ b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx @@ -27,7 +27,6 @@ import { SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, - Separator, Switch, Textarea, } from 'ui' @@ -116,10 +115,10 @@ export default function FormPatternsPageLayout() { -
+ - - {/* Text Input */} + {/* Text Input */} + @@ -136,10 +134,10 @@ export default function FormPatternsPageLayout() { )} /> + - - - {/* Password Input */} + {/* Password Input */} + @@ -156,10 +153,10 @@ export default function FormPatternsPageLayout() { )} /> + - - - {/* Copyable Input */} + {/* Copyable Input */} + )} /> + - - - {/* Number Input */} + {/* Number Input */} + )} /> + - - - {/* Input with Units */} + {/* Input with Units */} + @@ -230,10 +224,10 @@ export default function FormPatternsPageLayout() { )} /> + - - - {/* Textarea */} + {/* Textarea */} +