diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ca29fe71fbaa7..175aec83c51ee 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,42 +1,70 @@ # Supabase Monorepo -pnpm 10 + Turborepo monorepo. Requires Node >= 22. +pnpm 11 + Turborepo monorepo. Requires Node >= 22.13. ## Structure -| Directory | Purpose | -| ----------------- | ------------------------------------------------------------ | -| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 19 | -| `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 | +| Directory | Purpose | +| ------------------------ | --------------------------------------------------------------------------- | +| `apps/studio` | Supabase Studio/Dashboard — has its own `apps/studio/CLAUDE.md` (see below) | +| `apps/docs` | Documentation site — Next.js app router, MDX (port 3001) | +| `apps/www` | Marketing website — Next.js, app + pages (port 3000) | +| `apps/design-system` | Component demos — source of truth for Studio UI patterns (port 3003) | +| `apps/ui-library` | shadcn-style registry site for Supabase UI blocks (port 3004) | +| `apps/lite-studio` | Lightweight Studio — different stack: React Router 7 + Vite + Tailwind v4 | +| `packages/ui` | Shared UI components (shadcn/ui based) — `import { Button } from 'ui'` | +| `packages/ui-patterns` | Composite components — subpath imports, e.g. `ui-patterns/AssistantChat` | +| `packages/common` | Shared utils, telemetry constants, feature flags | +| `packages/api-types` | Generated platform Management API types | +| `packages/pg-meta` | SQL builders for Postgres introspection (`SafeSqlFragment`) | +| `packages/shared-data` | Static data: pricing, plans, regions, error codes | +| `e2e/studio`, `e2e/docs` | Playwright E2E tests | +| `supabase/` | Local Supabase project: edge functions, migrations, config.toml | ## 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 +pnpm install # install dependencies +pnpm dev:studio # run Studio dev server → http://localhost:8082 +pnpm dev:docs # run docs dev server +pnpm dev:www # run www dev server +pnpm test:studio # Studio unit tests (vitest) +pnpm e2e # Studio E2E tests (playwright) +pnpm build --filter=studio # build Studio +pnpm lint --filter=studio # lint Studio +pnpm typecheck # typecheck all packages +pnpm format # Prettier write (check: pnpm test:prettier) +pnpm generate:types # local DB types → supabase/functions/common/database-types.ts +pnpm api:codegen # platform Management API types → packages/api-types ``` +## CI + +Every PR must pass typecheck + lint (one workflow), Prettier, and a typos check. Other checks are path-filtered: Studio unit tests/build and the lint ratchet (ESLint warning count must not increase) run on `apps/studio/**` changes; app-specific test suites run on their own paths. + +Never hand-edit generated files: `packages/api-types/types/**`, `**/routeTree.gen.ts`, `**/__generated__/**`, `apps/docs/features/docs/generated/**`, `apps/www/.generated/**`, `supabase/functions/common/database-types.ts`. + ## Conventions -**UI** — import from `'ui'`, use `_Shadcn_` suffixed variants for form primitives. Check `packages/ui/index.tsx` before creating new primitives. +**UI** — import from `'ui'`; primitives are shadcn/ui-based and exported unsuffixed (`Input`, `Select`, `Form`, …). Use `Button` — the in-house component and the standard everywhere (a raw shadcn `Button_Shadcn_` also exists but is rarely the right choice). Check `packages/ui/index.tsx` before creating new primitives. Higher-level patterns live in `packages/ui-patterns`. **Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors. +**Exports** — named exports only; default exports are allowed only where a framework requires them (`pages/**`, `app/**`, config files — the eslint preset has the exact carve-out list). Lint-enforced across all apps via `eslint-config-supabase` (severity `warn` everywhere; hard-enforced in Studio by the lint ratchet). + **Language** — Use U.S. English everywhere. -**Studio shortcuts** — when adding or changing repeated Studio UI actions, use the shared shortcut registry and primitives in `apps/studio/state/shortcuts/` and `apps/studio/components/ui/Shortcut*.tsx`. Prefer registered, discoverable shortcuts over one-off keyboard listeners; keep `G then ...` chords for navigation. +## Skills -## Studio +The skills in `.claude/skills/` are the source of truth for conventions — load the relevant ones before working, don't guess: + +- `copywriting` — any user-facing text, anywhere in the monorepo +- `docs-content` — anything under `apps/docs` +- `telemetry-standards` — PostHog events, `packages/common/telemetry-constants.ts` +- `dev-toolbar-review` — `packages/dev-tools`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx` +- `safe-sql-execution` — any code that builds or executes SQL against user databases +- `vitest` / `vercel-composition-patterns` — generic unit-testing and React composition references -Pages router. Co-locate sub-components with parent. Avoid barrel re-export files. +## Studio -See studio-\* skills for detailed studio conventions. +Before working on anything in `apps/studio`, read `apps/studio/CLAUDE.md` if it isn't already in context — it maps Studio tasks to required skills and covers the TanStack Start migration rules. diff --git a/.claude/skills/studio-ui-patterns/SKILL.md b/.claude/skills/studio-ui-patterns/SKILL.md index c929e767a1309..cc875ec57104a 100644 --- a/.claude/skills/studio-ui-patterns/SKILL.md +++ b/.claude/skills/studio-ui-patterns/SKILL.md @@ -34,7 +34,7 @@ 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`; use `_Shadcn_` imports from `ui` for primitives +- Wrap inputs with `FormControl`; import primitives from `ui` Layout selection: diff --git a/.gitignore b/.gitignore index ce23cae5706e3..f66893690109d 100644 --- a/.gitignore +++ b/.gitignore @@ -123,8 +123,8 @@ next-env.d.ts !.claude/scripts/ !.claude/skills/ .claude/skills/me-* -CLAUDE.md !.claude/CLAUDE.md +CLAUDE.local.md #include template .env file for docker-compose !docker/.env diff --git a/apps/design-system/__registry__/default/block/chart-bar-interactive.tsx b/apps/design-system/__registry__/default/block/chart-bar-interactive.tsx index 655787cda6470..fca68de5bc732 100644 --- a/apps/design-system/__registry__/default/block/chart-bar-interactive.tsx +++ b/apps/design-system/__registry__/default/block/chart-bar-interactive.tsx @@ -146,7 +146,7 @@ export default function Component() { {['desktop', 'mobile'].map((key) => { const chart = key as keyof typeof chartConfig return ( - + + + ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx b/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx new file mode 100644 index 0000000000000..5aaf43fab0217 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx @@ -0,0 +1,40 @@ +import { Button } from 'ui' + +import { + AccountRow, + InterstitialShell, + LogoBox, + LogoPair, + SignOutButton, + SupabaseLogo, +} from './connect-interstitial-shared' + +/** Stand-in uploaded OAuth icon: checked-in solid-colour bitmap (not a real brand). */ +function UploadedAppLogo() { + return ( + + Acme + + ) +} + +export default function ConnectInterstitialLogoUploaded() { + return ( + } right={} />} + title="Authorize Acme" + description="Acme is requesting access to your organization" + > +
+ } /> + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx index 24c35e4da0856..e9100175ae04e 100644 --- a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx +++ b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx @@ -45,9 +45,9 @@ export function StripeLogo() { ) } -export function SupabaseLogo() { +export function SupabaseLogo({ forceLight = false }: { forceLight?: boolean } = {}) { return ( - + Drag and drop or{' '}
@@ -263,7 +269,12 @@ function TemplatesPage({ onNavigateToSmtp }: { onNavigateToSmtp: () => void }) { )} /> -
diff --git a/apps/design-system/registry/default/example/page-layout-edge-function.tsx b/apps/design-system/registry/default/example/page-layout-edge-function.tsx index 4377bee9ec173..a3b250d71e033 100644 --- a/apps/design-system/registry/default/example/page-layout-edge-function.tsx +++ b/apps/design-system/registry/default/example/page-layout-edge-function.tsx @@ -261,6 +261,7 @@ export default function PageLayoutEdgeFunction() { {pages.map((page) => ( @@ -251,7 +254,10 @@ function DataSources({ schema }: { schema: any }) { {schema[dataSource].block.attributes[attribute].type ?? ( - diff --git a/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx b/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx index dfb0b13813f55..457dc160e550c 100644 --- a/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx +++ b/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx @@ -23,7 +23,7 @@ pip install vecs boto3 You'll also need: - [Credentials to your AWS account](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html) -- [A Postgres Database with the pgvector extension](hosting.md) +- [A Postgres database with the pgvector extension](/docs/guides/database/extensions/pgvector) ## Create embeddings diff --git a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx index 04291fe8018fc..eda6aa21efeea 100644 --- a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx +++ b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx @@ -849,7 +849,7 @@ It's a good practice to provide a settings page where users can view all authori -For complete API reference, see the [OAuth methods in supabase-js](/docs/reference/javascript/auth-oauth). +For complete API reference, see the [OAuth methods in supabase-js](/docs/reference/javascript/auth-admin-oauth-server). ## Next steps diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index b89c58b9463c0..ed252782fd5a8 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -159,6 +159,14 @@ SUPABASE_URL=supabase_project_url SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key ``` + + + +```bash .env.local +VITE_SUPABASE_URL=supabase_project_url +VITE_SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key +``` + @@ -510,7 +518,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } ) - // Use `supabase` here for server-side work, e.g. await supabase.auth.getUser() + // Use `supabase` here for server-side work, e.g. await supabase.auth.getClaims() // Return the environment variables so the browser can create its own client. return json( @@ -674,7 +682,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } ) - // Use `supabase` here for server-side work, e.g. await supabase.auth.getUser() + // Use `supabase` here for server-side work, e.g. await supabase.auth.getClaims() // Return the env vars so the browser can create its own client. return data( @@ -823,6 +831,81 @@ language="typescript" + + + +### Write utility functions to create Supabase clients + +TanStack Start renders matched routes on the server by default, so `beforeLoad` and `loader` run server-side on the initial request. Unlike Next.js, this means you don't need a proxy or middleware layer to keep sessions fresh — the server client reads and writes the session cookie directly on each request. + +Create a `lib/supabase` folder at the root of your project, or inside the `./src` folder if you are using one, then add a file for each type of client: + +1. **Create a browser client in `lib/supabase/client.ts`.** Use it to access Supabase from components that run in the browser. +2. **Create a server client in `lib/supabase/server.ts`.** Use it to access Supabase from loaders, server functions, and other code that runs only on the server. + +<$Partial path="auth_methods.mdx" /> + +Copy the lib utility functions below into each file: + +
+ <$CodeTabs> + <$CodeSample + path="/auth/tanstack/lib/supabase/client.ts" + meta="name=lib/supabase/client.ts" + language="typescript" + /> + <$CodeSample + path="/auth/tanstack/lib/supabase/server.ts" + meta="name=lib/supabase/server.ts" + language="typescript" + /> + +
+ +### Protecting routes + +TanStack Start has no global middleware layer, so protect each route explicitly. + +To protect your routes: + +1. Write a server function, `fetchClaims`, that calls `supabase.auth.getClaims()` and returns the claims, or `null` if the session isn't valid. +1. Call `fetchClaims` from a layout route's `beforeLoad` hook — for example, `_protected.tsx` — before any nested route renders, and redirect to `/login` when it returns `null`. + + + +Skipping the check inside the server function exposes private data to unauthenticated users. `beforeLoad` runs on the server for the initial request and on the client for later navigation, but either way it only gates the route's render — it doesn't stop the server function from being called directly. Because there's no proxy re-checking every request, the server function is the only checkpoint that always runs, so it must call `supabase.auth.getClaims()` to authorize the request itself. + + + +`getClaims()` validates the JWT signature on every call, the same check the Next.js Proxy relies on. Calling it inside the server function gives TanStack Start's per-route check that same guarantee, because the function runs on every request to a protected route. + +
+ <$CodeTabs> + <$CodeSample + path="/auth/tanstack/lib/supabase/fetch-claims-server-fn.ts" + meta="name=lib/supabase/fetch-claims-server-fn.ts" + language="typescript" + /> + <$CodeSample + path="/auth/tanstack/routes/_protected.tsx" + meta="name=routes/_protected.tsx" + language="typescript" + /> + +
+ +Any other server function that returns or mutates private data needs this same check. Don't rely on a route being nested under `_protected` alone. + +## Congratulations + +You're done! To recap, you've successfully: + +- Set up a Supabase client utility to call Supabase from a browser component. You can use this if you need to call Supabase from the browser, for example to set up a realtime subscription. +- Set up a server client utility to call Supabase from loaders and server functions. +- Protected a route with `beforeLoad`, backed by a server function that authorizes the request itself. + +You can now use any Supabase features from your client or server code! +
diff --git a/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx b/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx index c013b25d8fb3e..fc2fc0e67d6fa 100644 --- a/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx +++ b/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx @@ -88,7 +88,7 @@ ELEVENLABS_API_KEY=your_api_key The project uses a couple of dependencies: - The [@supabase/supabase-js](/docs/reference/javascript) library to interact with the Supabase database. -- The ElevenLabs [JavaScript SDK](/docs/quickstart) to interact with the text-to-speech API. +- The ElevenLabs [JavaScript SDK](https://github.com/elevenlabs/elevenlabs-js) to interact with the text-to-speech API. - The open-source [object-hash](https://www.npmjs.com/package/object-hash) to generate a hash from the request parameters. Since Supabase Edge Function uses the [Deno runtime](https://deno.land/), you don't need to install the dependencies, rather you can [import](https://docs.deno.com/examples/npm/) them via the `npm:` prefix. diff --git a/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx b/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx index 8ec31003e500f..b5b574b5e98ed 100644 --- a/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx +++ b/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx @@ -98,7 +98,7 @@ The project uses a couple of dependencies: - The open-source [grammY Framework](https://grammy.dev/) to handle the Telegram webhook requests. - The [@supabase/supabase-js](/docs/reference/javascript) library to interact with the Supabase database. -- The ElevenLabs [JavaScript SDK](/docs/quickstart) to interact with the speech-to-text API. +- The ElevenLabs [JavaScript SDK](https://github.com/elevenlabs/elevenlabs-js) to interact with the speech-to-text API. Since Supabase Edge Function uses the [Deno runtime](https://deno.land/), you don't need to install the dependencies, rather you can [import](https://docs.deno.com/examples/npm/) them via the `npm:` prefix. diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx index 45a0a7892cc69..163c49283da6c 100644 --- a/apps/docs/content/guides/getting-started.mdx +++ b/apps/docs/content/guides/getting-started.mdx @@ -363,7 +363,7 @@ hideToc: true diff --git a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx index b59f80d39ad40..3fe6ac2a5daa3 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx @@ -11,7 +11,7 @@ breadcrumb: 'Framework Quickstarts' Create a TanStack Start app using the official CLI. ```bash -npm create @tanstack/start@latest my-app -- --package-manager npm --toolchain biome +npx @tanstack/cli@latest create my-app ``` ## 4. Install Agent Skills (optional) @@ -24,55 +24,87 @@ To install, run the following command in the root of your project: npx skills add supabase/agent-skills ``` -## 5. Install the Supabase client library +## 5. Install the Supabase client libraries -The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a TanStack Start app. - -Navigate to the TanStack Start app and install `supabase-js`. +Navigate to the TanStack Start app and install `supabase-js` and `@supabase/ssr`, the helper package that manages cookie-based sessions for server-side rendering. ```bash -cd my-app && npm install @supabase/supabase-js +cd my-app && npm install @supabase/supabase-js @supabase/ssr ``` ## 6. Declare Supabase environment variables -Create a `.env` file in the root of your project and populate with your Supabase connection variables that you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true): +Create a `.env.local` file in the root of your project and populate it with your Supabase connection variables. Get the values from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=tanstack). -```text name=.env +```text name=.env.local VITE_SUPABASE_URL= VITE_SUPABASE_PUBLISHABLE_KEY= ``` -<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> +<$Partial path="api_settings.mdx" variables={{ "framework": "tanstack", "tab": "frameworks" }} /> -## 7. Create a Supabase client utility +## 7. Create Supabase client utilities -Create a new file at `src/utils/supabase.ts` to initialize the Supabase client. +TanStack Start needs two Supabase clients: a browser client for components that run in the browser, and a server client for loaders and server functions. Create a `src/lib/supabase` folder with a file for each client. -```ts name=src/utils/supabase.ts -import { createClient } from '@supabase/supabase-js' +```ts name=src/lib/supabase/client.ts +/// +import { createBrowserClient } from '@supabase/ssr' -export const supabase = createClient( - import.meta.env.VITE_SUPABASE_URL, - import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY -) +export function createClient() { + return createBrowserClient( + import.meta.env.VITE_SUPABASE_URL!, + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY! + ) +} +``` + +```ts name=src/lib/supabase/server.ts +import { createServerClient } from '@supabase/ssr' +import { getCookies, setCookie, setResponseHeader } from '@tanstack/react-start/server' + +export function createClient() { + return createServerClient( + process.env.VITE_SUPABASE_URL!, + process.env.VITE_SUPABASE_PUBLISHABLE_KEY!, + { + cookies: { + getAll() { + return Object.entries(getCookies()).map(([name, value]) => ({ name, value })) + }, + setAll(cookies, headers) { + cookies.forEach(({ name, value, options }) => { + setCookie(name, value, options) + }) + + Object.entries(headers).forEach(([name, value]) => { + setResponseHeader(name, value) + }) + }, + }, + } + ) +} ``` -## 8. Query data from the app +## 8. Query Supabase data from TanStack Start -Replace the contents of `src/routes/index.tsx` with the following code to add a loader function that fetches the instruments data and displays it on the page. +Replace the contents of `src/routes/index.tsx` with the following to add a loader that queries the `instruments` table through the server client. The loader runs on the server, so the data is part of the initial server-rendered response. ```tsx name=src/routes/index.tsx import { createFileRoute } from '@tanstack/react-router' -import { supabase } from '../utils/supabase' +import { createClient } from '@/lib/supabase/server' export const Route = createFileRoute('/')({ loader: async () => { + const supabase = createClient() const { data: instruments } = await supabase.from('instruments').select() return { instruments } }, @@ -102,7 +134,8 @@ npm run dev ## Next steps +- Learn how to [protect routes and check sessions](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=tanstack) with the server client +- Set up a complete [login and sign-up flow](/ui/docs/tanstack/password-based-auth) from the Supabase UI Library - Explore [drop-in UI components](/ui) for your Supabase app -- Set up [Auth](/docs/guides/auth) for your app - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) diff --git a/apps/docs/content/guides/platform/billing-faq.mdx b/apps/docs/content/guides/platform/billing-faq.mdx index 83906fe29cd43..5c4941ac7f472 100644 --- a/apps/docs/content/guides/platform/billing-faq.mdx +++ b/apps/docs/content/guides/platform/billing-faq.mdx @@ -208,6 +208,10 @@ Note that any changes made to your billing details will only be reflected in you When an invoice becomes overdue, we will pause your projects and downgrade your organization to the Free Plan. You will be able to restore your projects once you have paid all outstanding invoices. +#### Can I use a credit top-up to pay an outstanding invoice? + +Credit top-ups apply only to future invoices. They cannot be used to pay or adjust outstanding invoices. + #### Why am I overdue? We were unable to charge your payment method. This likely means that the payment was not successfully processed with the credit card on your account profile. diff --git a/apps/docs/content/guides/platform/credits.mdx b/apps/docs/content/guides/platform/credits.mdx index cf3b30a36b1dc..7786982481bb7 100644 --- a/apps/docs/content/guides/platform/credits.mdx +++ b/apps/docs/content/guides/platform/credits.mdx @@ -67,6 +67,10 @@ height={758} Yes, once the payment is confirmed, you will get a matching invoice that can be accessed through your [organization's invoices page](/dashboard/org/_/billing#invoices). +### Can I use a credit top-up to pay an outstanding invoice? + +Credit top-ups apply only to future invoices. They cannot be used to pay or adjust outstanding invoices. + ### Can I transfer credits to another organization? Yes, you can transfer credits to another organization. Submit a [support ticket](https://supabase.help). diff --git a/apps/docs/content/guides/security/product-security.mdx b/apps/docs/content/guides/security/product-security.mdx index f47e9b1d1c21c..d1e8ef2158989 100644 --- a/apps/docs/content/guides/security/product-security.mdx +++ b/apps/docs/content/guides/security/product-security.mdx @@ -24,7 +24,7 @@ Various products at Supabase have their own hardening and configuration guides, - [Managing Postgres roles](/docs/guides/database/postgres/roles) - [Managing secrets with Vault](/docs/guides/database/vault) - [Postgres connection logging](/docs/guides/platform/postgres-connection-logging) -- [Superuser access and unsupported operations](docs/guides/database/postgres/roles-superuser) +- [Superuser access and unsupported operations](/docs/guides/database/postgres/roles-superuser) ## Storage diff --git a/apps/docs/data/errorCodes/realtimeErrorCodes.json b/apps/docs/data/errorCodes/realtimeErrorCodes.json index 6b8f910648f39..3d39e335df8fd 100644 --- a/apps/docs/data/errorCodes/realtimeErrorCodes.json +++ b/apps/docs/data/errorCodes/realtimeErrorCodes.json @@ -51,7 +51,7 @@ "resolution": "Your project may have been suspended for exceeding usage quotas. Contact support with your project reference ID and a description of your Realtime use case.", "references": [ { - "href": "https://supabase.com/docs/troubleshooting/realtime-project-suspended-for-exceeding-quotas", + "href": "https://supabase.com/docs/guides/troubleshooting/realtime-project-suspended-for-exceeding-quotas", "description": "Troubleshooting guide for suspended projects" } ] diff --git a/apps/docs/features/directives/CodeSample.client.tsx b/apps/docs/features/directives/CodeSample.client.tsx index ea206c1a49207..61542c0ff6a3a 100644 --- a/apps/docs/features/directives/CodeSample.client.tsx +++ b/apps/docs/features/directives/CodeSample.client.tsx @@ -2,7 +2,6 @@ import Link from 'next/link' import { useState, type PropsWithChildren } from 'react' - import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -42,7 +41,10 @@ function MultipleSources({ children, sources }: PropsWithChildren<{ sources: (st {children} - diff --git a/apps/docs/features/docs/Reference.navigation.client.tsx b/apps/docs/features/docs/Reference.navigation.client.tsx index e6308f3fcd647..4ddf294fd202b 100644 --- a/apps/docs/features/docs/Reference.navigation.client.tsx +++ b/apps/docs/features/docs/Reference.navigation.client.tsx @@ -331,6 +331,7 @@ function CompoundRefLink({ } + actions={} /> ) @@ -87,7 +87,9 @@ describe('IntegrationOverviewTab', () => { }) it('disables actions when extensions are uninstalled and hideRequiredExtensionsSection is false', () => { - customRender(Enable integration} />) + customRender( + Enable integration} /> + ) const actionsArea = screen.getByText('Enable integration').closest('[aria-disabled]') expect(actionsArea).toHaveAttribute('aria-disabled', 'true') diff --git a/apps/studio/components/interfaces/LocalDropdown.test.tsx b/apps/studio/components/interfaces/LocalDropdown.test.tsx index f34fa0c2b7f16..5afef5f725fe4 100644 --- a/apps/studio/components/interfaces/LocalDropdown.test.tsx +++ b/apps/studio/components/interfaces/LocalDropdown.test.tsx @@ -70,7 +70,9 @@ vi.mock('ui', async () => { children, ...props }: React.ButtonHTMLAttributes & { children?: ReactNode }) => ( - + ), cn: (...classes: Array) => classes.filter(Boolean).join(' '), DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, @@ -92,6 +94,7 @@ vi.mock('ui', async () => {
{children}
) : ( , + }) => ( + + ), Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, TooltipTrigger: ({ children }: { children: ReactNode }) =>
{children}
, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 25feaef32e1d9..b08a50d3d00d8 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -301,8 +301,9 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {

On successful payment, an invoice will be issued and you'll be granted credits equal - to the pre-tax amount. Credits will be applied to future invoices only and are not - refundable. The topped up credits do not expire. + to the pre-tax amount. Credits will be applied to future invoices only. They cannot be + used to pay or adjust outstanding invoices. Credits are non-refundable and do not + expire.

For larger discounted credit packages, please reach out to us via{' '} diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx index e8d390785fcd0..ddff1416f86a7 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -12,10 +12,15 @@ import { CollapsibleTrigger, } from 'ui' import { InfoTooltip } from 'ui-patterns/info-tooltip' -import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants' -import { LogoBox } from '@/components/layouts/InterstitialLayout' +import { getRequesterLogo } from './OAuthApps.utils' +import { + CONNECT_LOGO_LIGHT_TILE_CLASSNAME, + LogoBox, + LogoPair, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' import { InlineLink } from '@/components/ui/InlineLink' import { DOCS_URL } from '@/lib/constants' @@ -24,7 +29,6 @@ const PERMISSION_DETAILS_TRIGGER_CLASSNAME = 'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground' export interface AuthorizeRequesterDetailsProps { - icon: string | null name: string domain: string scopes: OAuthScope[] @@ -167,64 +171,59 @@ const PERMISSION_GROUPS: PermissionGroup[] = [ }, ] -const CUSTOM_LOGO_KEYS = { - perplexity: { icon: 'perplexity', hasDistinctDarkIcon: true }, - cursor: { icon: 'cursor', hasDistinctDarkIcon: true }, - claude: { icon: 'claude', hasDistinctDarkIcon: false }, - chatgpt: { icon: 'openai', hasDistinctDarkIcon: true }, - openai: { icon: 'openai', hasDistinctDarkIcon: true }, -} as const - -function getRequesterLogo({ +/** + * Connect interstitial header mark for `/authorize`. + * Curated logos resolve from allowlisted redirect_uri hosts; otherwise pair only + * when a usable remote icon is present, else show Supabase alone. + * + * Uploaded / unknown bitmaps have no light/dark metadata, so both tiles use + * fixed light chrome (`forceLight`) across Studio themes. Curated partners keep + * theme-reactive tiles and may swap dark assets when available. + */ +export const AuthorizeConnectLogo = ({ icon, name, - useDarkVariant, + redirectUri, }: { icon: string | null name: string - useDarkVariant: boolean -}) { - const searchableText = `${icon ?? ''} ${name}`.toLowerCase() - - for (const [match, asset] of Object.entries(CUSTOM_LOGO_KEYS)) { - if (searchableText.includes(match)) { - const customLogoUrl = getMcpClientIconSrc({ - icon: asset.icon, - useDarkVariant, - hasDistinctDarkIcon: asset.hasDistinctDarkIcon, - }) - - if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true } - } - } - - return { src: icon || '', isKnownClient: false } -} - -export const RequesterLogo = ({ icon, name }: { icon: string | null; name: string }) => { + redirectUri?: string | null +}) => { const [failedIcon, setFailedIcon] = useState(null) const { resolvedTheme } = useTheme() const logo = useMemo( - () => getRequesterLogo({ icon, name, useDarkVariant: resolvedTheme === 'dark' }), - [icon, name, resolvedTheme] + () => + getRequesterLogo({ + icon, + redirectUri, + useDarkVariant: resolvedTheme === 'dark', + }), + [icon, redirectUri, resolvedTheme] ) - const showLetter = !logo.src || failedIcon === logo.src + const hasUsableLogo = Boolean(logo.src) && failedIcon !== logo.src + + if (!hasUsableLogo) { + return + } + + const forceLightPair = !logo.isKnownClient return ( - - {showLetter ? ( - {name.slice(0, 1)} - ) : ( - {name} setFailedIcon(logo.src)} - /> - )} - + + {name} setFailedIcon(logo.src)} + /> + + } + right={} + /> ) } diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts new file mode 100644 index 0000000000000..cce0b1b7fe911 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts @@ -0,0 +1,91 @@ +import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' +import { describe, expect, test } from 'vitest' + +import { + findTrustedPartnerByRedirectUri, + getRedirectHostname, + getRequesterLogo, + hostMatchesAllowlist, + isLocalRedirectHost, +} from './OAuthApps.utils' + +describe('hostMatchesAllowlist', () => { + test('allows exact and subdomain hosts', () => { + expect(hostMatchesAllowlist('claude.ai', ['claude.ai'])).toBe(true) + expect(hostMatchesAllowlist('api.claude.ai', ['claude.ai'])).toBe(true) + }) + + test('rejects lookalike hosts', () => { + expect(hostMatchesAllowlist('claude.ai.evil.com', ['claude.ai'])).toBe(false) + expect(hostMatchesAllowlist('notclaude.ai', ['claude.ai'])).toBe(false) + expect(hostMatchesAllowlist('evilclaude.ai', ['claude.ai'])).toBe(false) + }) +}) + +describe('isLocalRedirectHost', () => { + test.each(['localhost', '127.0.0.1', '[::1]', '::1', 'app.localhost'])( + 'treats %s as local', + (host) => { + expect(isLocalRedirectHost(host)).toBe(true) + } + ) + + test('treats public hosts as remote', () => { + expect(isLocalRedirectHost('claude.ai')).toBe(false) + expect(isLocalRedirectHost('evil.com')).toBe(false) + }) +}) + +describe('getRedirectHostname', () => { + test('parses https redirect URIs', () => { + expect(getRedirectHostname('https://claude.ai/api/mcp/auth_callback')).toBe('claude.ai') + }) + + test('returns null for invalid URIs', () => { + expect(getRedirectHostname('not-a-url')).toBe(null) + expect(getRedirectHostname(null)).toBe(null) + }) +}) + +describe('findTrustedPartnerByRedirectUri', () => { + test('resolves Claude from redirect host', () => { + expect( + findTrustedPartnerByRedirectUri('https://claude.ai/api/mcp/auth_callback')?.displayName + ).toBe('Claude') + }) + + test('ignores localhost redirects', () => { + expect(findTrustedPartnerByRedirectUri('http://127.0.0.1:42813/callback')).toBe(null) + }) +}) + +describe('getRequesterLogo', () => { + test('uses curated assets only when redirect host is allowlisted', () => { + const trusted = getRequesterLogo({ + icon: null, + redirectUri: 'https://claude.ai/api/mcp/auth_callback', + useDarkVariant: false, + }) + expect(trusted).toEqual({ + src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }), + isKnownClient: true, + }) + + const namedOnly = getRequesterLogo({ + icon: null, + redirectUri: 'https://evil.com/callback', + useDarkVariant: false, + }) + expect(namedOnly).toEqual({ src: '', isKnownClient: false }) + }) + + test('falls back to the supplied icon URL when redirect is not trusted', () => { + expect( + getRequesterLogo({ + icon: 'https://example.com/icon.png', + redirectUri: 'https://evil.com/callback', + useDarkVariant: false, + }) + ).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false }) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts new file mode 100644 index 0000000000000..bc0df5ef05ee4 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts @@ -0,0 +1,101 @@ +import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' + +export type TrustedOAuthPartner = { + displayName: string + icon: string + hasDistinctDarkIcon: boolean + /** Exact host or parent host for redirect_uri (subdomains allowed). */ + redirectHosts: readonly string[] +} + +/** + * High-traffic MCP / OAuth partners with curated Connect logos. + * Logos resolve from redirect_uri host only — never from self-asserted name/website. + */ +export const TRUSTED_OAUTH_PARTNERS: readonly TrustedOAuthPartner[] = [ + { + displayName: 'Claude', + icon: 'claude', + hasDistinctDarkIcon: false, + redirectHosts: ['claude.ai', 'anthropic.com'], + }, + { + displayName: 'Cursor', + icon: 'cursor', + hasDistinctDarkIcon: true, + redirectHosts: ['cursor.com', 'cursor.sh'], + }, + { + displayName: 'ChatGPT', + icon: 'openai', + hasDistinctDarkIcon: true, + redirectHosts: ['chatgpt.com', 'openai.com'], + }, + { + displayName: 'Perplexity', + icon: 'perplexity', + hasDistinctDarkIcon: true, + redirectHosts: ['perplexity.ai'], + }, +] + +const LOCAL_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + +export function getRedirectHostname(redirectUri: string | null | undefined): string | null { + if (!redirectUri) return null + try { + const { hostname } = new URL(redirectUri) + return hostname.toLowerCase() || null + } catch { + return null + } +} + +export function isLocalRedirectHost(hostname: string | null | undefined): boolean { + if (!hostname) return false + const host = hostname.toLowerCase() + return LOCAL_REDIRECT_HOSTS.has(host) || host.endsWith('.localhost') +} + +export function hostMatchesAllowlist(hostname: string, allowedHosts: readonly string[]): boolean { + const host = hostname.toLowerCase() + return allowedHosts.some((allowed) => { + const entry = allowed.toLowerCase() + return host === entry || host.endsWith(`.${entry}`) + }) +} + +export function findTrustedPartnerByRedirectUri( + redirectUri: string | null | undefined +): TrustedOAuthPartner | null { + const hostname = getRedirectHostname(redirectUri) + if (!hostname || isLocalRedirectHost(hostname)) return null + + return ( + TRUSTED_OAUTH_PARTNERS.find((partner) => + hostMatchesAllowlist(hostname, partner.redirectHosts) + ) ?? null + ) +} + +export function getRequesterLogo({ + icon, + redirectUri, + useDarkVariant, +}: { + icon: string | null + redirectUri: string | null | undefined + useDarkVariant: boolean +}): { src: string; isKnownClient: boolean } { + const trusted = findTrustedPartnerByRedirectUri(redirectUri) + if (trusted) { + const customLogoUrl = getMcpClientIconSrc({ + icon: trusted.icon, + useDarkVariant, + hasDistinctDarkIcon: trusted.hasDistinctDarkIcon, + }) + if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true } + } + + return { src: icon || '', isKnownClient: false } +} diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx index 2f4dbd6b39ef8..0d47c4163ca54 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx @@ -449,12 +449,7 @@ export const PublishAppSidePanel = ({ - +

Select an organization to grant API access to

diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx index 4c1fa3af0c0ba..b39ac78d0718f 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx @@ -76,13 +76,12 @@ export const RevokeAppModal = ({
  • The application may also have a Secret API key with access. - Go to the{' '} - - Integration's Settings - - , and remove any listed Secret API key to fully revoke its access. + Navigate to{' '} + + Integrations + {' '} + on the project this app was installed, and remove any listed Secret API key + in the "Settings" tab of the integration to fully revoke its access.
  • diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx index 579f7c8c796a1..4f1a032705135 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx @@ -27,7 +27,7 @@ vi.mock('@/components/ui/ButtonTooltip', () => ({ type: _type, ...props }: any) => ( - diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx index a5be74739e1f3..f33ad16992f11 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx @@ -48,7 +48,22 @@ export const useSQLEditorContext = () => { return context } -export const SQLEditorProvider = ({ children }: PropsWithChildren) => { +type SQLEditorProviderProps = PropsWithChildren<{ + /** + * Test-only seam: production omits these and gets the real Monaco-backed + * controllers built from `editorRef`/`diffEditorRef` below. Tests pass a + * real in-memory implementation (see `tests/lib/sql-editor-test-utils.tsx`) + * instead of mocking Monaco. + */ + editor?: EditorController + diff?: DiffController +}> + +export const SQLEditorProvider = ({ + children, + editor: editorProp, + diff: diffProp, +}: SQLEditorProviderProps) => { const editorRef = useRef(null) const monacoRef = useRef(null) const diffEditorRef = useRef(null) @@ -148,7 +163,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { [] ) - const editor = useMemo( + const builtEditor = useMemo( () => ({ isReady: isEditorReady, getValue: getEditorValue, @@ -172,6 +187,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { clearHighlights, ] ) + const editor = editorProp ?? builtEditor const isDiffMounted = useCallback(() => diffEditorRef.current !== null, []) @@ -194,7 +210,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { diffEditorRef.current = editorInstance }, []) - const diff = useMemo( + const builtDiff = useMemo( () => ({ isMounted: isDiffMounted, getModifiedValue, @@ -203,6 +219,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { }), [isDiffMounted, getModifiedValue, setDiff, attachDiffEditor] ) + const diff = diffProp ?? builtDiff const value = useMemo( () => ({ diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts index 020a9667b8824..6006cde9feb5b 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts @@ -4,12 +4,56 @@ import { convertResultsToCSV, convertResultsToJSON, convertResultsToMarkdown, + formatCellValue, + formatClipboardValue, formatResults, getResultsHeaders, isLargeValue, } from './Results.utils' describe('Results.utils', () => { + describe('formatClipboardValue', () => { + it('returns empty string for null', () => { + expect(formatClipboardValue(null)).toBe('') + }) + + it('stringifies objects', () => { + expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}') + }) + + it('stringifies arrays', () => { + expect(formatClipboardValue([1, 2])).toBe('[1,2]') + }) + + it('converts primitives to string', () => { + expect(formatClipboardValue('hello')).toBe('hello') + expect(formatClipboardValue(42)).toBe('42') + expect(formatClipboardValue(false)).toBe('false') + }) + }) + + describe('formatCellValue', () => { + it('returns NULL for null', () => { + expect(formatCellValue(null)).toBe('NULL') + }) + + it('returns strings as-is', () => { + expect(formatCellValue('hello')).toBe('hello') + }) + + it('stringifies objects', () => { + expect(formatCellValue({ a: 1 })).toBe('{"a":1}') + }) + + it('stringifies numbers', () => { + expect(formatCellValue(42)).toBe('42') + }) + + it('stringifies booleans', () => { + expect(formatCellValue(true)).toBe('true') + }) + }) + describe('formatResults', () => { it('should stringify object values', () => { const results = [{ id: 1, data: { nested: true } }] diff --git a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.test.tsx b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.test.tsx new file mode 100644 index 0000000000000..d96a2d7ba849e --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.test.tsx @@ -0,0 +1,67 @@ +import { act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { usePrettifyQuery } from './usePrettifyQuery' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { formatSql } from '@/lib/formatSql' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' +import { + createInMemoryEditor, + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'prettify-snippet' +const MESSY_SQL = 'select id,name from users' + +function usePrettifyHarness({ isDiffOpen }: { isDiffOpen: boolean }) { + const { data: project } = useSelectedProjectQuery() + const prettifyQuery = usePrettifyQuery({ id: SNIPPET_ID, isDiffOpen }) + return { prettifyQuery, isReady: !!project } +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, name: 'My query', sql: MESSY_SQL }) +}) + +afterEach(() => { + resetSqlEditorStores() +}) + +describe('usePrettifyQuery', () => { + it('formats the editor SQL in place and writes it back to the snippet store', async () => { + const inMemoryEditor = createInMemoryEditor(MESSY_SQL) + const { result } = renderSqlEditorHook( + (props: { isDiffOpen: boolean }) => usePrettifyHarness(props), + { inMemoryEditor, initialProps: { isDiffOpen: false } } + ) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.prettifyQuery() + }) + + const expected = formatSql(MESSY_SQL) + expect(inMemoryEditor.editor.getValue()).toBe(expected) + expect(sqlEditorState.snippets[SNIPPET_ID].snippet.content?.unchecked_sql).toBe(expected) + }) + + it('is a no-op while a diff is open', async () => { + const inMemoryEditor = createInMemoryEditor(MESSY_SQL) + const { result } = renderSqlEditorHook( + (props: { isDiffOpen: boolean }) => usePrettifyHarness(props), + { inMemoryEditor, initialProps: { isDiffOpen: true } } + ) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.prettifyQuery() + }) + + expect(inMemoryEditor.editor.getValue()).toBe(MESSY_SQL) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.test.tsx new file mode 100644 index 0000000000000..61e8b723917b9 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.test.tsx @@ -0,0 +1,42 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useSnippetIdentity } from './useSnippetIdentity' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, +} from '@/tests/lib/sql-editor-test-utils' + +/** + * `useParams` is globally stubbed to `{ ref: 'default' }` (no snippet id), so + * these tests exercise the generated-id branch. The pure id/loading derivation + * itself is proven exhaustively against `deriveSnippetIdentity` in + * `SQLEditor.utils.test`; here we assert the hook wires the store in. + */ +describe('useSnippetIdentity', () => { + beforeEach(() => { + resetSqlEditorStores() + }) + + it('generates a fresh snippet id + name when there is no URL id', () => { + const { result } = renderSqlEditorHook(useSnippetIdentity) + + expect(result.current.urlId).toBeUndefined() + expect(result.current.id).toEqual(expect.any(String)) + expect(result.current.id.length).toBeGreaterThan(0) + expect(result.current.generatedNewSnippetName).toEqual(expect.any(String)) + expect(result.current.generatedNewSnippetName.length).toBeGreaterThan(0) + }) + + it('reports loading until the snippet content is present in the store', async () => { + const { result } = renderSqlEditorHook(useSnippetIdentity) + + // The generated snippet has no content in the store yet. + expect(result.current.isLoading).toBe(true) + + seedSnippet({ id: result.current.id, name: 'My query', sql: 'select 1;' }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetTitleGenerator.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSnippetTitleGenerator.test.tsx new file mode 100644 index 0000000000000..93012e33a44f7 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetTitleGenerator.test.tsx @@ -0,0 +1,50 @@ +import { act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'title-snippet' + +beforeEach(() => { + resetSqlEditorStores() + // The title endpoint (POST /ai/sql/title-v2) returns { title: 'Generated title' }. + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, name: 'Untitled query', sql: 'select 1;' }) +}) + +afterEach(() => { + resetSqlEditorStores() +}) + +describe('useSnippetTitleGenerator', () => { + it('names an untitled snippet from the generated title and queues it for saving', async () => { + const { result } = renderSqlEditorHook(useSnippetTitleGenerator) + + await act(async () => { + await result.current.setAiTitle(SNIPPET_ID, 'select 1;') + }) + + await waitFor(() => + expect(sqlEditorState.snippets[SNIPPET_ID].snippet.name).toBe('Generated title') + ) + expect(sqlEditorState.needsSaving.has(SNIPPET_ID)).toBe(true) + }) + + it('returns the generated title from generateSqlTitle', async () => { + const { result } = renderSqlEditorHook(useSnippetTitleGenerator) + + let title: string | undefined + await act(async () => { + title = (await result.current.generateSqlTitle({ sql: 'select 1;' })).title + }) + + expect(title).toBe('Generated title') + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx new file mode 100644 index 0000000000000..244f4fc443148 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx @@ -0,0 +1,152 @@ +import { act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' +import { DiffType } from './SQLEditor.types' +import { useSqlEditorAi } from './useSqlEditorAi' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { sidebarManagerState } from '@/state/sidebar-manager-state' +import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { + createInMemoryEditor, + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'ai-snippet' + +/** + * Composes the diff + prompt state hooks the AI hook depends on (production + * wires these together in `SQLEditorControllers`), so tests drive the real + * accept/discard/drain flows end to end. + */ +function useAiHarness({ editorMountCount = 1 }: { editorMountCount?: number } = {}) { + const diff = useSqlEditorDiff() + const prompt = useSqlEditorPrompt() + const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt }) + return { ai, diff, prompt } +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() +}) + +afterEach(() => { + resetSqlEditorStores() +}) + +describe('useSqlEditorAi — diff-request drain', () => { + it('copies the requested SQL straight into an empty editor and consumes the request', async () => { + sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('') + + const { result } = renderSqlEditorHook(useAiHarness, { inMemoryEditor }) + + await waitFor(() => expect(inMemoryEditor.editor.getValue()).toBe('select 100;')) + // One-shot: the request is drained so it can't re-apply to a later editor. + expect(sqlEditorDiffRequestState.pending).toBeUndefined() + expect(result.current.diff.isDiffOpen).toBe(false) + }) + + it('opens a diff between existing and requested SQL when the editor is non-empty', async () => { + sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('select 1;') + + const { result } = renderSqlEditorHook(useAiHarness, { inMemoryEditor }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true)) + // The diff-sync effect pushes the resolved diff into the (in-memory) diff editor. + expect(inMemoryEditor.getDiffOriginal()).toBe('select 1;') + expect(inMemoryEditor.getDiffModified()).toBe('select 42;') + expect(sqlEditorDiffRequestState.pending).toBeUndefined() + // The editor's own contents are untouched until the diff is accepted. + expect(inMemoryEditor.editor.getValue()).toBe('select 1;') + }) + + it('drains a pending request exactly once across editor remounts', async () => { + sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('') + + const { rerender } = renderSqlEditorHook(useAiHarness, { + inMemoryEditor, + initialProps: { editorMountCount: 1 }, + }) + + await waitFor(() => expect(inMemoryEditor.editor.getValue()).toBe('select 100;')) + + // Simulate a fresh editor mount: the consumed request must not re-apply. + inMemoryEditor.setValue('edited by user') + rerender({ editorMountCount: 2 }) + + await new Promise((r) => setTimeout(r, 20)) + expect(inMemoryEditor.editor.getValue()).toBe('edited by user') + }) +}) + +describe('useSqlEditorAi — accept / discard diff', () => { + async function openModificationDiff() { + sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('select 1;') + const utils = renderSqlEditorHook(useAiHarness, { inMemoryEditor }) + await waitFor(() => expect(utils.result.current.diff.isDiffOpen).toBe(true)) + return { ...utils, inMemoryEditor } + } + + it('accepting a modification writes the diff result back into the editor and closes the diff', async () => { + const { result, inMemoryEditor } = await openModificationDiff() + + await act(async () => { + await result.current.ai.acceptAiHandler() + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(false)) + expect(inMemoryEditor.editor.getValue()).toBe('select 42;') + }) + + it('discarding a diff leaves the editor untouched and closes the diff', async () => { + const { result, inMemoryEditor } = await openModificationDiff() + + await act(async () => { + result.current.ai.discardAiHandler() + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(false)) + expect(inMemoryEditor.editor.getValue()).toBe('select 1;') + }) +}) + +describe('useSqlEditorAi — debug', () => { + it('onDebug opens the assistant sidebar and starts a debug chat from the failing snippet', async () => { + seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' }) + sqlEditorSessionState.addResultError(SNIPPET_ID, { message: 'syntax error at or near "selct"' }) + + const { result, aiAssistantState } = renderSqlEditorHook(useAiHarness) + + await act(async () => { + await result.current.ai.onDebug() + }) + + // No sidebar is registered in the test tree, so opening queues a pending open. + expect(sidebarManagerState.pendingSidebarOpen).toBe(SIDEBAR_KEYS.AI_ASSISTANT) + + const activeChat = aiAssistantState.chats[aiAssistantState.activeChatId ?? ''] + expect(activeChat?.name).toBe('Debug SQL snippet') + expect(aiAssistantState.sqlSnippets).toEqual(['selct 1;']) + expect(aiAssistantState.initialInput).toContain('syntax error at or near "selct"') + }) + + it('buildDebugPrompt embeds the snippet SQL and its error message', async () => { + seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' }) + sqlEditorSessionState.addResultError(SNIPPET_ID, { message: 'boom' }) + + const { result } = renderSqlEditorHook(useAiHarness) + + const promptText = result.current.ai.buildDebugPrompt() + expect(promptText).toContain('boom') + expect(promptText).toContain('selct 1;') + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx new file mode 100644 index 0000000000000..4c4be71b112e1 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx @@ -0,0 +1,263 @@ +import { acceptUntrustedSql, untrustedSql, type SafeSqlFragment } from '@supabase/pg-meta' +import { act, waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSqlEditorExecution } from './useSqlEditorExecution' +import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { createDatabaseSelectorState } from '@/state/database-selector' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { addAPIMock } from '@/tests/lib/msw' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'execution-snippet' + +/** Promote raw text to the `SafeSqlFragment` the run pipeline expects, exactly + * as the toolbar/editor-panel promote it right at the user action. */ +const sql = (text: string): SafeSqlFragment => acceptUntrustedSql(untrustedSql(text)) + +type ExecutionArgs = Parameters[0] + +/** + * Wraps the hook with the two queries it depends on so tests can wait for the + * project + read-replicas data to load before firing a run (an unloaded project + * or connection string silently short-circuits `executeQuery`). + */ +function useExecutionHarness(args: ExecutionArgs) { + const { data: project } = useSelectedProjectQuery() + const { data: databases } = useReadReplicasQuery({ projectRef: 'default' }) + const execution = useSqlEditorExecution(args) + return { execution, isReady: !!project && !!databases } +} + +/** Registers a query-endpoint resolver that records every executed SQL body. */ +function captureExecutedQueries(rows: unknown[] = []) { + const queries: string[] = [] + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async ({ request }) => { + const body = (await request.json()) as { query: string } + queries.push(body.query) + return HttpResponse.json(rows) + }, + }) + return queries +} + +function renderExecution( + args: Partial = {}, + { selectedDatabaseId = 'default' }: { selectedDatabaseId?: string } = {} +) { + const databaseSelectorState = createDatabaseSelectorState() + databaseSelectorState.setSelectedDatabaseId(selectedDatabaseId) + + const setAiTitle = vi.fn() + const initialProps: ExecutionArgs = { + id: SNIPPET_ID, + isDiffOpen: false, + hasSelection: false, + setAiTitle, + ...args, + } + + const utils = renderSqlEditorHook((props: ExecutionArgs) => useExecutionHarness(props), { + initialProps, + databaseSelectorState, + }) + + return { ...utils, setAiTitle } +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, name: 'My query', sql: 'select 1;' }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useSqlEditorExecution', () => { + it('runs a non-destructive query and writes the result to the session store', async () => { + const rows = [{ id: 1, name: 'row-1' }] + const queries = captureExecutedQueries(rows) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].rows).toEqual(rows) + expect(queries.some((q) => /select 1/i.test(q))).toBe(true) + }) + + it('appends the auto-limit to a bare SELECT and records it on the result', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + // The session store's `limit` defaults to 100 (see resetSqlEditorStores). + expect(sqlEditorSessionState.results[SNIPPET_ID][0].autoLimit).toBe(100) + expect(queries.some((q) => /limit 100/i.test(q))).toBe(true) + }) + + it('gates a destructive query behind potentialIssues instead of running it', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('drop table foo;')) + }) + + // The warning-modal gate fired: issues are surfaced and nothing was executed. + await waitFor(() => expect(result.current.execution.potentialIssues).toBeDefined()) + expect(result.current.execution.potentialIssues?.hasDestructiveOperations).toBe(true) + expect(queries.some((q) => /drop table foo/i.test(q))).toBe(false) + expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeUndefined() + }) + + it('runs a destructive query when forced', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('drop table foo;'), true) + }) + + await waitFor(() => expect(queries.some((q) => /drop table foo/i.test(q))).toBe(true)) + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + }) + + it('highlights the error line and records the error on failure', async () => { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async ({ request }) => { + const body = (await request.json()) as { query: string } + // The event-triggers probe shares this endpoint; only fail the real run. + if (/select 1/i.test(body.query)) { + return HttpResponse.json( + { + message: 'syntax error', + position: '8', + formattedError: + 'ERROR: syntax error at or near "slect"\nLINE 3: slect 1;\n ^', + }, + { status: 400 } + ) + } + return HttpResponse.json([]) + }, + }) + + const { result, inMemoryEditor } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0].error).toBeDefined()) + // No selection → base line 0 + parsed `LINE 3` = 3. + expect(inMemoryEditor.getHighlightedLine()).toBe(3) + expect(inMemoryEditor.getRevealedLine()).toBe(3) + }) + + it("sends the selected database's connection string as the connection header", async () => { + const connectionString = 'postgresql://postgres@replica.example:5432/postgres' + const connectionHeaders: (string | null)[] = [] + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async ({ request }) => { + const body = (await request.json()) as { query: string } + if (/select 1/i.test(body.query)) { + connectionHeaders.push(request.headers.get('x-connection-encrypted')) + } + return HttpResponse.json([]) + }, + }) + + // Point the read-replicas list + selector at a replica with its own conn string. + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/databases', + response: [ + { + identifier: 'replica-1', + connectionString, + cloud_provider: 'AWS', + db_host: 'db.replica.supabase.co', + db_name: 'postgres', + db_port: 5432, + db_user: 'postgres', + inserted_at: '2024-01-01T00:00:00Z', + region: 'us-east-1', + restUrl: 'https://replica.supabase.co/rest/v1/', + size: 'ci_micro', + status: 'ACTIVE_HEALTHY', + }, + ], + }) + + const { result } = renderExecution({}, { selectedDatabaseId: 'replica-1' }) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(connectionHeaders.length).toBeGreaterThan(0)) + expect(connectionHeaders[0]).toBe(connectionString) + }) + + it('short-circuits while a diff is open', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution({ isDiffOpen: true }) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await new Promise((r) => setTimeout(r, 20)) + expect(queries.some((q) => /select 1/i.test(q))).toBe(false) + expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeUndefined() + }) + + it('does not auto-generate a title for an already-named snippet', async () => { + captureExecutedQueries([]) + + const { result, setAiTitle } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + expect(setAiTitle).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Settings/General/Project.test.tsx b/apps/studio/components/interfaces/Settings/General/Project.test.tsx index 82d5cce71846c..f5d6803b62d33 100644 --- a/apps/studio/components/interfaces/Settings/General/Project.test.tsx +++ b/apps/studio/components/interfaces/Settings/General/Project.test.tsx @@ -27,7 +27,14 @@ vi.mock('ui', () => ({ children: ReactNode asChild?: boolean type?: string - }) => (asChild ? <>{children} : ), + }) => + asChild ? ( + <>{children} + ) : ( + + ), Card: ({ children }: { children: ReactNode }) =>
    {children}
    , CardContent: ({ children }: { children: ReactNode }) =>
    {children}
    , })) diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx index b0fbecf523597..bfb0e7201cf31 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -29,7 +29,9 @@ const Page = ({ onClose }: { onClose: () => void }) => { const [open, setOpen] = useState(false) return ( - + void }) => { const [open, setOpen] = useState(false) return ( - + void }) => { const [open, setOpen] = useState(false) return ( - + - {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {canAllowSupportAccess(category, projectRef) && ( <>
    diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx index 146d68a5dd5e1..674ed5a11d241 100644 --- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -1,21 +1,13 @@ // End of third-party imports -import { SupportCategories } from '@supabase/shared-types/out/constants' import { ChevronRight } from 'lucide-react' import Link from 'next/link' import type { UseFormReturn } from 'react-hook-form' import { Badge, Collapsible, CollapsibleContent, CollapsibleTrigger, FormField, Switch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import type { ExtendedSupportCategories } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' -export const DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ - SupportCategories.ACCOUNT_DELETION, - SupportCategories.SALES_ENQUIRY, - SupportCategories.REFUND, -] - interface SupportAccessToggleProps { form: UseFormReturn align?: 'left' | 'right' diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index d88874ffb46c0..b072b3f5fc116 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -1,5 +1,6 @@ // End of third-party imports +import { SupportCategories } from '@supabase/shared-types/out/constants' import { DocsSearchResultType as PageType, type DocsSearchResult as Page, @@ -17,7 +18,7 @@ import { type UseQueryStatesKeysMap, } from 'nuqs' -import { CATEGORY_OPTIONS } from './Support.constants' +import { CATEGORY_OPTIONS, type ExtendedSupportCategories } from './Support.constants' import { getProjectDetail } from '@/data/projects/project-detail-query' import { DOCS_URL } from '@/lib/constants' import type { Organization } from '@/types' @@ -25,6 +26,23 @@ import type { Organization } from '@/types' export const NO_PROJECT_MARKER = 'no-project' export const NO_ORG_MARKER = 'no-org' +export const DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ + SupportCategories.ACCOUNT_DELETION, + SupportCategories.SALES_ENQUIRY, + SupportCategories.REFUND, +] + +export function canAllowSupportAccess( + category: ExtendedSupportCategories | undefined, + projectRef: string +): boolean { + return ( + !!category && + !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && + projectRef !== NO_PROJECT_MARKER + ) +} + export const formatMessage = ({ message, attachments = [], diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 17d6ad0e88413..eeb9a7d5bfb22 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -24,10 +24,11 @@ import { OrganizationSelector } from './OrganizationSelector' import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo' import { SubmitButton } from './SubmitButton' -import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import { SupportAccessToggle } from './SupportAccessToggle' import type { SupportFormValues } from './SupportForm.schema' import type { SupportFormActions, SupportFormState } from './SupportForm.state' import { + canAllowSupportAccess, formatMessage, formatStudioVersion, getOrgSubscriptionPlan, @@ -160,10 +161,9 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo ...values, organizationSlug: values.organizationSlug ?? NO_ORG_MARKER, projectRef: values.projectRef ?? NO_PROJECT_MARKER, - allowSupportAccess: - values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) - ? values.allowSupportAccess - : false, + allowSupportAccess: canAllowSupportAccess(values.category, values.projectRef) + ? values.allowSupportAccess + : false, library: values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined ? selectedLibrary.key @@ -254,7 +254,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo )} - {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {canAllowSupportAccess(category, projectRef) && ( <> diff --git a/apps/studio/components/interfaces/Support/SupportFormV3.tsx b/apps/studio/components/interfaces/Support/SupportFormV3.tsx index 5235151c4ce14..d8629c4c5f49f 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV3.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV3.tsx @@ -25,10 +25,11 @@ import { OrganizationSelector } from './OrganizationSelector' import { PlanExpectationInfoContent, ProjectAndPlanInfo } from './ProjectAndPlanInfo' import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo' import { SubmitButton } from './SubmitButton' -import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import { SupportAccessToggle } from './SupportAccessToggle' import type { SupportFormValues } from './SupportForm.schema' import type { SupportFormActions, SupportFormState } from './SupportForm.state' import { + canAllowSupportAccess, formatMessage, formatStudioVersion, getOrgSubscriptionPlan, @@ -171,10 +172,9 @@ export const SupportFormV3 = ({ ...values, organizationSlug: values.organizationSlug ?? NO_ORG_MARKER, projectRef: values.projectRef ?? NO_PROJECT_MARKER, - allowSupportAccess: - values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) - ? values.allowSupportAccess - : false, + allowSupportAccess: canAllowSupportAccess(values.category, values.projectRef) + ? values.allowSupportAccess + : false, library: values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined ? selectedLibrary.key @@ -265,7 +265,7 @@ export const SupportFormV3 = ({
    {(DASHBOARD_LOG_CATEGORIES.includes(category) || - (!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category)) || + canAllowSupportAccess(category, projectRef) || showPlanExpectationInfo || showDirectEmailInfo) && (
    @@ -275,7 +275,7 @@ export const SupportFormV3 = ({ )} - {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {canAllowSupportAccess(category, projectRef) && ( )} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index ffeae097fba95..4fa7f81fc3d00 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { platformComponents as components } from 'api-types' import dayjs from 'dayjs' +import { mockIntersectionObserver } from 'jsdom-testing-mocks' import { http, HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -15,6 +16,9 @@ import { customRender } from '@/tests/lib/custom-render' import { addAPIMock, mswServer, type APIErrorBody } from '@/tests/lib/msw' import { createMockProfileContext } from '@/tests/lib/profile-helpers' +// The project selector's infinite-scroll sentinel uses IntersectionObserver, which jsdom lacks +mockIntersectionObserver() + type ProjectDetailResponse = components['schemas']['ProjectDetailResponse'] type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse'] type OrganizationProjectsProject = OrganizationProjectsResponse['projects'][number] @@ -316,6 +320,19 @@ const getDashboardLogsToggle = (screen: Screen, type: 'find' | 'query' = 'find') : screen.queryByRole('switch', { name: labelMatcher }) } +const getSupportAccessToggle = (screen: Screen, type: 'find' | 'query' = 'find') => { + const labelMatcher = /allow support access to your project/i + return type === 'find' + ? screen.findByRole('switch', { name: labelMatcher }) + : screen.queryByRole('switch', { name: labelMatcher }) +} + +const selectNoSpecificProject = async (screen: Screen) => { + await userEvent.click(getProjectSelector(screen)) + const option = await screen.findByRole('option', { name: /no specific project/i }) + await userEvent.click(option) +} + const getSupportForm = () => { const form = document.querySelector('form#support-form') expect(form).not.toBeNull() @@ -634,6 +651,64 @@ describe('SupportFormPage', () => { ) }) + test('hides support access toggle and submits allowSupportAccess: false when no project is selected', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ result: 'ok' }) + }, + }) + + renderSupportFormPage() + + await waitFor( + () => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }, + { timeout: 5_000 } + ) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + // A project is selected, so the toggle to grant support access is available + await expect(getSupportAccessToggle(screen)).resolves.toBeInTheDocument() + + await selectNoSpecificProject(screen) + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('No specific project') + }) + + // Support access is granted on a per-project basis, so the toggle should disappear + // once no project is selected, rather than allowing it to be enabled with nothing to grant access to + await waitFor(() => { + expect(getSupportAccessToggle(screen, 'query')).not.toBeInTheDocument() + }) + + await fillField(getSummaryField(screen), 'Cannot access my account') + await fillField(getMessageField(screen), 'I need help accessing my Supabase account') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + projectRef: NO_PROJECT_MARKER, + organizationSlug: 'org-1', + allowSupportAccess: false, + }) + }, 10_000) + test('loading a URL with an invalid project slug falls back to first organization and project', async () => { mswServer.use( http.get(`${API_URL}/platform/projects/:ref`, () => @@ -1805,6 +1880,9 @@ describe('SupportFormPage', () => { await userEvent.click(dashboardLogToggle!) expect(dashboardLogToggle).not.toBeChecked() + // Support access is per-project, so with no project selected the toggle shouldn't be offered + expect(getSupportAccessToggle(screen, 'query')).not.toBeInTheDocument() + await fillField(getSummaryField(screen), 'Cannot access my account') await fillField(getMessageField(screen), 'I need help accessing my Supabase account') @@ -1822,7 +1900,7 @@ describe('SupportFormPage', () => { organizationSlug: NO_ORG_MARKER, library: '', affectedServices: '', - allowSupportAccess: true, + allowSupportAccess: false, verified: true, tags: ['dashboard-support-form'], browserInformation: 'Chrome', diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index d17b469956ec7..1764b546c7259 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -158,9 +158,12 @@ export const DestinationLogo = ({ icon, name }: { icon?: ReactNode; name: string ) +/** Fixed light tile chrome for Connect pairs with unclassified (uploaded) marks. */ +export const CONNECT_LOGO_LIGHT_TILE_CLASSNAME = 'border-black/10 bg-white' + /** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */ -export const SupabaseLogo = () => ( - +export const SupabaseLogo = ({ forceLight = false }: { forceLight?: boolean } = {}) => ( + Supabase ) diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx index 6a2ebf1f56ba7..bfd02e291fec8 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -156,7 +156,11 @@ vi.mock('@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarni ResourceExhaustionWarningBanner: () => null, })) vi.mock('@/components/ui/ButtonTooltip', () => ({ - ButtonTooltip: ({ children, ...props }: any) => , + ButtonTooltip: ({ children, ...props }: any) => ( + + ), })) vi.mock('@/components/ui/PartnerIcon', () => ({ default: () =>
    , diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx index b4246956938d7..0d22406570bce 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx @@ -58,8 +58,12 @@ vi.mock('../EdgeFunctionBlock/EdgeFunctionBlock', () => ({ {showReplaceWarning && (

    An edge function with this name already exists.

    - - + +
    )}
    @@ -73,7 +77,11 @@ vi.mock('./ConfirmFooter', () => ({ }: { confirmLabel?: string onConfirm?: () => void - }) => , + }) => ( + + ), })) describe('EdgeFunctionRenderer', () => { diff --git a/apps/studio/components/ui/Shortcut.test.tsx b/apps/studio/components/ui/Shortcut.test.tsx index b0b90d101ece2..8663459425123 100644 --- a/apps/studio/components/ui/Shortcut.test.tsx +++ b/apps/studio/components/ui/Shortcut.test.tsx @@ -28,7 +28,7 @@ describe('Shortcut', () => { it('renders the wrapped child', () => { render( {}}> - + ) expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument() @@ -37,7 +37,7 @@ describe('Shortcut', () => { it('wraps the child in ShortcutTooltip', () => { render( {}}> - + ) const tooltip = screen.getByTestId('shortcut-tooltip') @@ -50,7 +50,7 @@ describe('Shortcut', () => { const handler = vi.fn() render( - + ) expect(mockUseShortcut).toHaveBeenCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, undefined) @@ -61,7 +61,7 @@ describe('Shortcut', () => { const options = { enabled: true, registerInCommandMenu: true } render( - + ) expect(mockUseShortcut).toHaveBeenCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, options) @@ -75,7 +75,7 @@ describe('Shortcut', () => { onTrigger={handler} options={{ enabled: false }} > - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, { @@ -84,7 +84,7 @@ describe('Shortcut', () => { rerender( - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, { @@ -96,14 +96,14 @@ describe('Shortcut', () => { const handler = vi.fn() const { rerender } = render( - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith(SHORTCUT_IDS.NAV_HOME, handler, undefined) rerender( - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith( @@ -118,7 +118,7 @@ describe('Shortcut', () => { it('forwards shortcutId to ShortcutTooltip', () => { render( {}}> - + ) expect(mockShortcutTooltip).toHaveBeenCalled() @@ -135,7 +135,7 @@ describe('Shortcut', () => { sideOffset={8} delayDuration={100} > - + ) const props = mockShortcutTooltip.mock.calls.at(-1)![0] @@ -148,7 +148,7 @@ describe('Shortcut', () => { it('forwards label override to ShortcutTooltip', () => { render( {}} label="Go home"> - + ) expect(mockShortcutTooltip.mock.calls.at(-1)![0].label).toBe('Go home') @@ -157,7 +157,7 @@ describe('Shortcut', () => { it('omits undefined positioning props rather than fabricating them', () => { render( {}}> - + ) const props = mockShortcutTooltip.mock.calls.at(-1)![0] @@ -175,7 +175,9 @@ describe('Shortcut', () => { const onTrigger = vi.fn() render( - + ) @@ -188,7 +190,7 @@ describe('Shortcut', () => { const onTrigger = vi.fn() render( - + ) expect(onTrigger).not.toHaveBeenCalled() diff --git a/apps/studio/scripts/ratchet-rules.json b/apps/studio/scripts/ratchet-rules.json index 80826ac1e4a26..4f34402cd7887 100644 --- a/apps/studio/scripts/ratchet-rules.json +++ b/apps/studio/scripts/ratchet-rules.json @@ -19,7 +19,6 @@ "jsx-a11y/anchor-is-valid", "jsx-a11y/heading-has-content", "jsx-a11y/no-distracting-elements", - "supabase/require-explicit-tabindex", "valtio/state-snapshot-rule", "react-hook-form/no-use-watch" ] diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx index 5d45af7e14b17..477bc847fd108 100644 --- a/apps/studio/tests/components/ApiAuthorization.test.tsx +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -10,7 +10,7 @@ import { ApiAuthorizationScreen, type ApiAuthorizationScreenProps, } from '@/components/interfaces/ApiAuthorization/ApiAuthorization' -import { RequesterLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' +import { AuthorizeConnectLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query' import type { ProfileContextType } from '@/lib/profile' import { createMockOrganizationResponse } from '@/tests/helpers' @@ -122,28 +122,74 @@ function renderScreen(props: Partial = {}) { // --- Tests --- -describe('RequesterLogo', () => { +describe('AuthorizeConnectLogo', () => { test.each([ - ['Cursor', 'cursor'], - ['Claude', 'claude'], - ['ChatGPT', 'openai'], - ['OpenAI', 'openai'], - ['Perplexity', 'perplexity'], - ])('resolves %s to a shared MCP icon asset', (name, iconKey) => { - customRender() + ['Cursor', 'https://cursor.com/callback', 'cursor'], + ['Claude', 'https://claude.ai/api/mcp/auth_callback', 'claude'], + ['ChatGPT', 'https://chatgpt.com/callback', 'openai'], + ['OpenAI', 'https://openai.com/callback', 'openai'], + ['Perplexity', 'https://www.perplexity.ai/callback', 'perplexity'], + ])('pairs %s with Supabase when redirect host is allowlisted', (name, redirectUri, iconKey) => { + customRender() expect(screen.getByAltText(name)).toHaveAttribute( 'src', getMcpClientIconSrc({ icon: iconKey, useDarkVariant: false }) ) + expect(screen.getByAltText('Supabase')).toBeInTheDocument() }) - test('falls back to the requester initial when the icon fails to load', () => { - customRender() + test('does not use a curated logo from the requester name alone', () => { + customRender( + + ) + + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByAltText('Claude')).not.toBeInTheDocument() + }) + + test('shows Supabase alone when the requester has no icon', () => { + customRender() + + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByAltText('Acme')).not.toBeInTheDocument() + expect(screen.queryByText('A')).not.toBeInTheDocument() + }) + + test('shows Supabase alone when the requester icon fails to load', () => { + customRender( + + ) fireEvent.error(screen.getByAltText('Unknown App')) - expect(screen.getByText('U')).toBeInTheDocument() + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByAltText('Unknown App')).not.toBeInTheDocument() + expect(screen.queryByText('U')).not.toBeInTheDocument() + }) + + test('forces light tiles when pairing an uploaded OAuth app icon', () => { + customRender( + + ) + + expect(screen.getByAltText('Acme').parentElement).toHaveClass('bg-white') + expect(screen.getByAltText('Acme').parentElement).toHaveClass('border-black/10') + expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-white') + expect(screen.getByAltText('Supabase').parentElement).toHaveClass('border-black/10') + }) + + test('keeps theme tiles for curated partners', () => { + customRender( + + ) + + expect(screen.getByAltText('Cursor').parentElement).toHaveClass('bg-surface-75') + expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-surface-75') }) }) @@ -282,11 +328,40 @@ describe('ApiAuthorizationScreen', () => { renderScreen() await screen.findByText('Authorize API access for My OAuth App') expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByText('M')).not.toBeInTheDocument() expect(screen.getByRole('combobox')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() }) + test('pairs curated MCP requesters with Supabase when redirect host is allowlisted', async () => { + mockBothEndpoints( + createMockAuthResponse({ + name: 'Cursor', + icon: null, + redirect_uri: 'https://cursor.com/callback', + }) + ) + renderScreen() + await screen.findByText('Authorize API access for Cursor') + expect(screen.getByAltText('Cursor')).toBeInTheDocument() + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + }) + + test('shows Supabase alone when name looks trusted but redirect host is not allowlisted', async () => { + mockBothEndpoints( + createMockAuthResponse({ + name: 'Claude', + icon: null, + redirect_uri: 'https://evil.com/callback', + }) + ) + renderScreen() + await screen.findByText('Authorize API access for Claude') + expect(screen.queryByAltText('Claude')).not.toBeInTheDocument() + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + }) + test('auto-selects the only organization when no organization_slug is provided', async () => { mockBothEndpoints() renderScreen() diff --git a/apps/studio/tests/components/SQLEditor/Results.utils.test.ts b/apps/studio/tests/components/SQLEditor/Results.utils.test.ts deleted file mode 100644 index 1b2e0300ed665..0000000000000 --- a/apps/studio/tests/components/SQLEditor/Results.utils.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { - formatCellValue, - formatClipboardValue, -} from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils' - -describe('formatClipboardValue', () => { - test('returns empty string for null', () => { - expect(formatClipboardValue(null)).toBe('') - }) - - test('stringifies objects', () => { - expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}') - }) - - test('stringifies arrays', () => { - expect(formatClipboardValue([1, 2])).toBe('[1,2]') - }) - - test('converts primitives to string', () => { - expect(formatClipboardValue('hello')).toBe('hello') - expect(formatClipboardValue(42)).toBe('42') - expect(formatClipboardValue(false)).toBe('false') - }) -}) - -describe('formatCellValue', () => { - test('returns NULL for null', () => { - expect(formatCellValue(null)).toBe('NULL') - }) - - test('returns strings as-is', () => { - expect(formatCellValue('hello')).toBe('hello') - }) - - test('stringifies objects', () => { - expect(formatCellValue({ a: 1 })).toBe('{"a":1}') - }) - - test('stringifies numbers', () => { - expect(formatCellValue(42)).toBe('42') - }) - - test('stringifies booleans', () => { - expect(formatCellValue(true)).toBe('true') - }) -}) diff --git a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx b/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx deleted file mode 100644 index 9d6718f24ca9d..0000000000000 --- a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx +++ /dev/null @@ -1,492 +0,0 @@ -import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' -import { HttpResponse } from 'msw' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' - -import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor' -import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types' -import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' -import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' -import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' -import { customRender } from '@/tests/lib/custom-render' -import { addAPIMock } from '@/tests/lib/msw' - -/** - * Characterization tests for the (pre-decomposition) `SQLEditor` monolith. - * - * These tests are not intended to be a model of best practices; they - * over-mock and assert on implementation details. They are temporary to - * capture regressions while the editor is being decomposed into smaller, more - * testable components. - */ - -const SNIPPET_ID = 'test-snippet-id' - -// A single mutable fake-editor state so tests can drive selection / value / -// decoration ids, shared into the hoisted module mocks below. -const mocks = vi.hoisted(() => { - const state = { - value: 'select 1;', - selection: null as null | { startLineNumber: number }, - selectionValue: undefined as string | undefined, - decorations: ['decoration-1'] as string[], - scrollHandler: null as null | ((e: { scrollTop: number }) => void), - diffModifiedValue: 'select 2;', - } - - const editor = { - getValue: () => state.value, - getSelection: () => state.selection, - getModel: () => ({ - getValueInRange: (_selection: unknown) => state.selectionValue, - getFullModelRange: () => ({ __fullRange: true }), - }), - executeEdits: vi.fn(), - deltaDecorations: vi.fn( - (_oldDecorations: string[], _newDecorations: any[]) => state.decorations - ), - revealLineInCenter: vi.fn(), - onDidScrollChange: vi.fn((cb: (e: { scrollTop: number }) => void) => { - state.scrollHandler = cb - }), - setScrollTop: vi.fn(), - focus: vi.fn(), - } - - class FakeRange { - startLineNumber: number - startColumn: number - endLineNumber: number - endColumn: number - constructor(a: number, b: number, c: number, d: number) { - this.startLineNumber = a - this.startColumn = b - this.endLineNumber = c - this.endColumn = d - } - } - const monaco = { Range: FakeRange } - - const diffEditor = { - getModel: () => ({ - original: { setValue: vi.fn(), getValue: () => '' }, - modified: { setValue: vi.fn(), getValue: () => state.diffModifiedValue }, - }), - getModifiedEditor: () => ({ revealLineInCenter: vi.fn() }), - } - - return { state, editor, monaco, diffEditor } -}) - -// --- Editor fakes ----------------------------------------------------------- - -vi.mock('@/components/interfaces/SQLEditor/MonacoEditor', async () => { - const { useEffect } = await vi.importActual('react') - return { - MonacoEditor: (props: any) => { - useEffect(() => { - props.editorRef.current = mocks.editor - props.monacoRef.current = mocks.monaco - props.onMount?.(mocks.editor) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return ( -
    -
    {props.placeholder}
    - - -
    - ) - }, - } -}) - -vi.mock('@/components/ui/DiffEditor', async () => { - const { useEffect } = await vi.importActual('react') - return { - DiffEditor: (props: any) => { - useEffect(() => { - props.onMount?.(mocks.diffEditor) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return
    - }, - } -}) - -vi.mock('@/components/ui/AIEditor/ResizableAIWidget', () => ({ - default: (props: any) => ( -
    - - - -
    - ), -})) - -// --- Child panel stubs (not under test) ------------------------------------- - -vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityActions', () => ({ - UtilityActions: (props: any) => ( -
    - - - {String(props.isExecuting)} -
    - ), -})) - -vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel', () => ({ - UtilityPanel: (props: any) => ( -
    - {props.activeTab} - -
    - ), -})) - -vi.mock('@/components/interfaces/SQLEditor/RunQueryWarningModal', () => ({ - RunQueryWarningModal: (props: any) => - props.visible ? ( -
    - - - -
    - ) : null, -})) - -// --- Context / selection hook stubs (orthogonal infra, not under test) ------ - -vi.mock('@/components/interfaces/SQLEditor/useAddDefinitions', () => ({ - useAddDefinitions: () => {}, -})) - -vi.mock('@/hooks/misc/useSelectedProject', () => ({ - useSelectedProjectQuery: () => ({ - data: { - id: 1, - ref: 'default', - connectionString: 'postgresql://postgres@localhost:5432/postgres', - }, - }), -})) - -vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ - useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org' } }), -})) - -vi.mock('@/hooks/misc/useOrgOptedIntoAi', () => ({ - useOrgAiOptInLevel: () => ({ - aiOptInLevel: 'disabled', - includeSchemaMetadata: false, - isHipaaProjectDisallowed: false, - }), -})) - -vi.mock('@/data/read-replicas/replicas-query', () => ({ - useReadReplicasQuery: () => ({ - data: [ - { - identifier: 'default', - connectionString: 'postgresql://postgres@localhost:5432/postgres', - }, - ], - isSuccess: true, - }), -})) - -vi.mock('@/data/database-event-triggers/database-event-triggers-query', () => ({ - useDatabaseEventTriggersQuery: () => ({ data: undefined }), -})) - -// `common` is globally mocked in vitestSetup to `{ ref: 'default' }`. Re-mock it -// here to also provide a snippet id (so `id` is stable and points at our seeded -// snippet) and a stable `useFlag`. -vi.mock('common', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useParams: () => ({ ref: 'default', id: SNIPPET_ID }), - useFlag: () => false, - } -}) - -// --- Helpers ---------------------------------------------------------------- - -function seedSnippet(sql: string, name = 'My query') { - ;(sqlEditorState.snippets as any)[SNIPPET_ID] = { - projectRef: 'default', - splitSizes: [50, 50], - snippet: { - id: SNIPPET_ID, - name, - project_id: 1, - owner_id: 1, - content: { sql, unchecked_sql: sql, schema_version: '1.0', favorite: false }, - }, - } -} - -function resetStores() { - for (const key of Object.keys(sqlEditorState.snippets)) { - delete (sqlEditorState.snippets as any)[key] - } - for (const key of Object.keys(sqlEditorSessionState.results)) { - delete (sqlEditorSessionState.results as any)[key] - } - sqlEditorDiffRequestState.pending = undefined -} - -const NON_EXPLAIN_ROWS = [{ id: 1, name: 'row-1' }] - -function mockQuerySuccess(rows: unknown[] = NON_EXPLAIN_ROWS) { - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: () => HttpResponse.json(rows), - }) -} - -function mockQueryError(body: Record) { - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: () => HttpResponse.json(body, { status: 400 }), - }) -} - -beforeEach(() => { - vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { - return setTimeout(() => cb(0), 0) as unknown as number - }) - resetStores() - seedSnippet('select 1;') - mocks.state.value = 'select 1;' - mocks.state.selection = null - mocks.state.selectionValue = undefined - mocks.state.decorations = ['decoration-1'] - mocks.editor.executeEdits.mockClear() - mocks.editor.deltaDecorations.mockClear() - mocks.editor.revealLineInCenter.mockClear() - mocks.editor.focus.mockClear() -}) - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() - vi.restoreAllMocks() -}) - -async function renderEditor() { - const utils = customRender() - // The Monaco editor is loaded via next/dynamic; wait for the fake to mount. - await screen.findByTestId('monaco-editor') - return utils -} - -describe('SQLEditor characterization', () => { - test('1a. successful run adds result and keeps Results tab active', async () => { - const addResult = vi.spyOn(sqlEditorSessionState, 'addResult') - mockQuerySuccess(NON_EXPLAIN_ROWS) - - await renderEditor() - expect(screen.getByTestId('active-tab')).toHaveTextContent('results') - - fireEvent.click(screen.getByTestId('editor-run')) - - await waitFor(() => expect(addResult).toHaveBeenCalled()) - // The snippet id and rows are the load-bearing part; autoLimit is derived - // from the (SELECT) query + configured row limit and is asserted loosely. - expect(addResult.mock.calls[0][0]).toBe(SNIPPET_ID) - expect(addResult.mock.calls[0][1]).toEqual(NON_EXPLAIN_ROWS) - expect(screen.getByTestId('active-tab')).toHaveTextContent('results') - }) - - test('2. run error with position highlights the computed line and reveals it', async () => { - const addResultError = vi.spyOn(sqlEditorSessionState, 'addResultError') - mockQueryError({ - message: 'syntax error', - position: '8', - formattedError: 'ERROR: syntax error at or near "slect"\nLINE 3: slect 1;\n ^', - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - - await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalled()) - - // With no selection, startLineNumber is 0 → highlighted line === parsed LINE (3). - const [oldDecorations, newDecorations] = mocks.editor.deltaDecorations.mock.calls[0] - expect(oldDecorations).toEqual([]) - expect(newDecorations[0].range.startLineNumber).toBe(3) - expect(newDecorations[0].range.endLineNumber).toBe(3) - expect(mocks.editor.revealLineInCenter).toHaveBeenCalledWith(3) - await waitFor(() => expect(addResultError).toHaveBeenCalled()) - }) - - test('2b. the next run clears the previously-set line highlights', async () => { - // First: produce a highlight. - mockQueryError({ - message: 'syntax error', - position: '8', - formattedError: 'ERROR: syntax error\nLINE 2: foo\n', - }) - await renderEditor() - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalledTimes(1)) - - // Second run should clear the stored decorations before running again. - mockQuerySuccess(NON_EXPLAIN_ROWS) - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => - expect(mocks.editor.deltaDecorations).toHaveBeenCalledWith(['decoration-1'], []) - ) - }) - - test('3. running via the run button refocuses the editor', async () => { - mockQuerySuccess(NON_EXPLAIN_ROWS) - await renderEditor() - - fireEvent.click(screen.getByTestId('run-button')) - - await waitFor(() => expect(mocks.editor.focus).toHaveBeenCalled()) - }) - - test('3b. run button is disabled and short-circuits while a diff is open', async () => { - // Queue a diff request against a non-empty editor so a diff opens on mount. - mocks.state.value = 'select 1;' - sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification) - - await renderEditor() - // The queued request drains on mount and opens a diff, which disables Run. - // (Assert via the run button rather than the dynamically-imported diff editor.) - await waitFor(() => expect(screen.getByTestId('run-button')).toBeDisabled()) - - const addResult = vi.spyOn(sqlEditorSessionState, 'addResult') - // The run button is disabled while diffing; clicking should not execute. - fireEvent.click(screen.getByTestId('run-button')) - // Give any async work a chance to (not) happen. - await new Promise((r) => setTimeout(r, 20)) - expect(addResult).not.toHaveBeenCalled() - }) - - test('4. a diff request queued before mount drains exactly once', async () => { - // Empty editor → drain copies the SQL in via executeEdits('apply-ai-message'). - mocks.state.value = '' - sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification) - - const { unmount } = await renderEditor() - - await waitFor(() => - expect(mocks.editor.executeEdits).toHaveBeenCalledWith('apply-ai-message', expect.any(Array)) - ) - expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1) - // Request was consumed (drained), so it cannot re-apply. - expect(sqlEditorDiffRequestState.pending).toBeUndefined() - - // A fresh mount must NOT re-apply the already-consumed request. - unmount() - await renderEditor() - await new Promise((r) => setTimeout(r, 20)) - expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1) - }) - - test('5. the ask-ai widget renders only while the prompt is open', async () => { - mocks.state.value = 'select 1;' - await renderEditor() - - expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('editor-prompt')) - expect(await screen.findByTestId('ask-ai')).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('ask-ai-cancel')) - await waitFor(() => expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument()) - }) - - test('6. a destructive query opens the warning modal; confirm re-runs forced', async () => { - mocks.state.value = 'drop table foo;' - let forcedQuery = '' - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: async ({ request }) => { - const body = (await request.json()) as { query: string } - forcedQuery = body.query - return HttpResponse.json(NON_EXPLAIN_ROWS) - }, - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - - // Destructive query → confirmation modal, and no query is sent yet. - expect(await screen.findByTestId('warning-modal')).toBeInTheDocument() - expect(forcedQuery).toBe('') - - // Confirming forces the (same) destructive query to actually run. - fireEvent.click(screen.getByTestId('warn-confirm')) - await waitFor(() => expect(forcedQuery).toMatch(/drop table foo/i)) - }) - - test('6b. confirm-with-RLS re-runs with appended enable-RLS statements', async () => { - mocks.state.value = 'create table foo (id int);' - let capturedQuery = '' - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: async ({ request }) => { - const body = (await request.json()) as { query: string } - capturedQuery = body.query - return HttpResponse.json(NON_EXPLAIN_ROWS) - }, - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - expect(await screen.findByTestId('warning-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('warn-confirm-rls')) - await waitFor(() => expect(capturedQuery).toMatch(/enable row level security/i)) - }) -}) diff --git a/apps/studio/tests/lib/custom-render.tsx b/apps/studio/tests/lib/custom-render.tsx index 808bfbdb1a7e6..f182c109a3e60 100644 --- a/apps/studio/tests/lib/custom-render.tsx +++ b/apps/studio/tests/lib/custom-render.tsx @@ -10,7 +10,7 @@ import { ProfileContext, type ProfileContextType } from '@/lib/profile' type AdapterProps = Partial[0]> -const CustomWrapper = ({ +export const CustomWrapper = ({ children, queryClient, nuqs, diff --git a/apps/studio/tests/lib/sql-editor-test-utils.tsx b/apps/studio/tests/lib/sql-editor-test-utils.tsx new file mode 100644 index 0000000000000..3be4957d80b96 --- /dev/null +++ b/apps/studio/tests/lib/sql-editor-test-utils.tsx @@ -0,0 +1,365 @@ +import { untrustedSql } from '@supabase/pg-meta' +import type { QueryClient } from '@tanstack/react-query' +import { renderHook, type RenderHookOptions } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import type { ReactNode } from 'react' + +import type { + ContentDiff, + DiffController, + EditorController, +} from '@/components/interfaces/SQLEditor/SQLEditor.types' +import { + computeErrorHighlightLine, + createSqlSnippetSkeletonV2, +} from '@/components/interfaces/SQLEditor/SQLEditor.utils' +import { SQLEditorProvider } from '@/components/interfaces/SQLEditor/SQLEditorContext' +import { API_URL, OPT_IN_TAGS } from '@/lib/constants' +import type { ProfileContextType } from '@/lib/profile' +import { AiAssistantStateContext, createAiAssistantState } from '@/state/ai-assistant-state' +import { + createDatabaseSelectorState, + DatabaseSelectorStateContext, +} from '@/state/database-selector' +import { + createRoleImpersonationState, + RoleImpersonationStateContext, +} from '@/state/role-impersonation-state' +import { sidebarManagerState } from '@/state/sidebar-manager-state' +import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' +import { CustomWrapper } from '@/tests/lib/custom-render' +import { addAPIMock, mswServer } from '@/tests/lib/msw' + +export type InMemoryEditor = { + editor: EditorController + diff: DiffController + setValue: (value: string) => void + setSelection: (value: string | undefined, startLineNumber?: number) => void + getHighlightedLine: () => number | undefined + getRevealedLine: () => number | undefined + getDiffOriginal: () => string + getDiffModified: () => string + getDiffRevealedLine: () => number | undefined + setDiffMounted: (mounted: boolean) => void +} + +export function createInMemoryEditor(initialValue: string = ''): InMemoryEditor { + let value = initialValue + let selectionValue: string | undefined + let selectionStartLine: number | undefined + let highlightedLine: number | undefined + let revealedLine: number | undefined + + let diffMounted = true + let diffOriginal = '' + let diffModified = '' + let diffRevealedLine: number | undefined + + const editor: EditorController = { + isReady: () => true, + getValue: () => value, + getSelectionStartLine: () => selectionStartLine, + getSql: (snippetContent) => untrustedSql(selectionValue || value || snippetContent || ''), + replaceAll: (text) => { + value = text + }, + focus: () => {}, + revealLineInCenter: (line) => { + revealedLine = line + }, + highlightErrorLine: (error, hasSelection) => { + const startLineNumber = hasSelection ? (selectionStartLine ?? 0) : 0 + const line = computeErrorHighlightLine(error, startLineNumber) + if (Number.isNaN(line)) return + highlightedLine = line + revealedLine = line + }, + clearHighlights: () => { + highlightedLine = undefined + }, + } + + const diff: DiffController = { + isMounted: () => diffMounted, + getModifiedValue: () => diffModified, + setDiff: (contentDiff: ContentDiff, revealLine: number) => { + diffOriginal = contentDiff.original + diffModified = contentDiff.modified + diffRevealedLine = revealLine + }, + attach: () => { + diffMounted = true + }, + } + + return { + editor, + diff, + setValue: (v) => { + value = v + }, + setSelection: (v, startLine = 1) => { + selectionValue = v + selectionStartLine = v === undefined ? undefined : startLine + }, + getHighlightedLine: () => highlightedLine, + getRevealedLine: () => revealedLine, + getDiffOriginal: () => diffOriginal, + getDiffModified: () => diffModified, + getDiffRevealedLine: () => diffRevealedLine, + setDiffMounted: (mounted) => { + diffMounted = mounted + }, + } +} + +/** Call in `beforeEach` for full isolation between SQL editor hook tests. */ +export function resetSqlEditorStores() { + for (const key of Object.keys(sqlEditorState.snippets)) { + delete sqlEditorState.snippets[key] + } + for (const key of Object.keys(sqlEditorState.folders)) { + delete sqlEditorState.folders[key] + } + sqlEditorState.needsSaving.clear() + sqlEditorState.pendingFolderSaves.clear() + + for (const key of Object.keys(sqlEditorSessionState.results)) { + delete sqlEditorSessionState.results[key] + } + sqlEditorSessionState.limit = 100 + + sqlEditorDiffRequestState.pending = undefined + + sidebarManagerState.sidebars = {} + sidebarManagerState.activeSidebar = undefined + sidebarManagerState.pendingSidebarOpen = undefined + sidebarManagerState.isMaximised = false +} + +export function seedSnippet({ + id, + projectRef = 'default', + name = 'Test query', + sql = '', + ownerId = 1, + projectId = 1, +}: { + id: string + projectRef?: string + name?: string + sql?: string + ownerId?: number + projectId?: number +}) { + const snippet = createSqlSnippetSkeletonV2({ + name, + sql, + owner_id: ownerId, + project_id: projectId, + idOverride: id, + }) + sqlEditorState.addSnippet({ projectRef, snippet }) + return snippet +} + +export function setupSqlEditorMocks({ + ref = 'default', + connectionString = 'postgresql://postgres@localhost:5432/postgres', + orgId = 1, + orgSlug = 'test-org', + optInTags = [OPT_IN_TAGS.AI_SQL], + queryRows = [] as unknown[], +}: { + ref?: string + connectionString?: string + orgId?: number + orgSlug?: string + optInTags?: string[] + queryRows?: unknown[] +} = {}) { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + id: 1, + ref, + organization_id: orgId, + name: 'Test Project', + status: 'ACTIVE_HEALTHY', + cloud_provider: 'AWS', + region: 'us-east-1', + db_host: `db.${ref}.supabase.co`, + restUrl: `https://${ref}.supabase.co/rest/v1/`, + inserted_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + subscription_id: 'sub_123', + is_branch_enabled: false, + is_physical_backups_enabled: false, + high_availability: false, + integration_source: null, + connectionString, + is_hibernating: false, + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: [ + { + id: orgId, + slug: orgSlug, + name: 'Test Org', + billing_email: 'test@supabase.io', + billing_partner: null, + integration_source: null, + is_owner: true, + opt_in_tags: optInTags, + organization_missing_address: false, + organization_missing_tax_id: false, + organization_requires_mfa: false, + plan: { id: 'free', name: 'Free' }, + restriction_data: null, + restriction_status: null, + stripe_customer_id: null, + subscription_id: null, + usage_billing_enabled: false, + }, + ], + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/billing/subscription', + response: { + addons: [], + billing_cycle_anchor: 1700000000, + billing_via_partner: false, + current_period_end: 1700000000, + current_period_start: 1700000000, + next_invoice_at: 1700000000, + payment_method_type: 'card', + plan: { id: 'free', name: 'Free' }, + project_addons: [], + scheduled_plan_change: null, + usage_billing_enabled: false, + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/settings', + response: { + cloud_provider: 'AWS', + db_dns_name: `db.${ref}.supabase.co`, + db_host: `db.${ref}.supabase.co`, + db_ip_addr_config: 'ipv4', + db_name: 'postgres', + db_port: 5432, + db_user: 'postgres', + inserted_at: '2024-01-01T00:00:00Z', + name: 'Test Project', + ref, + region: 'us-east-1', + ssl_enforced: false, + status: 'ACTIVE_HEALTHY', + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/databases', + response: [ + { + identifier: ref, + connectionString, + cloud_provider: 'AWS', + db_host: `db.${ref}.supabase.co`, + db_name: 'postgres', + db_port: 5432, + db_user: 'postgres', + inserted_at: '2024-01-01T00:00:00Z', + region: 'us-east-1', + restUrl: `https://${ref}.supabase.co/rest/v1/`, + size: 'ci_micro', + status: 'ACTIVE_HEALTHY', + }, + ], + }) + + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json(queryRows), + }) + + // Internal Next API routes (not part of the platform OpenAPI spec), hit via + // raw fetch — same MSW server, just registered directly rather than through + // the typed `addAPIMock` helper. + mswServer.use( + http.post(`${API_URL}/ai/sql/title-v2`, async () => + HttpResponse.json({ title: 'Generated title', description: '' }) + ), + http.post(`${API_URL}/ai/code/complete`, async () => HttpResponse.json('select 1;')) + ) +} + +type NuqsAdapterProps = Partial[0]> + +type RenderSqlEditorHookOptions = { + initialProps?: TProps + queryClient?: QueryClient + nuqs?: NuqsAdapterProps + profileContext?: ProfileContextType + inMemoryEditor?: InMemoryEditor + aiAssistantState?: ReturnType + databaseSelectorState?: ReturnType + roleImpersonationState?: ReturnType +} + +export function renderSqlEditorHook( + hook: (props: TProps) => TResult, + options?: RenderSqlEditorHookOptions +) { + const inMemoryEditor = options?.inMemoryEditor ?? createInMemoryEditor() + const aiAssistantState = options?.aiAssistantState ?? createAiAssistantState() + const databaseSelectorState = options?.databaseSelectorState ?? createDatabaseSelectorState() + const roleImpersonationState = + options?.roleImpersonationState ?? + createRoleImpersonationState('default', { current: async () => ({}) }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + {children} + + + + + + ) + + const result = renderHook(hook, { + initialProps: options?.initialProps, + wrapper, + } as RenderHookOptions) + + return { + ...result, + inMemoryEditor, + aiAssistantState, + databaseSelectorState, + roleImpersonationState, + } +} diff --git a/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx b/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx index 7b2a51afa4651..dca41fefd6f89 100644 --- a/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx +++ b/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx @@ -35,6 +35,7 @@ export function DashboardFeaturesSection({
    {tabs.map((tab, index) => ( - diff --git a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx index 5a396516ebbfd..0587801d4a33f 100644 --- a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx +++ b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx @@ -118,6 +118,7 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) { const Icon = ICONS[useCase.icon] return ( + ) - return url ? {renderButton()} : renderButton() + if (url) { + return ( + + {content} + + ) + } + + return ( + + ) } export default Button diff --git a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx index ede236257488c..ad5cd2d56a778 100644 --- a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx +++ b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx @@ -57,6 +57,7 @@ export function ChangelogDetailSidebar({ slug, url, labels, className }: Props) View discussion on GitHub {' '} / + > +
    + +
    +
    + {props.title} +

    {props.description}

    ) diff --git a/apps/www/components/LaunchWeek/X/Album/Player.tsx b/apps/www/components/LaunchWeek/X/Album/Player.tsx index a3b4f0910d7c2..954774e6fc1fb 100644 --- a/apps/www/components/LaunchWeek/X/Album/Player.tsx +++ b/apps/www/components/LaunchWeek/X/Album/Player.tsx @@ -34,6 +34,7 @@ const Player = () => {