mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
* 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
410 lines
15 KiB
Plaintext
410 lines
15 KiB
Plaintext
---
|
|
description: How to generate pages and interfaces in Studio, a web interface for managing Supabase projects
|
|
globs:
|
|
alwaysApply: true
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
- Next.js app using pages router
|
|
- Pages go in @apps/studio/pages
|
|
- Project related pages go in @apps/studio/pages/projects/[ref]
|
|
- Organization related pages go in @apps/studio/pages/org/[slug]
|
|
- Studio specific components go in @apps/studio/components
|
|
- Studio specific generic UI components go in @apps/studio/components/ui
|
|
- Studio specific components related to individual pages go in @apps/studio/components/interfaces e.g. @apps/studio/components/interfaces/Auth
|
|
- Generic helper functions go in @apps/studio/lib
|
|
- Generic hooks go in @apps/studio/hooks
|
|
|
|
## Component system
|
|
|
|
Our primitive component system is in @packages/ui and is based off shadcn/ui components. These components can be shared across all @apps e.g. studio and docs. Do not introduce new ui components unless asked to.
|
|
|
|
- UI components are imported from this package across apps e.g. import { Button, Badge } from 'ui'
|
|
- Some components have a _Shadcn_ namespace appended to component name e.g. import { Input*Shadcn* } from 'ui'
|
|
- We should be using _Shadcn_ components where possible
|
|
- Before composing interfaces, read @packages/ui/index.tsx file for a full list of available components
|
|
|
|
## Styling
|
|
|
|
We use Tailwind for styling.
|
|
|
|
- You should never use tailwind classes for colours and instead use classes we've defined ourselves
|
|
- Backgrounds // most of the time you will not need to define a background
|
|
- 'bg' used for main app surface background
|
|
- 'bg-muted' for elevating content // you can use Card instead
|
|
- 'bg-warning' for highlighting information that needs to be acted on
|
|
- 'bg-destructive' for highlighting issues
|
|
- Text
|
|
- 'text-foreground' for primary text like headings
|
|
- 'text-foreground-light' for body text
|
|
- 'text-foreground-lighter' for subtle text
|
|
- 'text-warning' for calling out information that needs action
|
|
- 'text-destructive' for calling out when something went wrong
|
|
- When needing to apply typography styles, read @apps/studio/styles/typography.scss and use one of the available classes instead of hard coding classes e.g. use "heading-default" instead of "text-sm font-medium"
|
|
- When applying focus styles for keyboard navigation, read @apps/studio/styles/focus.scss for any appropriate classes for consistency with other focus styles
|
|
|
|
## Page structure
|
|
|
|
When creating a new page follow these steps:
|
|
|
|
- Create the page in @apps/studio/pages
|
|
- Use the PageLayout component that has the following props
|
|
|
|
```jsx
|
|
export interface NavigationItem {
|
|
id?: string
|
|
label: string
|
|
href?: string
|
|
icon?: ReactNode
|
|
onClick?: () => void
|
|
badge?: string
|
|
active?: boolean
|
|
}
|
|
|
|
interface PageLayoutProps {
|
|
children?: ReactNode
|
|
title?: string | ReactNode
|
|
subtitle?: string | ReactNode
|
|
icon?: ReactNode
|
|
breadcrumbs?: Array<{
|
|
label?: string
|
|
href?: string
|
|
element?: ReactNode
|
|
}>
|
|
primaryActions?: ReactNode
|
|
secondaryActions?: ReactNode
|
|
navigationItems?: NavigationItem[]
|
|
className?: string
|
|
size?: 'default' | 'full' | 'large' | 'small'
|
|
isCompact?: boolean
|
|
}
|
|
```
|
|
|
|
- If a page has page related actions, add them to primary and secondary action props e.g. Users page has "Create new user" action
|
|
- If a page is within an existing section (e.g. Auth), you should use the related layout component e.g. AuthLayout
|
|
- Create a new component in @apps/studio/components/interfaces for the contents of the page
|
|
- Use ScaffoldContainer if the page should be center aligned in a container
|
|
- Use ScaffoldSection, ScaffoldSectionTitle, ScaffoldSectionDescription if the page has multiple sections
|
|
|
|
### Page example
|
|
|
|
```jsx
|
|
import { MyPageComponent } from 'components/interfaces/MyPage/MyPageComponent'
|
|
import AuthLayout from './AuthLayout'
|
|
import DefaultLayout from 'components/layouts/DefaultLayout'
|
|
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
|
import type { NextPageWithLayout } from 'types'
|
|
|
|
const MyPage: NextPageWithLayout = () => {
|
|
return (
|
|
<ScaffoldContainer>
|
|
<MyPageComponent />
|
|
</ScaffoldContainer>
|
|
)
|
|
}
|
|
|
|
MyPage.getLayout = (page) => (
|
|
<DefaultLayout>
|
|
<AuthLayout>{page}</AuthLayout>
|
|
</DefaultLayout>
|
|
)
|
|
|
|
export default MyPage
|
|
|
|
export const MyPageComponent = () => (
|
|
<ScaffoldSection isFullWidth>
|
|
<div>
|
|
<ScaffoldSectionTitle>My page section</ScaffoldSectionTitle>
|
|
<ScaffoldSectionDescription>A brief description of the purpose of the page</ScaffoldSectionDescription>
|
|
</div>
|
|
// Content goes here
|
|
</ScaffoldSection>
|
|
)
|
|
```
|
|
|
|
## Forms
|
|
|
|
Forms in Supabase Studio should follow consistent patterns to ensure a cohesive user experience across settings pages and side panels.
|
|
|
|
### 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,
|
|
Card,
|
|
CardContent,
|
|
CardFooter,
|
|
Form_Shadcn_,
|
|
FormField_Shadcn_,
|
|
FormControl_Shadcn_,
|
|
Input_Shadcn_,
|
|
Switch,
|
|
} from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
|
|
const formSchema = z.object({
|
|
name: z.string().min(1, 'Name is required'),
|
|
enableFeature: z.boolean(),
|
|
})
|
|
|
|
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 formSchema>) {
|
|
// handle mutation with onSuccess/onError toast
|
|
}
|
|
|
|
return (
|
|
<Form_Shadcn_ {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
<Card>
|
|
<CardContent>
|
|
<FormField_Shadcn_
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Name"
|
|
description="A descriptive name for this resource"
|
|
>
|
|
<FormControl_Shadcn_>
|
|
<Input_Shadcn_ {...field} placeholder="Enter name" />
|
|
</FormControl_Shadcn_>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
<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>
|
|
</Card>
|
|
</form>
|
|
</Form_Shadcn_>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 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
|
|
- Cards can have sections with CardContent
|
|
- Use CardFooter for actions
|
|
- Only use CardHeader and CardTitle if the card content has not been described by the surrounding content e.g. Page title or ScaffoldSectionTitle
|
|
- Use CardHeader and CardTitle when you are using multiple Cards to group related pieces of content e.g. Primary branch, Persistent branches, Preview branches
|
|
|
|
## 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
|
|
|
|
- When doing a mutation, always use the mutate function. Always use onSuccess and onError with a toast.success and toast.error.
|
|
- Use mutateAsync only if the mutation is part of multiple async actions. Wrap the mutateAsync call with try/catch block and add toast.success and toast.error.
|
|
|
|
## Tables
|
|
|
|
- Use the generic ui table components for most tables
|
|
- Tables are generally contained witin a card
|
|
- If a table has associated actions, they should go above on right hand side
|
|
- If a table has associated search or filters, they should go above on left hand side
|
|
- If a table is the main content of a page, and it does not have search or filters, you can add table actions to primary and secondary actions of PageLayout
|
|
- If a table is the main content of a page section, and it does not have search or filters, you can add table actions to the right of ScaffoldSectionTitle
|
|
- For simple lists of objects you can use ResourceList with ResourceListItem instead
|
|
|
|
### Table example
|
|
|
|
```jsx
|
|
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
|
;<Table>
|
|
<TableCaption>A list of your recent invoices.</TableCaption>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[100px]">Invoice</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Method</TableHead>
|
|
<TableHead className="text-right">Amount</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<TableRow>
|
|
<TableCell className="font-medium">INV001</TableCell>
|
|
<TableCell>Paid</TableCell>
|
|
<TableCell>Credit Card</TableCell>
|
|
<TableCell className="text-right">$250.00</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
```
|
|
|
|
## Alerts
|
|
|
|
- Use Admonition component to alert users of important actions or restrictions in place
|
|
- Place the Admonition either at the top of the contents of the page (below page title) or at the top of the related ScaffoldSection , below ScaffoldTitle
|
|
- Use sparingly
|
|
|
|
### Alert example
|
|
|
|
```jsx
|
|
<Admonition
|
|
type="note"
|
|
title="No authentication logs available for this user"
|
|
description="Auth events such as logging in will be shown here"
|
|
/>
|
|
```
|