mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
chore(studio): migrate cursor rules to claude skills + add CLAUDE.md (#44343)
Migrates all studio-related Cursor rules to Claude skills and adds a top-level `.claude/CLAUDE.md` for project context. Docs rules left in place. **Decisions:** - Only studio + testing rules migrated — docs rules intentionally left in `.cursor/rules/docs/` - Vitest skill already shared via symlink (`.claude/skills/vitest` → `.agents/skills/vitest`) — nothing to migrate - Grouped ~21 granular cursor rules into 5 new skills + 1 updated skill by topic - `studio-architecture` skill fully merged into `CLAUDE.md` and deleted to avoid overlap - Skills are self-contained (content inlined, not relying on sub-files) since Claude reads SKILL.md first - Skills cross-reference each other inline where relevant (e.g. best-practices → testing, error-handling, queries) - No `paths` frontmatter — would auto-inject full skill content on every matching file. Current description-based matching is more selective and token-efficient. **Removed:** - `.cursor/rules/studio/` (21 rule files covering architecture, best practices, UI patterns, queries, styling, etc.) - `.cursor/rules/testing/` (e2e-studio + unit-integration rules) - `.cursor/rules/studio-useStaticEffectEvent.mdc` - `.claude/skills/studio-architecture/` — fully merged into CLAUDE.md to avoid duplication - `.claude/skills/studio-testing/rules/` — orphaned sub-files after inlining content into SKILL.md **Added:** - `.claude/CLAUDE.md` — concise monorepo overview with structure, commands, and conventions. Absorbs studio-architecture content. References `studio-*` skills for detail. - `.claude/skills/studio-best-practices/` — boolean naming, component structure, loading/error/success patterns, state management, hooks, TypeScript conventions. Cross-references `vercel-composition-patterns`, `studio-ui-patterns`, `studio-queries`, `studio-error-handling`, and `studio-testing` inline where relevant. - `.claude/skills/studio-ui-patterns/` — layout, forms, tables, charts, empty states, navigation, cards, alerts, sheets. Grouped from ~10 separate cursor rules into one cohesive skill. - `.claude/skills/studio-queries/` — React Query `queryOptions` pattern, `keys.ts` structure, mutation hook template, imperative fetching. - `.claude/skills/use-static-effect-event/` — the `useStaticEffectEvent` hook: when to use, when not to, patterns, implementation. **Changed:** - `.claude/skills/studio-e2e-tests/` — renamed from `e2e-studio-tests` for `studio-*` naming consistency. Merged race condition, waiting strategy, test structure, assertion, and cleanup patterns from the cursor e2e rule. - `.claude/skills/studio-testing/` — inlined key content from sub-rule files directly into SKILL.md so it's self-contained. Removed broken `AGENTS.md` reference. Deleted orphaned `rules/` sub-files. - `.claude/skills/vercel-composition-patterns/` — added note that Studio uses React 18, so React 19 patterns should be skipped. - `.gitignore` — added `!.claude/CLAUDE.md` exception so it's tracked. ## To test - Open Claude Code in the repo, verify `.claude/CLAUDE.md` loads as project context - Ask Claude about Studio conventions and verify it references the right skills - Check that `studio-*` skills appear in the skill list --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,34 +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 by destructuring `isDirty` from `formState` (`const { isDirty } = form.formState`) then use it 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,5 @@
|
||||
---
|
||||
applyTo: "e2e/studio/**,apps/studio/**"
|
||||
applyTo: 'e2e/studio/**,apps/studio/**'
|
||||
---
|
||||
|
||||
# Studio E2E Test Review Rules
|
||||
@@ -9,16 +9,19 @@ 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 })
|
||||
```
|
||||
@@ -31,18 +34,21 @@ All comments are **advisory**.
|
||||
## 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)
|
||||
@@ -57,6 +63,7 @@ All comments are **advisory**.
|
||||
```
|
||||
|
||||
- **`force: true` on clicks** — make elements visible first instead
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await menuButton.click({ force: true })
|
||||
@@ -76,4 +83,4 @@ All comments are **advisory**.
|
||||
- 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/e2e-studio-tests/SKILL.md`
|
||||
Canonical standard: `.claude/skills/studio-e2e-tests/SKILL.md`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user