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
This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2025-12-11 18:03:30 +01:00
1 parent 65a96b6363
commit 754af51312
7 files changed
+237 -117

No files matched your search

+173 -26
View File
@@ -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<z.infer<typeof profileSchema>>({
resolver: zodResolver(profileSchema),
defaultValues: { username: '' },
export function SettingsForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { name: '', enableFeature: false },
mode: 'onSubmit',
reValidateMode: 'onBlur',
})
function onSubmit(values: z.infer<typeof profileSchema>) {
// handle values
function onSubmit(values: z.infer<typeof formSchema>) {
// handle mutation with onSuccess/onError toast
}
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="space-y-6">
<CardContent>
<FormField_Shadcn_
control={form.control}
name="username"
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Username"
description="This is your public display name."
label="Name"
description="A descriptive name for this resource"
>
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="shadcn" autoComplete="off" {...field} />
<Input_Shadcn_ {...field} placeholder="Enter name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end">
<Button type="primary" htmlType="submit">
<CardContent>
<FormField_Shadcn_
control={form.control}
name="enableFeature"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable Feature"
description="Toggle this feature on or off"
>
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button type="primary" htmlType="submit" disabled={!form.formState.isDirty}>
Submit
</Button>
</CardFooter>
@@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { name: '' },
})
function onSubmit(values: z.infer<typeof formSchema>) {
// handle mutation
setOpen(false)
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Create Resource</SheetTitle>
</SheetHeader>
<Form_Shadcn_ {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="overflow-auto flex-grow px-0"
>
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Name" description="A descriptive name">
<FormControl_Shadcn_ className="col-span-6 min-w-100">
<Input_Shadcn_ {...field} placeholder="Enter name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
</form>
</Form_Shadcn_>
<SheetFooter>
<Button type="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="primary" form={formId} htmlType="submit">
Create
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
```
### 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: `<PrePostTab postTab="MB"><Input_Shadcn_ /></PrePostTab>`
- **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'
;<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
@@ -27,7 +27,6 @@ import {
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Separator,
Switch,
Textarea,
} from 'ui'
@@ -116,10 +115,10 @@ export default function FormPatternsPageLayout() {
</PageSectionMeta>
<PageSectionContent>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6 space-y-6">
{/* Text Input */}
{/* Text Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
@@ -128,7 +127,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Text Input"
description="Single-line text entry for short values"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Enter text" />
@@ -136,10 +134,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Password Input */}
{/* Password Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="password"
@@ -148,7 +146,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Password Input"
description="Masked input for secure text entry"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} type="password" placeholder="Enter password" />
@@ -156,10 +153,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Copyable Input */}
{/* Copyable Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="apiKey"
@@ -168,7 +165,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Copyable Input"
description="Read-only input with copy-to-clipboard functionality"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<Input
@@ -182,10 +178,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Number Input */}
{/* Number Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="maxConnections"
@@ -194,7 +190,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Number Input"
description="Numeric input with min/max validation"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_
@@ -208,10 +203,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Input with Units */}
{/* Input with Units */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="duration"
@@ -220,7 +215,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Input with Units"
description="Input with additional unit label"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<PrePostTab postTab="MB" className="w-full">
@@ -230,10 +224,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Textarea */}
{/* Textarea */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="description"
@@ -242,7 +236,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Textarea"
description="Multi-line text input for longer content"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Textarea
@@ -255,10 +248,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Icon Upload */}
{/* Icon Upload */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="description"
@@ -267,7 +260,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Icon upload"
description="For icons, avatars, or small images with preview"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<div className="flex gap-4 items-center">
@@ -322,10 +314,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* File Upload */}
{/* File Upload */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="description"
@@ -334,7 +326,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="File Upload"
description="Drag-and-drop or select files for upload"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<div
@@ -416,10 +407,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Switch */}
{/* Switch */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="enableFeature"
@@ -435,15 +426,14 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Checkbox */}
{/* Checkbox */}
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Checkbox"
description="Boolean values or multiple selections"
className="[&>div]:md:w-1/2"
>
<div className="w-full flex flex-col gap-4">
<FormField_Shadcn_
@@ -511,10 +501,10 @@ export default function FormPatternsPageLayout() {
/>
</div>
</FormItemLayout>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Select */}
{/* Select */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="region"
@@ -523,7 +513,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Select (Dropdown)"
description="Single selection from a list of options"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
@@ -546,10 +535,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Multi-Select */}
{/* Multi-Select */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="schemas"
@@ -558,7 +547,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Multi-Select"
description="Multiple selection from a list"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<MultiSelector
onValuesChange={field.onChange}
@@ -584,10 +572,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Radio Group */}
{/* Radio Group */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="queueType"
@@ -596,7 +584,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Radio Group"
description="Single selection from multiple options"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
@@ -615,10 +602,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Date Picker */}
{/* Date Picker */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="expiryDate"
@@ -627,7 +614,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Date Picker"
description="Date selection with calendar popover"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Popover_Shadcn_>
@@ -653,10 +639,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Field Array */}
{/* Field Array */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="redirectUris"
@@ -665,7 +651,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Field Array"
description="Dynamic list for adding/removing items"
className="[&>div]:md:w-1/2"
>
<div className="space-y-2 w-full">
{fields.map((field, index) => (
@@ -704,17 +689,16 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Action Field */}
{/* Action Field */}
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Action Field"
description="Button or link for navigation or performable actions"
className="[&>div]:md:w-1/2"
>
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-center justify-end">
<Button
type="default"
icon={<ExternalLink size={14} />}
@@ -370,7 +370,6 @@ export const PerformanceSettingsForm = () => {
<div className="relative">
<PrePostTab postTab={chosenUnit === 'percent' ? '%' : 'connections'}>
<Input_Shadcn_
className="w-20"
type="number"
{...field}
min={3}
@@ -310,7 +310,7 @@ export const ProtectionAuthSettingsForm = () => {
label="Prevent use of leaked passwords"
description="Rejects the use of known or easy to guess passwords on sign up or password change. "
>
<div className="flex items-center gap-2">
<div className="flex items-center justify-end gap-2">
<Badge variant={field.value ? 'success' : 'default'}>
{field.value ? 'Enabled' : 'Disabled'}
</Badge>
@@ -92,11 +92,7 @@ export const OrganizationDetailsForm = () => {
render={({ field }) => (
<FormItemLayout label="Organization name" layout="flex-row-reverse">
<FormControl_Shadcn_>
<Input
{...field}
className="w-full max-w-full md:w-96"
disabled={!canUpdateOrganization || isUpdatingDetails}
/>
<Input {...field} disabled={!canUpdateOrganization || isUpdatingDetails} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -110,12 +106,7 @@ export const OrganizationDetailsForm = () => {
<CopyButton type="text" iconOnly text={selectedOrganization?.slug ?? ''} />
}
>
<Input
disabled
className="w-full max-w-full md:w-64"
id="slug"
value={selectedOrganization?.slug ?? ''}
/>
<Input disabled id="slug" value={selectedOrganization?.slug ?? ''} />
</PrePostTab>
</FormItemLayout>
</CardContent>
@@ -147,18 +147,16 @@ export const SecuritySettings = () => {
<FormControl_Shadcn_>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={
!isPaidPlan ||
!canUpdateMfaConfig ||
!hasMFAEnabled ||
isUpdatingMfa
}
/>
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={
!isPaidPlan ||
!canUpdateMfaConfig ||
!hasMFAEnabled ||
isUpdatingMfa
}
/>
</TooltipTrigger>
{(!canUpdateMfaConfig || !hasMFAEnabled) && (
<TooltipContent side="bottom">
@@ -233,7 +233,8 @@ const FlexContainer = cva('', {
},
{
layout: 'flex-row-reverse',
className: 'flex flex-col justify-center items-start md:items-end shrink-0',
className:
'flex flex-col justify-center items-start md:items-end shrink-0 md:w-1/2 xl:w-2/5 md:min-w-100 [&>div]:md:w-full',
},
],
})