mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Merge branch 'master' into feat/storage-file-selector
This commit is contained in:
571 files changed
+67321
-33688
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# Supabase Monorepo
|
||||
|
||||
pnpm 10 + Turborepo monorepo. Requires Node >= 22.
|
||||
|
||||
## Structure
|
||||
|
||||
| Directory | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 18 |
|
||||
| `apps/docs` | Documentation site |
|
||||
| `apps/www` | Marketing website |
|
||||
| `packages/ui` | Shared UI components (shadcn/ui based) |
|
||||
| `packages/common` | Shared utilities and telemetry constants |
|
||||
| `e2e/studio` | Playwright E2E tests for Studio |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm dev:studio # run Studio dev server
|
||||
pnpm test:studio # run Studio unit tests (vitest)
|
||||
pnpm --prefix e2e/studio run e2e # run Studio E2E tests (playwright)
|
||||
pnpm build --filter=studio # build Studio
|
||||
pnpm lint --filter=studio # lint Studio
|
||||
pnpm typecheck # typecheck all packages
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
**UI** — import from `'ui'`, use `_Shadcn_` suffixed variants for form primitives. Check `packages/ui/index.tsx` before creating new primitives.
|
||||
|
||||
**Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors.
|
||||
|
||||
## Studio
|
||||
|
||||
Pages router. Co-locate sub-components with parent. Avoid barrel re-export files.
|
||||
|
||||
See studio-\* skills for detailed studio conventions.
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: studio-best-practices
|
||||
description: React and TypeScript best practices for Supabase Studio. Use when writing
|
||||
or reviewing Studio components — covers boolean naming, component structure, loading/error
|
||||
states, state management, custom hooks, event handlers, conditional rendering,
|
||||
performance, and TypeScript conventions.
|
||||
---
|
||||
|
||||
# Studio Best Practices
|
||||
|
||||
Applies to `apps/studio/**/*.{ts,tsx}`.
|
||||
|
||||
## Boolean Naming
|
||||
|
||||
Use descriptive prefixes — derive from existing state rather than storing separately:
|
||||
|
||||
- `is` — state/identity: `isLoading`, `isPaused`, `isNewRecord`
|
||||
- `has` — possession: `hasPermission`, `hasData`
|
||||
- `can` — capability: `canUpdateColumns`, `canDelete`
|
||||
- `should` — conditional behavior: `shouldFetch`, `shouldRender`
|
||||
|
||||
Extract complex conditions into named variables:
|
||||
|
||||
```tsx
|
||||
// ❌ inline multi-condition
|
||||
{
|
||||
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && <Button />
|
||||
}
|
||||
|
||||
// ✅ named variable
|
||||
const canShowAddButton =
|
||||
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading
|
||||
{
|
||||
canShowAddButton && <Button />
|
||||
}
|
||||
```
|
||||
|
||||
Derive booleans — don't store them:
|
||||
|
||||
```tsx
|
||||
// ❌ stored derived state
|
||||
const [isFormValid, setIsFormValid] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsFormValid(name.length > 0 && email.includes('@'))
|
||||
}, [name, email])
|
||||
|
||||
// ✅ derived
|
||||
const isFormValid = name.length > 0 && email.includes('@')
|
||||
```
|
||||
|
||||
## Component Structure
|
||||
|
||||
See `vercel-composition-patterns` skill for compound component and composition patterns.
|
||||
|
||||
Keep components under 200–300 lines. Split when you see:
|
||||
|
||||
- Multiple distinct UI sections
|
||||
- Complex conditional rendering
|
||||
- Multiple unrelated `useState` calls
|
||||
- Hard to understand at a glance
|
||||
|
||||
Co-locate sub-components in the same directory as the parent. Avoid barrel re-export files.
|
||||
|
||||
Extract repeated JSX patterns into small components.
|
||||
|
||||
## Data Fetching
|
||||
|
||||
All data fetching uses TanStack Query (React Query). See `studio-queries` skill for query/mutation patterns and `studio-error-handling` skill for error display conventions.
|
||||
|
||||
### Loading / Error / Success Pattern
|
||||
|
||||
Top level:
|
||||
|
||||
```tsx
|
||||
const { data, error, isLoading, isError, isSuccess } = useQuery(...)
|
||||
|
||||
if (isLoading) return <GenericSkeletonLoader />
|
||||
if (isError) return <AlertError error={error} subject="Failed to load data" />
|
||||
if (isSuccess && data.length === 0) return <EmptyState />
|
||||
return <DataDisplay data={data} />
|
||||
```
|
||||
|
||||
Use early returns — avoid deeply nested conditionals.
|
||||
|
||||
Inline:
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
{isLoading && <InlineLoader />}
|
||||
{isError && <InlineError error={error} />}
|
||||
{isSuccess && data.length === 0 && <EmptyState />}
|
||||
{isSuccess && data.length > 0 && <DataDisplay data={data} />}
|
||||
</div>
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Keep state as local as possible; lift only when needed.
|
||||
|
||||
Group related form state with `react-hook-form` rather than multiple `useState` calls. See `studio-ui-patterns` skill for form layout and component conventions.
|
||||
|
||||
```tsx
|
||||
// ❌ multiple related useState
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
// ✅ grouped with react-hook-form
|
||||
const form = useForm<FormValues>({ defaultValues: { name: '', email: '' } })
|
||||
```
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
Extract complex or reusable logic into hooks. Return objects, not arrays:
|
||||
|
||||
```tsx
|
||||
// ❌ array return (hard to extend)
|
||||
return [value, toggle]
|
||||
|
||||
// ✅ object return
|
||||
return { value, toggle, setTrue, setFalse }
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
- Prop callbacks: `on` prefix (`onClose`, `onSave`)
|
||||
- Internal handlers: `handle` prefix (`handleSubmit`, `handleCancel`)
|
||||
|
||||
Use `useCallback` for handlers passed to memoized children; avoid unnecessary inline arrow functions.
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```tsx
|
||||
// Simple show/hide
|
||||
<>{isVisible && <Component />}</>
|
||||
|
||||
// Binary choice
|
||||
<>{isLoading ? <Spinner /> : <Content />}</>
|
||||
|
||||
// Multiple conditions — use early returns, not nested ternaries
|
||||
if (isLoading) return <Spinner />
|
||||
if (isError) return <Error />
|
||||
return <Content />
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
`useMemo` for genuinely expensive computations (measured, not assumed). Don't wrap everything — only optimize when you have a measured problem or are passing values to memoized children.
|
||||
|
||||
## TypeScript
|
||||
|
||||
Define prop interfaces explicitly. Use discriminated unions for complex state:
|
||||
|
||||
```tsx
|
||||
type AsyncState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error }
|
||||
```
|
||||
|
||||
Avoid `as any` / `as Type` casts. Validate at boundaries with zod:
|
||||
|
||||
```tsx
|
||||
// ❌ type cast
|
||||
const user = apiResponse as User
|
||||
|
||||
// ✅ zod parse
|
||||
const user = userSchema.parse(apiResponse)
|
||||
// or safe:
|
||||
const result = userSchema.safeParse(apiResponse)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Extract logic into `.utils.ts` pure functions and test exhaustively. See the `studio-testing` skill for the full testing strategy and decision tree.
|
||||
+204
-2
@@ -1,6 +1,9 @@
|
||||
---
|
||||
name: e2e-studio-tests
|
||||
description: Run e2e tests in the Studio app. Use when asked to run e2e tests, run studio tests, playwright tests, or test the feature.
|
||||
name: studio-e2e-tests
|
||||
description: Write and run Playwright E2E tests for Supabase Studio. Use when asked
|
||||
to run e2e tests, write new E2E tests, or debug flaky tests. Covers running commands,
|
||||
avoiding race conditions, waiting strategies, selectors, helper functions, and CI
|
||||
vs local differences.
|
||||
---
|
||||
|
||||
# E2E Studio Tests
|
||||
@@ -70,17 +73,20 @@ test.describe.configure({ mode: 'serial' })
|
||||
### Selector priority (best to worst)
|
||||
|
||||
1. **`getByRole` with accessible name** - Most robust, tests accessibility
|
||||
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Save' })
|
||||
page.getByRole('button', { name: 'Configure API privileges' })
|
||||
```
|
||||
|
||||
2. **`getByTestId`** - Stable, explicit test hooks
|
||||
|
||||
```typescript
|
||||
page.getByTestId('table-editor-side-panel')
|
||||
```
|
||||
|
||||
3. **`getByText` with exact match** - Good for unique text
|
||||
|
||||
```typescript
|
||||
page.getByText('Data API Access', { exact: true })
|
||||
```
|
||||
@@ -93,12 +99,14 @@ test.describe.configure({ mode: 'serial' })
|
||||
### Patterns to avoid
|
||||
|
||||
- **XPath selectors** - Fragile to DOM changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
locator('xpath=ancestor::div[contains(@class, "space-y")]')
|
||||
```
|
||||
|
||||
- **Parent traversal with `locator('..')`** - Breaks when structure changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
element.locator('..').getByRole('button')
|
||||
@@ -123,6 +131,7 @@ When a component lacks a good accessible name, add one in the source code:
|
||||
```
|
||||
|
||||
Then use it in tests:
|
||||
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Configure API privileges' })
|
||||
```
|
||||
@@ -141,6 +150,76 @@ const popover = page.locator('[data-radix-popper-content-wrapper]')
|
||||
const roleSection = popover.getByText('Anonymous (anon)', { exact: true })
|
||||
```
|
||||
|
||||
## Avoiding Race Conditions
|
||||
|
||||
**Set up API waiters BEFORE triggering actions.** This is the most common source of flaky tests.
|
||||
|
||||
```ts
|
||||
// ❌ Race condition — response may complete before waiter is set up
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
|
||||
// ✅ Waiter is ready before the action
|
||||
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await apiPromise
|
||||
```
|
||||
|
||||
Same rule applies before navigation:
|
||||
|
||||
```ts
|
||||
const loadPromise = waitForTableToLoad(page, ref)
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await loadPromise
|
||||
```
|
||||
|
||||
When an action triggers multiple API calls, wait for all of them:
|
||||
|
||||
```ts
|
||||
const createTablePromise = waitForApiResponseWithTimeout(page, (r) =>
|
||||
r.url().includes('query?key=table-create')
|
||||
)
|
||||
const tablesPromise = waitForApiResponseWithTimeout(page, (r) =>
|
||||
r.url().includes('tables?include_columns=true')
|
||||
)
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await Promise.all([createTablePromise, tablesPromise])
|
||||
```
|
||||
|
||||
## Waiting Strategies
|
||||
|
||||
Playwright auto-waits for elements to be actionable — prefer this over manual timeouts.
|
||||
|
||||
Use `expect.poll` for dynamic state changes:
|
||||
|
||||
```ts
|
||||
await expect.poll(async () => await page.getByLabel(`View ${tableName}`).count()).toBe(0)
|
||||
```
|
||||
|
||||
Use `waitForSelector` with state for element lifecycle:
|
||||
|
||||
```ts
|
||||
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
|
||||
```
|
||||
|
||||
Avoid `networkidle` — use specific API waits instead:
|
||||
|
||||
```ts
|
||||
// ❌ Unreliable and slow
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// ✅ Specific API response
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'tables')
|
||||
```
|
||||
|
||||
Timeouts are acceptable only for client-side debounces:
|
||||
|
||||
```ts
|
||||
await page.getByRole('textbox').fill('search term')
|
||||
await page.waitForTimeout(300) // allow debounce
|
||||
```
|
||||
|
||||
## Avoiding `waitForTimeout`
|
||||
|
||||
Never use `waitForTimeout` - always wait for something specific:
|
||||
@@ -175,6 +254,129 @@ await expect(menuButton).toBeVisible()
|
||||
await menuButton.click()
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Always import from the custom test utility:
|
||||
|
||||
```ts
|
||||
import { test } from '../utils/test.js'
|
||||
```
|
||||
|
||||
Use `withFileOnceSetup` for expensive setup that should run once per file:
|
||||
|
||||
```ts
|
||||
test.beforeAll(async ({ browser, ref }) => {
|
||||
await withFileOnceSetup(import.meta.url, async () => {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await deleteTestTables(page, ref)
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await releaseFileOnceCleanup(import.meta.url)
|
||||
})
|
||||
```
|
||||
|
||||
Dismiss toasts before interacting — they can overlay buttons:
|
||||
|
||||
```ts
|
||||
const dismissToastsIfAny = async (page: Page) => {
|
||||
const closeButtons = page.getByRole('button', { name: 'Close toast' })
|
||||
const count = await closeButtons.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await closeButtons.nth(i).click()
|
||||
}
|
||||
}
|
||||
|
||||
await dismissToastsIfAny(page)
|
||||
await page.getByRole('button', { name: 'New table' }).click()
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
Always include descriptive messages for easier debugging:
|
||||
|
||||
```ts
|
||||
// ❌ No context on failure
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
|
||||
// ✅ Clear message on failure
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save' }),
|
||||
'Save button should be visible after form is filled'
|
||||
).toBeVisible()
|
||||
```
|
||||
|
||||
Use explicit timeouts for slow operations:
|
||||
|
||||
```ts
|
||||
await expect(
|
||||
page.getByText(`Table ${tableName} is good to go!`),
|
||||
'Success toast should be visible after table creation'
|
||||
).toBeVisible({ timeout: 50000 })
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
Extract reusable operations into domain helpers (e.g. `e2e/studio/utils/storage-helpers.ts`).
|
||||
Use the existing wait utilities:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createApiResponseWaiter,
|
||||
waitForApiResponse,
|
||||
waitForGridDataToLoad,
|
||||
waitForTableToLoad,
|
||||
} from '../utils/wait-for-response.js'
|
||||
```
|
||||
|
||||
Use `expectClipboardValue` instead of manual clipboard reads with hardcoded timeouts:
|
||||
|
||||
```ts
|
||||
// ❌ Brittle
|
||||
await page.evaluate(() => navigator.clipboard.readText())
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// ✅ Uses Playwright auto-retries
|
||||
await expectClipboardValue({ page, value: 'expectedValue' })
|
||||
```
|
||||
|
||||
## API Mocking
|
||||
|
||||
```ts
|
||||
await page.route('*/**/logs.all*', async (route) => {
|
||||
await route.fulfill({ body: JSON.stringify(mockAPILogs) })
|
||||
})
|
||||
```
|
||||
|
||||
Use soft waits for optional API calls:
|
||||
|
||||
```ts
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
|
||||
soft: true,
|
||||
fallbackWaitMs: 1000,
|
||||
})
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
Clean up test data in `beforeAll`/`beforeEach`. Check before deleting to handle existing state gracefully:
|
||||
|
||||
```ts
|
||||
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
||||
if ((await bucketRow.count()) === 0) return
|
||||
// proceed with deletion
|
||||
```
|
||||
|
||||
Reset local storage after tests that modify it:
|
||||
|
||||
```ts
|
||||
import { resetLocalStorage } from '../utils/reset-local-storage.js'
|
||||
|
||||
await resetLocalStorage(page, ref)
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### View trace
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: studio-queries
|
||||
description: React Query conventions for data fetching in Supabase Studio. Use when
|
||||
writing or reviewing query hooks, mutation hooks, or query keys in apps/studio/data/.
|
||||
Covers queryOptions pattern, keys.ts structure, mutation hook template, and imperative
|
||||
fetching.
|
||||
---
|
||||
|
||||
# Studio Queries & Mutations (React Query)
|
||||
|
||||
Follow the patterns in `apps/studio/data/`. Reference examples:
|
||||
|
||||
- Query options: `apps/studio/data/table-editor/table-editor-query.ts`
|
||||
- Mutation hook: `apps/studio/data/edge-functions/edge-functions-update-mutation.ts`
|
||||
- Keys: `apps/studio/data/edge-functions/keys.ts`
|
||||
|
||||
## Query Keys
|
||||
|
||||
Define a `keys.ts` per domain. Export `*Keys` helpers using array keys with `as const`. Never inline query keys in components.
|
||||
|
||||
```ts
|
||||
export const edgeFunctionsKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
|
||||
detail: (projectRef: string | undefined, slug: string | undefined) =>
|
||||
['projects', projectRef, 'edge-function', slug, 'detail'] as const,
|
||||
}
|
||||
```
|
||||
|
||||
## Query Options (preferred pattern)
|
||||
|
||||
Use `queryOptions` from `@tanstack/react-query`. This gives type safety and works with both `useQuery()` and `queryClient.fetchQuery()`.
|
||||
|
||||
Rules:
|
||||
|
||||
- Export `XVariables`, `XData`, and `XError` types (prefixed with the domain name)
|
||||
- Implement a **private** `getX(variables, signal?)` function:
|
||||
- Throws if required variables are missing
|
||||
- Passes `signal` for cancellation
|
||||
- Calls `handleError(error)` on failure (which throws); returns `data` on success
|
||||
- Not exported — use `queryClient.fetchQuery(xQueryOptions(...))` for imperative fetching
|
||||
- Export `xQueryOptions()` using `queryOptions`
|
||||
- Gate with `enabled` so the query doesn't run until required variables exist
|
||||
- Platform-only queries: include `IS_PLATFORM` from `lib/constants` in `enabled`
|
||||
- Don't add extra params to `xQueryOptions` — callers override by destructuring: `{ ...xQueryOptions(vars), enabled: true }`
|
||||
|
||||
```ts
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { xKeys } from './keys'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { ResponseError } from '@/types'
|
||||
|
||||
export type XVariables = { projectRef?: string }
|
||||
export type XError = ResponseError
|
||||
|
||||
async function getX({ projectRef }: XVariables, signal?: AbortSignal) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
const { data, error } = await get('/v1/projects/{ref}/x', {
|
||||
params: { path: { ref: projectRef } },
|
||||
signal,
|
||||
})
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type XData = Awaited<ReturnType<typeof getX>>
|
||||
|
||||
export const xQueryOptions = ({ projectRef }: XVariables) =>
|
||||
queryOptions({
|
||||
queryKey: xKeys.list(projectRef),
|
||||
queryFn: ({ signal }) => getX({ projectRef }, signal),
|
||||
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
|
||||
})
|
||||
```
|
||||
|
||||
## Using Query Options in Components
|
||||
|
||||
```ts
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { xQueryOptions } from '@/data/x/x-query'
|
||||
|
||||
const { data, isPending, isError } = useQuery(xQueryOptions({ projectRef: project?.ref }))
|
||||
```
|
||||
|
||||
## Imperative Fetching (outside React or in callbacks)
|
||||
|
||||
```ts
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (id: number) => {
|
||||
const data = await queryClient.fetchQuery(xQueryOptions({ id, projectRef: project?.ref }))
|
||||
// use data...
|
||||
},
|
||||
[project?.ref, queryClient]
|
||||
)
|
||||
```
|
||||
|
||||
## Mutation Hook
|
||||
|
||||
- Export a `Variables` type with `projectRef`, identifiers, and `payload`
|
||||
- Implement a private `updateX(vars)` function with required variable validation and `handleError`
|
||||
- Wrap in `useXMutation()`:
|
||||
- Accepts `UseMutationOptions` (omit `mutationFn`)
|
||||
- Invalidates `list()` + `detail()` keys in `onSuccess` with `await Promise.all([...])`
|
||||
- Defaults to `toast.error(...)` when `onError` isn't provided
|
||||
|
||||
```ts
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { xKeys } from './keys'
|
||||
|
||||
type XUpdateVariables = { projectRef: string; slug: string; payload: XPayload }
|
||||
|
||||
export const useXUpdateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<XData, XError, XUpdateVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: updateX,
|
||||
async onSuccess(data, variables, context) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: xKeys.detail(variables.projectRef, variables.slug),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: xKeys.list(variables.projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to update: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Component Usage
|
||||
|
||||
- Use React Query v5 flags: `isPending` for initial load, `isFetching` for background refetches
|
||||
- Render states explicitly in order: pending → error → success
|
||||
@@ -68,36 +68,108 @@ Is the logic a pure transformation (parse, format, validate, compute)?
|
||||
NO -> Write a component test
|
||||
```
|
||||
|
||||
## How to Use
|
||||
## 1. Extract Logic Into Utility Files (CRITICAL)
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
Remove as much logic from components as possible. Put it in co-located
|
||||
`.utils.ts` files as pure functions: arguments in, return value out.
|
||||
|
||||
```
|
||||
rules/testing-extract-logic.md
|
||||
rules/testing-exhaustive-permutations.md
|
||||
**File naming:**
|
||||
|
||||
- Utility: `ComponentName.utils.ts` next to the component
|
||||
- Test: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
|
||||
|
||||
```tsx
|
||||
// ❌ Logic buried in component — hard to test without rendering
|
||||
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
|
||||
const handleSubmit = () => {
|
||||
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
|
||||
let sanitized = taxIdValue
|
||||
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
|
||||
sanitized = taxId.vatPrefix + taxIdValue
|
||||
}
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
return <form onSubmit={handleSubmit}>...</form>
|
||||
}
|
||||
|
||||
// ✅ Logic extracted to .utils.ts — trivially testable
|
||||
// TaxID.utils.ts
|
||||
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
|
||||
const taxId = TAX_IDS.find((t) => t.name === name)
|
||||
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
|
||||
return taxId.vatPrefix + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// TaxIdForm.tsx — thin shell
|
||||
const handleSubmit = () => {
|
||||
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
## 2. Test Every Permutation (CRITICAL)
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Real codebase references
|
||||
Once logic is extracted, test exhaustively. Every code path needs a test:
|
||||
|
||||
## Full Compiled Document
|
||||
- Valid inputs (happy path for each branch)
|
||||
- Invalid / malformed inputs
|
||||
- Empty values, null values, missing fields
|
||||
- Edge cases (timestamps with colons, special characters, boundary values)
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
```ts
|
||||
// ❌ Only happy path
|
||||
test('parses a filter', () => {
|
||||
expect(formatFilterURLParams('id:gte:20')).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
|
||||
})
|
||||
|
||||
// ✅ Every permutation
|
||||
test('parses valid filter', () => { ... })
|
||||
test('handles timestamp with colons in value', () => { ... })
|
||||
test('rejects malformed filter with missing parts', () => { ... })
|
||||
test('rejects unrecognized operator', () => { ... })
|
||||
test('allows empty filter value', () => { ... })
|
||||
```
|
||||
|
||||
## 3. Component Tests for Complex UI Only (HIGH)
|
||||
|
||||
Only write component tests when there is complex UI interaction logic that
|
||||
cannot be captured by testing utility functions alone.
|
||||
|
||||
**Valid reasons:** conditional rendering from user interaction sequences,
|
||||
popover open/close with keyboard/mouse, multi-step form transitions.
|
||||
|
||||
**Not valid:** testing a calculation or transformation that happens to live
|
||||
in a component — extract to `.utils.ts` and unit test instead.
|
||||
|
||||
```tsx
|
||||
// Studio component test conventions
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { customRender } from 'tests/lib/custom-render' // always use customRender, not raw render
|
||||
import { addAPIMock } from 'tests/lib/msw' // API mocking in beforeEach
|
||||
```
|
||||
|
||||
## 4. E2E Tests for Shared Features (HIGH)
|
||||
|
||||
If a feature exists in both self-hosted and platform, create an E2E test.
|
||||
Cover mouse clicks AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys).
|
||||
|
||||
Extract reusable interactions into `e2e/studio/utils/*-helpers.ts`. Use
|
||||
try/finally for resource cleanup. For E2E execution details, see the
|
||||
`studio-e2e-tests` skill.
|
||||
|
||||
## Codebase References
|
||||
|
||||
| What | Where |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Util test examples | `tests/components/Grid/Grid.utils.test.ts`, `tests/components/Billing/TaxID.utils.test.ts`, `tests/components/Editor/SpreadsheetImport.utils.test.ts` |
|
||||
| Component test examples | `tests/features/logs/LogsFilterPopover.test.tsx`, `tests/components/CopyButton.test.tsx` |
|
||||
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
|
||||
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
|
||||
| Custom render | `tests/lib/custom-render.tsx` |
|
||||
| MSW mock setup | `tests/lib/msw.ts` (`addAPIMock`) |
|
||||
| Test README | `tests/README.md` |
|
||||
| Vitest config | `vitest.config.ts` |
|
||||
| Related skills | `e2e-studio-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
|
||||
| What | Where |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Util test examples | `apps/studio/tests/components/Grid/Grid.utils.test.ts`, `apps/studio/tests/components/Billing/TaxID.utils.test.ts`, `apps/studio/tests/components/Editor/SpreadsheetImport.utils.test.ts` |
|
||||
| Component test examples | `apps/studio/tests/features/logs/LogsFilterPopover.test.tsx`, `apps/studio/tests/components/CopyButton.test.tsx` |
|
||||
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
|
||||
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
|
||||
| Custom render | `apps/studio/tests/lib/custom-render.tsx` |
|
||||
| MSW mock setup | `apps/studio/tests/lib/msw.ts` (`addAPIMock`) |
|
||||
| Test README | `apps/studio/tests/README.md` |
|
||||
| Vitest config | `apps/studio/vitest.config.ts` |
|
||||
| Related skills | `studio-e2e-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: Component Tests Are for Complex UI Logic Only
|
||||
impact: HIGH
|
||||
impactDescription: prevents slow, brittle tests that should be unit tests
|
||||
tags: testing, components, ui, react
|
||||
---
|
||||
|
||||
## Component Tests Are for Complex UI Logic Only
|
||||
|
||||
Only write component tests (`.test.tsx`) when there is complex UI interaction
|
||||
logic that cannot be captured by testing utility functions alone.
|
||||
|
||||
**Valid reasons for a component test:**
|
||||
|
||||
- Conditional rendering based on user interaction sequences
|
||||
- Popover/dropdown open/close behavior with keyboard and mouse
|
||||
- Form state transitions across multiple steps
|
||||
- Components that coordinate multiple async operations visually
|
||||
|
||||
**Not a valid reason:** testing a calculation, transformation, parsing, or
|
||||
validation that happens to live inside a component. Extract that logic into a
|
||||
`.utils.ts` file and unit test it instead.
|
||||
|
||||
**Incorrect (rendering a component just to test logic):**
|
||||
|
||||
```tsx
|
||||
test('formats the display value correctly', () => {
|
||||
render(<PriceDisplay amount={1234} currency="USD" />)
|
||||
expect(screen.getByText('$12.34')).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
This is really testing a formatting function. Extract it:
|
||||
|
||||
```ts
|
||||
// PriceDisplay.utils.ts
|
||||
export function formatPrice(amount: number, currency: string): string { ... }
|
||||
|
||||
// PriceDisplay.utils.test.ts
|
||||
test('formats USD cents to dollars', () => {
|
||||
expect(formatPrice(1234, 'USD')).toBe('$12.34')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (component test for real UI interaction logic):**
|
||||
|
||||
```tsx
|
||||
// Testing popover open/close, filter application, keyboard dismiss
|
||||
describe('LogsFilterPopover', () => {
|
||||
test('opens popover and shows filter options', async () => {
|
||||
customRender(<LogsFilterPopover onFiltersChange={vi.fn()} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByText('Apply')).toBeVisible()
|
||||
})
|
||||
|
||||
test('applies selected filters on submit', async () => {
|
||||
const onChange = vi.fn()
|
||||
customRender(<LogsFilterPopover onFiltersChange={onChange} />)
|
||||
// ... interact with UI ...
|
||||
await userEvent.click(screen.getByText('Apply'))
|
||||
expect(onChange).toHaveBeenCalledWith(expectedFilters)
|
||||
})
|
||||
|
||||
test('closes on Escape key', async () => {
|
||||
customRender(<LogsFilterPopover onFiltersChange={vi.fn()} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(screen.queryByText('Apply')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Studio component test conventions:**
|
||||
|
||||
```tsx
|
||||
// Always use customRender, not raw render
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
// Use userEvent for popovers, fireEvent for dropdowns
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { customRender } from 'tests/lib/custom-render'
|
||||
// Use addAPIMock for API mocking in beforeEach
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
```
|
||||
|
||||
See `tests/README.md` for full conventions on custom render, MSW mocking,
|
||||
and nuqs URL parameter testing.
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
title: E2E Tests for Self-Hosted and Platform Features
|
||||
impact: HIGH
|
||||
impactDescription: ensures critical shared features work across deployment targets
|
||||
tags: testing, e2e, playwright, self-hosted, platform
|
||||
---
|
||||
|
||||
## E2E Tests for Self-Hosted and Platform Features
|
||||
|
||||
If a feature exists in both self-hosted and the Supabase platform, create an
|
||||
E2E test to cover it. E2E tests live in `e2e/studio/features/*.spec.ts`.
|
||||
|
||||
**What to cover in E2E tests:**
|
||||
|
||||
- Mouse/click interactions AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys)
|
||||
- Full user flows end-to-end
|
||||
- Both adding and removing/clearing state
|
||||
- Setup and teardown (create resources in `try`, clean up in `finally`)
|
||||
|
||||
**Incorrect (only tests mouse clicks):**
|
||||
|
||||
```ts
|
||||
test('can add a filter', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Add filter' }).click()
|
||||
await page.getByRole('option', { name: 'id' }).click()
|
||||
// ... only click-based interactions
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (covers clicks AND keyboard shortcuts):**
|
||||
|
||||
```ts
|
||||
test.describe('Basic Filter Operations', () => {
|
||||
test('can add a filter by clicking', async ({ page }) => {
|
||||
await addFilter(page, ref, 'id', 'equals', '1')
|
||||
await expect(page.getByTestId('filter-condition')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard Navigation - Freeform Input', () => {
|
||||
test('Enter selects column from suggestions', async ({ page }) => {
|
||||
await getFilterBarInput(page).press('Enter')
|
||||
await expect(page.getByTestId('operator-input')).toBeFocused()
|
||||
})
|
||||
|
||||
test('Backspace on empty input highlights last condition', async ({ page }) => {
|
||||
await addFilter(page, ref, 'id', 'equals', '1')
|
||||
await getFilterBarInput(page).press('Backspace')
|
||||
await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'true')
|
||||
})
|
||||
|
||||
test('Escape clears highlight', async ({ page }) => {
|
||||
// ...
|
||||
await getFilterBarInput(page).press('Escape')
|
||||
await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'false')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**E2E helper pattern:** Extract reusable interactions into helper files at
|
||||
`e2e/studio/utils/*-helpers.ts`:
|
||||
|
||||
```ts
|
||||
// e2e/studio/utils/filter-bar-helpers.ts
|
||||
export async function addFilter(page, ref, column, operator, value) {
|
||||
await selectColumnFilter(page, column)
|
||||
await selectOperator(page, column, operator)
|
||||
// ... fill value, wait for API response
|
||||
}
|
||||
|
||||
export async function setupFilterBarPage(page, ref, editorUrl) {
|
||||
await page.goto(editorUrl)
|
||||
await enableFilterBar(page)
|
||||
await page.reload()
|
||||
}
|
||||
```
|
||||
|
||||
This keeps spec files focused on assertions while helpers handle the
|
||||
interaction mechanics.
|
||||
|
||||
**Always use try/finally for resource cleanup:**
|
||||
|
||||
```ts
|
||||
test('filters the table', async ({ page, ref }) => {
|
||||
const tableName = await createTable(page, ref)
|
||||
try {
|
||||
await setupFilterBarPage(page, ref, editorUrl)
|
||||
await navigateToTable(page, ref, tableName)
|
||||
await addFilter(page, ref, 'id', 'equals', '1')
|
||||
// assertions...
|
||||
} finally {
|
||||
await dropTable(page, ref, tableName)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
For E2E execution details (running tests, selectors, debugging), use the
|
||||
`e2e-studio-tests` skill.
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
title: Test Every Permutation of Utility Functions
|
||||
impact: CRITICAL
|
||||
impactDescription: catches edge cases and regressions in business logic
|
||||
tags: testing, utils, coverage, permutations
|
||||
---
|
||||
|
||||
## Test Every Permutation of Utility Functions
|
||||
|
||||
Once logic is extracted into a pure function, test it exhaustively. Every code
|
||||
path should have a test. Don't just test the happy path.
|
||||
|
||||
**What to cover:**
|
||||
|
||||
- Valid inputs (happy path for each branch)
|
||||
- Invalid / malformed inputs
|
||||
- Empty values, null values, missing fields
|
||||
- Edge cases (timestamps with colons, special characters, boundary values)
|
||||
- Security-sensitive inputs (XSS payloads, external URLs) where relevant
|
||||
|
||||
**Incorrect (only tests the happy path):**
|
||||
|
||||
```ts
|
||||
describe('formatFilterURLParams', () => {
|
||||
test('parses a filter', () => {
|
||||
const result = formatFilterURLParams('id:gte:20')
|
||||
expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (tests every permutation):**
|
||||
|
||||
```ts
|
||||
describe('formatFilterURLParams', () => {
|
||||
test('parses valid filter', () => {
|
||||
const result = formatFilterURLParams('id:gte:20')
|
||||
expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
|
||||
})
|
||||
|
||||
test('handles timestamp with colons in value', () => {
|
||||
const result = formatFilterURLParams('created:gte:2024-01-01T00:00:00')
|
||||
expect(result).toStrictEqual({
|
||||
column: 'created',
|
||||
operator: 'gte',
|
||||
value: '2024-01-01T00:00:00',
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects malformed filter with missing parts', () => {
|
||||
const result = formatFilterURLParams('id')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('rejects unrecognized operator', () => {
|
||||
const result = formatFilterURLParams('id:nope:20')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('allows empty filter value', () => {
|
||||
const result = formatFilterURLParams('name:eq:')
|
||||
expect(result).toStrictEqual({ column: 'name', operator: 'eq', value: '' })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Another real example -- `inferColumnType` tests every data type:**
|
||||
|
||||
```ts
|
||||
describe('inferColumnType', () => {
|
||||
test('defaults to text for empty data', () => { ... })
|
||||
test('defaults to text for missing column', () => { ... })
|
||||
test('defaults to text for null values', () => { ... })
|
||||
test('detects integer', () => { ... }) // "42" -> int8
|
||||
test('detects float', () => { ... }) // "161.72" -> float8
|
||||
test('detects boolean', () => { ... }) // "true"/"false" -> bool
|
||||
test('detects boolean with nulls', () => { ... })
|
||||
test('detects JSON object', () => { ... }) // "{}" -> jsonb
|
||||
test('detects timestamp', () => { ... }) // multiple formats -> timestamptz
|
||||
})
|
||||
```
|
||||
|
||||
The goal: if someone changes the function, at least one test should break for
|
||||
any behavioral change.
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
title: Extract Logic Into Utility Files
|
||||
impact: CRITICAL
|
||||
impactDescription: makes business logic trivially testable without rendering components
|
||||
tags: testing, utils, extraction, pure-functions
|
||||
---
|
||||
|
||||
## Extract Logic Into Utility Files
|
||||
|
||||
Remove as much logic from components as possible. Put it in co-located
|
||||
`.utils.ts` files as pure functions: arguments in, return value out. No React
|
||||
hooks, no context, no side effects.
|
||||
|
||||
**File naming convention:**
|
||||
|
||||
- Utility file: `ComponentName.utils.ts` next to the component
|
||||
- Test file: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
|
||||
- Or under `tests/unit/` for non-component utilities
|
||||
|
||||
**Incorrect (logic buried inside a component):**
|
||||
|
||||
```tsx
|
||||
// components/Billing/TaxIdForm.tsx
|
||||
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
|
||||
const handleSubmit = () => {
|
||||
// Logic buried in the component -- hard to test without rendering
|
||||
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
|
||||
let sanitized = taxIdValue
|
||||
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
|
||||
sanitized = taxId.vatPrefix + taxIdValue
|
||||
}
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
|
||||
return <form onSubmit={handleSubmit}>...</form>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (logic extracted to a utility file):**
|
||||
|
||||
```ts
|
||||
// components/Billing/TaxID.utils.ts
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
|
||||
// Pure function: args in, return out
|
||||
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
|
||||
const taxId = TAX_IDS.find((t) => t.name === name)
|
||||
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
|
||||
return taxId.vatPrefix + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/Billing/TaxIdForm.tsx
|
||||
import { sanitizeTaxIdValue } from './TaxID.utils'
|
||||
|
||||
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
|
||||
const handleSubmit = () => {
|
||||
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
return <form onSubmit={handleSubmit}>...</form>
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// tests/components/Billing/TaxID.utils.test.ts
|
||||
import { sanitizeTaxIdValue } from 'components/.../TaxID.utils'
|
||||
|
||||
describe('sanitizeTaxIdValue', () => {
|
||||
test('prefixes unprefixed EU tax ID', () => {
|
||||
expect(sanitizeTaxIdValue({ value: '12345678', name: 'AT VAT' })).toBe('ATU12345678')
|
||||
})
|
||||
|
||||
test('passes through already-prefixed EU tax ID', () => {
|
||||
expect(sanitizeTaxIdValue({ value: 'ATU12345678', name: 'AT VAT' })).toBe('ATU12345678')
|
||||
})
|
||||
|
||||
test('passes through non-EU tax ID unchanged', () => {
|
||||
expect(sanitizeTaxIdValue({ value: '12-3456789', name: 'US EIN' })).toBe('12-3456789')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
The component becomes a thin shell that calls the utility. All business logic
|
||||
is testable without rendering anything.
|
||||
|
||||
**Real codebase examples:**
|
||||
|
||||
- `components/grid/SupabaseGrid.utils.ts` -- URL param parsing, used by 15+ components
|
||||
- `components/.../SpreadsheetImport/SpreadsheetImport.utils.tsx` -- CSV parsing, column type inference
|
||||
- `components/.../BillingCustomerData/TaxID.utils.ts` -- tax ID sanitization and comparison
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: studio-ui-patterns
|
||||
description: Design system UI patterns for Supabase Studio. Use when building or updating
|
||||
pages, forms, tables, charts, empty states, navigation, cards, alerts, or side panels
|
||||
(sheets). Covers layout selection, component choice, and placement conventions.
|
||||
---
|
||||
|
||||
# Studio UI Patterns
|
||||
|
||||
The Design System docs and demos are the source of truth. Always check the relevant
|
||||
demo file before composing new UI.
|
||||
|
||||
## Layout
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/layout.mdx`
|
||||
|
||||
Build pages with `PageContainer`, `PageHeader`, and `PageSection`.
|
||||
|
||||
| Content type | `size` |
|
||||
| ----------------- | ----------- |
|
||||
| Settings / config | `"default"` |
|
||||
| Lists / tables | `"large"` |
|
||||
| Full-screen views | `"full"` |
|
||||
|
||||
- If filters/search exist on a list page, align table actions with the filters (don't use `PageHeaderAside`/`PageSectionAside` for those actions)
|
||||
- If no filters, actions can go in `PageHeaderAside` or `PageSectionAside`
|
||||
|
||||
Demos: `page-layout-settings.tsx`, `page-layout-list.tsx`, `page-layout-list-simple.tsx`, `page-layout-detail.tsx`
|
||||
(all in `apps/design-system/registry/default/example/`)
|
||||
|
||||
## Forms
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/forms.mdx`
|
||||
|
||||
- Use `react-hook-form` + `zod`
|
||||
- Use `FormItemLayout` instead of manually composing `FormItem`/`FormLabel`/`FormMessage`/`FormDescription`
|
||||
- Wrap inputs with `FormControl_Shadcn_`; use `_Shadcn_` imports from `ui` for primitives
|
||||
|
||||
Layout selection:
|
||||
|
||||
| Context | Layout | Container |
|
||||
| ------------------------------------------ | ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| Page (settings/config) | `FormItemLayout layout="flex-row-reverse"` | `Card` (`CardContent` per field; `CardFooter` for actions) |
|
||||
| Side panel — wide | `FormItemLayout layout="horizontal"` | `SheetSection` |
|
||||
| Side panel — narrow (`size="sm"` or below) | `FormItemLayout layout="vertical"` | `SheetSection` |
|
||||
|
||||
Dirty state / submit:
|
||||
|
||||
- Destructure `isDirty` from `form.formState` to show Cancel and disable Save
|
||||
- Show loading on submit button via `loading` prop
|
||||
- If submit button is outside `<form>`, set a stable `formId` and use `form` prop on the button
|
||||
|
||||
Demos: `form-patterns-pagelayout.tsx`, `form-patterns-sidepanel.tsx`
|
||||
|
||||
## Tables
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/tables.mdx`
|
||||
|
||||
| Pattern | Use when |
|
||||
| ---------- | ----------------------------------------------------------------------- |
|
||||
| `Table` | Simple, static, semantic display |
|
||||
| Data Table | TanStack-powered; sorting, filtering, pagination; composed per use-case |
|
||||
| Data Grid | Virtualization, column resizing, or complex cell editing |
|
||||
|
||||
- Actions: above the table, aligned right
|
||||
- Search/filters: above the table, aligned left
|
||||
- If table is primary content with no filters, actions can live in the page's primary/secondary actions area
|
||||
|
||||
Demos: `table-demo.tsx`, `data-table-demo.tsx`, `data-grid-demo.tsx`
|
||||
|
||||
## Charts
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/charts.mdx`
|
||||
|
||||
- Use provided chart building blocks; avoid passing raw Recharts components to `ChartContent`
|
||||
- Use `useChart` context flags for loading/disabled states
|
||||
- Keep composition straightforward — avoid over-abstraction
|
||||
|
||||
Demos (in `apps/design-system/__registry__/default/block/`): `chart-composed-demo.tsx`, `chart-composed-basic.tsx`, `chart-composed-states.tsx`, `chart-composed-metrics.tsx`, `chart-composed-actions.tsx`, `chart-composed-table.tsx`
|
||||
|
||||
## Empty States
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/empty-states.mdx`
|
||||
|
||||
| Scenario | Pattern |
|
||||
| ------------------------ | ------------------------------------------------------------------- |
|
||||
| Initial / onboarding | Presentational empty state with value prop + clear next action |
|
||||
| Data-heavy lists | Informational empty state matching the list/table layout |
|
||||
| Zero results from search | Keep layout consistent with data state to avoid jarring transitions |
|
||||
| Missing route | Centered `Admonition` |
|
||||
|
||||
Demos: `empty-state-presentational-icon.tsx`, `empty-state-initial-state-informational.tsx`, `empty-state-zero-items-table.tsx`, `data-grid-empty-state.tsx`, `empty-state-missing-route.tsx`
|
||||
|
||||
## Navigation
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/navigation.mdx`
|
||||
|
||||
- Use `NavMenu` for a horizontal list of related views within a consistent page layout
|
||||
- Activating an item must trigger a **URL change** — no local-only tab state
|
||||
|
||||
## Cards
|
||||
|
||||
- Group related information in cards
|
||||
- `CardContent` for sections, `CardFooter` for actions
|
||||
- Only use `CardHeader`/`CardTitle` when context isn't already provided by surrounding content
|
||||
- Use headers/titles when multiple cards represent distinct groups (e.g. multiple settings sections)
|
||||
|
||||
## Alerts
|
||||
|
||||
- Use `Admonition` to call out important actions, restrictions, or critical context
|
||||
- Place at the **top of a page's content** (below page title) or **top of the relevant section** (below section title)
|
||||
- Use sparingly
|
||||
|
||||
## Sheets (Side Panels)
|
||||
|
||||
Use a `Sheet` when switching pages would be disruptive and the user needs to maintain context (e.g. selecting a row from a list to edit).
|
||||
|
||||
Structure:
|
||||
|
||||
- `SheetContent` with `size="lg"` for forms needing horizontal layout
|
||||
- Use `SheetHeader`, `SheetTitle`, `SheetSection`, `SheetFooter`
|
||||
- Submit/cancel actions go in `SheetFooter`
|
||||
|
||||
Forms in sheets:
|
||||
|
||||
- `layout="horizontal"` for wider sheets
|
||||
- `layout="vertical"` for narrow sheets (`size="sm"` or below)
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: use-static-effect-event
|
||||
description: useStaticEffectEvent hook in Supabase Studio — a userland polyfill for
|
||||
React's useEffectEvent. Use when you need to read latest state/props inside a useEffect
|
||||
without re-triggering it, or when stale closures in Effects are causing bugs.
|
||||
---
|
||||
|
||||
# useStaticEffectEvent
|
||||
|
||||
Located at `apps/studio/hooks/useStaticEffectEvent.ts`.
|
||||
|
||||
A userland polyfill for React's `useEffectEvent` (stable in React 19.2). It solves the stale closure problem: gives you a **stable callback** that always reads the latest props/state without those values triggering Effect re-runs.
|
||||
|
||||
## The Problem It Solves
|
||||
|
||||
Without it, you face two bad options inside `useEffect`:
|
||||
|
||||
1. **Add values to dependencies** → unnecessary Effect re-runs (teardown/reconnect)
|
||||
2. **Omit from dependencies** → stale closure bugs (outdated values)
|
||||
|
||||
```tsx
|
||||
// Problem: re-runs every time `theme` changes, even though we only
|
||||
// want to reconnect when `roomId` changes
|
||||
useEffect(() => {
|
||||
const connection = createConnection(roomId)
|
||||
connection.on('connected', () => {
|
||||
showNotification('Connected!', theme) // theme causes unwanted reconnects
|
||||
})
|
||||
return () => connection.disconnect()
|
||||
}, [roomId, theme])
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
1. Read latest state/props inside an Effect without re-triggering it
|
||||
2. Create stable callbacks that always use current values
|
||||
3. Avoid stale closures in event handlers used within Effects
|
||||
|
||||
### Pattern 1: Sync data without re-running on every change
|
||||
|
||||
```tsx
|
||||
const syncApiPrivileges = useStaticEffectEvent(() => {
|
||||
if (hasLoadedInitialData.current) return
|
||||
if (!apiAccessStatus.isSuccess) return
|
||||
if (!privilegesForTable) return
|
||||
|
||||
hasLoadedInitialData.current = true
|
||||
setPrivileges(privilegesForTable.privileges)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
syncApiPrivileges()
|
||||
}, [apiAccessStatus.status, syncApiPrivileges])
|
||||
```
|
||||
|
||||
### Pattern 2: Stable callbacks for async operations
|
||||
|
||||
```tsx
|
||||
const exportInternal = useStaticEffectEvent(
|
||||
async ({ bypassConfirmation }: { bypassConfirmation: boolean }) => {
|
||||
if (!params.enabled) return
|
||||
const { projectRef, connectionString, entity, totalRows } = params
|
||||
// complex async logic using latest params
|
||||
}
|
||||
)
|
||||
|
||||
// Stable reference — safe to use in useCallback
|
||||
const exportInDesiredFormat = useCallback(
|
||||
() => exportInternal({ bypassConfirmation: false }),
|
||||
[exportInternal]
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 3: Infinite scroll / pagination triggers
|
||||
|
||||
```tsx
|
||||
const fetchNext = useStaticEffectEvent(() => {
|
||||
if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(fetchNext, [lastItem, fetchNext])
|
||||
```
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
**Don't use it to hide legitimate dependencies:**
|
||||
|
||||
```tsx
|
||||
// ❌ Bad — roomId IS a legitimate dependency; this hides a bug
|
||||
const connect = useStaticEffectEvent(() => {
|
||||
const connection = createConnection(roomId)
|
||||
connection.connect()
|
||||
})
|
||||
useEffect(() => {
|
||||
connect()
|
||||
}, [connect]) // Won't reconnect when roomId changes!
|
||||
|
||||
// ✅ roomId belongs in deps
|
||||
useEffect(() => {
|
||||
const connection = createConnection(roomId)
|
||||
connection.connect()
|
||||
return () => connection.disconnect()
|
||||
}, [roomId])
|
||||
```
|
||||
|
||||
**Don't use it for simple event handlers outside Effects:**
|
||||
|
||||
```tsx
|
||||
// ❌ Unnecessary — not used inside an Effect
|
||||
const handleClick = useStaticEffectEvent(() => console.log(count))
|
||||
|
||||
// ✅ Regular function is fine
|
||||
const handleClick = () => console.log(count)
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Only call the returned function **inside Effects** (`useEffect`, `useLayoutEffect`)
|
||||
2. Don't pass it to other components or hooks as a callback prop
|
||||
3. Use for **non-reactive logic only** — reads values but shouldn't trigger re-runs
|
||||
4. **Include it in dependency arrays** when used in `useEffect` (it's stable, won't cause re-runs)
|
||||
|
||||
## How It Works
|
||||
|
||||
```tsx
|
||||
export const useStaticEffectEvent = <Callback extends Function>(callback: Callback) => {
|
||||
const callbackRef = useRef(callback)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
callbackRef.current = callback // always latest
|
||||
})
|
||||
|
||||
const eventFn = useCallback((...args: any) => {
|
||||
return callbackRef.current(...args)
|
||||
}, []) // stable reference
|
||||
|
||||
return eventFn as unknown as Callback
|
||||
}
|
||||
```
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: vercel-composition-patterns
|
||||
description:
|
||||
React composition patterns that scale. Use when refactoring components with
|
||||
description: React composition patterns that scale. Use when refactoring components with
|
||||
boolean prop proliferation, building flexible component libraries, or
|
||||
designing reusable APIs. Triggers on tasks involving compound components,
|
||||
render props, context providers, or component architecture. Includes React 19
|
||||
@@ -64,7 +63,7 @@ Reference these guidelines when:
|
||||
|
||||
### 4. React 19 APIs (MEDIUM)
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this section if using React 18 or earlier.
|
||||
> **⚠️ React 19+ only.** Supabase Studio currently uses React 18 — skip these patterns in Studio code.
|
||||
|
||||
- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
description: Guidelines for using the useStaticEffectEvent hook in Studio - a polyfill for React's useEffectEvent pattern
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# useStaticEffectEvent Hook
|
||||
|
||||
The `useStaticEffectEvent` hook (located at `apps/studio/hooks/useStaticEffectEvent.ts`) is a userland implementation of React's `useEffectEvent` pattern. It solves the stale closure problem by providing a stable callback reference that always accesses the latest props and state values.
|
||||
|
||||
## What Problem Does It Solve?
|
||||
|
||||
When using `useEffect`, you often need to access props or state inside your Effect, but you don't want changes to those values to re-run the Effect. Without `useStaticEffectEvent`, you'd face two bad options:
|
||||
|
||||
1. **Add them to dependencies** - causes unnecessary Effect re-runs (teardown/reconnect cycles)
|
||||
2. **Omit from dependencies** - causes stale closure bugs where your callback uses outdated values
|
||||
|
||||
```tsx
|
||||
// Problem: This Effect re-runs every time `theme` changes, even though
|
||||
// we only want to reconnect when `roomId` changes
|
||||
useEffect(() => {
|
||||
const connection = createConnection(roomId)
|
||||
connection.on('connected', () => {
|
||||
showNotification('Connected!', theme) // `theme` causes unwanted re-runs
|
||||
})
|
||||
return () => connection.disconnect()
|
||||
}, [roomId, theme]) // Adding theme causes unnecessary reconnections
|
||||
```
|
||||
|
||||
## When to Use useStaticEffectEvent
|
||||
|
||||
Use `useStaticEffectEvent` when you need to:
|
||||
|
||||
1. **Read latest state/props inside an Effect without re-triggering it**
|
||||
2. **Create stable callbacks that always use current values**
|
||||
3. **Avoid stale closure bugs in event handlers used within Effects**
|
||||
|
||||
### Pattern 1: Syncing data without re-running on every change
|
||||
|
||||
```tsx
|
||||
// ✅ Good - sync data when status changes, but always read latest state
|
||||
const syncApiPrivileges = useStaticEffectEvent(() => {
|
||||
if (hasLoadedInitialData.current) return
|
||||
if (!apiAccessStatus.isSuccess) return
|
||||
if (!privilegesForTable) return
|
||||
|
||||
hasLoadedInitialData.current = true
|
||||
setPrivileges(privilegesForTable.privileges)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
syncApiPrivileges()
|
||||
}, [apiAccessStatus.status, syncApiPrivileges])
|
||||
```
|
||||
|
||||
### Pattern 2: Stable callbacks for async operations
|
||||
|
||||
```tsx
|
||||
// ✅ Good - wrap complex async logic that reads many values
|
||||
const exportInternal = useStaticEffectEvent(
|
||||
async ({ bypassConfirmation }: { bypassConfirmation: boolean }): Promise<void> => {
|
||||
if (!params.enabled) return
|
||||
const { projectRef, connectionString, entity, totalRows } = params
|
||||
// ... complex async logic using latest params
|
||||
}
|
||||
)
|
||||
|
||||
// This callback is stable and can be safely used in useCallback
|
||||
const exportInDesiredFormat = useCallback(
|
||||
() => exportInternal({ bypassConfirmation: false }),
|
||||
[exportInternal]
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 3: Infinite scroll / pagination triggers
|
||||
|
||||
```tsx
|
||||
// ✅ Good - always read latest pagination state when scrolling triggers fetch
|
||||
const fetchNext = useStaticEffectEvent(() => {
|
||||
if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(fetchNext, [lastItem, fetchNext])
|
||||
```
|
||||
|
||||
## When NOT to Use useStaticEffectEvent
|
||||
|
||||
### Don't use it to avoid specifying legitimate dependencies
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - hiding the fact that this should re-run when roomId changes
|
||||
const connect = useStaticEffectEvent(() => {
|
||||
const connection = createConnection(roomId)
|
||||
connection.connect()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
connect() // BUG: Won't reconnect when roomId changes!
|
||||
}, [connect])
|
||||
|
||||
// ✅ Good - roomId is a legitimate dependency
|
||||
useEffect(() => {
|
||||
const connection = createConnection(roomId)
|
||||
connection.connect()
|
||||
return () => connection.disconnect()
|
||||
}, [roomId])
|
||||
```
|
||||
|
||||
### Don't use it for simple event handlers outside Effects
|
||||
|
||||
```tsx
|
||||
// ❌ Unnecessary - not used inside an Effect
|
||||
const handleClick = useStaticEffectEvent(() => {
|
||||
console.log(count)
|
||||
})
|
||||
|
||||
// ✅ Good - regular function or useCallback is fine
|
||||
const handleClick = () => {
|
||||
console.log(count)
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The hook uses a ref to store the latest callback and returns a stable wrapper function:
|
||||
|
||||
```tsx
|
||||
export const useStaticEffectEvent = <Callback extends Function>(callback: Callback) => {
|
||||
const callbackRef = useRef(callback)
|
||||
|
||||
// Update the ref on every render with the latest callback
|
||||
useLayoutEffect(() => {
|
||||
callbackRef.current = callback
|
||||
})
|
||||
|
||||
// Return a stable function that calls the latest callback
|
||||
const eventFn = useCallback((...args: any) => {
|
||||
return callbackRef.current(...args)
|
||||
}, [])
|
||||
|
||||
return eventFn as unknown as Callback
|
||||
}
|
||||
```
|
||||
|
||||
## Relationship to React's useEffectEvent
|
||||
|
||||
This hook is a polyfill for React's experimental `useEffectEvent` (now stable in React 19.2). The core concept is identical:
|
||||
|
||||
- Extract non-reactive logic into a stable function
|
||||
- Always access the latest props/state without adding them as Effect dependencies
|
||||
- Should only be called from within Effects
|
||||
|
||||
When React's `useEffectEvent` becomes widely available, this hook can be replaced with the official API.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Only call the returned function inside Effects** (useEffect, useLayoutEffect)
|
||||
2. **Don't pass the function to other components or hooks** as a callback prop
|
||||
3. **Use for non-reactive logic only** - logic that reads values but shouldn't trigger re-runs
|
||||
4. **Include it in dependency arrays** when used in useEffect (the function is stable, so it won't cause re-runs)
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
description: 'Studio: index rule for architecture, style, and UI composition patterns'
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio
|
||||
|
||||
Use the nested rules in this folder for focused guidance while working in `apps/studio/`.
|
||||
|
||||
## Architecture and style
|
||||
|
||||
- `studio/project-structure`
|
||||
- `studio/component-system`
|
||||
- `studio/styling`
|
||||
- `studio/best-practices`
|
||||
|
||||
## UI composition (Design System patterns)
|
||||
|
||||
- `studio/layout`
|
||||
- `studio/forms`
|
||||
- `studio/tables`
|
||||
- `studio/charts`
|
||||
- `studio/empty-states`
|
||||
- `studio/navigation`
|
||||
|
||||
## Common UI building blocks
|
||||
|
||||
- `studio/sheets`
|
||||
- `studio/cards`
|
||||
- `studio/alerts`
|
||||
- `studio/react-query`
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
description: "Studio: alert/admonition usage and placement"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio alerts
|
||||
|
||||
- Use `Admonition` to call out important actions, restrictions, or critical context.
|
||||
- Place at the top of a page’s content (below the page title) or at the top of the relevant section (below the section title).
|
||||
- Use sparingly.
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
---
|
||||
description: "Studio: React and TypeScript best practices for maintainable Studio code"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio Best Practices
|
||||
|
||||
## Boolean Handling
|
||||
|
||||
### Assign complex conditions to descriptive variables
|
||||
|
||||
When you have multiple conditions in a single expression, extract them into well-named boolean variables. This improves readability and makes the code self-documenting.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - complex inline condition
|
||||
{
|
||||
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && (
|
||||
<Button onClick={onAddColumn}>New column</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ Good - extract to descriptive variables
|
||||
const isTableEntity = isTableLike(selectedTable)
|
||||
const canShowAddButton = !isSchemaLocked && isTableEntity && canUpdateColumns && !isLoading
|
||||
|
||||
{
|
||||
canShowAddButton && <Button onClick={onAddColumn}>New column</Button>
|
||||
}
|
||||
```
|
||||
|
||||
### Use consistent naming conventions for booleans
|
||||
|
||||
- Use `is` prefix for state/identity: `isLoading`, `isPaused`, `isNewRecord`, `isError`
|
||||
- Use `has` prefix for possession: `hasPermission`, `hasShownModal`, `hasData`
|
||||
- Use `can` prefix for capability/permission: `canUpdateColumns`, `canDelete`, `canEdit`
|
||||
- Use `should` prefix for conditional behavior: `shouldFetch`, `shouldRender`, `shouldValidate`
|
||||
|
||||
```tsx
|
||||
// ✅ Good examples from codebase
|
||||
const isNewRecord = column === undefined
|
||||
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
|
||||
const isMatureProject = dayjs(project?.inserted_at).isBefore(dayjs().subtract(10, 'day'))
|
||||
const { can: canUpdateColumns } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'columns'
|
||||
)
|
||||
```
|
||||
|
||||
### Derive boolean state instead of storing it
|
||||
|
||||
When a boolean can be computed from existing state, derive it rather than storing it separately.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - storing derived state
|
||||
const [isFormValid, setIsFormValid] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormValid(name.length > 0 && email.includes('@'))
|
||||
}, [name, email])
|
||||
|
||||
// ✅ Good - derive from existing state
|
||||
const isFormValid = name.length > 0 && email.includes('@')
|
||||
```
|
||||
|
||||
## Component Structure
|
||||
|
||||
### Break down large components
|
||||
|
||||
Components should ideally be under 200-300 lines. If a component grows larger, consider splitting it.
|
||||
|
||||
**Signs a component should be split:**
|
||||
|
||||
- Multiple distinct UI sections
|
||||
- Complex conditional rendering logic
|
||||
- Multiple useState hooks for unrelated state
|
||||
- Difficult to understand at a glance
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - monolithic component with everything inline
|
||||
const UserDashboard = () => {
|
||||
// 50 lines of hooks and state
|
||||
// 100 lines of handlers
|
||||
// 300 lines of JSX with nested conditions
|
||||
}
|
||||
|
||||
// ✅ Good - split into focused sub-components
|
||||
const UserDashboard = () => {
|
||||
return (
|
||||
<div>
|
||||
<UserHeader />
|
||||
<UserStats />
|
||||
<UserActivitySection />
|
||||
<UserSettingsPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Co-locate related components
|
||||
|
||||
Place sub-components in the same directory as the parent component. Avoid using barrel files (files that do nothing but re-export things from other files) for imports.
|
||||
|
||||
```
|
||||
components/interfaces/Auth/Users/
|
||||
├── UserPanel.tsx
|
||||
├── UserOverview.tsx
|
||||
├── UserLogs.tsx
|
||||
├── Users.constants.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
### Extract repeated JSX patterns
|
||||
|
||||
If you find yourself copying similar JSX blocks, extract them into a component.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - repeated pattern
|
||||
<TabsTrigger_Shadcn_ value="overview" className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none">
|
||||
Overview
|
||||
</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_ value="logs" className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none">
|
||||
Logs
|
||||
</TabsTrigger_Shadcn_>
|
||||
|
||||
// ✅ Good - extract to component
|
||||
const PanelTab = ({ value, children }: { value: string; children: ReactNode }) => (
|
||||
<TabsTrigger_Shadcn_
|
||||
value={value}
|
||||
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
|
||||
>
|
||||
{children}
|
||||
</TabsTrigger_Shadcn_>
|
||||
)
|
||||
```
|
||||
|
||||
## Loading and Error States
|
||||
|
||||
### Use consistent loading/error/success pattern
|
||||
|
||||
Follow a consistent pattern for handling async states:
|
||||
|
||||
```tsx
|
||||
const { data, error, isLoading, isError, isSuccess } = useQuery()
|
||||
|
||||
// Handle loading state first
|
||||
if (isLoading) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (isError) {
|
||||
return <AlertError error={error} subject="Failed to load data" />
|
||||
}
|
||||
|
||||
// Handle empty state if needed
|
||||
if (isSuccess && data.length === 0) {
|
||||
return <EmptyState />
|
||||
}
|
||||
|
||||
// Render success state
|
||||
return <DataDisplay data={data} />
|
||||
```
|
||||
|
||||
### Use early returns for guard clauses
|
||||
|
||||
Prefer early returns over deeply nested conditionals:
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - deeply nested
|
||||
const Component = () => {
|
||||
if (data) {
|
||||
if (!isError) {
|
||||
if (hasPermission) {
|
||||
return <ActualContent />
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ✅ Good - early returns
|
||||
const Component = () => {
|
||||
if (!data) return null
|
||||
if (isError) return <ErrorDisplay />
|
||||
if (!hasPermission) return <PermissionDenied />
|
||||
|
||||
return <ActualContent />
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Keep state as local as possible
|
||||
|
||||
Start with local state and lift up only when needed.
|
||||
|
||||
```tsx
|
||||
// ✅ Good - state lives where it's used
|
||||
const SearchableList = () => {
|
||||
const [filterString, setFilterString] = useState('')
|
||||
|
||||
const filteredItems = items.filter((item) => item.name.includes(filterString))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input value={filterString} onChange={(e) => setFilterString(e.target.value)} />
|
||||
<List items={filteredItems} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Group related state with objects or reducers
|
||||
|
||||
When you have multiple related pieces of state, consider grouping them:
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - multiple related useState calls
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
|
||||
// ✅ Good - grouped state for forms (use react-hook-form)
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: { name: '', email: '', phone: '' },
|
||||
})
|
||||
```
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
### Extract complex logic into custom hooks
|
||||
|
||||
When logic becomes reusable or complex, extract it:
|
||||
|
||||
```tsx
|
||||
// ✅ Good - extracted to custom hook
|
||||
export function useAsyncCheckPermissions(action: string, resource: string) {
|
||||
const { permissions, isLoading, isSuccess } = useGetProjectPermissions()
|
||||
|
||||
const can = useMemo(() => {
|
||||
if (!IS_PLATFORM) return true
|
||||
if (!isSuccess || !permissions) return false
|
||||
return doPermissionsCheck(permissions, action, resource)
|
||||
}, [isSuccess, permissions, action, resource])
|
||||
|
||||
return { isLoading, isSuccess, can }
|
||||
}
|
||||
|
||||
// Usage
|
||||
const { can: canUpdateColumns } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'columns'
|
||||
)
|
||||
```
|
||||
|
||||
### Return objects from hooks for better extensibility
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - returning array (hard to extend)
|
||||
const useToggle = () => {
|
||||
const [value, setValue] = useState(false)
|
||||
return [value, () => setValue((v) => !v)]
|
||||
}
|
||||
|
||||
// ✅ Good - returning object (easy to extend)
|
||||
const useToggle = (initial = false) => {
|
||||
const [value, setValue] = useState(initial)
|
||||
return {
|
||||
value,
|
||||
toggle: () => setValue((v) => !v),
|
||||
setTrue: () => setValue(true),
|
||||
setFalse: () => setValue(false),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
### Name handlers consistently
|
||||
|
||||
Use `on` prefix for prop callbacks and `handle` prefix for internal handlers:
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
onClose: () => void // Callback prop
|
||||
onSave: (data: Data) => void
|
||||
}
|
||||
|
||||
const Component = ({ onClose, onSave }: Props) => {
|
||||
const handleSubmit = () => {
|
||||
// Internal handler
|
||||
// process data
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// cleanup
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid inline arrow functions for expensive operations
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - creates new function every render
|
||||
<ExpensiveList items={items} onItemClick={(item) => handleItemClick(item)} />
|
||||
|
||||
// ✅ Good - stable reference with useCallback
|
||||
const handleItemClick = useCallback(
|
||||
(item: Item) => {
|
||||
// handle click
|
||||
},
|
||||
[dependencies]
|
||||
)
|
||||
|
||||
<ExpensiveList items={items} onItemClick={handleItemClick} />
|
||||
```
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
### Use appropriate patterns for different scenarios
|
||||
|
||||
```tsx
|
||||
// Simple show/hide - use &&
|
||||
{
|
||||
isVisible && <Component />
|
||||
}
|
||||
|
||||
// Binary choice - use ternary
|
||||
{
|
||||
isLoading ? <Spinner /> : <Content />
|
||||
}
|
||||
|
||||
// Multiple conditions - use early returns or extracted component
|
||||
const StatusDisplay = ({ status }: { status: Status }) => {
|
||||
if (status === 'loading') return <Spinner />
|
||||
if (status === 'error') return <ErrorMessage />
|
||||
if (status === 'empty') return <EmptyState />
|
||||
return <DataDisplay />
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid nested ternaries
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - nested ternary
|
||||
{
|
||||
isLoading ? <Spinner /> : isError ? <Error /> : <Content />
|
||||
}
|
||||
|
||||
// ✅ Good - separate conditions or early returns
|
||||
if (isLoading) return <Spinner />
|
||||
if (isError) return <Error />
|
||||
return <Content />
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Use useMemo for expensive computations
|
||||
|
||||
```tsx
|
||||
// ✅ Good - memoize expensive filtering
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((item) => item.name.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
[items, searchQuery]
|
||||
)
|
||||
```
|
||||
|
||||
### Avoid premature optimization
|
||||
|
||||
Don't wrap everything in useMemo/useCallback. Only optimize when:
|
||||
|
||||
- You have measured a performance problem
|
||||
- The computation is genuinely expensive
|
||||
- The value is passed to memoized children
|
||||
|
||||
## TypeScript
|
||||
|
||||
### Define prop interfaces explicitly
|
||||
|
||||
```tsx
|
||||
interface UserCardProps {
|
||||
user: User
|
||||
onEdit: (user: User) => void
|
||||
onDelete: (userId: string) => void
|
||||
isEditable?: boolean
|
||||
}
|
||||
|
||||
export const UserCard = ({ user, onEdit, onDelete, isEditable = true }: UserCardProps) => {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Use discriminated unions for complex state
|
||||
|
||||
```tsx
|
||||
type AsyncState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error }
|
||||
```
|
||||
|
||||
### Avoid type casting, prefer validation with zod
|
||||
|
||||
Never use type casting (e.g., `as any`, `as Type`). Instead, validate values at runtime using zod schemas. This ensures type safety and catches runtime errors.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad - type casting bypasses type checking
|
||||
const user = apiResponse as User
|
||||
const data = unknownValue as string
|
||||
|
||||
// ✅ Good - validate with zod schema
|
||||
const userSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
})
|
||||
|
||||
const user = userSchema.parse(apiResponse)
|
||||
const data = z.string().parse(unknownValue)
|
||||
|
||||
// ✅ Good - safe parsing with error handling
|
||||
const result = userSchema.safeParse(apiResponse)
|
||||
if (result.success) {
|
||||
const user = result.data
|
||||
} else {
|
||||
// handle validation errors
|
||||
}
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
description: "Studio: Card usage for grouping related content and actions"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio cards
|
||||
|
||||
- Use cards to group related pieces of information.
|
||||
- Use `CardContent` for sections and `CardFooter` for actions.
|
||||
- Only use `CardHeader`/`CardTitle` when the card content is not already described by surrounding content (page title, section title, etc).
|
||||
- Prefer headers/titles when multiple cards represent distinct groups (e.g. multiple settings groups).
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
description: "Studio: composable chart patterns built on Recharts and our chart presentational components"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio charts
|
||||
|
||||
Use the Design System UI pattern docs as the source of truth:
|
||||
|
||||
- Documentation: `apps/design-system/content/docs/ui-patterns/charts.mdx`
|
||||
- Demos:
|
||||
- `apps/design-system/__registry__/default/block/chart-composed-demo.tsx`
|
||||
- `apps/design-system/__registry__/default/block/chart-composed-basic.tsx`
|
||||
- `apps/design-system/__registry__/default/block/chart-composed-states.tsx`
|
||||
- `apps/design-system/__registry__/default/block/chart-composed-metrics.tsx`
|
||||
- `apps/design-system/__registry__/default/block/chart-composed-actions.tsx`
|
||||
- `apps/design-system/__registry__/default/block/chart-composed-table.tsx`
|
||||
|
||||
## Best practices
|
||||
|
||||
- Prefer provided chart building blocks over passing raw Recharts components to `ChartContent`.
|
||||
- Use `useChart` context flags for consistent loading/disabled handling.
|
||||
- Keep chart composition straightforward; avoid over-abstraction.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
description: 'Studio: UI component system (packages/ui + shadcn primitives)'
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
- packages/ui/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio component system
|
||||
|
||||
Our primitive component system lives in `packages/ui` and is based on shadcn/ui patterns.
|
||||
|
||||
- Prefer using components exported from `ui` (e.g. `import { Button } from 'ui'`).
|
||||
- Prefer `_Shadcn_`-suffixed components for form components e.g. `Input_Shadcn_`.
|
||||
- Avoid introducing new primitives unless explicitly requested.
|
||||
- Browse available exports in `packages/ui/index.tsx` before composing new UI.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
description: 'Studio: empty state patterns (presentational vs informational vs zero-results vs missing route)'
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio empty states
|
||||
|
||||
Use the Design System UI pattern docs as the source of truth:
|
||||
|
||||
- Documentation: `apps/design-system/content/docs/ui-patterns/empty-states.mdx`
|
||||
- Demos:
|
||||
- `apps/design-system/registry/default/example/empty-state-presentational-icon.tsx`
|
||||
- `apps/design-system/registry/default/example/empty-state-initial-state-informational.tsx`
|
||||
- `apps/design-system/registry/default/example/empty-state-zero-items-table.tsx`
|
||||
- `apps/design-system/registry/default/example/data-grid-empty-state.tsx`
|
||||
- `apps/design-system/registry/default/example/empty-state-missing-route.tsx`
|
||||
|
||||
## Quick guidance
|
||||
|
||||
- Initial states: use presentational empty states when onboarding/value prop + a clear next action helps.
|
||||
- Data-heavy lists: prefer informational empty states that match the list/table layout.
|
||||
- Zero results: keep the UI consistent with the data state to avoid jarring transitions.
|
||||
- Missing routes: prefer a centered `Admonition` pattern.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
description: "Studio: form patterns (page layouts + side panels) and react-hook-form conventions"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio forms
|
||||
|
||||
Use the Design System UI pattern docs as the source of truth:
|
||||
|
||||
- Documentation: `apps/design-system/content/docs/ui-patterns/forms.mdx`
|
||||
- Demos:
|
||||
- `apps/design-system/registry/default/example/form-patterns-pagelayout.tsx`
|
||||
- `apps/design-system/registry/default/example/form-patterns-sidepanel.tsx`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Build forms with `react-hook-form` + `zod`.
|
||||
- Use `FormItemLayout` instead of manually composing `FormItem`/`FormLabel`/`FormMessage`/`FormDescription`.
|
||||
- Wrap inputs with `FormControl_Shadcn_`.
|
||||
- Use `_Shadcn_` imports from `ui` for form primitives where available.
|
||||
|
||||
## Layout selection
|
||||
|
||||
- Page layouts: `FormItemLayout layout="flex-row-reverse"` inside `Card` (`CardContent` per field; `CardFooter` for actions).
|
||||
- Side panels (wide): `FormItemLayout layout="horizontal"` inside `SheetSection`.
|
||||
- Side panels (narrow, `size="sm"` or below): `FormItemLayout layout="vertical"`.
|
||||
|
||||
## Actions and state
|
||||
|
||||
- Handle dirty state (`form.formState.isDirty`) to show Cancel and to disable Save.
|
||||
- Show loading on submit buttons via `loading`.
|
||||
- When submit button is outside the `<form>`, set a stable `formId` and use the button’s `form` prop.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: 'Studio: page layout patterns (PageContainer/PageHeader/PageSection) and sizing guidance. Use to learn how to create or update existing pages in Studio.'
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio layout
|
||||
|
||||
Use the Design System UI pattern docs as the source of truth:
|
||||
|
||||
- Documentation: `apps/design-system/content/docs/ui-patterns/layout.mdx`
|
||||
- Demos:
|
||||
- `apps/design-system/registry/default/example/page-layout-settings.tsx`
|
||||
- `apps/design-system/registry/default/example/page-layout-list.tsx`
|
||||
- `apps/design-system/registry/default/example/page-layout-list-simple.tsx`
|
||||
- `apps/design-system/registry/default/example/page-layout-detail.tsx`
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Build pages using `PageContainer`, `PageHeader`, and `PageSection` for consistent spacing and max-widths.
|
||||
- Choose `size` based on content:
|
||||
- Settings/config: `size="default"`
|
||||
- List/table-heavy: `size="large"`
|
||||
- Full-screen experiences: `size="full"`
|
||||
- For list pages:
|
||||
- If filters/search exist, align table actions with filters (avoid `PageHeaderAside`/`PageSectionAside` for those actions).
|
||||
- If no filters/search, actions can go in `PageHeaderAside` or `PageSectionAside` depending on context.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
description: "Studio: navigation patterns (page-level NavMenu + URL-driven navigation)"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio navigation
|
||||
|
||||
Use the Design System UI pattern docs as the source of truth:
|
||||
|
||||
- Documentation: `apps/design-system/content/docs/ui-patterns/navigation.mdx`
|
||||
|
||||
## NavMenu
|
||||
|
||||
- Use `NavMenu` for a horizontal list of related views within a consistent page layout.
|
||||
- Activating an item should trigger a URL change (no local-only tab state).
|
||||
- See: `apps/design-system/content/docs/components/nav-menu.mdx`
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
description: "Studio: project structure and where code lives"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio project structure
|
||||
|
||||
- Studio is a Next.js app using the pages router.
|
||||
- Pages live in `apps/studio/pages`.
|
||||
- Project pages: `apps/studio/pages/projects/[ref]`
|
||||
- Org pages: `apps/studio/pages/org/[slug]`
|
||||
- Studio components live in `apps/studio/components`.
|
||||
- Studio UI helpers: `apps/studio/components/ui`
|
||||
- Interface/page components: `apps/studio/components/interfaces` (e.g. `apps/studio/components/interfaces/Auth`)
|
||||
- Shared hooks: `apps/studio/hooks`
|
||||
- Shared helpers: `apps/studio/lib`
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
---
|
||||
description: 'Studio: data fetching conventions for queries/mutations (React Query hooks)'
|
||||
globs:
|
||||
- apps/studio/data/**/*.{ts,tsx}
|
||||
- apps/studio/pages/**/*.{ts,tsx}
|
||||
- apps/studio/components/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio queries & mutations (React Query)
|
||||
|
||||
Follow the `apps/studio/data/` patterns:
|
||||
|
||||
- Query options: `apps/studio/data/table-editor/table-editor-query.ts`
|
||||
- Mutation hook: `apps/studio/data/edge-functions/edge-functions-update-mutation.ts`
|
||||
- Keys: `apps/studio/data/edge-functions/keys.ts`
|
||||
- Page usage: `apps/studio/pages/project/[ref]/database/tables/[id].tsx`
|
||||
|
||||
## Organize query keys
|
||||
|
||||
- Define a `keys.ts` per domain and export `*Keys` helpers (use array keys with `as const`).
|
||||
- Do not inline query keys in components.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
export const edgeFunctionsKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
|
||||
detail: (projectRef: string | undefined, slug: string | undefined) =>
|
||||
['projects', projectRef, 'edge-function', slug, 'detail'] as const,
|
||||
}
|
||||
```
|
||||
|
||||
## Write query options (preferred pattern)
|
||||
|
||||
Use `queryOptions` from `@tanstack/react-query` to define reusable query configurations. This pattern:
|
||||
|
||||
- Provides type safety for query keys and data
|
||||
- Can be used with `useQuery()` in components
|
||||
- Can be used with `queryClient.fetchQuery()` for imperative fetching
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Export `XVariables`, `XData`, and `XError` types from the file (prefixed with the domain name).
|
||||
- Implement a private `getX(variables, signal?)` function that:
|
||||
- throws if required variables are missing
|
||||
- passes the `signal` through to the fetcher for cancellation
|
||||
- calls `handleError(error)` on failure (which throws) — the function returns `data` on success
|
||||
- this function should NOT be exported. For imperative fetching, use `queryClient.fetchQuery(xQueryOptions(...))`
|
||||
- Export `xQueryOptions()` using `queryOptions` from `@tanstack/react-query`.
|
||||
- Gate with `enabled` so the query doesn't run until required variables exist (and platform-only queries should include `IS_PLATFORM` from `lib/constants`).
|
||||
- When migrating away from exporting `useQuery`, move all options into the `xQueryOptions` as default values.
|
||||
- No extra options should be added as params, if the user wants to overwrite the options, they can do by destructuring the query options. For example, `{ ...xQueryOptions(vars), enabled: true }`.
|
||||
|
||||
Template:
|
||||
|
||||
```ts
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { xKeys } from './keys'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { ResponseError } from '@/types'
|
||||
|
||||
export type XVariables = { projectRef?: string }
|
||||
export type XError = ResponseError
|
||||
|
||||
async function getX({ projectRef }: XVariables, signal?: AbortSignal) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
const { data, error } = await get('/v1/projects/{ref}/x', {
|
||||
params: { path: { ref: projectRef } },
|
||||
signal,
|
||||
})
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type XData = Awaited<ReturnType<typeof getX>>
|
||||
|
||||
export const xQueryOptions = ({ projectRef }: XVariables) => {
|
||||
return queryOptions({
|
||||
queryKey: xKeys.list(projectRef),
|
||||
queryFn: ({ signal }) => getX({ projectRef }, signal),
|
||||
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Using query options in components
|
||||
|
||||
Use `useQuery` directly with the query options:
|
||||
|
||||
```ts
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { xQueryOptions } from '@/data/x/x-query'
|
||||
|
||||
// In component:
|
||||
const { data, isPending, isError } = useQuery(
|
||||
xQueryOptions({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
## Imperative fetching (outside React or in callbacks)
|
||||
|
||||
Use `queryClient.fetchQuery()` with the query options:
|
||||
|
||||
```ts
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { xQueryOptions } from '@/data/x/x-query'
|
||||
|
||||
// In component:
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (id: number) => {
|
||||
const data = await queryClient.fetchQuery(
|
||||
xQueryOptions({
|
||||
id,
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
)
|
||||
// use data...
|
||||
},
|
||||
[project?.connectionString, projectRef, queryClient]
|
||||
)
|
||||
```
|
||||
|
||||
## Write a mutation hook
|
||||
|
||||
- Export a `Variables` type that includes `projectRef`, identifiers (e.g. `slug`), and `payload`.
|
||||
- Implement an `updateX(vars)` function that validates required variables and uses `handleError`.
|
||||
- Prefer a `useXMutation()` wrapper that:
|
||||
- accepts `UseCustomMutationOptions` (omit `mutationFn`)
|
||||
- invalidates the relevant `list()` + `detail()` keys in `onSuccess` and `await`s them via `Promise.all`
|
||||
- defaults to a `toast.error(...)` when `onError` isn't provided
|
||||
|
||||
Template:
|
||||
|
||||
```ts
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { xKeys } from './keys'
|
||||
import type { UseCustomMutationOptions } from '@/data/custom-mutation'
|
||||
|
||||
type XUpdateVariables = { projectRef: string; slug: string; payload: XPayload }
|
||||
|
||||
export const useXUpdateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<XData, XError, XUpdateVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: updateX,
|
||||
async onSuccess(data, variables, context) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: xKeys.detail(variables.projectRef, variables.slug),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: xKeys.list(variables.projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to update: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Component usage
|
||||
|
||||
- Prefer React Query's v5 flags:
|
||||
- `isPending` for initial load (often aliased to `isLoading`)
|
||||
- `isFetching` for background refetches
|
||||
- Render states explicitly (pending → error → success), like `apps/studio/pages/project/[ref]/database/tables/[id].tsx`.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: "Studio: side panels (Sheet) for context-preserving workflows"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio sheets
|
||||
|
||||
Use a `Sheet` when switching to a new page would be disruptive and the user should keep context (e.g. selecting an item from a list to edit details).
|
||||
|
||||
## Structure
|
||||
|
||||
- Prefer `SheetContent` with `size="lg"` for forms that need horizontal layout.
|
||||
- Use `SheetHeader`, `SheetTitle`, `SheetSection`, and `SheetFooter` for consistent structure.
|
||||
- Place submit/cancel actions in `SheetFooter`.
|
||||
|
||||
## Forms in sheets
|
||||
|
||||
- Prefer `FormItemLayout`:
|
||||
- `layout="horizontal"` for wider sheets
|
||||
- `layout="vertical"` for narrow sheets (`size="sm"` or below)
|
||||
- See `@studio/forms` for the canonical patterns and demos.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
description: "Studio: styling rules (Tailwind + semantic tokens + typography/focus utilities)"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx,scss}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio styling
|
||||
|
||||
- Use Tailwind.
|
||||
- Do not hardcode Tailwind color tokens; use our semantic classes:
|
||||
- backgrounds: `bg`, `bg-muted`, `bg-warning`, `bg-destructive`
|
||||
- text: `text-foreground`, `text-foreground-light`, `text-foreground-lighter`, `text-warning`, `text-destructive`
|
||||
- Use existing typography utilities from `apps/studio/styles/typography.scss` instead of recreating styles.
|
||||
- Use existing focus utilities from `apps/studio/styles/focus.scss` for consistent keyboard focus styling.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
description: "Studio: table patterns (Table vs Data Table vs Data Grid) and placement of actions/filters"
|
||||
globs:
|
||||
- apps/studio/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Studio tables
|
||||
|
||||
Use the Design System UI pattern docs as the source of truth:
|
||||
|
||||
- Documentation: `apps/design-system/content/docs/ui-patterns/tables.mdx`
|
||||
- Demos:
|
||||
- `apps/design-system/registry/default/example/table-demo.tsx`
|
||||
- `apps/design-system/registry/default/example/data-table-demo.tsx`
|
||||
- `apps/design-system/registry/default/example/data-grid-demo.tsx`
|
||||
|
||||
## Choose the right pattern
|
||||
|
||||
- `Table`: simple, static, semantic table display.
|
||||
- Data Table: TanStack-powered pattern for sorting/filtering/pagination; composed per use case.
|
||||
- Data Grid: only when you need virtualization, column resizing, or complex cell editing.
|
||||
|
||||
## Actions and filters placement
|
||||
|
||||
- Actions: above the table, aligned right.
|
||||
- Search/filters: above the table, aligned left.
|
||||
- If the table is the primary page content and has no filters/search, actions can live in the page’s primary/secondary actions area.
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
---
|
||||
description: "Testing: Playwright E2E best practices for Studio tests (avoid flake + race conditions)"
|
||||
globs:
|
||||
- e2e/studio/**/*.ts
|
||||
- e2e/studio/**/*.spec.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# E2E Testing Best Practices
|
||||
|
||||
## Getting Context
|
||||
|
||||
Before writing or modifying tests, use the Playwright MCP to understand:
|
||||
|
||||
- Available page elements and their roles/locators
|
||||
- Current page state and network activity
|
||||
- Existing test patterns in the codebase
|
||||
|
||||
Avoid extensive code reading - let Playwright's inspection tools guide your understanding of the UI.
|
||||
|
||||
## Avoiding Race Conditions
|
||||
|
||||
### Set up API waiters BEFORE triggering actions
|
||||
|
||||
The most common source of flaky tests is race conditions between UI actions and API calls. Always create response waiters before clicking buttons or navigating.
|
||||
|
||||
```ts
|
||||
// ❌ Bad - race condition: response might complete before waiter is set up
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
|
||||
// ✅ Good - waiter is ready before action
|
||||
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await apiPromise
|
||||
```
|
||||
|
||||
### Use `createApiResponseWaiter` for pre-navigation waits
|
||||
|
||||
When you need to wait for a response that happens during navigation:
|
||||
|
||||
```ts
|
||||
// ✅ Good - waiter created before navigation
|
||||
const loadPromise = waitForTableToLoad(page, ref)
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await loadPromise
|
||||
```
|
||||
|
||||
### Wait for multiple related API calls with Promise.all
|
||||
|
||||
When an action triggers multiple API calls, wait for all of them:
|
||||
|
||||
```ts
|
||||
// ✅ Good - wait for all related API calls
|
||||
const createTablePromise = waitForApiResponseWithTimeout(page, (response) =>
|
||||
response.url().includes('query?key=table-create')
|
||||
)
|
||||
const tablesPromise = waitForApiResponseWithTimeout(page, (response) =>
|
||||
response.url().includes('tables?include_columns=true')
|
||||
)
|
||||
const entitiesPromise = waitForApiResponseWithTimeout(page, (response) =>
|
||||
response.url().includes('query?key=entity-types-')
|
||||
)
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await Promise.all([createTablePromise, tablesPromise, entitiesPromise])
|
||||
```
|
||||
|
||||
## Waiting Strategies
|
||||
|
||||
### Prefer Playwright's built-in auto-waiting
|
||||
|
||||
Playwright automatically waits for elements to be actionable. Use this instead of manual timeouts:
|
||||
|
||||
```ts
|
||||
// ❌ Bad - arbitrary timeout
|
||||
await page.waitForTimeout(2000)
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
|
||||
// ✅ Good - auto-waits for element to be visible and enabled
|
||||
await page.getByRole('button', { name: 'Submit' }).click()
|
||||
```
|
||||
|
||||
### Use `expect.poll` for dynamic assertions
|
||||
|
||||
When waiting for state to change:
|
||||
|
||||
```ts
|
||||
// ✅ Good - polls until condition is met
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.getByLabel(`View ${tableName}`).count()
|
||||
})
|
||||
.toBe(0)
|
||||
```
|
||||
|
||||
### Use `waitForSelector` with state for element lifecycle
|
||||
|
||||
```ts
|
||||
// ✅ Good - wait for panel to close
|
||||
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
|
||||
```
|
||||
|
||||
### Avoid `networkidle` - use specific API waits instead
|
||||
|
||||
```ts
|
||||
// ❌ Bad - unreliable and slow
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// ✅ Good - wait for specific API response
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'tables')
|
||||
```
|
||||
|
||||
### Use timeouts sparingly and only for non-API waits
|
||||
|
||||
```ts
|
||||
// ✅ Acceptable - waiting for client-side debounce
|
||||
await page.getByRole('textbox').fill('search term')
|
||||
await page.waitForTimeout(300) // Allow debounce to complete
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Use the custom test utility
|
||||
|
||||
Always import from the custom test utility for consistent fixtures:
|
||||
|
||||
```ts
|
||||
import { test } from '../utils/test.js'
|
||||
```
|
||||
|
||||
### Use `withFileOnceSetup` for expensive setup
|
||||
|
||||
When setup is expensive (cleanup, seeding), run it once per file:
|
||||
|
||||
```ts
|
||||
test.beforeAll(async ({ browser, ref }) => {
|
||||
await withFileOnceSetup(import.meta.url, async () => {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
|
||||
// Expensive setup logic (e.g., cleanup old test data)
|
||||
await deleteTestTables(page, ref)
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await releaseFileOnceCleanup(import.meta.url)
|
||||
})
|
||||
```
|
||||
|
||||
### Dismiss toasts before interacting with UI
|
||||
|
||||
Toasts can overlay buttons and block interactions:
|
||||
|
||||
```ts
|
||||
const dismissToastsIfAny = async (page: Page) => {
|
||||
const closeButtons = page.getByRole('button', { name: 'Close toast' })
|
||||
const count = await closeButtons.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await closeButtons.nth(i).click()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Good - dismiss toasts before clicking
|
||||
await dismissToastsIfAny(page)
|
||||
await page.getByRole('button', { name: 'New table' }).click()
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
### Always include descriptive messages
|
||||
|
||||
```ts
|
||||
// ❌ Bad - no context on failure
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
|
||||
// ✅ Good - clear message on failure
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save' }),
|
||||
'Save button should be visible after form is filled'
|
||||
).toBeVisible()
|
||||
```
|
||||
|
||||
### Use appropriate timeouts for slow operations
|
||||
|
||||
```ts
|
||||
// ✅ Good - explicit timeout for slow operations
|
||||
await expect(
|
||||
page.getByText(`Table ${tableName} is good to go!`),
|
||||
'Success toast should be visible after table creation'
|
||||
).toBeVisible({ timeout: 50000 })
|
||||
```
|
||||
|
||||
## Locators
|
||||
|
||||
### Prefer role-based locators
|
||||
|
||||
```ts
|
||||
// ✅ Good - semantic and resilient
|
||||
page.getByRole('button', { name: 'Save' })
|
||||
page.getByRole('textbox', { name: 'Username' })
|
||||
page.getByRole('menuitem', { name: 'Delete' })
|
||||
|
||||
// ❌ Avoid - brittle CSS selectors
|
||||
page.locator('.btn-primary')
|
||||
page.locator('#submit-button')
|
||||
```
|
||||
|
||||
### Use test IDs for complex elements
|
||||
|
||||
```ts
|
||||
// ✅ Good - stable identifier for complex elements
|
||||
page.getByTestId('table-editor-side-panel')
|
||||
page.getByTestId('action-bar-save-row')
|
||||
```
|
||||
|
||||
### Use `filter` for finding elements in context
|
||||
|
||||
```ts
|
||||
// ✅ Good - find button within specific row
|
||||
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
||||
await bucketRow.getByRole('button').click()
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### Extract reusable operations into helpers
|
||||
|
||||
Create helper functions for common operations:
|
||||
|
||||
```ts
|
||||
// e2e/studio/utils/storage-helpers.ts
|
||||
export const createBucket = async (
|
||||
page: Page,
|
||||
ref: string,
|
||||
bucketName: string,
|
||||
isPublic: boolean = false
|
||||
) => {
|
||||
await navigateToStorageFiles(page, ref)
|
||||
|
||||
// Check if already exists
|
||||
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
||||
if ((await bucketRow.count()) > 0) return
|
||||
|
||||
await dismissToastsIfAny(page)
|
||||
|
||||
// Create bucket with proper waits
|
||||
const apiPromise = waitForApiResponse(page, 'storage', ref, 'bucket', { method: 'POST' })
|
||||
await page.getByRole('button', { name: 'New bucket' }).click()
|
||||
await page.getByRole('textbox', { name: 'Bucket name' }).fill(bucketName)
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await apiPromise
|
||||
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: bucketName }),
|
||||
`Bucket ${bucketName} should be visible`
|
||||
).toBeVisible()
|
||||
}
|
||||
```
|
||||
|
||||
### Use the existing wait utilities
|
||||
|
||||
```ts
|
||||
import {
|
||||
createApiResponseWaiter,
|
||||
waitForApiResponse,
|
||||
waitForGridDataToLoad,
|
||||
waitForTableToLoad,
|
||||
} from '../utils/wait-for-response.js'
|
||||
```
|
||||
|
||||
### Use the existing assertions utilities
|
||||
|
||||
#### Clipboard assertions
|
||||
|
||||
```ts
|
||||
// ❌ Avoid - brittle hard coded timeout
|
||||
await page.evaluate(() => navigator.clipboard.readText())
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// ✅ Good - this utility function uses Playwright auto-retries mechanisms
|
||||
await expectClipboardValue({
|
||||
page,
|
||||
value: 'expectedValue'
|
||||
})
|
||||
```
|
||||
|
||||
## API Mocking
|
||||
|
||||
### Mock APIs for isolated testing
|
||||
|
||||
```ts
|
||||
// ✅ Good - mock API response
|
||||
await page.route('*/**/logs.all*', async (route) => {
|
||||
await route.fulfill({ body: JSON.stringify(mockAPILogs) })
|
||||
})
|
||||
```
|
||||
|
||||
### Use soft waits for optional API calls
|
||||
|
||||
```ts
|
||||
// ✅ Good - don't fail if API doesn't respond
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
|
||||
soft: true,
|
||||
fallbackWaitMs: 1000,
|
||||
})
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
### Clean up test data in beforeAll/beforeEach
|
||||
|
||||
```ts
|
||||
test.beforeEach(async ({ page, ref }) => {
|
||||
await deleteAllBuckets(page, ref)
|
||||
})
|
||||
```
|
||||
|
||||
### Handle existing state gracefully
|
||||
|
||||
```ts
|
||||
// ✅ Good - check before trying to delete
|
||||
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
||||
if ((await bucketRow.count()) === 0) return
|
||||
// proceed with deletion
|
||||
```
|
||||
|
||||
### Reset local storage when needed
|
||||
|
||||
```ts
|
||||
import { resetLocalStorage } from '../utils/reset-local-storage.js'
|
||||
|
||||
// Clean up after tests that modify local storage
|
||||
await resetLocalStorage(page, ref)
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: "Testing: unit/integration conventions for Studio test files"
|
||||
globs:
|
||||
- apps/studio/**/*.test.ts
|
||||
- apps/studio/**/*.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow the guidelines in `apps/studio/tests/README.md` when writing tests for Studio.
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Copilot Code Review Instructions
|
||||
|
||||
## Review Policy — Read This First
|
||||
|
||||
You are a code reviewer for a large TypeScript/Next.js/React monorepo. Your reviews must be **low-noise and high-signal**. The team acts on fewer than 20% of default Copilot suggestions, so every comment you leave must earn its place.
|
||||
|
||||
### Confidence Threshold
|
||||
|
||||
Only comment when you are **>85% confident** the issue is a real bug, security vulnerability, or logic error. If you are unsure, do not comment. Silence is better than noise.
|
||||
|
||||
### What NOT to Comment On
|
||||
|
||||
Our CI pipeline already validates the following. **Never comment on these topics:**
|
||||
|
||||
- **Formatting or whitespace** — Prettier runs on every PR
|
||||
- **Linting issues** — ESLint with auto-fix runs on every PR
|
||||
- **Type errors** — TypeScript strict-mode typecheck runs on every PR
|
||||
- **Typos or spelling** — Automated typo detection runs on every PR
|
||||
- **Missing tests for trivial changes** — Handled by topic-specific test instructions
|
||||
- **Import ordering or grouping** — Handled by linter
|
||||
- **Naming style preferences** (camelCase vs snake_case debates) — Follow existing file conventions
|
||||
- **Accessibility attributes on shadcn/Radix UI components** — See `studio-shadcn-components.instructions.md` for details
|
||||
|
||||
### What TO Comment On (Priority Order)
|
||||
|
||||
1. **Logic errors and bugs** — Off-by-one, null derefs, wrong conditional, unreachable code, incorrect early returns
|
||||
2. **Security vulnerabilities** — XSS, SQL injection, auth bypass, secrets in code, unsafe `dangerouslySetInnerHTML`
|
||||
3. **Race conditions and async bugs** — Missing `await`, unhandled promise rejections, stale closures, effect cleanup issues
|
||||
4. **Data loss risks** — Destructive operations without confirmation, missing error handling on writes
|
||||
5. **API contract violations** — Wrong HTTP method, missing auth headers, incorrect request/response shapes
|
||||
|
||||
### Comment Style
|
||||
|
||||
- **Be advisory, not prescriptive.** Use "Consider..." or "This may..." — never demand changes.
|
||||
- **One comment per distinct issue.** Do not leave multiple comments about the same underlying problem.
|
||||
- **No self-contradictions.** If you suggest a change, do not then flag a problem with your own suggestion.
|
||||
- **Do not comment on individual commits.** Review the final state of the PR diff only.
|
||||
|
||||
## Repo Context
|
||||
|
||||
This is a TypeScript/Next.js/React monorepo:
|
||||
@@ -11,16 +47,13 @@ This is a TypeScript/Next.js/React monorepo:
|
||||
|
||||
## Topic-Specific Guidelines
|
||||
|
||||
Detailed review rules are in path-specific instruction files under `.github/instructions/`:
|
||||
Path-specific rules in `.github/instructions/`:
|
||||
|
||||
- **Telemetry**: `studio-telemetry.instructions.md` — event naming, property conventions, feature flag measurement
|
||||
- **Testing**: `studio-testing.instructions.md` — test strategy, extraction patterns, coverage expectations
|
||||
- **Error Handling**: `studio-error-handling.instructions.md` — error classification, `ErrorMatcher` usage
|
||||
- **E2E Tests**: `studio-e2e-tests.instructions.md` — selector priority, anti-patterns (`waitForTimeout`, `force: true`)
|
||||
- **Composition Patterns**: `studio-composition-patterns.instructions.md` — avoid boolean props, use compound components
|
||||
- **shadcn/Radix Components**: `studio-shadcn-components.instructions.md` — accessibility handled by primitives, do not flag
|
||||
|
||||
These files are scoped to `apps/studio/` and applied automatically by Copilot during reviews.
|
||||
|
||||
## References
|
||||
|
||||
For the full, authoritative versions of these standards:
|
||||
|
||||
- Telemetry: `.claude/skills/telemetry-standards/SKILL.md`
|
||||
- Testing: `.claude/skills/studio-testing/SKILL.md`
|
||||
These files are scoped to `apps/studio/` and applied automatically during reviews.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# React Composition Patterns Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Avoid boolean prop proliferation. Use composition (compound components, explicit variants, children) instead of boolean flags to customize behavior.
|
||||
|
||||
## When to Flag
|
||||
|
||||
### 1. Boolean Prop Proliferation (HIGH)
|
||||
|
||||
Flag components accumulating boolean props like `isThread`, `isEditing`, `showAttachments`. Each boolean doubles the state space.
|
||||
|
||||
```tsx
|
||||
// BAD — unclear intent, combinatorial explosion
|
||||
<Composer isThread isDMThread isEditing isForwarding={false} />
|
||||
|
||||
// GOOD — self-documenting variants
|
||||
<ThreadComposer channelId="abc" />
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
```
|
||||
|
||||
### 2. Render Props Instead of Children (MEDIUM)
|
||||
|
||||
Flag `renderX` callback props when `children` composition would work.
|
||||
|
||||
```tsx
|
||||
// BAD — render prop for structure
|
||||
<Composer renderFooter={() => <F />} />
|
||||
|
||||
// GOOD — compound component
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
```
|
||||
|
||||
### 3. UI Coupled to State Implementation (MEDIUM)
|
||||
|
||||
Flag UI components calling specific state hooks like `useGlobalChannelState()` directly. The provider should own the state implementation; UI should only use a generic context interface.
|
||||
|
||||
```tsx
|
||||
// BAD — UI knows HOW state is managed
|
||||
const state = useGlobalChannelState(channelId)
|
||||
|
||||
// GOOD — provider owns implementation, UI uses context
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<Composer /> {/* reads from context */}
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
### 4. State Trapped in Child Components (MEDIUM)
|
||||
|
||||
Flag state that siblings or dialogs need but can't access without prop drilling or refs. Lift it into a provider.
|
||||
|
||||
```tsx
|
||||
// BAD — sibling can't access state
|
||||
function ForwardComposer() {
|
||||
const [state, setState] = useState(init)
|
||||
}
|
||||
// ForwardButton is a sibling and can't reach state
|
||||
|
||||
// GOOD — provider at shared ancestor
|
||||
<ForwardMessageProvider>
|
||||
<Composer /> {/* can access state */}
|
||||
<ForwardButton /> {/* can also access state */}
|
||||
</ForwardMessageProvider>
|
||||
```
|
||||
|
||||
### 5. React 19 API Updates
|
||||
|
||||
Flag `forwardRef` and `useContext` in new code — use `ref` as a regular prop and `use()` instead.
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
const Input = forwardRef((props, ref) => <input ref={ref} />)
|
||||
const value = useContext(MyContext)
|
||||
|
||||
// GOOD
|
||||
function Input({ ref, ...props }) { return <input ref={ref} /> }
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
## Key Principle
|
||||
|
||||
Lift state → Compose UI → Inject via generic context → No boolean prop proliferation.
|
||||
|
||||
Canonical standard: `.claude/skills/vercel-composition-patterns/SKILL.md`
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
applyTo: 'e2e/studio/**,apps/studio/**'
|
||||
---
|
||||
|
||||
# Studio E2E Test Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Selector Priority (best to worst)
|
||||
|
||||
1. **`getByRole` with accessible name** — most robust, tests accessibility
|
||||
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Save' })
|
||||
```
|
||||
|
||||
2. **`getByTestId`** — stable, explicit test hooks
|
||||
|
||||
```typescript
|
||||
page.getByTestId('table-editor-side-panel')
|
||||
```
|
||||
|
||||
3. **`getByText` with exact match** — good for unique text
|
||||
|
||||
```typescript
|
||||
page.getByText('Data API Access', { exact: true })
|
||||
```
|
||||
|
||||
4. **`locator` with CSS** — use sparingly, more fragile
|
||||
```typescript
|
||||
page.locator('[data-state="open"]')
|
||||
```
|
||||
|
||||
## Patterns to Flag
|
||||
|
||||
- **XPath selectors** — fragile to DOM changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
locator('xpath=ancestor::div[contains(@class, "space-y")]')
|
||||
```
|
||||
|
||||
- **Parent traversal with `locator('..')`** — breaks when structure changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
element.locator('..').getByRole('button')
|
||||
```
|
||||
|
||||
- **`waitForTimeout`** — never use; wait for something specific instead
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// GOOD — wait for UI element
|
||||
await expect(page.getByText('Success')).toBeVisible()
|
||||
|
||||
// GOOD — wait for API response
|
||||
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await saveButton.click()
|
||||
await apiPromise
|
||||
```
|
||||
|
||||
- **`force: true` on clicks** — make elements visible first instead
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await menuButton.click({ force: true })
|
||||
|
||||
// GOOD — hover to reveal, then click
|
||||
await tableRow.hover()
|
||||
await expect(menuButton).toBeVisible()
|
||||
await menuButton.click()
|
||||
```
|
||||
|
||||
- **Broad `filter({ hasText })` on generic elements** — may match multiple elements; scope to specific containers instead
|
||||
|
||||
## Good Practices to Encourage
|
||||
|
||||
- Scope selectors to containers: `page.getByTestId('side-panel').getByRole('switch')`
|
||||
- Add `aria-label` to icon-only buttons in source code for better test selectors
|
||||
- Use `test.describe.configure({ mode: 'serial' })` for tests sharing database state
|
||||
- Add messages to expects: `await expect(locator, 'why').toBeVisible({ timeout: 30000 })`
|
||||
|
||||
Canonical standard: `.claude/skills/studio-e2e-tests/SKILL.md`
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# Studio Error Handling Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Architecture
|
||||
|
||||
Errors flow: `handleError()` → throws typed subclass → React Query catches → `ErrorMatcher` reads `errorType` → renders troubleshooting. The component does an O(1) lookup — it never does regex matching.
|
||||
|
||||
## When to Flag
|
||||
|
||||
- PR passes `error.message` instead of the full `error` object to `ErrorMatcher` — the class type is lost
|
||||
- PR puts regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`
|
||||
- PR uses `Object.assign` to stamp `errorType` on an error — should throw a proper subclass instead
|
||||
- PR passes a raw URL string for support links — should use `supportFormParams={{ projectRef }}`
|
||||
- PR puts the page title inside the error mapping — it belongs on the `<ErrorMatcher>` caller
|
||||
- PR adds callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead
|
||||
|
||||
## Correct Usage
|
||||
|
||||
```tsx
|
||||
{isError && (
|
||||
<ErrorMatcher
|
||||
title="Failed to load tables"
|
||||
error={error}
|
||||
supportFormParams={{ projectRef }}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `data/error-patterns.ts` | `{ pattern, ErrorClass }` array — regex lives here |
|
||||
| `types/api-errors.ts` | Error classes, `KnownErrorType` union |
|
||||
| `ErrorMatcher.tsx` | Reads `errorType`, looks up mapping, renders |
|
||||
| `error-mappings.tsx` | `Record<KnownErrorType, { id, Troubleshooting }>` |
|
||||
|
||||
Canonical standard: `.claude/skills/studio-error-handling/SKILL.md`
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# shadcn/Radix UI Component Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Core Principle
|
||||
|
||||
This project uses **shadcn/ui** components built on **Radix UI** primitives (from `packages/ui/`). These components provide comprehensive accessibility out-of-the-box. **Do not flag missing accessibility attributes that are already handled by the underlying Radix primitives.**
|
||||
|
||||
## Components with Built-In Accessibility — Do NOT Flag
|
||||
|
||||
The following components (imported from `ui`) already handle ARIA roles, keyboard navigation, focus management, and screen reader support automatically via Radix UI primitives:
|
||||
|
||||
| Component | What Radix Handles |
|
||||
|-----------|-------------------|
|
||||
| `Dialog`, `AlertDialog` | `role="dialog"`, `aria-modal`, focus trapping, ESC to close |
|
||||
| `DropdownMenu`, `ContextMenu` | `role="menu"` / `role="menuitem"`, arrow key navigation |
|
||||
| `Select` | `role="combobox"`, `aria-expanded`, keyboard selection |
|
||||
| `Tabs` | `role="tablist"` / `role="tab"` / `role="tabpanel"`, `aria-selected`, arrow keys |
|
||||
| `Checkbox` | `role="checkbox"`, `aria-checked`, Space to toggle |
|
||||
| `RadioGroup` | `role="radio"`, `aria-checked`, arrow key navigation |
|
||||
| `Switch` | `role="switch"`, `aria-checked`, keyboard toggle |
|
||||
| `Tooltip` | Trigger/content association, show/hide timing |
|
||||
| `Accordion`, `Collapsible` | `aria-expanded`, Enter/Space to toggle |
|
||||
| `Popover`, `HoverCard` | Focus management, dismiss on ESC |
|
||||
| `Slider` | `role="slider"`, `aria-valuemin/max/now`, arrow keys |
|
||||
| `Toggle`, `ToggleGroup` | `aria-pressed`, keyboard support |
|
||||
| `ScrollArea` | Accessible scrollbar replacement |
|
||||
| `NavigationMenu` | `role="navigation"`, keyboard navigation |
|
||||
|
||||
### Specifically, Never Flag These
|
||||
|
||||
- Missing `role` on `Dialog`, `AlertDialog`, `DropdownMenu`, `Select`, `Tabs`, `RadioGroup`, or other Radix-based components — roles are set by the primitive
|
||||
- Missing `aria-modal` on `Dialog` or `AlertDialog` — set automatically
|
||||
- Missing `aria-expanded` on `Accordion`, `Collapsible`, `Select`, or `DropdownMenu` triggers — managed by Radix state
|
||||
- Missing `aria-selected` on `Tabs` — managed by `TabsPrimitive`
|
||||
- Missing `aria-checked` on `Checkbox`, `RadioGroup`, or `Switch` — managed by Radix state
|
||||
- Missing keyboard event handlers (`onKeyDown`, `onKeyUp`) on interactive Radix components — keyboard support is built-in
|
||||
- Missing focus management in `Dialog` or `AlertDialog` — focus trapping is automatic
|
||||
- Missing `aria-label` on `DialogClose` or `AlertDialogCancel` — these render a visible `<span className="sr-only">Close</span>`
|
||||
|
||||
## What TO Flag
|
||||
|
||||
Only flag accessibility issues for:
|
||||
|
||||
1. **Custom interactive elements** not using Radix primitives (e.g., a `<div onClick>` that should be a `<button>`)
|
||||
2. **Icon-only buttons** missing an accessible label — `<Button>` alone does not add one; use `aria-label` or `<span className="sr-only">`
|
||||
3. **Missing `Label` association** — form inputs should be paired with `<Label htmlFor="...">` or wrapped in a `<Field>` component
|
||||
4. **Images missing `alt` text** — not handled by any component library
|
||||
5. **Color-only state indicators** — state changes should not rely solely on color
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
run: cd apps/studio && pnpm evals:setup
|
||||
|
||||
- name: Run Evals
|
||||
uses: braintrustdata/eval-action@v1
|
||||
uses: braintrustdata/eval-action@c0dd75b29984a0cc63a827d6e8da2f23f2752be4 # v1.0.16
|
||||
with:
|
||||
api_key: ${{ secrets.BRAINTRUST_API_KEY }}
|
||||
runtime: node
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: cache cargo
|
||||
id: cache-cargo
|
||||
if: steps.filter.outputs.docs == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
|
||||
@@ -59,6 +59,33 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Fetch release notes
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
CURRENT_FULL=$(git show HEAD:pnpm-workspace.yaml | grep -oP "(?<='@supabase/supabase-js': )[\d]+\.[\d]+\.[\d]+(-[\w.]+)?")
|
||||
CURRENT_BASE=$(echo "$CURRENT_FULL" | grep -oP '[\d]+\.[\d]+\.[\d]+')
|
||||
[[ "$CURRENT_FULL" == *"-"* ]] && INCLUDE_CURRENT=true || INCLUDE_CURRENT=false
|
||||
[[ "$VERSION" == *"-"* ]] && STABLE_ONLY=false || STABLE_ONLY=true
|
||||
RELEASES=$(gh api "repos/supabase/supabase-js/releases?per_page=100")
|
||||
RELEASE_NOTES=$(echo "$RELEASES" | jq -r \
|
||||
--arg current "v${CURRENT_BASE}" \
|
||||
--arg new "v${VERSION}" \
|
||||
--argjson stable_only "$STABLE_ONLY" \
|
||||
--argjson include_current "$INCLUDE_CURRENT" \
|
||||
'[.[] | select(.draft == false) | select(if $stable_only then .prerelease == false else true end)] |
|
||||
(map(.tag_name) | index($new)) as $start |
|
||||
(map(.tag_name) | index($current)) as $end |
|
||||
($end | if . != null and $include_current then . + 1 else . end) as $end_adj |
|
||||
if $start == null then ["Target version not found in last 100 releases."]
|
||||
elif $end_adj != null and $start >= $end_adj then ["Downgrade — no release notes available."]
|
||||
else [.[$start:$end_adj][] | "## " + .tag_name + "\n\n" + (.body // "No release notes.")]
|
||||
end | .[]')
|
||||
echo "RELEASE_NOTES<<EOF" >> "$GITHUB_ENV"
|
||||
echo "$RELEASE_NOTES" >> "$GITHUB_ENV"
|
||||
echo "EOF" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
@@ -84,6 +111,12 @@ jobs:
|
||||
- Updated @supabase/postgest-js to ${{ github.event.inputs.version }}
|
||||
- Refreshed pnpm-lock.yaml
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
|
||||
${{ env.RELEASE_NOTES }}
|
||||
|
||||
This PR was created automatically.
|
||||
branch: 'gha/auto-update-js-libs-v${{ github.event.inputs.version }}'
|
||||
base: 'master'
|
||||
base: 'master'
|
||||
@@ -123,6 +123,7 @@ next-env.d.ts
|
||||
!.claude/skills/
|
||||
.claude/skills/me-*
|
||||
CLAUDE.md
|
||||
!.claude/CLAUDE.md
|
||||
|
||||
#include template .env file for docker-compose
|
||||
!docker/.env
|
||||
|
||||
@@ -731,6 +731,28 @@ export const Index: Record<string, any> = {
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"data-input-with-reveal-copy-editable": {
|
||||
name: "data-input-with-reveal-copy-editable",
|
||||
type: "components:example",
|
||||
registryDependencies: ["data-input"],
|
||||
component: React.lazy(() => import("@/registry/default/example/data-input-with-reveal-copy-editable")),
|
||||
source: "",
|
||||
files: ["registry/default/example/data-input-with-reveal-copy-editable.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"data-input-with-reveal-copy-editable-empty": {
|
||||
name: "data-input-with-reveal-copy-editable-empty",
|
||||
type: "components:example",
|
||||
registryDependencies: ["data-input"],
|
||||
component: React.lazy(() => import("@/registry/default/example/data-input-with-reveal-copy-editable-empty")),
|
||||
source: "",
|
||||
files: ["registry/default/example/data-input-with-reveal-copy-editable-empty.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"date-picker-demo": {
|
||||
name: "date-picker-demo",
|
||||
type: "components:example",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { SonnerToaster as Toaster } from 'ui'
|
||||
|
||||
import { useConfig } from '@/hooks/use-config'
|
||||
|
||||
export function SonnerToaster() {
|
||||
const [config] = useConfig()
|
||||
return <Toaster position={config.sonnerPosition} expand={config.sonnerExpand} />
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { Metadata, Viewport } from 'next'
|
||||
|
||||
import { customFont, sourceCodePro } from './fonts'
|
||||
import { ThemeProvider } from './Providers'
|
||||
import { SonnerToaster } from './SonnerToast'
|
||||
import { Toaster } from './toaster'
|
||||
|
||||
const className = `${customFont.variable} ${sourceCodePro.variable}`
|
||||
|
||||
@@ -139,7 +139,7 @@ export default async function Layout({ children }: RootLayoutProps) {
|
||||
<div vaul-drawer-wrapper="">
|
||||
<div className="relative flex min-h-screen flex-col bg-background">{children}</div>
|
||||
</div>
|
||||
<SonnerToaster />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import { SonnerToaster } from 'ui'
|
||||
|
||||
import { useConfig } from '@/hooks/use-config'
|
||||
|
||||
export function Toaster() {
|
||||
const [config] = useConfig()
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<SonnerToaster
|
||||
position={config.sonnerPosition}
|
||||
expand={config.sonnerExpand}
|
||||
theme={theme as 'light' | 'dark' | 'system'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -144,6 +144,23 @@ The component displays:
|
||||
|
||||
<ComponentPreview name="table-sort" />
|
||||
|
||||
For TanStack tables, prefer the shared adapter instead of reimplementing the `TableHeadSort` bridge in each table.
|
||||
|
||||
```tsx
|
||||
import { TanStackTableHeadSort } from 'ui-patterns/Table'
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
const columns: ColumnDef<Row>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <TanStackTableHeadSort column={column}>Name</TanStackTableHeadSort>,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
This keeps TanStack tables aligned with the same `TableHeadSort` visual treatment and sorting cycle used by manual tables.
|
||||
|
||||
### Row icons
|
||||
|
||||
When adding icon columns to your table, use [Accessibility](../accessibility) markup by including a screen reader-only label in the corresponding Table Head using the `sr-only` class. This ensures that assistive technologies can properly identify the column's purpose. Remove these icon cells when loading or displaying zero results to maintain a clean and consistent table structure.
|
||||
|
||||
@@ -22,6 +22,10 @@ Inputs with sensitive values can be both revealed _and_ copied, but only in succ
|
||||
|
||||
<ComponentPreview name="data-input-with-reveal-copy" peekCode wide />
|
||||
|
||||
Inputs can be edited without revealing their value.
|
||||
|
||||
<ComponentPreview name="data-input-with-reveal-copy-editable" peekCode wide />
|
||||
|
||||
You can also partially truncate the value by overriding the placeholder value.
|
||||
|
||||
Consider if the value needs to be revealed in the first place, as only copying is sufficient in most cases.
|
||||
|
||||
@@ -49,7 +49,7 @@ Use the shared [Key/Value Field Array](../fragments/key-value-field-array) fragm
|
||||
|
||||
4. **Use Cards for grouping**: Wrap form sections in `Card` components with `CardContent` and `CardFooter` for actions.
|
||||
|
||||
5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`.
|
||||
5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate)
|
||||
|
||||
6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications.
|
||||
|
||||
|
||||
@@ -104,8 +104,13 @@ Studio implementation (preferred in Studio code):
|
||||
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
|
||||
|
||||
const form = useForm(...)
|
||||
// Always destructure formState values otherwise they won't be updated
|
||||
// See https://react-hook-form.com/docs/useform/formstate
|
||||
const { isDirty } = form.formState
|
||||
|
||||
const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
|
||||
checkIsDirty: () => form.formState.isDirty,
|
||||
checkIsDirty: () => isDirty,
|
||||
onClose,
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev": "next dev --turbopack --port 3003",
|
||||
"dev:full": "concurrently \"pnpm dev\" \"pnpm content:dev\"",
|
||||
"build": "pnpm run content:build && pnpm run build:registry && next build --turbopack",
|
||||
"build:registry": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache",
|
||||
"build:registry": "tsx ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"content:dev": "contentlayer2 dev",
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
export default function DataInputWithRevealCopy() {
|
||||
return <Input containerClassName="w-full max-w-sm" reveal copy />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
export default function DataInputWithRevealCopy() {
|
||||
return <Input containerClassName="w-full max-w-sm" reveal copy defaultValue="1234567890" />
|
||||
}
|
||||
@@ -30,9 +30,9 @@ import {
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableHeadSort,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { TanStackTableHeadSort } from 'ui-patterns/Table'
|
||||
|
||||
const data: Payment[] = [
|
||||
{
|
||||
@@ -99,19 +99,23 @@ export const columns: ColumnDef<Payment>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
header: ({ column }) => <TanStackTableHeadSort column={column}>Status</TanStackTableHeadSort>,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <div className="capitalize">{row.getValue('status')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
header: ({ column }) => <TanStackTableHeadSort column={column}>Email</TanStackTableHeadSort>,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <div className="lowercase">{row.getValue('email')}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => <div className="text-right">Amount</div>,
|
||||
header: ({ column }) => (
|
||||
<TanStackTableHeadSort column={column} className="justify-end">
|
||||
Amount
|
||||
</TanStackTableHeadSort>
|
||||
),
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue('amount'))
|
||||
@@ -164,33 +168,6 @@ export default function DataTableDemo() {
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
|
||||
// Convert TanStack Table's SortingState to the string format expected by TableHeadSort
|
||||
const getSortString = React.useMemo(() => {
|
||||
if (sorting.length === 0) return ''
|
||||
const sort = sorting[0]
|
||||
return `${sort.id}:${sort.desc ? 'desc' : 'asc'}`
|
||||
}, [sorting])
|
||||
|
||||
// Handle sort changes from TableHeadSort and convert to TanStack Table's SortingState
|
||||
const handleSortChange = React.useCallback(
|
||||
(column: string) => {
|
||||
const currentSort = sorting.find((s) => s.id === column)
|
||||
if (currentSort) {
|
||||
if (currentSort.desc) {
|
||||
// Cycle: desc -> remove sort
|
||||
setSorting([])
|
||||
} else {
|
||||
// Cycle: asc -> desc
|
||||
setSorting([{ id: column, desc: true }])
|
||||
}
|
||||
} else {
|
||||
// New column, start with asc
|
||||
setSorting([{ id: column, desc: false }])
|
||||
}
|
||||
},
|
||||
[sorting]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@@ -254,11 +231,20 @@ export default function DataTableDemo() {
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnId = header.column.id
|
||||
const canSort = header.column.getCanSort()
|
||||
const sort = header.column.getIsSorted()
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
aria-sort={
|
||||
header.column.getCanSort()
|
||||
? sort === 'asc'
|
||||
? 'ascending'
|
||||
: sort === 'desc'
|
||||
? 'descending'
|
||||
: 'none'
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
columnId === 'amount'
|
||||
? 'text-right'
|
||||
@@ -267,18 +253,9 @@ export default function DataTableDemo() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder ? null : canSort ? (
|
||||
<TableHeadSort
|
||||
column={columnId}
|
||||
currentSort={getSortString}
|
||||
onSortChange={handleSortChange}
|
||||
className={columnId === 'amount' ? 'justify-end' : undefined}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHeadSort>
|
||||
) : (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
|
||||
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay/ErrorDisplay'
|
||||
|
||||
export default function ErrorDisplayDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
|
||||
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay/ErrorDisplay'
|
||||
|
||||
export default function ErrorDisplayWithChildren() {
|
||||
return (
|
||||
|
||||
@@ -14,10 +14,14 @@ import {
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
PrePostTab,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Select_Shadcn_,
|
||||
@@ -219,9 +223,12 @@ export default function FormPatternsPageLayout() {
|
||||
description="Input with additional unit label"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<PrePostTab postTab="MB" className="w-full">
|
||||
<Input_Shadcn_ {...field} type="number" min={5} max={30} />
|
||||
</PrePostTab>
|
||||
<InputGroup>
|
||||
<InputGroupInput {...field} type="number" min={5} max={30} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText className="font-mono">MB</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
@@ -252,6 +259,35 @@ export default function FormPatternsPageLayout() {
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{/* Textarea with addon */}
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Textarea"
|
||||
description="Multi-line text input for longer content with addon"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
{...field}
|
||||
rows={4}
|
||||
placeholder="Enter multi-line text"
|
||||
className="resize-none"
|
||||
/>
|
||||
<InputGroupAddon align="block-end">
|
||||
<InputGroupText>120 characters left</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{/* Icon Upload */}
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
@@ -622,7 +658,7 @@ export default function FormPatternsPageLayout() {
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="outline"
|
||||
className="w-full justify-start text-left font-normal px-3 py-4"
|
||||
className="bg-control w-full justify-start text-left font-normal px-3 py-4"
|
||||
icon={<CalendarIcon className="h-4 w-4" />}
|
||||
>
|
||||
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
PrePostTab,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Select_Shadcn_,
|
||||
@@ -233,9 +236,12 @@ export default function FormPatternsSidePanel() {
|
||||
description="Input with additional unit label"
|
||||
>
|
||||
<FormControl_Shadcn_ className="col-span-6">
|
||||
<PrePostTab postTab="MB" className="w-full">
|
||||
<Input_Shadcn_ {...field} type="number" min={5} max={30} />
|
||||
</PrePostTab>
|
||||
<InputGroup>
|
||||
<InputGroupInput {...field} type="number" min={5} max={30} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>MB</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
@@ -653,7 +659,7 @@ export default function FormPatternsSidePanel() {
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="outline"
|
||||
className="w-full justify-start text-left font-normal px-3 py-4"
|
||||
className="bg-control w-full justify-start text-left font-normal px-3 py-4"
|
||||
icon={<CalendarIcon className="h-4 w-4" />}
|
||||
>
|
||||
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidBasic() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidERDiagram() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidErSimple() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidFlowchartSubgraph() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidFlowchart() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidOAuthFlow() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidSequenceApi() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidSequenceAsync() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mermaid } from 'ui'
|
||||
import { Mermaid } from 'ui-patterns/Mermaid'
|
||||
|
||||
export default function MermaidSequenceSync() {
|
||||
return (
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
PrePostTab,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
@@ -125,9 +127,12 @@ export default function PageLayoutSettings() {
|
||||
description="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<PrePostTab postTab="seconds">
|
||||
<Input_Shadcn_ type="number" min={0} {...field} />
|
||||
</PrePostTab>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>seconds</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput type="number" min={0} {...field} />
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
@@ -196,9 +201,14 @@ export default function PageLayoutSettings() {
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FormControl_Shadcn_>
|
||||
<PrePostTab postTab={<HoursOrNeverText value={field.value || 0} />}>
|
||||
<Input_Shadcn_ type="number" min={0} {...field} />
|
||||
</PrePostTab>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>
|
||||
<HoursOrNeverText value={field.value || 0} />
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput type="number" min={0} {...field} />
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
@@ -218,9 +228,14 @@ export default function PageLayoutSettings() {
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FormControl_Shadcn_>
|
||||
<PrePostTab postTab={<HoursOrNeverText value={field.value || 0} />}>
|
||||
<Input_Shadcn_ type="number" {...field} />
|
||||
</PrePostTab>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>
|
||||
<HoursOrNeverText value={field.value || 0} />
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput type="number" {...field} />
|
||||
</InputGroup>
|
||||
</FormControl_Shadcn_>
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
|
||||
@@ -439,6 +439,18 @@ export const examples: Registry = [
|
||||
registryDependencies: ['data-input'],
|
||||
files: ['example/data-input-with-reveal-copy.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'data-input-with-reveal-copy-editable',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['data-input'],
|
||||
files: ['example/data-input-with-reveal-copy-editable.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'data-input-with-reveal-copy-editable-empty',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['data-input'],
|
||||
files: ['example/data-input-with-reveal-copy-editable-empty.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'date-picker-demo',
|
||||
type: 'components:example',
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSources": false,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,40 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "tsconfig/base.json",
|
||||
"extends": "tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"allowJs": false,
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@ui/*": ["./../../packages/ui/src/*"], // handle ui package paths
|
||||
"contentlayer/generated": ["./.contentlayer/generated"],
|
||||
"icons/*": ["./../../packages/icons/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".contentlayer/generated",
|
||||
"./../../packages/ui/src/**/*.d.ts"
|
||||
".contentlayer/generated"
|
||||
],
|
||||
"exclude": ["node_modules", "./scripts/build-registry.mts"]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": false
|
||||
},
|
||||
"include": [".contentlayer/generated", "scripts/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare module '*.css' {
|
||||
export const styles: Record<string, string>
|
||||
export default styles
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
export const styles: Record<string, string>
|
||||
export default styles
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public/llms.txt
|
||||
public/llms/
|
||||
# Generated guide markdown files
|
||||
public/docs/
|
||||
public/docs.tar.gz
|
||||
|
||||
# Copied examples folder
|
||||
/examples/
|
||||
|
||||
@@ -16,14 +16,13 @@ import {
|
||||
removeRedundantH1,
|
||||
} from '~/features/docs/GuidesMdx.utils'
|
||||
import { newEditLink } from '~/features/helpers.edit-link'
|
||||
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
|
||||
import { Guide, GuideArticle, GuideFooter, GuideHeader, GuideMdxContent } from '~/features/ui/guide'
|
||||
import { GUIDES_DIRECTORY, isValidGuideFrontmatter } from '~/lib/docs'
|
||||
import { linkTransform, type UrlTransformFunction } from '~/lib/mdx/plugins/rehypeLinkTransform'
|
||||
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
|
||||
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
|
||||
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
|
||||
import { octokit } from '~/lib/octokit'
|
||||
import { getGitHubFileContents, octokit } from '~/lib/octokit'
|
||||
import type { SerializeOptions } from '~/types/next-mdx-remote-serialize'
|
||||
|
||||
// We fetch these docs at build time from an external repo
|
||||
@@ -85,13 +84,6 @@ async function getLatestRelease(after: string | null = null) {
|
||||
owner: org,
|
||||
name: repo,
|
||||
after,
|
||||
request: {
|
||||
fetch: (url: RequestInfo | URL, options?: RequestInit) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -357,26 +349,14 @@ const getContent = async (params: Params) => {
|
||||
throw new Error('No latest release found for federated wrappers pages')
|
||||
}
|
||||
|
||||
const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}`
|
||||
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
|
||||
cache: 'force-cache',
|
||||
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch wrappers docs from GitHub (network error): ${err}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch wrappers docs from GitHub: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
const rawContent = await response.text()
|
||||
let rawContent = await getGitHubFileContents({
|
||||
org,
|
||||
repo,
|
||||
path: `${docsDir}/${remoteFile}`,
|
||||
branch: tag,
|
||||
})
|
||||
|
||||
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/`
|
||||
|
||||
|
||||
@@ -2,25 +2,19 @@ import { codeBlock } from 'common-tags'
|
||||
import { Check, PlusCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Heading, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
|
||||
import {
|
||||
CodeBlock,
|
||||
Heading,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
} from 'ui'
|
||||
|
||||
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
|
||||
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
|
||||
import { getGitHubFileContents } from '~/lib/octokit'
|
||||
import { TabPanel, Tabs } from '~/features/ui/Tabs'
|
||||
import {
|
||||
terraformDocsBranch,
|
||||
terraformDocsDocsDir,
|
||||
terraformDocsOrg,
|
||||
terraformDocsRepo,
|
||||
} from '../terraformConstants'
|
||||
import { GuideTemplate, newEditLink } from '@/features/docs/GuidesMdx.template'
|
||||
import { genGuideMeta } from '@/features/docs/GuidesMdx.utils'
|
||||
import { TabPanel, Tabs } from '@/features/ui/Tabs'
|
||||
import { getGitHubFileContents } from '@/lib/octokit'
|
||||
|
||||
const meta = {
|
||||
title: 'Terraform Provider reference',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { CodeBlock } from 'ui'
|
||||
import { Heading } from 'ui/src/components/CustomHTMLElements'
|
||||
import { type TOCHeader } from '~/components/GuidesSidebar'
|
||||
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
|
||||
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
|
||||
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
|
||||
import type { Parameter } from '~/lib/refGenerator/refTypes'
|
||||
import specFile from '~/spec/cli_v1_config.yaml' with { type: 'yml' }
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import { Heading } from 'ui/src/components/CustomHTMLElements'
|
||||
|
||||
const meta = {
|
||||
title: 'Supabase CLI config',
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import '@code-hike/mdx/styles'
|
||||
import '@code-hike/mdx/styles.css'
|
||||
import 'config/code-hike.scss'
|
||||
import 'ui-patterns/ShimmeringLoader/index.css'
|
||||
import '../styles/main.scss'
|
||||
import '../styles/new-docs.scss'
|
||||
import '../styles/prism-okaidia.scss'
|
||||
|
||||
import { TelemetryTagManager } from 'common'
|
||||
|
||||
import { genFaviconData } from 'common/MetaFavicons/app-router'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { GlobalProviders } from '~/features/app.providers'
|
||||
import { TopNavSkeleton } from '~/layouts/MainSkeleton'
|
||||
import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants'
|
||||
import { getCustomContent } from '~/lib/custom-content/getCustomContent'
|
||||
import { TelemetryTagManager } from 'common'
|
||||
import { genFaviconData } from 'common/MetaFavicons/app-router'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
|
||||
const { metadataApplicationName, metadataTitle } = getCustomContent([
|
||||
'metadata:application_name',
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { isFeatureEnabled } from 'common'
|
||||
import { type Metadata, type ResolvingMetadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { cn, IconBackground, TextLink } from 'ui'
|
||||
import { cn, IconBackground } from 'ui'
|
||||
import { IconPanel } from 'ui-patterns/IconPanel'
|
||||
import { TextLink } from 'ui-patterns/TextLink'
|
||||
|
||||
import { isFeatureEnabled } from 'common'
|
||||
import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker'
|
||||
import { MIGRATION_PAGES } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
import { GlassPanelWithIconPicker } from '~/features/ui/GlassPanelWithIconPicker'
|
||||
import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker'
|
||||
import HomeLayout from '~/layouts/HomeLayout'
|
||||
import { BASE_PATH } from '~/lib/constants'
|
||||
import MenuIconPicker from '@/components/Navigation/NavigationMenu/MenuIconPicker'
|
||||
import { MIGRATION_PAGES } from '@/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
import { GlassPanelWithIconPicker } from '@/features/ui/GlassPanelWithIconPicker'
|
||||
import { IconPanelWithIconPicker } from '@/features/ui/IconPanelWithIconPicker'
|
||||
import HomeLayout from '@/layouts/HomeLayout'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
const { sdkCsharp, sdkDart, sdkKotlin, sdkPython, sdkSwift } = isFeatureEnabled([
|
||||
'sdk:csharp',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { KJUR } from 'jsrsasign'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { Button, CodeBlock, Input, Select } from 'ui'
|
||||
import { Button, Input, Select } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
|
||||
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
|
||||
const now = new Date()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { KJUR } from 'jsrsasign'
|
||||
import { useState } from 'react'
|
||||
import { Button, CodeBlock } from 'ui'
|
||||
import { Button } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
|
||||
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
|
||||
|
||||
|
||||
@@ -2862,6 +2862,7 @@ export const self_hosting: NavMenuConstant = {
|
||||
},
|
||||
{ name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' },
|
||||
{ name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' },
|
||||
{ name: 'Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' },
|
||||
{ name: 'Configure Social Login (OAuth)', url: '/guides/self-hosting/self-hosted-oauth' },
|
||||
{ name: 'Configure Phone Login & MFA', url: '/guides/self-hosting/self-hosted-phone-mfa' },
|
||||
{ name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' },
|
||||
|
||||
@@ -156,7 +156,7 @@ export const GET: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
getAll() {
|
||||
return parseCookieHeader(Astro.request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, _headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
Astro.cookies.set(name, value, options)
|
||||
)
|
||||
@@ -191,7 +191,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const requestUrl = new URL(request.url)
|
||||
const code = requestUrl.searchParams.get('code')
|
||||
const next = requestUrl.searchParams.get('next') || '/'
|
||||
const headers = new Headers()
|
||||
const responseHeaders = new Headers()
|
||||
|
||||
if (code) {
|
||||
const supabase = createServerClient(
|
||||
@@ -202,10 +202,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, cacheHeaders) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
)
|
||||
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -214,12 +215,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
if (!error) {
|
||||
return redirect(next, { headers })
|
||||
return redirect(next, { headers: responseHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// return the user to an error page with instructions
|
||||
return redirect('/auth/auth-code-error', { headers })
|
||||
return redirect('/auth/auth-code-error', { headers: responseHeaders })
|
||||
}
|
||||
```
|
||||
|
||||
@@ -240,11 +241,14 @@ app.get("/auth/callback", async function (req, res) {
|
||||
process.env.SUPABASE_PUBLISHABLE_KEY, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return parseCookieHeader(context.req.headers.cookie ?? '')
|
||||
return parseCookieHeader(req.headers.cookie ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
context.res.appendHeader('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
res.appendHeader('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
)
|
||||
Object.entries(headers).forEach(([key, value]) =>
|
||||
res.setHeader(key, value)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -212,7 +212,7 @@ export default async function ConsentPage({
|
||||
{
|
||||
cookies: {
|
||||
getAll: async () => (await cookies()).getAll(),
|
||||
setAll: async (cookiesToSet) => {
|
||||
setAll: async (cookiesToSet, _headers) => {
|
||||
const cookieStore = await cookies()
|
||||
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
|
||||
},
|
||||
@@ -297,7 +297,7 @@ export async function POST(request: Request) {
|
||||
{
|
||||
cookies: {
|
||||
getAll: async () => (await cookies()).getAll(),
|
||||
setAll: async (cookiesToSet) => {
|
||||
setAll: async (cookiesToSet, _headers) => {
|
||||
const cookieStore = await cookies()
|
||||
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
|
||||
},
|
||||
|
||||
@@ -280,7 +280,7 @@ export const GET: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, _headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => cookies.set(name, value, options))
|
||||
},
|
||||
},
|
||||
@@ -801,7 +801,7 @@ export const GET: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, _headers) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => cookies.set(name, value, options))
|
||||
},
|
||||
},
|
||||
|
||||
@@ -72,7 +72,9 @@ Do not enable ISR on any route where authentication is handled or where a sessio
|
||||
|
||||
When `@supabase/ssr` refreshes a session token server-side, it writes the updated JWT to the HTTP response via a `Set-Cookie` header. If your CDN (e.g. Vercel Edge, Cloudflare) caches that response and serves it to a different user, that user's browser will store the cached token and be signed in as the wrong person.
|
||||
|
||||
To prevent this, set `Cache-Control: private, no-store` on responses from any route that handles authentication, typically your middleware. Most CDNs respect this header and will not cache the response.
|
||||
As of `@supabase/ssr` v0.10.0, the library automatically passes the necessary cache headers (`Cache-Control`, `Expires`, `Pragma`) to your `setAll` callback as a second argument whenever a token refresh occurs. If your `setAll` implementation applies those headers to the response (as shown in the examples in [Creating a Supabase client for SSR](/docs/guides/auth/server-side/creating-a-client)), no additional manual configuration is needed for most CDNs.
|
||||
|
||||
If you are on an older version or need to set headers manually, add `Cache-Control: private, no-store` to responses from any route that handles authentication:
|
||||
|
||||
#### Next.js middleware
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ The Proxy is responsible for:
|
||||
|
||||
The cookies object lets the Supabase client know how to access the cookies, so it can read and write the user session data. To make `@supabase/ssr` framework-agnostic, the cookies methods aren't hard-coded. These utility functions adapt `@supabase/ssr`'s cookie handling for Next.js.
|
||||
|
||||
The `set` and `remove` methods for the server client need error handlers, because Next.js throws an error if cookies are set from Server Components. You can safely ignore this error because you'll set up Proxy in the next step to write refreshed cookies to storage.
|
||||
`setAll` is called whenever the library needs to write cookies, for example after a token refresh. It receives two arguments: the array of cookies to set, and a `headers` object containing cache headers (`Cache-Control`, `Expires`, `Pragma`) that must be applied to the HTTP response to prevent CDNs from caching the response and leaking the session to other users. In the Proxy, apply these headers to the response. In Server Components, the headers cannot be set, which is why the `setAll` call is wrapped in a try/catch and the error is ignored. The Proxy handles writing cookies and headers on every request.
|
||||
|
||||
The cookie is named `sb-<project_ref>-auth-token` by default.
|
||||
|
||||
@@ -346,9 +346,12 @@ const supabase = createServerClient(
|
||||
getAll() {
|
||||
return parseCookieHeader(Astro.request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
Astro.cookies.set(name, value))
|
||||
Object.entries(headers).forEach(([key, value]) =>
|
||||
Astro.response.headers.set(key, value)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -388,7 +391,7 @@ export async function GET(context: APIContext) {
|
||||
getAll() {
|
||||
return parseCookieHeader(context.request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, _headers) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
context.cookies.set(name, value))
|
||||
},
|
||||
@@ -417,7 +420,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
getAll() {
|
||||
return parseCookieHeader(context.request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, _headers) {
|
||||
cookiesToSet.forEach(({ name, value }) => context.cookies.set(name, value))
|
||||
},
|
||||
},
|
||||
@@ -452,7 +455,7 @@ import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const headers = new Headers()
|
||||
const responseHeaders = new Headers()
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.SUPABASE_URL!,
|
||||
@@ -462,17 +465,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, cacheHeaders) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
)
|
||||
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return new Response('...', {
|
||||
headers,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -486,7 +490,7 @@ import { type ActionFunctionArgs } from '@remix-run/node'
|
||||
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const headers = new Headers()
|
||||
const responseHeaders = new Headers()
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.SUPABASE_URL!,
|
||||
@@ -496,17 +500,18 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, cacheHeaders) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value, options))
|
||||
)
|
||||
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return new Response('...', {
|
||||
headers,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -563,23 +568,24 @@ import { LoaderFunctionArgs } from 'react-router'
|
||||
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const headers = new Headers()
|
||||
const responseHeaders = new Headers()
|
||||
|
||||
const supabase = createServerClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, cacheHeaders) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
headers.append('Set-Cookie', serializeCookieHeader(name, value))
|
||||
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value))
|
||||
)
|
||||
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return new Response('...', {
|
||||
headers,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -593,23 +599,24 @@ import { type ActionFunctionArgs } from '@react-router'
|
||||
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const headers = new Headers()
|
||||
const responseHeaders = new Headers()
|
||||
|
||||
const supabase = createServerClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get('Cookie') ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, cacheHeaders) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
headers.append('Set-Cookie', serializeCookieHeader(name, value))
|
||||
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value))
|
||||
)
|
||||
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return new Response('...', {
|
||||
headers,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -670,10 +677,11 @@ exports.createClient = (context) => {
|
||||
getAll() {
|
||||
return parseCookieHeader(context.req.headers.cookie ?? '')
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
context.res.appendHeader('Set-Cookie', serializeCookieHeader(name, value))
|
||||
)
|
||||
Object.entries(headers).forEach(([key, value]) => context.res.setHeader(key, value))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ sidebar_label: 'Monitoring'
|
||||
|
||||
Monitoring replication lag is important and there are 3 ways to do this:
|
||||
|
||||
1. Dashboard - Under the [Reports](/docs/guides/platform/reports) of the dashboard, you can view the replication lag of your project
|
||||
1. Dashboard - Under the [Reports](/docs/guides/telemetry/reports) of the dashboard, you can view the replication lag of your project
|
||||
2. Database -
|
||||
- pg_stat_subscription (subscriber) - if PID is null, then the subscription is not active
|
||||
- pg_stat_subscription_stats - look here for error_count to see if there were issues applying or syncing (if yes, check the logs for why)
|
||||
|
||||
@@ -27,4 +27,4 @@ When you merge any branch into your main project, Supabase automatically runs a
|
||||
6. **Seed** - Runs seed files to populate your branch with initial data (must be [enabled in config.toml](/docs/guides/deployment/branching/configuration#branch-configuration-with-remotes) for persistent branches)
|
||||
7. **Deploy** - Deploys any changed Edge Functions and updates function secrets
|
||||
|
||||
If a parent deployment step fails, all dependent children steps will be skipped. For e.g., if your database migrations failed at step 5, our runner will not seed your branch because step 6 is skipped. If you are using GitHub integration, the same deployment workflow will be run on every commit pushed to your git branch.
|
||||
If a parent deployment step fails, all dependent child steps will be skipped. For instance, if your database migrations failed at step 5, our runner will not seed your branch because step 6 is skipped. If you are using GitHub integration, the same deployment workflow will be run on every commit pushed to your git branch.
|
||||
@@ -9,7 +9,7 @@ description: 'Learn how to use Supabase in your Ionic React App.'
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/mhartington/supabase-ionic-react).
|
||||
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/ionic-react-user-management).
|
||||
|
||||
</Admonition>
|
||||
|
||||
@@ -17,11 +17,11 @@ If you get stuck while working through this guide, refer to the [full example on
|
||||
|
||||
## Building the app
|
||||
|
||||
Let's start building the React app from scratch.
|
||||
Start building the React app from scratch.
|
||||
|
||||
### Initialize an Ionic React app
|
||||
|
||||
We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
|
||||
Use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
|
||||
an app called `supabase-ionic-react`:
|
||||
|
||||
```bash
|
||||
@@ -30,352 +30,63 @@ ionic start supabase-ionic-react blank --type react
|
||||
cd supabase-ionic-react
|
||||
```
|
||||
|
||||
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
Install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
And finally we want to save the environment variables in a `.env`.
|
||||
All we need are the API URL and the key that you copied [earlier](#get-api-details).
|
||||
Save the environment variables in a `.env`. You need the API URL and the key that you copied [earlier](#get-api-details).
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```bash name=.env
|
||||
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
|
||||
VITE_SUPABASE_KEY=YOUR_SUPABASE_KEY
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed
|
||||
on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
||||
With the API credentials in place, create a helper file to initialize the Supabase client. These variables will be exposed
|
||||
in the browser, which is safe because they use a restricted publishable key and the SQL quickstart enables [Row Level Security](/docs/guides/auth#row-level-security) on the `profiles` table.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```js name=src/supabaseClient.ts
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || ''
|
||||
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || ''
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabasePublishableKey)
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/ionic-react-user-management/src/supabaseClient.ts"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=src/supabaseClient.ts"
|
||||
/>
|
||||
|
||||
### Set up a login route
|
||||
|
||||
Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
Set up a React component to manage logins and sign ups which uses Magic Links, so users can sign in with their email without using passwords.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```jsx name=/src/pages/Login.tsx
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonToast,
|
||||
useIonLoading,
|
||||
} from '@ionic/react';
|
||||
|
||||
import {supabase} from '../supabaseClient'
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const [showLoading, hideLoading] = useIonLoading();
|
||||
const [showToast ] = useIonToast();
|
||||
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
console.log()
|
||||
e.preventDefault();
|
||||
await showLoading();
|
||||
try {
|
||||
await supabase.auth.signInWithOtp({
|
||||
"email": email
|
||||
});
|
||||
await showToast({ message: 'Check your email for the login link!' });
|
||||
} catch (e: any) {
|
||||
await showToast({ message: e.error_description || e.message , duration: 5000});
|
||||
} finally {
|
||||
await hideLoading();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Login</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<div className="ion-padding">
|
||||
<h1>Supabase + Ionic React</h1>
|
||||
<p>Sign in via magic link with your email below</p>
|
||||
</div>
|
||||
<IonList inset={true}>
|
||||
<form onSubmit={handleLogin}>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Email</IonLabel>
|
||||
<IonInput
|
||||
value={email}
|
||||
name="email"
|
||||
onIonChange={(e) => setEmail(e.detail.value ?? '')}
|
||||
type="email"
|
||||
></IonInput>
|
||||
</IonItem>
|
||||
<div className="ion-text-center">
|
||||
<IonButton type="submit" fill="clear">
|
||||
Login
|
||||
</IonButton>
|
||||
</div>
|
||||
</form>
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/ionic-react-user-management/src/pages/Login.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=src/pages/Login.tsx"
|
||||
/>
|
||||
|
||||
### Account page
|
||||
|
||||
After a user is signed in we can allow them to edit their profile details and manage their account.
|
||||
After a user signs in, they should be able to edit their profile details and manage their account.
|
||||
|
||||
Let's create a new component for that called `Account.tsx`.
|
||||
Create a new component for that called `Account.tsx`.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```jsx name=src/pages/Account.tsx
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonLoading,
|
||||
useIonToast,
|
||||
useIonRouter
|
||||
} from '@ionic/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
|
||||
export function AccountPage() {
|
||||
const [showLoading, hideLoading] = useIonLoading();
|
||||
const [showToast] = useIonToast();
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const router = useIonRouter();
|
||||
const [profile, setProfile] = useState({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const getSession = async () => {
|
||||
setSession(await supabase.auth.getSession().then((res) => res.data.session))
|
||||
}
|
||||
getSession()
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getProfile();
|
||||
}, [session]);
|
||||
const getProfile = async () => {
|
||||
console.log('get');
|
||||
await showLoading();
|
||||
try {
|
||||
const user = await supabase.auth.getUser();
|
||||
const { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user!.data.user?.id)
|
||||
.single();
|
||||
|
||||
if (error && status !== 406) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setProfile({
|
||||
username: data.username,
|
||||
website: data.website,
|
||||
avatar_url: data.avatar_url,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast({ message: error.message, duration: 5000 });
|
||||
} finally {
|
||||
await hideLoading();
|
||||
}
|
||||
};
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
router.push('/', 'forward', 'replace');
|
||||
}
|
||||
const updateProfile = async (e?: any, avatar_url: string = '') => {
|
||||
e?.preventDefault();
|
||||
|
||||
console.log('update ');
|
||||
await showLoading();
|
||||
|
||||
try {
|
||||
const user = await supabase.auth.getUser();
|
||||
|
||||
const updates = {
|
||||
id: user!.data.user?.id,
|
||||
...profile,
|
||||
avatar_url: avatar_url,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
const { error } = await supabase.from('profiles').upsert(updates);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast({ message: error.message, duration: 5000 });
|
||||
} finally {
|
||||
await hideLoading();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Account</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<form onSubmit={updateProfile}>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<p>Email</p>
|
||||
<p>{session?.user?.email}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Name</IonLabel>
|
||||
<IonInput
|
||||
type="text"
|
||||
name="username"
|
||||
value={profile.username}
|
||||
onIonChange={(e) =>
|
||||
setProfile({ ...profile, username: e.detail.value ?? '' })
|
||||
}
|
||||
></IonInput>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Website</IonLabel>
|
||||
<IonInput
|
||||
type="url"
|
||||
name="website"
|
||||
value={profile.website}
|
||||
onIonChange={(e) =>
|
||||
setProfile({ ...profile, website: e.detail.value ?? '' })
|
||||
}
|
||||
></IonInput>
|
||||
</IonItem>
|
||||
<div className="ion-text-center">
|
||||
<IonButton fill="clear" type="submit">
|
||||
Update Profile
|
||||
</IonButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="ion-text-center">
|
||||
<IonButton fill="clear" onClick={signOut}>
|
||||
Log Out
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/ionic-react-user-management/src/pages/Account.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=src/pages/Account.tsx"
|
||||
/>
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `App.tsx`:
|
||||
Now that you have all the components in place, update `App.tsx`:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```jsx name=src/App.tsx
|
||||
import { Redirect, Route } from 'react-router-dom'
|
||||
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'
|
||||
import { IonReactRouter } from '@ionic/react-router'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
import '@ionic/react/css/ionic.bundle.css'
|
||||
|
||||
/* Theme variables */
|
||||
import './theme/variables.css'
|
||||
import { LoginPage } from './pages/Login'
|
||||
import { AccountPage } from './pages/Account'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
|
||||
setupIonicReact()
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
useEffect(() => {
|
||||
const getSession = async () => {
|
||||
setSession(await supabase.auth.getSession().then((res) => res.data.session))
|
||||
}
|
||||
getSession()
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
}, [])
|
||||
return (
|
||||
<IonApp>
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
<Route
|
||||
exact
|
||||
path="/"
|
||||
render={() => {
|
||||
return session ? <Redirect to="/account" /> : <LoginPage />
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/account">
|
||||
<AccountPage />
|
||||
</Route>
|
||||
</IonRouterOutlet>
|
||||
</IonReactRouter>
|
||||
</IonApp>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/ionic-react-user-management/src/App.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=src/App.tsx"
|
||||
/>
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
|
||||
@@ -383,7 +94,7 @@ Once that's done, run this in a terminal window:
|
||||
ionic serve
|
||||
```
|
||||
|
||||
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
||||
Then open your browser to the URL printed by `ionic serve` (by default, [http://localhost:8100](http://localhost:8100)) and you should see the completed app.
|
||||
|
||||

|
||||
|
||||
@@ -403,142 +114,30 @@ npm install @ionic/pwa-elements @capacitor/camera
|
||||
|
||||
Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.
|
||||
|
||||
With those packages installed we can update our `index.tsx` to include an additional bootstrapping call for the Ionic PWA Elements.
|
||||
With those packages installed update `index.tsx` to include an additional bootstrapping call for the Ionic PWA Elements.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/index.tsx
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import App from './App'
|
||||
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
|
||||
import reportWebVitals from './reportWebVitals'
|
||||
|
||||
import { defineCustomElements } from '@ionic/pwa-elements/loader'
|
||||
defineCustomElements(window)
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
|
||||
serviceWorkerRegistration.unregister()
|
||||
reportWebVitals()
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/ionic-react-user-management/src/index.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=src/index.tsx"
|
||||
/>
|
||||
|
||||
Then create an `AvatarComponent`.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```jsx name=src/components/Avatar.tsx
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { person } from 'ionicons/icons';
|
||||
import { Camera, CameraResultType } from '@capacitor/camera';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
import './Avatar.css'
|
||||
export function Avatar({
|
||||
url,
|
||||
onUpload,
|
||||
}: {
|
||||
url: string;
|
||||
onUpload: (e: any, file: string) => Promise<void>;
|
||||
}) {
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
downloadImage(url);
|
||||
}
|
||||
}, [url]);
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
const photo = await Camera.getPhoto({
|
||||
resultType: CameraResultType.DataUrl,
|
||||
});
|
||||
|
||||
const file = await fetch(photo.dataUrl!)
|
||||
.then((res) => res.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new File([blob], 'my-file', { type: `image/${photo.format}` })
|
||||
);
|
||||
|
||||
const fileName = `${Math.random()}-${new Date().getTime()}.${
|
||||
photo.format
|
||||
}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(fileName, file);
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
onUpload(null, fileName);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = async (path: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.storage
|
||||
.from('avatars')
|
||||
.download(path);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
const url = URL.createObjectURL(data!);
|
||||
setAvatarUrl(url);
|
||||
} catch (error: any) {
|
||||
console.log('Error downloading image: ', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="avatar">
|
||||
<div className="avatar_wrapper" onClick={uploadAvatar}>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} />
|
||||
) : (
|
||||
<IonIcon icon={person} className="no-avatar" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/ionic-react-user-management/src/components/Avatar.tsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=src/components/Avatar.tsx"
|
||||
/>
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget to the Account page:
|
||||
And then add the widget to the Account page:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```jsx name=src/pages/Account.tsx
|
||||
// Import the new component
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
|
||||
// ...
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Account</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<Avatar url={profile.avatar_url} onUpload={updateProfile}></Avatar>
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
<$CodeSample
|
||||
path="/user-management/ionic-react-user-management/src/pages/Account.tsx"
|
||||
lines={[[16, 16], [101, 110]]}
|
||||
meta="name=src/pages/Account.tsx"
|
||||
/>
|
||||
|
||||
At this stage you have a fully functional application!
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: 'Custom Email Templates'
|
||||
description: 'Configure custom email templates with self-hosted Supabase instance'
|
||||
subtitle: 'Configure custom email templates with self-hosted Supabase instance'
|
||||
---
|
||||
|
||||
When running a self-hosted Supabase instance, you can fully customize emails sent by Supabase Auth.
|
||||
|
||||
## Overview
|
||||
|
||||
Supabase Auth does not read email templates from mounted Docker volumes. Instead, it expects each template to be available at a URL that returns a valid HTML template.
|
||||
|
||||
This URL:
|
||||
|
||||
- Does not need to be public
|
||||
- Must be reachable from `auth` service
|
||||
- Must return a valid Golang HTML template
|
||||
|
||||
To provide templates to Supabase Auth, you need a service that serves static HTML files. This can be any server of your choice. You can even use `kong` service which is included with the default Supabase docker configuration. The only requirement is that the `auth` service must be able to reach it via a HTTP GET request.
|
||||
|
||||
This guide uses [Caddy](https://github.com/caddyserver/caddy) for serving templates.
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
If Supabase Auth cannot fetch the template or if the fetched template is invalid, it falls back to the default template.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Authentication email templates
|
||||
|
||||
Authentication email templates can be configured using the following environment variables:
|
||||
|
||||
- `GOTRUE_MAILER_TEMPLATES_<AUTH_FLOW>`: Provide a custom template URL. Falls back to the default template if not set.
|
||||
- `GOTRUE_MAILER_SUBJECTS_<AUTH_FLOW>`: Customize the email subject. Falls back to the default subject if not set.
|
||||
|
||||
| Auth flow | Sent |
|
||||
| ------------------ | -------------------------------------------------------------------- |
|
||||
| `CONFIRMATION` | When a user signs up and needs to verify their email address |
|
||||
| `RECOVERY` | When a user requests a password reset |
|
||||
| `MAGIC_LINK` | When a user requests a magic link for password-less authentication |
|
||||
| `INVITE` | When a user is invited to join your application via email invitation |
|
||||
| `EMAIL_CHANGE` | When a user requests to change their email address |
|
||||
| `REAUTHENTICATION` | When a user needs to re-authenticate for sensitive operations |
|
||||
|
||||
For example:
|
||||
|
||||
```sh
|
||||
GOTRUE_MAILER_TEMPLATES_MAGIC_LINK='<template_url>'
|
||||
GOTRUE_MAILER_SUBJECTS_MAGIC_LINK='<custom_subject>'
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
Below is an example configuration for setting up a custom invite template.
|
||||
|
||||
### Step 1: Create a templates directory
|
||||
|
||||
Create a `templates` directory inside the existing `volumes` directory and add your email templates to it.
|
||||
|
||||
Your directory structure should look like this:
|
||||
|
||||
```
|
||||
volumes/
|
||||
templates/
|
||||
invite.html
|
||||
```
|
||||
|
||||
### Step 2: Update `docker-compose.yml`
|
||||
|
||||
Update the `auth` service to depend on `templates-server`, and pass the email template environment variables. Then add a `templates-server` service to serve the templates from `./volumes/templates`.
|
||||
|
||||
```yml
|
||||
services:
|
||||
auth:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
templates-server: # 👈 new dependency
|
||||
condition: service_started
|
||||
environment:
|
||||
GOTRUE_MAILER_TEMPLATES_INVITE: 'http://templates-server/invite.html'
|
||||
GOTRUE_MAILER_SUBJECTS_INVITE: 'You have been invited'
|
||||
|
||||
templates-server:
|
||||
image: caddy:2-alpine
|
||||
command: ['caddy', 'file-server', '-r', '/templates', '--listen', ':80']
|
||||
volumes:
|
||||
- ./volumes/templates:/templates
|
||||
```
|
||||
|
||||
#### What this configuration does
|
||||
|
||||
- Adds a `templates-server`service that runs alongside the Supabase services in the same docker network.
|
||||
- Serves your custom email template files from the `./volumes/templates` directory.
|
||||
- Keeps the templates-server private to the Docker network (no published ports), so it is not accessible from outside.
|
||||
- Allows the `auth` service to fetch templates via `http://templates-server/<template>.html`.
|
||||
|
||||
### Step 3: Restart containers
|
||||
|
||||
```sh
|
||||
docker compose up -d --force-recreate --no-deps auth templates-server
|
||||
```
|
||||
|
||||
## Notification email templates
|
||||
|
||||
Notification email templates can be configured using the following environment variables:
|
||||
|
||||
- `GOTRUE_MAILER_NOTIFICATIONS_<NOTIFICATION_TYPE>_ENABLED`: Enable the notification email
|
||||
- `GOTRUE_MAILER_TEMPLATES_<NOTIFICATION_TYPE>_NOTIFICATION`: Provide a custom template URL. Falls back to the default template if not set.
|
||||
- `GOTRUE_MAILER_SUBJECTS_<NOTIFICATION_TYPE>_NOTIFICATION`: Customize the email subject. Falls back to the default subject if not set.
|
||||
|
||||
| Notification Type | Sent |
|
||||
| ----------------------- | ----------------------------------------------------- |
|
||||
| `PASSWORD_CHANGED` | When a user's password is changed |
|
||||
| `EMAIL_CHANGED` | When a user's email address is changed |
|
||||
| `PHONE_CHANGED` | When a user's phone number is changed |
|
||||
| `MFA_FACTOR_ENROLLED` | When a new MFA factor is added to the user's account |
|
||||
| `MFA_FACTOR_UNENROLLED` | When an MFA factor is removed from the user's account |
|
||||
| `IDENTITY_LINKED` | When a new identity is linked to the account |
|
||||
| `IDENTITY_UNLINKED` | When an identity is unlinked from the account |
|
||||
|
||||
For example:
|
||||
|
||||
```sh
|
||||
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED='true'
|
||||
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION='<template_url>'
|
||||
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION='<custom_subject>'
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
Below is an example configuration for setting up a custom password changed notification template.
|
||||
|
||||
### Step 1: Create the templates directory
|
||||
|
||||
This example reuses the directory created in **Auth Email Templates – Step 1** (`volumes/templates`). Add your notification email templates to this directory.
|
||||
|
||||
Your directory structure should look like this:
|
||||
|
||||
```
|
||||
volumes/
|
||||
templates/
|
||||
invite.html
|
||||
password_changed_notification.html
|
||||
```
|
||||
|
||||
### Step 2: Update `docker-compose.yml`
|
||||
|
||||
Update the `auth` service in `docker-compose.yml` to enable password changed notification
|
||||
|
||||
```yml
|
||||
services:
|
||||
auth:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
templates-server:
|
||||
condition: service_started
|
||||
environment:
|
||||
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: 'true' # 👈 enabling the notification is required
|
||||
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION: 'http://templates-server/password_changed_notification.html'
|
||||
GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: 'Your password has been changed'
|
||||
|
||||
templates-server:
|
||||
image: caddy:2-alpine
|
||||
command: ['caddy', 'file-server', '-r', '/templates', '--listen', ':80']
|
||||
volumes:
|
||||
- ./volumes/templates:/templates
|
||||
```
|
||||
|
||||
### Step 3: Restart containers
|
||||
|
||||
```sh
|
||||
docker compose up -d --force-recreate --no-deps auth templates-server
|
||||
```
|
||||
@@ -446,7 +446,7 @@ See the [Configure Phone Login & MFA](/docs/guides/self-hosting/self-hosted-phon
|
||||
|
||||
Configuring the Supabase AI Assistant is optional. By adding **your own** `OPENAI_API_KEY` to `.env` you can enable AI services, which help with writing SQL queries, statements, and policies.
|
||||
|
||||
#### Setting database's `log_min_messages`
|
||||
#### Setting log_min_messages in Postgres
|
||||
|
||||
By default, the database's `log_min_messages` configuration is set to `fatal` in [docker-compose.yml](https://github.com/supabase/supabase/blob/df8729a82b1847e2989c14ede27965612761d503/docker/docker-compose.yml#L466) to prevent redundant logs generated by Realtime. You can configure `log_min_messages` using any of the Postgres [Severity Levels](https://www.postgresql.org/docs/current/runtime-config-logging.html#RUNTIME-CONFIG-SEVERITY-LEVELS).
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ When `JWT_KEYS` is set, Auth will start signing new user session JWTs with the n
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Rotating `sb_` API keys
|
||||
## Rotating the new API keys
|
||||
|
||||
If your new API keys are compromised or you want to rotate them periodically, you can regenerate `sb_publishable` and `sb_secret` without touching the asymmetric key pair:
|
||||
|
||||
@@ -175,9 +175,9 @@ Regenerating asymmetric keys invalidates all ES256 user sessions. Plan a mainten
|
||||
|
||||
Below are a few notes on the details of the new authentication architecture.
|
||||
|
||||
### What `supabase-js` sends
|
||||
### What client SDK sends
|
||||
|
||||
Every request includes two headers:
|
||||
Every request via `supabase-js` includes two headers:
|
||||
|
||||
- `apikey` - the API key (`sb_` or legacy JWT)
|
||||
- `Authorization` - when unauthenticated, the client SDK copies the API key here (`Bearer sb_publishable_xxx` or `Bearer eyJ...`). When authenticated, this contains the user session JWT minted by Auth.
|
||||
|
||||
@@ -24,7 +24,7 @@ This returns `"Hello from Edge Functions!"`.
|
||||
|
||||
## Create a new function
|
||||
|
||||
### Step 1: Create a new directory with an `index.ts` file in `volumes/functions/`:
|
||||
### Step 1: Add a new function directory and the function code
|
||||
|
||||
```
|
||||
mkdir -p volumes/functions/my-function &&
|
||||
@@ -44,13 +44,13 @@ Deno.serve(async (req: Request) => {
|
||||
})
|
||||
```
|
||||
|
||||
### Step 2: Restart the functions service to pick up the new function:
|
||||
### Step 2: Restart the functions service to pick up the new function
|
||||
|
||||
```bash
|
||||
docker compose restart functions --no-deps
|
||||
```
|
||||
|
||||
### Step 3: Invoke your function:
|
||||
### Step 3: Invoke your function
|
||||
|
||||
```bash
|
||||
curl -X POST http://<your-domain>:8000/functions/v1/my-function \
|
||||
|
||||
@@ -56,7 +56,7 @@ The default `.env.example` and `docker-compose.yml` include commented-out placeh
|
||||
2. Set the **authorized redirect URL**, e.g., `https://<your-domain>/auth/v1/callback`
|
||||
3. Copy the **client ID** and **client secret** into your `.env` file.
|
||||
|
||||
### Step 2: Configure variables in the `.env` file
|
||||
### Step 2: Configure environment variables
|
||||
|
||||
Uncomment the lines for your provider in `.env` and add your client ID and secret, e.g., for Google:
|
||||
|
||||
@@ -66,7 +66,9 @@ GOOGLE_CLIENT_ID=your-client-id
|
||||
GOOGLE_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
### Step 3: Enable the matching lines in `docker-compose.yml`
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### Step 3: Enable the matching lines in Docker Compose configuration
|
||||
|
||||
Uncomment the corresponding `GOTRUE_EXTERNAL_` lines in the `auth` service's `environment`:
|
||||
|
||||
@@ -400,12 +402,12 @@ For detailed client-side integration, see [Social Login](/docs/guides/auth/socia
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Provider not enabled" or provider shows `false` in `/auth/v1/settings`
|
||||
### "Provider not enabled" or provider seen as false in settings
|
||||
|
||||
- Check that `GOTRUE_EXTERNAL_*_ENABLED` is set to `true` in `docker-compose.yml`
|
||||
- Verify the `.env` variable is not empty, e.g., check with `docker compose exec auth env | grep GOOGLE`
|
||||
|
||||
### Variables added to `.env` but provider still not working
|
||||
### Variables added to the environment but provider still not working
|
||||
|
||||
Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for:
|
||||
|
||||
@@ -415,7 +417,7 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED}
|
||||
|
||||
Run `docker compose exec auth env | grep GOTRUE_EXTERNAL` to verify the variables are reaching the container.
|
||||
|
||||
### `SITE_URL` or redirect URL errors after login
|
||||
### Site URL or redirect URL errors after login
|
||||
|
||||
After a successful OAuth login, the Auth service redirects to `SITE_URL` or a URL from `ADDITIONAL_REDIRECT_URLS`. Ensure:
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ The default `.env.example` and `docker-compose.yml` include commented-out SMS pr
|
||||
|
||||
To enable SMS delivery:
|
||||
|
||||
### Step 1: Uncomment and configure the settings in `.env`
|
||||
### Step 1: Uncomment and configure the environment variables
|
||||
|
||||
```
|
||||
SMS_PROVIDER=twilio
|
||||
@@ -38,7 +38,9 @@ SMS_TWILIO_AUTH_TOKEN=your-auth-token
|
||||
SMS_TWILIO_MESSAGE_SERVICE_SID=your-message-service-sid
|
||||
```
|
||||
|
||||
### Step 2: Uncomment the matching lines in `docker-compose.yml`
|
||||
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
||||
|
||||
### Step 2: Uncomment the matching lines in Docker Compose configuration
|
||||
|
||||
Uncomment the `GOTRUE_SMS_*` lines in the `auth` service's `environment` block:
|
||||
|
||||
@@ -200,7 +202,7 @@ Common causes:
|
||||
- Provider credentials are wrong
|
||||
- Phone number format is wrong (use E.164 format: `+1234567890`)
|
||||
|
||||
### Variables are configured in `.env` but not working
|
||||
### Variables added to the environment but not working
|
||||
|
||||
Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for:
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ kong:
|
||||
KONG_SSL_CERT_KEY: /home/kong/server.key
|
||||
```
|
||||
|
||||
### Step 3: Update configuration variables in `.env`
|
||||
### Step 3: Update configuration variables
|
||||
|
||||
Edit your `.env` file to use HTTPS with the Kong HTTPS port:
|
||||
|
||||
|
||||
@@ -134,6 +134,40 @@ storage:
|
||||
- Open Studio and upload a file to a bucket. List the file using the AWS CLI or `rclone` to confirm the S3 endpoint works.
|
||||
- If using an S3 backend: confirm the file appears in your S3 provider's console.
|
||||
|
||||
## Session token
|
||||
|
||||
You can authenticate to Supabase's S3-compatible storage using a user’s JWT to enforce Row-Level Security (RLS) across S3 operations. This is useful when initializing the S3 client on the server for a specific user session, or when using the client directly from the frontend.
|
||||
|
||||
All operations performed with a session token are scoped to the authenticated user, and any RLS policies defined in the storage schema will be applied.
|
||||
|
||||
To authenticate with S3 using a session token, provide the following credentials:
|
||||
|
||||
- **region:** value from the `REGION` environment variable in your `.env` file
|
||||
- **access_key_id:** value from the `STORAGE_TENANT_ID` environment variable in your `.env` file
|
||||
- **secret_access_key:** value from the `ANON_KEY` environment variable
|
||||
- **session_token:** a valid user JWT
|
||||
|
||||
Example using the `aws-sdk` library:
|
||||
|
||||
```javascript
|
||||
import { S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
const client = new S3Client({
|
||||
forcePathStyle: true,
|
||||
region: 'stub', // REGION in .env
|
||||
endpoint: 'http://<your-domain>/storage/v1/s3', // Edit <your-domain>
|
||||
credentials: {
|
||||
accessKeyId: 'stub', // STORAGE_TENANT_ID in .env
|
||||
secretAccessKey: 'your-anon-key', // ANON_KEY in .env
|
||||
sessionToken: session.access_token,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Signature mismatch errors
|
||||
|
||||
@@ -108,3 +108,9 @@ const client = new S3Client({
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
On self-hosted Supabase, the `accessKeyId` is the `STORAGE_TENANT_ID` environment variable defined in the `.env` file. Refer to the [self-hosted S3 guide](/docs/guides/self-hosting/self-hosted-s3#session-token) for more details.
|
||||
|
||||
</Admonition>
|
||||
@@ -95,6 +95,16 @@ response = supabase.storage.from_('bucket_name').upload('file_path', file)
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<TabPanel id="curl" label="cURL">
|
||||
|
||||
```bash
|
||||
curl -X POST "https://{your_project_ref}.supabase.co/storage/v1/object/{bucket_name}/{file_path}" \
|
||||
-H "apikey: {your_anon_key}" \
|
||||
-H "Authorization: Bearer {your_jwt_token}" \
|
||||
--data-binary "@/local/path/to/your/file.ext"
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Overwriting files
|
||||
@@ -181,6 +191,17 @@ response = supabase.storage.from_('bucket_name').upload('file_path', file, {
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<TabPanel id="curl" label="cURL">
|
||||
|
||||
```bash
|
||||
curl -X POST "https://{your_project_ref}.supabase.co/storage/v1/object/{bucket_name}/{file_path}" \
|
||||
-H "apikey: {your_anon_key}" \
|
||||
-H "Authorization: Bearer {your_jwt_token}" \
|
||||
-H "x-upsert: true" \
|
||||
--data-binary "@/local/path/to/your/file.ext"
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
We do advise against overwriting files when possible, as our Content Delivery Network will take sometime to propagate the changes to all the edge nodes leading to stale content.
|
||||
@@ -269,6 +290,17 @@ response = supabase.storage.from_('bucket_name').upload('file_path', file, {
|
||||
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
<TabPanel id="curl" label="cURL">
|
||||
|
||||
```bash
|
||||
curl -X POST "https://{your_project_ref}.supabase.co/storage/v1/object/{bucket_name}/{file_path}" \
|
||||
-H "apikey: {your_anon_key}" \
|
||||
-H "Authorization: Bearer {your_jwt_token}" \
|
||||
-H "Content-Type: {Content-Type}" \
|
||||
--data-binary "@/local/path/to/your/file.ext"
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Concurrency
|
||||
|
||||
+5
-2
@@ -51,7 +51,7 @@ export async function createClient() {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, _headers) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
|
||||
} catch {
|
||||
@@ -84,12 +84,15 @@ export async function updateSession(request: NextRequest) {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
setAll(cookiesToSet, headers) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
|
||||
supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options))
|
||||
Object.entries(headers).forEach(([key, value]) =>
|
||||
supabaseResponse.headers.set(key, value)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Loaded 100 of 571 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user