From 0b53a1869f5cc7284376d4465a8929886b729a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cemal=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 21 Jul 2026 12:13:13 +0200 Subject: [PATCH 01/17] fix(studio): wire up siwc-enabled query param opt-in on sign-in/sign-up (#48126) Add `useSiwcQueryParamOptIn`, which flips on the ChatGPT sign-in rollout localStorage flag when `?siwc-enabled=1` is present, and call it from both pages/sign-in.tsx and pages/sign-up.tsx. ## Summary by CodeRabbit * **New Features** * Added support for enabling the sign-in experience via `?siwc-enabled=1`, automatically updating the stored opt-in flag on both sign-in and sign-up pages. * **Tests** * Added coverage confirming the stored flag is updated only for `siwc-enabled=1`, and not for missing, non-`1`, `0`, or repeated/array values. * Added assertions that the behavior is triggered consistently when rendering the sign-in and sign-up pages. --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Joshen Lim --- .../__tests__/useSiwcQueryParamOptIn.test.ts | 87 +++++++++++++++++++ .../hooks/misc/useSiwcQueryParamOptIn.ts | 27 ++++++ apps/studio/pages/sign-in.tsx | 3 + apps/studio/pages/sign-up.tsx | 3 + apps/studio/tests/pages/sign-in.test.tsx | 70 +++++++++++++++ apps/studio/tests/pages/sign-up.test.tsx | 46 ++++++++++ 6 files changed, 236 insertions(+) create mode 100644 apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts create mode 100644 apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts create mode 100644 apps/studio/tests/pages/sign-in.test.tsx create mode 100644 apps/studio/tests/pages/sign-up.test.tsx diff --git a/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts b/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts new file mode 100644 index 0000000000000..71f2acc58d081 --- /dev/null +++ b/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react' +import mockRouter from 'next-router-mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSiwcQueryParamOptIn } from '../useSiwcQueryParamOptIn' + +vi.mock('next/router', () => import('next-router-mock')) + +// tests/vitestSetup.ts globally mocks `common`'s useParams to always return `{ ref: 'default' }`, +// which would make this hook's `siwcEnabled` lookup always undefined. Restore the real +// implementation here so useParams reflects the mocked router's query params. +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +const mockSetValue = vi.hoisted(() => vi.fn()) +const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn()) + +vi.mock('../useLocalStorage', () => ({ + useLocalStorageQuery: mockUseLocalStorageQuery, +})) + +describe('useSiwcQueryParamOptIn', () => { + beforeEach(() => { + mockRouter.setCurrentUrl('/sign-in') + mockSetValue.mockClear() + mockUseLocalStorageQuery.mockReturnValue([false, mockSetValue]) + }) + + it('enables the flag when siwc-enabled=1 is present', () => { + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).toHaveBeenCalledWith(true) + }) + + it('does nothing when the param is absent', () => { + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('does nothing for a non-"1" value', () => { + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=true') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('does nothing when siwc-enabled=0', () => { + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('only considers the first value when the param is repeated (array value)', () => { + // useParams (from 'common') flattens repeated query params to their first occurrence, so + // only the first "0" here is seen by the hook, and it does nothing. + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0&siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('still calls the setter when the flag is already true (idempotent no-op is the setter’s job)', () => { + mockUseLocalStorageQuery.mockReturnValue([true, mockSetValue]) + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).toHaveBeenCalledWith(true) + }) + + it('works the same way on the sign-up URL', () => { + mockRouter.setCurrentUrl('/sign-up?siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).toHaveBeenCalledWith(true) + }) +}) diff --git a/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts b/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts new file mode 100644 index 0000000000000..400c7a71b03e9 --- /dev/null +++ b/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts @@ -0,0 +1,27 @@ +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useEffect } from 'react' + +import { useLocalStorageQuery } from './useLocalStorage' + +/** + * Lets a shareable link (e.g. `/sign-in?siwc-enabled=1`) flip on the manual ChatGPT sign-in + * rollout switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, read by + * `useEnabledIdentityProviders`) for this browser, instead of requiring a devtools localStorage + * edit. Only ever meaningful on `/sign-in` and `/sign-up`, where this param would be linked to. + * + * Only the exact string `'1'` opts in; the flag is never cleared based on the param's absence, so + * it persists once set. + */ +export function useSiwcQueryParamOptIn() { + const { siwcEnabled } = useParams() + const [, setChatgptLocalStorageEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, + false + ) + + useEffect(() => { + if (siwcEnabled === '1') { + setChatgptLocalStorageEnabled(true) + } + }, [siwcEnabled, setChatgptLocalStorageEnabled]) +} diff --git a/apps/studio/pages/sign-in.tsx b/apps/studio/pages/sign-in.tsx index 9cf0b1c6ea513..24335a2af7850 100644 --- a/apps/studio/pages/sign-in.tsx +++ b/apps/studio/pages/sign-in.tsx @@ -14,11 +14,14 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders' import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn' import { IS_PLATFORM } from '@/lib/constants' import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' import type { NextPageWithLayout } from '@/types' const SignInPage: NextPageWithLayout = () => { + useSiwcQueryParamOptIn() + const router = useRouter() const [showOtherOptions, setShowOtherOptions] = useState(false) diff --git a/apps/studio/pages/sign-up.tsx b/apps/studio/pages/sign-up.tsx index 382a78a36acb6..417f1dc4bfc4b 100644 --- a/apps/studio/pages/sign-up.tsx +++ b/apps/studio/pages/sign-up.tsx @@ -9,10 +9,13 @@ import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders' import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn' import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' import type { NextPageWithLayout } from '@/types' const SignUpPage: NextPageWithLayout = () => { + useSiwcQueryParamOptIn() + const [showOtherOptions, setShowOtherOptions] = useState(false) const { dashboardAuthSignUp: signUpEnabled } = useIsFeatureEnabled(['dashboard_auth:sign_up']) diff --git a/apps/studio/tests/pages/sign-in.test.tsx b/apps/studio/tests/pages/sign-in.test.tsx new file mode 100644 index 0000000000000..e0e2b6933e4ce --- /dev/null +++ b/apps/studio/tests/pages/sign-in.test.tsx @@ -0,0 +1,70 @@ +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import SignInPage from '@/pages/sign-in' + +const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({ + mockUseSiwcQueryParamOptIn: vi.fn(), + mockUseIsFeatureEnabled: vi.fn(), +})) + +vi.mock('next/router', () => ({ + useRouter: () => ({ query: {}, replace: vi.fn() }), +})) + +vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({ + useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn, +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: mockUseIsFeatureEnabled, +})) + +vi.mock('@/hooks/custom-content/useCustomContent', () => ({ + useCustomContent: () => ({ + dashboardAuthCustomProvider: undefined, + dashboardAuthCustomProviders: undefined, + }), +})) + +vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({ + useEnabledIdentityProviders: () => [], +})) + +vi.mock('@/hooks/misc/useInboundBranding', () => ({ + useInboundBranding: () => ({ focusProvider: undefined }), +})) + +vi.mock('@/components/interfaces/SignIn/LastSignInWrapper', () => ({ + LastSignInWrapper: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/interfaces/SignIn/SignInForm', () => ({ + SignInForm: () =>
SignInForm
, +})) + +vi.mock('@/components/interfaces/SignIn/SignInWithCustom', () => ({ + SignInWithCustom: () =>
SignInWithCustom
, +})) + +vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({ + SignInWithExternalProvider: () =>
SignInWithExternalProvider
, +})) + +describe('/sign-in', () => { + beforeEach(() => { + mockUseSiwcQueryParamOptIn.mockClear() + mockUseIsFeatureEnabled.mockReturnValue({ + dashboardAuthSignInWithSso: false, + dashboardAuthSignInWithEmail: false, + dashboardAuthSignUp: false, + }) + }) + + it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => { + render() + + expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled() + }) +}) diff --git a/apps/studio/tests/pages/sign-up.test.tsx b/apps/studio/tests/pages/sign-up.test.tsx new file mode 100644 index 0000000000000..37b54070a365a --- /dev/null +++ b/apps/studio/tests/pages/sign-up.test.tsx @@ -0,0 +1,46 @@ +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import SignUpPage from '@/pages/sign-up' + +const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({ + mockUseSiwcQueryParamOptIn: vi.fn(), + mockUseIsFeatureEnabled: vi.fn(), +})) + +vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({ + useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn, +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: mockUseIsFeatureEnabled, +})) + +vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({ + useEnabledIdentityProviders: () => [], +})) + +vi.mock('@/hooks/misc/useInboundBranding', () => ({ + useInboundBranding: () => ({ focusProvider: undefined }), +})) + +vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({ + SignInWithExternalProvider: () =>
SignInWithExternalProvider
, +})) + +vi.mock('@/components/interfaces/SignIn/SignUpForm', () => ({ + SignUpForm: () =>
SignUpForm
, +})) + +describe('/sign-up', () => { + beforeEach(() => { + mockUseSiwcQueryParamOptIn.mockClear() + mockUseIsFeatureEnabled.mockReturnValue({ dashboardAuthSignUp: true }) + }) + + it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => { + render() + + expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled() + }) +}) From fe5aff692d3fcceb68ccddf39d1e08c069a6c223 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Tue, 21 Jul 2026 12:17:05 +0200 Subject: [PATCH 02/17] feat: Add deployment/ci federated content and data (#48041) --- apps/docs/.gitignore | 1 + .../guides/deployment/ci/[[...slug]]/page.tsx | 26 ++++ .../app/guides/deployment/ci/[slug]/page.tsx | 134 ------------------ .../fetch-federated-content.ts | 2 + .../scripts/federated-content/sources/ci.ts | 46 ++++++ apps/docs/scripts/federated-content/types.ts | 2 + 6 files changed, 77 insertions(+), 134 deletions(-) create mode 100644 apps/docs/app/guides/deployment/ci/[[...slug]]/page.tsx delete mode 100644 apps/docs/app/guides/deployment/ci/[slug]/page.tsx create mode 100644 apps/docs/scripts/federated-content/sources/ci.ts diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index f27e790639814..fd14b1c048bb7 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -46,6 +46,7 @@ public/docs/ /content/guides/graphql.mdx /content/guides/deployment/terraform.mdx /content/guides/deployment/terraform/tutorial.mdx +/content/guides/deployment/ci/ # Downloaded TypeDoc dumps under spec/reference///. Regenerated by # `cd apps/docs/spec && make download.tsdoc.v2`. Hand-authored files in the diff --git a/apps/docs/app/guides/deployment/ci/[[...slug]]/page.tsx b/apps/docs/app/guides/deployment/ci/[[...slug]]/page.tsx new file mode 100644 index 0000000000000..06c603390fbf0 --- /dev/null +++ b/apps/docs/app/guides/deployment/ci/[[...slug]]/page.tsx @@ -0,0 +1,26 @@ +import { GuideTemplate } from '~/features/docs/GuidesMdx.template' +import { + genGuideMeta, + genGuidesStaticParams, + getGuidesMarkdown, +} from '~/features/docs/GuidesMdx.utils' +import { getEmptyArray } from '~/features/helpers.fn' +import { IS_DEV } from '~/lib/constants' + +type Params = { slug?: string[] } + +const ActionDocs = async (props: { params: Promise }) => { + const params = await props.params + const slug = ['deployment', 'ci', ...(params.slug ?? [])] + const data = await getGuidesMarkdown(slug) + + return +} + +const generateStaticParams = !IS_DEV ? genGuidesStaticParams('deployment/ci') : getEmptyArray +const generateMetadata = genGuideMeta((params: { slug?: string[] }) => + getGuidesMarkdown(['deployment', 'ci', ...(params.slug ?? [])]) +) + +export default ActionDocs +export { generateStaticParams, generateMetadata } diff --git a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx deleted file mode 100644 index 4053787f65ee6..0000000000000 --- a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { notFound } from 'next/navigation' -import { relative } from 'node:path' -import rehypeSlug from 'rehype-slug' - -import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' -import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { getGitHubFileContents } from '~/lib/octokit' -import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' -import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' -import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' -import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs' -import { SerializeOptions } from '~/types/next-mdx-remote-serialize' - -export const dynamicParams = false - -// We fetch these docs at build time from an external repo -const org = 'supabase' -const repo = 'setup-cli' -const branch = 'gh-pages' -const docsDir = 'docs' -const externalSite = 'https://supabase.github.io/setup-cli' - -// Each external docs page is mapped to a local page -const pageMap = [ - { - slug: 'generating-types', - meta: { - title: 'Generate types using GitHub Actions', - description: 'End-to-end type safety across client, server, and database.', - subtitle: 'End-to-end type safety across client, server, and database.', - tocVideo: 'VSNgAIObBdw', - }, - remoteFile: 'generating-types.md', - }, - { - slug: 'testing', - meta: { - title: 'Automated testing using GitHub Actions', - description: 'Run your tests when you or your team make changes.', - subtitle: 'Run your tests when you or your team make changes.', - }, - remoteFile: 'testing.md', - }, - { - slug: 'backups', - meta: { - title: 'Automated backups using GitHub Actions', - description: 'Backup your database on a regular basis.', - subtitle: 'Backup your database on a regular basis.', - }, - remoteFile: 'backups.md', - }, -] - -type Params = { slug: string } - -const ActionDocs = async (props: { params: Promise }) => { - const params = await props.params - const { meta, ...data } = await getContent(params) - - const options = { - mdxOptions: { - remarkPlugins: [remarkMkDocsAdmonition, remarkPyMdownTabs, [removeTitle, meta.title]], - rehypePlugins: [[linkTransform, urlTransform], rehypeSlug], - }, - } as SerializeOptions - - return -} - -/** - * Fetch markdown from external repo - */ -const getContent = async ({ slug }: Params) => { - const page = pageMap.find(({ slug: validSlug }) => validSlug && validSlug === slug) - - if (!page) { - notFound() - } - - const { remoteFile, meta } = page - - const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - - const content = removeRedundantH1( - await getGitHubFileContents({ org, repo, path: `${docsDir}/${remoteFile}`, branch }) - ) - - return { - pathname: `/guides/deployment/ci/${slug}` satisfies `/${string}`, - meta, - content, - editLink, - } -} - -const urlTransform: UrlTransformFunction = (url) => { - try { - const externalSiteUrl = new URL(externalSite) - - const placeholderHostname = 'placeholder' - const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`) - - // Don't modify a url with a FQDN or a url that's only a hash - if (hostname !== placeholderHostname || pathname === '/') { - return url - } - - const relativePage = ( - pathname.endsWith('.md') - ? pathname.replace(/\.md$/, '') - : relative(externalSiteUrl.pathname, pathname) - ).replace(/^\//, '') - - const page = pageMap.find(({ remoteFile }) => `${relativePage}.md` === remoteFile) - - // If we have a mapping for this page, use the mapped path - if (page) { - return page.slug + hash - } - - // If we don't have this page in our docs, link to original docs - return `${externalSite}/${relativePage}${hash}` - } catch (err) { - console.error('Error transforming markdown URL', err) - return url - } -} - -const generateStaticParams = () => pageMap.map(({ slug }) => ({ slug })) -const generateMetadata = genGuideMeta(getContent) - -export default ActionDocs -export { generateMetadata, generateStaticParams } diff --git a/apps/docs/scripts/federated-content/fetch-federated-content.ts b/apps/docs/scripts/federated-content/fetch-federated-content.ts index 50b8e10bdd4fc..23f19a912c727 100644 --- a/apps/docs/scripts/federated-content/fetch-federated-content.ts +++ b/apps/docs/scripts/federated-content/fetch-federated-content.ts @@ -125,6 +125,8 @@ async function fetchPage(source: FederatedContentSource, page: FederatedPage): P editLink: `${source.org}/${source.repo}/blob/${source.branch}/${remotePath(source, page)}`, } if (page.meta.subtitle) frontmatter.subtitle = page.meta.subtitle + if (page.meta.description) frontmatter.description = page.meta.description + if (page.meta.tocVideo) frontmatter.tocVideo = page.meta.tocVideo return matter.stringify(`${content}\n`, frontmatter) } diff --git a/apps/docs/scripts/federated-content/sources/ci.ts b/apps/docs/scripts/federated-content/sources/ci.ts new file mode 100644 index 0000000000000..a7a8cefc8038a --- /dev/null +++ b/apps/docs/scripts/federated-content/sources/ci.ts @@ -0,0 +1,46 @@ +import type { FederatedContentSource } from '../types' + +// We fetch these docs at build time from an external repo +const ci: FederatedContentSource = { + section: 'deployment/ci', + org: 'supabase', + repo: 'setup-cli', + branch: 'gh-pages', + docsDir: 'docs', + externalSite: 'https://supabase.github.io/setup-cli', + pageMap: [ + { + slug: 'generating-types', + meta: { + title: 'Generate types using GitHub Actions', + description: 'End-to-end type safety across client, server, and database.', + subtitle: 'End-to-end type safety across client, server, and database.', + tocVideo: 'VSNgAIObBdw', + }, + remoteFile: 'generating-types.md', + dropLeadingHeading: true, + }, + { + slug: 'testing', + meta: { + title: 'Automated testing using GitHub Actions', + description: 'Run your tests when you or your team make changes.', + subtitle: 'Run your tests when you or your team make changes.', + }, + remoteFile: 'testing.md', + dropLeadingHeading: true, + }, + { + slug: 'backups', + meta: { + title: 'Automated backups using GitHub Actions', + description: 'Backup your database on a regular basis.', + subtitle: 'Backup your database on a regular basis.', + }, + remoteFile: 'backups.md', + dropLeadingHeading: true, + }, + ], +} + +export default ci diff --git a/apps/docs/scripts/federated-content/types.ts b/apps/docs/scripts/federated-content/types.ts index d6a2bee2afd7e..34470d5f56dd3 100644 --- a/apps/docs/scripts/federated-content/types.ts +++ b/apps/docs/scripts/federated-content/types.ts @@ -7,6 +7,8 @@ export interface FederatedPage { meta: { title: string subtitle?: string + description?: string + tocVideo?: string } /** Path of the file in the remote repo, relative to `docsDir`. */ remoteFile: string From b90c84dce7c4a56e4d603f920615914697ffc98a Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Tue, 21 Jul 2026 12:23:25 +0200 Subject: [PATCH 03/17] feat: Add ai-skills federated content and data (#48045) --- .../ai-skills/AiSkills.utils.ts | 55 ++----------------- .../ai-skills/AiSkillsIndex.tsx | 54 +++++------------- .../getting-started/ai-skills/CopyButton.tsx | 29 ---------- .../internals/generate-guides-markdown.ts | 2 + .../markdown-schema/AiSkillsIndex.ts | 27 +++++++++ .../fetch-federated-content.ts | 55 ++++++++++++++++++- 6 files changed, 102 insertions(+), 120 deletions(-) delete mode 100644 apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx create mode 100644 apps/docs/internals/markdown-schema/AiSkillsIndex.ts diff --git a/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts b/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts index b6b688530820e..b63abea02f323 100644 --- a/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts +++ b/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts @@ -1,21 +1,8 @@ -import matter from 'gray-matter' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { GENERATED_DIRECTORY } from '~/lib/docs' import { cache } from 'react' -import { OCTOKIT_RETRY_OPTIONS, getGitHubFileContents, octokit } from '~/lib/octokit' - -const SKILLS_REPO = { - org: 'supabase', - repo: 'agent-skills', - branch: 'main', - path: 'skills', -} - -interface SkillMetadata { - name?: string - title?: string - description?: string -} - interface SkillSummary { name: string description: string @@ -23,40 +10,8 @@ interface SkillSummary { } async function getAiSkillsImpl(): Promise { - const { data: contents } = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', { - owner: SKILLS_REPO.org, - repo: SKILLS_REPO.repo, - path: SKILLS_REPO.path, - ref: SKILLS_REPO.branch, - request: OCTOKIT_RETRY_OPTIONS, - }) - - if (!Array.isArray(contents)) { - throw new Error('Expected directory listing from GitHub agent skills repo') - } - - const skillDirs = contents.filter((item) => item.type === 'dir') - - const skills = await Promise.all( - skillDirs.map(async (item) => { - const skillPath = `${SKILLS_REPO.path}/${item.name}/SKILL.md` - const rawContent = await getGitHubFileContents({ - org: SKILLS_REPO.org, - repo: SKILLS_REPO.repo, - branch: SKILLS_REPO.branch, - path: skillPath, - }) - const { data } = matter(rawContent) as { data: SkillMetadata } - - return { - name: item.name, - description: data.description || '', - installCommand: `npx skills add supabase/agent-skills --skill ${item.name}`, - } - }) - ) - - return skills.sort((a, b) => a.name.localeCompare(b.name)) + const raw = await readFile(join(GENERATED_DIRECTORY, 'ai-skills.json'), 'utf-8') + return JSON.parse(raw) } export const getAiSkills = cache(getAiSkillsImpl) diff --git a/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx b/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx index 1f5311a563ce2..0d04fd700cc96 100644 --- a/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx +++ b/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx @@ -1,5 +1,7 @@ +import { CodeBlock } from 'ui-patterns/CodeBlock' +import Heading from 'ui/src/components/CustomHTMLElements/Heading' + import { getAiSkills } from './AiSkills.utils' -import { CopyButton } from './CopyButton' export async function AiSkillsIndex() { let skills: Awaited> = [] @@ -17,44 +19,18 @@ export async function AiSkillsIndex() { ) } + return ( -
- - - - - - - - - - {skills.map((skill) => ( - - - - - - ))} - -
SkillDescriptionInstall command
- - {skill.name} - - {skill.description} -
-
- - - {skill.installCommand} - -
-
-
-
+ <> + {skills.map((skill) => ( +
+ {skill.name} +

{skill.description}

+ + {skill.installCommand} + +
+ ))} + ) } diff --git a/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx b/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx deleted file mode 100644 index 1f9740676554e..0000000000000 --- a/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Check, Copy } from 'lucide-react' -import { cn } from 'ui' - -export function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - return ( - - ) -} diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts index 5e19d7fd2fbc8..e018300827d0d 100644 --- a/apps/docs/internals/generate-guides-markdown.ts +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -15,6 +15,7 @@ import { mcpConfigPanelMarkdown as McpConfigPanel } from 'ui-patterns/McpUrlBuil import { addBaseUrlPrefix, getInternalLinkBaseUrl, withDocsBasePath } from './internal-links' import { AccordionItem } from './markdown-schema/Accordion' import { Admonition } from './markdown-schema/Admonition' +import { AiSkillsIndex } from './markdown-schema/AiSkillsIndex' import { AuthProviders } from './markdown-schema/AuthProviders' import { ComputeDiskLimitsTable } from './markdown-schema/ComputeDiskLimitsTable' import { ContentListings } from './markdown-schema/ContentListings' @@ -165,6 +166,7 @@ function applySchema(parent: Parent, schema: ComponentSchema): void { const SCHEMA: ComponentSchema = { AccordionItem, Admonition, + AiSkillsIndex, IconCheck, IconX, Image, diff --git a/apps/docs/internals/markdown-schema/AiSkillsIndex.ts b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts new file mode 100644 index 0000000000000..efa3e6174f420 --- /dev/null +++ b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + +const SKILLS_PATH = path.join(process.cwd(), 'features/docs/generated/ai-skills.json') + +interface SkillSummary { + name: string + description: string + installCommand: string +} + +export const AiSkillsIndex = (): string => { + const skills: SkillSummary[] = JSON.parse(readFileSync(SKILLS_PATH, 'utf-8')) + + return skills + .map( + (skill) => + `### ${skill.name} + +${skill.description} + +\`\`\`sh +${skill.installCommand} +\`\`\`` + ) + .join('\n\n') +} diff --git a/apps/docs/scripts/federated-content/fetch-federated-content.ts b/apps/docs/scripts/federated-content/fetch-federated-content.ts index 23f19a912c727..b105012765a9e 100644 --- a/apps/docs/scripts/federated-content/fetch-federated-content.ts +++ b/apps/docs/scripts/federated-content/fetch-federated-content.ts @@ -8,7 +8,7 @@ import { GENERATED_DIRECTORY, GUIDES_DIRECTORY } from '~/lib/docs' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs' -import { getGitHubFileContents } from '~/lib/octokit' +import { getGitHubFileContents, octokit, OCTOKIT_RETRY_OPTIONS } from '~/lib/octokit' import matter from 'gray-matter' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm' @@ -163,10 +163,61 @@ async function fetchSource(source: FederatedContentSource): Promise { ]) } +const AI_SKILLS_REPO = { + org: 'supabase', + repo: 'agent-skills', + branch: 'main', + path: 'skills', +} + +/** + * Lists the skill directories in the agent-skills repo, fetches each + * `SKILL.md`'s frontmatter, and writes the summary to + * `features/docs/generated/ai-skills.json` for `AiSkills.utils.ts` to read. + */ +async function fetchAiSkills(): Promise { + const { data: contents } = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', { + owner: AI_SKILLS_REPO.org, + repo: AI_SKILLS_REPO.repo, + path: AI_SKILLS_REPO.path, + ref: AI_SKILLS_REPO.branch, + request: OCTOKIT_RETRY_OPTIONS, + }) + + if (!Array.isArray(contents)) { + throw new Error('Expected directory listing from GitHub agent skills repo') + } + + const skillDirs = contents.filter((item) => item.type === 'dir') + + const skills = await Promise.all( + skillDirs.map(async (item) => { + const rawContent = await getGitHubFileContents({ + org: AI_SKILLS_REPO.org, + repo: AI_SKILLS_REPO.repo, + branch: AI_SKILLS_REPO.branch, + path: `${AI_SKILLS_REPO.path}/${item.name}/SKILL.md`, + }) + const { data } = matter(rawContent) as { data: { description?: string } } + + return { + name: item.name, + description: data.description || '', + installCommand: `npx skills add supabase/agent-skills --skill ${item.name}`, + } + }) + ) + + skills.sort((a, b) => a.name.localeCompare(b.name)) + + await mkdir(GENERATED_DIRECTORY, { recursive: true }) + await writeFile(join(GENERATED_DIRECTORY, 'ai-skills.json'), JSON.stringify(skills, null, 2)) +} + async function fetchFederatedContent() { const sources = await loadSources() - await Promise.all(sources.map(fetchSource)) + await Promise.all([...sources.map(fetchSource), fetchAiSkills()]) const pageCount = sources.reduce((sum, source) => sum + source.pageMap.length, 0) console.log( From 7ce1291bb727507702b25ff41c5b28c9119b6e76 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Tue, 21 Jul 2026 12:33:36 +0200 Subject: [PATCH 04/17] feat: Add ai/python federated content and data (#48043) --- apps/docs/.gitignore | 1 + .../app/guides/ai/python/[[...slug]]/page.tsx | 26 ++++ .../docs/app/guides/ai/python/[slug]/page.tsx | 135 ------------------ .../federated-content/sources/python.ts | 43 ++++++ 4 files changed, 70 insertions(+), 135 deletions(-) create mode 100644 apps/docs/app/guides/ai/python/[[...slug]]/page.tsx delete mode 100644 apps/docs/app/guides/ai/python/[slug]/page.tsx create mode 100644 apps/docs/scripts/federated-content/sources/python.ts diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index fd14b1c048bb7..59e71b63a898b 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -47,6 +47,7 @@ public/docs/ /content/guides/deployment/terraform.mdx /content/guides/deployment/terraform/tutorial.mdx /content/guides/deployment/ci/ +/content/guides/ai/python/ # Downloaded TypeDoc dumps under spec/reference///. Regenerated by # `cd apps/docs/spec && make download.tsdoc.v2`. Hand-authored files in the diff --git a/apps/docs/app/guides/ai/python/[[...slug]]/page.tsx b/apps/docs/app/guides/ai/python/[[...slug]]/page.tsx new file mode 100644 index 0000000000000..6c0b8fbbb2950 --- /dev/null +++ b/apps/docs/app/guides/ai/python/[[...slug]]/page.tsx @@ -0,0 +1,26 @@ +import { GuideTemplate } from '~/features/docs/GuidesMdx.template' +import { + genGuideMeta, + genGuidesStaticParams, + getGuidesMarkdown, +} from '~/features/docs/GuidesMdx.utils' +import { getEmptyArray } from '~/features/helpers.fn' +import { IS_DEV } from '~/lib/constants' + +type Params = { slug?: string[] } + +const PythonClientDocs = async (props: { params: Promise }) => { + const params = await props.params + const slug = ['ai', 'python', ...(params.slug ?? [])] + const data = await getGuidesMarkdown(slug) + + return +} + +const generateStaticParams = !IS_DEV ? genGuidesStaticParams('ai/python') : getEmptyArray +const generateMetadata = genGuideMeta((params: { slug?: string[] }) => + getGuidesMarkdown(['ai', 'python', ...(params.slug ?? [])]) +) + +export default PythonClientDocs +export { generateStaticParams, generateMetadata } diff --git a/apps/docs/app/guides/ai/python/[slug]/page.tsx b/apps/docs/app/guides/ai/python/[slug]/page.tsx deleted file mode 100644 index e31bc6b159546..0000000000000 --- a/apps/docs/app/guides/ai/python/[slug]/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { notFound } from 'next/navigation' -import { relative } from 'path' -import rehypeSlug from 'rehype-slug' - -import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' -import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { getGitHubFileContents } from '~/lib/octokit' -import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' -import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' -import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' -import { SerializeOptions } from '~/types/next-mdx-remote-serialize' - -export const dynamicParams = false - -// We fetch these docs at build time from an external repo -const org = 'supabase' -const repo = 'vecs' -const branch = 'main' -const docsDir = 'docs' -const externalSite = 'https://supabase.github.io/vecs' - -// Each external docs page is mapped to a local page -const pageMap = [ - { - slug: 'api', - meta: { - title: 'API', - }, - remoteFile: 'api.md', - }, - { - slug: 'collections', - meta: { - title: 'Collections', - }, - remoteFile: 'concepts_collections.md', - }, - { - slug: 'indexes', - meta: { - title: 'Indexes', - }, - remoteFile: 'concepts_indexes.md', - }, - { - slug: 'metadata', - meta: { - title: 'Metadata', - }, - remoteFile: 'concepts_metadata.md', - }, -] - -interface Params { - slug: string -} - -const PythonClientDocs = async (props: { params: Promise }) => { - const params = await props.params - const { meta, ...data } = await getContent(params) - - const options = { - mdxOptions: { - remarkPlugins: [remarkMkDocsAdmonition, [removeTitle, meta.title]], - rehypePlugins: [[linkTransform, urlTransform], rehypeSlug], - }, - } as SerializeOptions - - return -} - -/** - * Fetch markdown from external repo - */ -const getContent = async ({ slug }: Params) => { - const page = pageMap.find(({ slug: validSlug }) => validSlug && validSlug === slug) - - if (!page) { - notFound() - } - - const { remoteFile, meta } = page - - const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - - const content = removeRedundantH1( - await getGitHubFileContents({ org, repo, path: `${docsDir}/${remoteFile}`, branch }) - ) - - return { - pathname: `/guides/ai/python/${slug}` satisfies `/${string}`, - meta, - content, - editLink, - } -} - -const urlTransform: UrlTransformFunction = (url) => { - try { - const externalSiteUrl = new URL(externalSite) - - const placeholderHostname = 'placeholder' - const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`) - - // Don't modify a url with a FQDN or a url that's only a hash - if (hostname !== placeholderHostname || pathname === '/') { - return url - } - - const relativePage = ( - pathname.endsWith('.md') - ? pathname.replace(/\.md$/, '') - : relative(externalSiteUrl.pathname, pathname) - ).replace(/^\//, '') - - const page = pageMap.find(({ remoteFile }) => `${relativePage}.md` === remoteFile) - - // If we have a mapping for this page, use the mapped path - if (page) { - return page.slug + hash - } - - // If we don't have this page in our docs, link to original docs - return `${externalSite}/${relativePage}${hash}` - } catch (err) { - console.error('Error transforming markdown URL', err) - return url - } -} - -const generateStaticParams = () => pageMap.map(({ slug }) => ({ slug })) -const generateMetadata = genGuideMeta(getContent) - -export default PythonClientDocs -export { generateMetadata, generateStaticParams } diff --git a/apps/docs/scripts/federated-content/sources/python.ts b/apps/docs/scripts/federated-content/sources/python.ts new file mode 100644 index 0000000000000..d117cc319875d --- /dev/null +++ b/apps/docs/scripts/federated-content/sources/python.ts @@ -0,0 +1,43 @@ +import type { FederatedContentSource } from '../types' + +// We fetch these docs at build time from an external repo +const python: FederatedContentSource = { + section: 'ai/python', + org: 'supabase', + repo: 'vecs', + branch: 'main', + docsDir: 'docs', + externalSite: 'https://supabase.github.io/vecs', + pageMap: [ + { + slug: 'api', + meta: { + title: 'API', + }, + remoteFile: 'api.md', + }, + { + slug: 'collections', + meta: { + title: 'Collections', + }, + remoteFile: 'concepts_collections.md', + }, + { + slug: 'indexes', + meta: { + title: 'Indexes', + }, + remoteFile: 'concepts_indexes.md', + }, + { + slug: 'metadata', + meta: { + title: 'Metadata', + }, + remoteFile: 'concepts_metadata.md', + }, + ], +} + +export default python From 3d1d34bbc7a0e68eaa816e23203370b92d6e9ad6 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 21 Jul 2026 14:29:21 +0200 Subject: [PATCH 05/17] chore(studio): add valtio and react-hook-form ESLint ratchet rules (#48037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Chore / tooling — adds new ESLint rules for `valtio` and `react-hook-form`. ## What is the current behavior? Studio uses `valtio` and `react-hook-form` heavily, but neither library's dedicated ESLint plugin was installed, so their common API pitfalls were only caught at runtime. ## What is the new behavior? Adds `eslint-plugin-valtio` and `eslint-plugin-react-hook-form` (6 rules total) as `warn`, wired into the existing lint ratchet (`scripts/ratchet-rules.json` + baselines) so current violations are grandfathered and only new ones fail CI — no existing code is changed. Since `eslint-plugin-react-hook-form@0.3.1` still calls the removed ESLint 8 `context.getScope()`, it is wrapped with `fixupPluginRules` from `@eslint/compat` so its rules run under flat config / ESLint 9. ## Additional context Baselines captured: `valtio/state-snapshot-rule` (1), `valtio/avoid-this-in-proxy` (1), `react-hook-form/no-use-watch` (77), and the three recommended react-hook-form rules (0 each). ## Summary by CodeRabbit * **Code Quality** * Expanded linting for Valtio state usage, including safer proxy usage and snapshot-related patterns. * Added React Hook Form lint rules to encourage safer form state handling and discourage problematic watch usage. * Updated accessibility lint configuration and improved ESLint reliability by enabling an ESLint 8→9 compatibility shim for affected rules. * **Maintenance** * Updated ESLint rule baselines and ratcheting settings to match newly enabled rules. * Added required ESLint plugins to the Studio linting setup. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../studio/.github/eslint-rule-baselines.json | 64 +++++++++++++++++-- apps/studio/eslint.config.cjs | 19 +++++- apps/studio/package.json | 3 + apps/studio/scripts/ratchet-rules.json | 5 +- pnpm-lock.yaml | 52 +++++++++++++++ 5 files changed, 137 insertions(+), 6 deletions(-) diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 8a8bd15bd1628..97ba9ff3652f2 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -11,7 +11,7 @@ "jsx-a11y/aria-proptypes": 0, "jsx-a11y/role-supports-aria-props": 0, "jsx-a11y/anchor-has-content": 0, - "jsx-a11y/control-has-associated-label": 249, + "jsx-a11y/control-has-associated-label": 248, "jsx-a11y/label-has-associated-control": 36, "jsx-a11y/aria-role": 0, "jsx-a11y/no-redundant-roles": 4, @@ -19,7 +19,10 @@ "jsx-a11y/tabindex-no-positive": 0, "jsx-a11y/anchor-is-valid": 7, "jsx-a11y/heading-has-content": 2, - "jsx-a11y/no-distracting-elements": 0 + "jsx-a11y/no-distracting-elements": 0, + "supabase/require-explicit-tabindex": 0, + "valtio/state-snapshot-rule": 1, + "react-hook-form/no-use-watch": 76 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -871,7 +874,6 @@ "components/interfaces/Database/Publications/PublicationsList.tsx": 1, "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx": 2, "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx": 2, - "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx": 1, "components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx": 1, "components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx": 1, "components/interfaces/Database/Replication/RowMenu.tsx": 1, @@ -1059,6 +1061,60 @@ "jsx-a11y/heading-has-content": { "components/layouts/Scaffold.tsx": 2 }, - "jsx-a11y/no-distracting-elements": {} + "jsx-a11y/no-distracting-elements": {}, + "supabase/require-explicit-tabindex": {}, + "valtio/state-snapshot-rule": { + "state/storage-explorer.tsx": 1 + }, + "react-hook-form/no-use-watch": { + "components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx": 3, + "components/interfaces/Account/Preferences/DeleteAccountButton.tsx": 1, + "components/interfaces/Advisors/CreateRuleSheet.tsx": 1, + "components/interfaces/Auth/AuditLogsForm.tsx": 1, + "components/interfaces/Auth/BasicAuthSettingsForm.tsx": 2, + "components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx": 1, + "components/interfaces/Auth/Hooks/CreateHookSheet.tsx": 1, + "components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx": 2, + "components/interfaces/Auth/OAuthApps/CreateOrUpdateOAuthAppSheet.tsx": 1, + "components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx": 3, + "components/interfaces/Auth/PerformanceSettingsForm.tsx": 1, + "components/interfaces/Auth/RateLimits/RateLimits.tsx": 6, + "components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx": 1, + "components/interfaces/Auth/SmtpForm/SmtpForm.tsx": 2, + "components/interfaces/Auth/ThirdPartyAuthForm/CreateAwsCognitoAuthDialog.tsx": 1, + "components/interfaces/Auth/Users/BanUserModal.tsx": 1, + "components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx": 1, + "components/interfaces/BranchManagement/CreateBranchModal.tsx": 1, + "components/interfaces/Database/Extensions/EnableExtensionModal.tsx": 1, + "components/interfaces/Database/Functions/CreateFunction/index.tsx": 1, + "components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx": 1, + "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx": 1, + "components/interfaces/Database/Triggers/TriggerSheet.tsx": 1, + "components/interfaces/DiskManagement/DiskManagementForm.tsx": 3, + "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx": 1, + "components/interfaces/Integrations/Queues/QueuesSettings.tsx": 1, + "components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx": 1, + "components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx": 2, + "components/interfaces/Organization/BillingSettings/BillingEmail.tsx": 1, + "components/interfaces/Organization/BillingSettings/CreditTopUp.tsx": 1, + "components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx": 1, + "components/interfaces/Organization/NewOrg/NewOrgForm.tsx": 5, + "components/interfaces/Organization/SSO/SSOConfig.tsx": 4, + "components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx": 1, + "components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx": 2, + "components/interfaces/Realtime/RealtimeSettings.tsx": 1, + "components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx": 1, + "components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx": 1, + "components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx": 1, + "components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx": 3, + "components/interfaces/SignIn/ResetPasswordForm.tsx": 1, + "components/interfaces/SignIn/SignInMfaForm.tsx": 1, + "components/interfaces/SignIn/SignUpForm.tsx": 1, + "components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.tsx": 2, + "components/interfaces/Storage/CreateBucketModal.tsx": 2, + "components/interfaces/Storage/EditBucketModal.tsx": 2, + "components/interfaces/Storage/StorageSettings/StorageSettings.tsx": 1, + "components/interfaces/Support/LinkSupportTicketForm.tsx": 1 + } } } diff --git a/apps/studio/eslint.config.cjs b/apps/studio/eslint.config.cjs index c61b3261ebb10..c01030124c234 100644 --- a/apps/studio/eslint.config.cjs +++ b/apps/studio/eslint.config.cjs @@ -1,6 +1,12 @@ const { defineConfig } = require('eslint/config') +const { fixupPluginRules } = require('@eslint/compat') const barrelFiles = require('eslint-plugin-barrel-files') const jsxA11y = require('eslint-plugin-jsx-a11y') +const valtio = require('eslint-plugin-valtio') +// eslint-plugin-react-hook-form@0.3.1 (latest) still calls the ESLint 8 +// `context.getScope()`, which ESLint 9 removed. fixupPluginRules shims the +// deprecated context methods so the rules run under flat config. +const reactHookForm = require('eslint-plugin-react-hook-form') const supabaseConfig = require('eslint-config-supabase/next') // Analytics SQL wire boundary — see the block below for context. Shared so the @@ -41,6 +47,8 @@ module.exports = defineConfig([ plugins: { 'barrel-files': barrelFiles, 'jsx-a11y': jsxA11y, + valtio, + 'react-hook-form': fixupPluginRules(reactHookForm), }, rules: { '@next/next/no-img-element': 'off', @@ -66,7 +74,10 @@ module.exports = defineConfig([ 'jsx-a11y/aria-proptypes': 'warn', 'jsx-a11y/role-supports-aria-props': 'warn', 'jsx-a11y/anchor-has-content': 'warn', - 'jsx-a11y/control-has-associated-label': ['warn', { controlComponents: ['Button', 'Switch'] }], + 'jsx-a11y/control-has-associated-label': [ + 'warn', + { controlComponents: ['Button', 'Switch'] }, + ], 'jsx-a11y/label-has-associated-control': [ 'warn', { labelComponents: ['Label'], controlComponents: ['Input', 'Switch'] }, @@ -78,6 +89,12 @@ module.exports = defineConfig([ 'jsx-a11y/anchor-is-valid': 'warn', 'jsx-a11y/heading-has-content': 'warn', 'jsx-a11y/no-distracting-elements': 'warn', + 'valtio/state-snapshot-rule': 'warn', + 'valtio/avoid-this-in-proxy': 'error', + 'react-hook-form/destructuring-formstate': 'error', + 'react-hook-form/no-access-control': 'error', + 'react-hook-form/no-nested-object-setvalue': 'error', + 'react-hook-form/no-use-watch': 'warn', }, }, // Analytics SQL wire boundary: every call to a SQL-bearing analytics diff --git a/apps/studio/package.json b/apps/studio/package.json index 837e9b4280d78..082bcecfc0954 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -159,6 +159,7 @@ }, "devDependencies": { "@babel/core": "*", + "@eslint/compat": "^2.1.0", "@faker-js/faker": "^9.9.0", "@graphql-codegen/cli": "5.0.5", "@graphql-typed-document-node/core": "^3.2.0", @@ -198,6 +199,8 @@ "eslint-config-supabase": "workspace:*", "eslint-plugin-barrel-files": "^2.0.7", "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react-hook-form": "^0.3.1", + "eslint-plugin-valtio": "^0.8.0", "graphql-ws": "5.14.1", "import-in-the-middle": "^2.0.0", "jsdom-testing-mocks": "^1.13.1", diff --git a/apps/studio/scripts/ratchet-rules.json b/apps/studio/scripts/ratchet-rules.json index 4f0135b422ef0..80826ac1e4a26 100644 --- a/apps/studio/scripts/ratchet-rules.json +++ b/apps/studio/scripts/ratchet-rules.json @@ -18,5 +18,8 @@ "jsx-a11y/tabindex-no-positive", "jsx-a11y/anchor-is-valid", "jsx-a11y/heading-has-content", - "jsx-a11y/no-distracting-elements" + "jsx-a11y/no-distracting-elements", + "supabase/require-explicit-tabindex", + "valtio/state-snapshot-rule", + "react-hook-form/no-use-watch" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fc7165af4e99..c64b8b7953d51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1238,6 +1238,9 @@ importers: '@babel/core': specifier: '*' version: 7.29.7(supports-color@8.1.1) + '@eslint/compat': + specifier: ^2.1.0 + version: 2.1.0(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1)) '@faker-js/faker': specifier: ^9.9.0 version: 9.9.0 @@ -1352,6 +1355,12 @@ importers: eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1)) + eslint-plugin-react-hook-form: + specifier: ^0.3.1 + version: 0.3.1 + eslint-plugin-valtio: + specifier: ^0.8.0 + version: 0.8.0 graphql-ws: specifier: 5.14.1 version: 5.14.1(graphql@16.11.0) @@ -3786,6 +3795,15 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/compat@2.1.0': + resolution: {integrity: sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + '@eslint/config-array@0.21.0': resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3798,6 +3816,10 @@ packages: resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -11049,6 +11071,10 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + eslint-plugin-react-hook-form@0.3.1: + resolution: {integrity: sha512-+KHoQvjGa6gxxDaVTDPXmqOL+tJ2fWTBggBQTafoVTTe41xLnq94+ZXpb7oTDDRMP97UN908iIX3mNwQqnRxHw==} + engines: {node: '>=0.10.0'} + eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -11067,6 +11093,10 @@ packages: eslint: '>6.6.0' turbo: '>2.0.0' + eslint-plugin-valtio@0.8.0: + resolution: {integrity: sha512-bsqtbNB2Vhp4jWExsV3lZRwFYoWCkBb6K1LUuRgItdN7SjOQlvTcNdm4u8CfHl2N76TM9kDuftnoVs/cXIyJLw==} + engines: {node: '>=12.7.0'} + eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -15515,6 +15545,10 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + requireindex@1.1.0: + resolution: {integrity: sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==} + engines: {node: '>=0.10.5'} + requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -19141,6 +19175,12 @@ snapshots: '@eslint-community/regexpp@4.12.1': {} + '@eslint/compat@2.1.0(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 9.37.0(jiti@2.7.0)(supports-color@8.1.1) + '@eslint/config-array@0.21.0(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 2.1.6 @@ -19157,6 +19197,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.1(supports-color@8.1.1)': dependencies: ajv: 6.14.0 @@ -27401,6 +27445,10 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 + eslint-plugin-react-hook-form@0.3.1: + dependencies: + requireindex: 1.1.0 + eslint-plugin-react-hooks@5.2.0(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1)): dependencies: eslint: 9.37.0(jiti@2.7.0)(supports-color@8.1.1) @@ -27433,6 +27481,8 @@ snapshots: eslint: 9.37.0(jiti@2.7.0)(supports-color@8.1.1) turbo: 2.9.14 + eslint-plugin-valtio@0.8.0: {} + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -33162,6 +33212,8 @@ snapshots: transitivePeerDependencies: - supports-color + requireindex@1.1.0: {} + requireindex@1.2.0: {} requires-port@1.0.0: {} From da7a10be6b4723f4863d8d036e0815c0589a4fe3 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Tue, 21 Jul 2026 06:40:50 -0600 Subject: [PATCH 06/17] chore: simplify CPU messaging for compute sizes (#48109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Simplify CPU messaging on the Compute and Disk docs page and in Studio's compute size UI to keep it generic rather than architecture-specific. ## Test plan - [x] Unit tests pass - [x] Typecheck passes - [x] Lint passes ## Summary by CodeRabbit * **Updates** * Simplified compute size labels across the UI by removing cloud-provider architecture details from CPU text. * Standardized CPU descriptions to show core counts and whether resources are shared or dedicated. * Updated the “Compute Size” pricing/specs table in the compute & disk guide to use generic CPU labels while keeping pricing, memory, and database size guidance the same. --- .../guides/platform/compute-and-disk.mdx | 28 +++++++++---------- .../fields/ComputeSizeField.tsx | 5 +--- .../ProjectCreation/ComputeSizeSelector.tsx | 7 +---- .../components/ui/ComputeBadgeWrapper.tsx | 6 +--- apps/studio/lib/cloudprovider-utils.test.ts | 24 ---------------- apps/studio/lib/cloudprovider-utils.ts | 12 -------- apps/studio/tests/pages/new/[slug].test.tsx | 2 +- 7 files changed, 18 insertions(+), 66 deletions(-) delete mode 100644 apps/studio/lib/cloudprovider-utils.test.ts delete mode 100644 apps/studio/lib/cloudprovider-utils.ts diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index 05be8fe5fe8fb..a14a36589215f 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -16,20 +16,20 @@ In paid organizations, Nano Compute are billed at the same price as Micro Comput -| Compute Size | Hourly Price USD | Monthly Price USD | CPU | Memory | Max DB Size (Recommended)[^2] | -| ------------ | ------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------- | ------------ | ----------------------------- | -| Nano[^3] | | | Shared | Up to 0.5 GB | 500 MB | -| Micro | | ~ | 2-core ARM (shared) | 1 GB | 10 GB | -| Small | | ~ | 2-core ARM (shared) | 2 GB | 50 GB | -| Medium | | ~ | 2-core ARM (shared) | 4 GB | 100 GB | -| Large | | ~ | 2-core ARM (dedicated) | 8 GB | 200 GB | -| XL | | ~ | 4-core ARM (dedicated) | 16 GB | 500 GB | -| 2XL | | ~ | 8-core ARM (dedicated) | 32 GB | 1 TB | -| 4XL | | ~ | 16-core ARM (dedicated) | 64 GB | 2 TB | -| 8XL | | ~,870 | 32-core ARM (dedicated) | 128 GB | 4 TB | -| 12XL | | ~,800 | 48-core ARM (dedicated) | 192 GB | 6 TB | -| 16XL | | ~,730 | 64-core ARM (dedicated) | 256 GB | 10 TB | -| >16XL | - | [Contact Us](/dashboard/support/new?category=sales&subject=Enquiry%20about%20larger%20instance%20sizes) | Custom | Custom | Custom | +| Compute Size | Hourly Price USD | Monthly Price USD | CPU | Memory | Max DB Size (Recommended)[^2] | +| ------------ | ------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------- | ------------ | ----------------------------- | +| Nano[^3] | | | Shared | Up to 0.5 GB | 500 MB | +| Micro | | ~ | 2-core (shared) | 1 GB | 10 GB | +| Small | | ~ | 2-core (shared) | 2 GB | 50 GB | +| Medium | | ~ | 2-core (shared) | 4 GB | 100 GB | +| Large | | ~ | 2-core (dedicated) | 8 GB | 200 GB | +| XL | | ~ | 4-core (dedicated) | 16 GB | 500 GB | +| 2XL | | ~ | 8-core (dedicated) | 32 GB | 1 TB | +| 4XL | | ~ | 16-core (dedicated) | 64 GB | 2 TB | +| 8XL | | ~,870 | 32-core (dedicated) | 128 GB | 4 TB | +| 12XL | | ~,800 | 48-core (dedicated) | 192 GB | 6 TB | +| 16XL | | ~,730 | 64-core (dedicated) | 256 GB | 10 TB | +| >16XL | - | [Contact Us](/dashboard/support/new?category=sales&subject=Enquiry%20about%20larger%20instance%20sizes) | Custom | Custom | Custom | [^1]: Database max connections are recommended values and can be [customized via `max_connections`](/docs/guides/database/custom-postgres-config) depending on your use case. Be aware of [these considerations](/docs/guides/troubleshooting/how-to-change-max-database-connections-_BQ8P5) before modifying. diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 43058206532d0..3ca0fc769a10d 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -35,7 +35,6 @@ import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils' import { DOCS_URL } from '@/lib/constants' const INITIALLY_VISIBLE_COUNT = 6 @@ -197,8 +196,6 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { ) : ( <> {visibleOptions.map((compute) => { - const cpuArchitecture = getCloudProviderArchitecture(project?.cloud_provider) - const lockedMicroDueToPITR = compute.identifier === 'ci_micro' && !!subscriptionPitr const lockedNanoDueToPlan = @@ -220,7 +217,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const cpuLabel = (() => { const cpuCores = compute.meta?.cpu_cores if (typeof cpuCores === 'number') { - return `${cpuCores}-core ${cpuArchitecture} CPU` + return `${cpuCores}-core CPU` } if (cpuCores) { return `${cpuCores} CPU` diff --git a/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx index 730ed0ef42dee..49ab883d35f35 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx @@ -17,7 +17,6 @@ import { CreateProjectForm } from './ProjectCreation.schema' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' import { instanceSizeSpecs } from '@/data/projects/new-project.constants' -import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils' import { DOCS_URL } from '@/lib/constants' interface ComputeSizeSelectorProps { @@ -77,11 +76,7 @@ export const ComputeSizeSelector = ({ form }: ComputeSizeSelectorProps) => {
{instanceSizeSpecs[option].ram} RAM /{' '} - {instanceSizeSpecs[option].cpu}{' '} - {getCloudProviderArchitecture( - form.getValues('cloudProvider') as CloudProvider - )}{' '} - CPU + {instanceSizeSpecs[option].cpu} CPU

( @@ -66,9 +65,6 @@ export const ComputeBadgeWrapper = ({ // once open it will fetch the addons const [open, setOpenState] = useState(false) - // returns hardcoded values for infra - const cpuArchitecture = getCloudProviderArchitecture(cloudProvider) - // fetches addons const { data: addons, isPending: isLoadingAddons } = useProjectAddonsQuery( { projectRef }, @@ -159,7 +155,7 @@ export const ComputeBadgeWrapper = ({ <> diff --git a/apps/studio/lib/cloudprovider-utils.test.ts b/apps/studio/lib/cloudprovider-utils.test.ts deleted file mode 100644 index faa36c52b77d6..0000000000000 --- a/apps/studio/lib/cloudprovider-utils.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { getCloudProviderArchitecture } from './cloudprovider-utils' -import { PROVIDERS } from './constants' - -describe('getCloudProviderArchitecture', () => { - it('should return the correct architecture', () => { - const result = getCloudProviderArchitecture(PROVIDERS.AWS.id) - - expect(result).toBe('ARM') - }) - - it('should return the correct architecture for fly', () => { - const result = getCloudProviderArchitecture(PROVIDERS.FLY.id) - - expect(result).toBe('x86 64-bit') - }) - - it('should return an empty string if the cloud provider is not supported', () => { - const result = getCloudProviderArchitecture('unknown') - - expect(result).toBe('') - }) -}) diff --git a/apps/studio/lib/cloudprovider-utils.ts b/apps/studio/lib/cloudprovider-utils.ts deleted file mode 100644 index e40898ff32a87..0000000000000 --- a/apps/studio/lib/cloudprovider-utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PROVIDERS } from './constants' - -export function getCloudProviderArchitecture(cloudProvider: string | undefined) { - switch (cloudProvider) { - case PROVIDERS.AWS.id: - return 'ARM' - case PROVIDERS.FLY.id: - return 'x86 64-bit' - default: - return '' - } -} diff --git a/apps/studio/tests/pages/new/[slug].test.tsx b/apps/studio/tests/pages/new/[slug].test.tsx index 8e343be4b704b..3c77f3cfc76d3 100644 --- a/apps/studio/tests/pages/new/[slug].test.tsx +++ b/apps/studio/tests/pages/new/[slug].test.tsx @@ -481,7 +481,7 @@ describe('project creation wizard', () => { await generateAndWaitForStrongPassword() await user.click(getSelectTriggerByLabel('Compute size')) - await user.click(await screen.findByText('4 GB RAM / 2-core ARM CPU')) + await user.click(await screen.findByText('4 GB RAM / 2-core CPU')) fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) From 3a085f985b8454d7c45b29c2477b4c807e2413bf Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:44:54 -0600 Subject: [PATCH 07/17] fix(docs): guard federated-content schema reads when artifact is absent (#48144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Ivan Vasilov** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1784639352877839?thread_ts=1784625513.046239&cid=C0161K73J1J)_ ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Running `pnpm --filter=docs run build:guides-markdown` standalone — as `apps/www`'s prebuild does, without first running `build:federated-content` — crashes with `ENOENT ... ai-skills.json` (and the equivalent for `terraform.schema.json`). The `AiSkillsIndex` and `TerraformProviderSchema` markdown-schema handlers `readFileSync` a gitignored, build-time-generated JSON artifact unconditionally. In the full docs build these files exist because `build:federated-content` runs first (via the docs `prebuild`), but in the standalone / www path they do not, so the read throws. These unguarded reads were introduced in #48045 (ai-skills) and #48010 (terraform), which surfaced as the www Vercel build failure. ## What is the new behavior? Both handlers now render an empty section (return an empty string) when the generated artifact is absent, so `build:guides-markdown` succeeds in the standalone / www path. When the artifact IS present (the full docs build, which runs `build:federated-content` first), behavior is byte-for-byte unchanged — the file is parsed and rendered exactly as before. The full docs build is not affected. ## Additional context Implemented with a minimal `existsSync` guard (from the already-imported `node:fs`) in each handler: - `apps/docs/internals/markdown-schema/AiSkillsIndex.ts` - `apps/docs/internals/markdown-schema/TerraformProviderSchema.ts` No changes to any `package.json`, `.gitignore`, or the generators — the fix is confined to the two handlers. --- _Generated by [Claude Code](https://claude.ai/code/session_01RynCtzP874KrpN8CPf7n7n)_ Co-authored-by: Claude --- apps/docs/internals/markdown-schema/AiSkillsIndex.ts | 8 +++++++- .../internals/markdown-schema/TerraformProviderSchema.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/docs/internals/markdown-schema/AiSkillsIndex.ts b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts index efa3e6174f420..d2e7f1cc31de1 100644 --- a/apps/docs/internals/markdown-schema/AiSkillsIndex.ts +++ b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' const SKILLS_PATH = path.join(process.cwd(), 'features/docs/generated/ai-skills.json') @@ -10,6 +10,12 @@ interface SkillSummary { } export const AiSkillsIndex = (): string => { + // `build:guides-markdown` can run standalone (e.g. apps/www's prebuild) without + // `build:federated-content`, so this generated artifact may not exist yet. + if (!existsSync(SKILLS_PATH)) { + return '' + } + const skills: SkillSummary[] = JSON.parse(readFileSync(SKILLS_PATH, 'utf-8')) return skills diff --git a/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts b/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts index 91c31732e28b3..fae407b18edb2 100644 --- a/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts +++ b/apps/docs/internals/markdown-schema/TerraformProviderSchema.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' const SCHEMA_PATH = path.join(process.cwd(), 'features/docs/generated/terraform.schema.json') @@ -15,6 +15,12 @@ function attributesTable(attributes: Record, extraColumns: string[] } export const TerraformProviderSchema = (): string => { + // `build:guides-markdown` can run standalone (e.g. apps/www's prebuild) without + // `build:federated-content`, so this generated artifact may not exist yet. + if (!existsSync(SCHEMA_PATH)) { + return '' + } + const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8')) const provider = schema.provider_schemas['registry.terraform.io/supabase/supabase'] From d0abc7a64aa253c68b911bb266a4a919f8b53193 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:06:16 +0200 Subject: [PATCH 08/17] fix: cron job has no default for timeout input (#48103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cron job form has no default value for the timeout input. However, if left empty, the zod schema default it to 0 which fails validation. By setting a default value on the input we ensure: - a value is actually provided - validation triggers if users clear the input ## Summary by CodeRabbit * **Bug Fixes** * Cron job forms now use a consistent 1-second timeout default across function and HTTP request types. * Changing the cron job “Type” clears any previously generated snippet and resets the timeout back to the shared default. * Form initialization and reset behavior were improved to prevent stale timeout/snippet state when creating or editing cron jobs. --- .../CreateCronJobSheet.constants.ts | 6 ++++-- .../CreateCronJobSheet/CreateCronJobSheet.tsx | 13 ++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts index 45100581cf78a..7d5cb4f4d8fa9 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts @@ -38,11 +38,13 @@ const addHttpHeaderIssues = ( }) } +export const DEFAULT_TIMEOUT = 1000 + const edgeFunctionSchema = z.object({ type: z.literal('edge_function'), method: z.enum(['GET', 'POST']), edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'), - timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), + timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(DEFAULT_TIMEOUT), httpHeaders: httpHeadersSchema, httpBody: z .string() @@ -69,7 +71,7 @@ const httpRequestSchema = z.object({ invalidMessage: 'Please provide a valid URL', prefixMessage: 'Please prefix your URL with http:// or https://', }), - timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), + timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(DEFAULT_TIMEOUT), httpHeaders: httpHeadersSchema, httpBody: z .string() diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx index 67335691638d2..8bcc3fc00c1ad 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx @@ -40,6 +40,7 @@ import { HttpRequestSection } from '../HttpRequestSection' import { SqlFunctionSection } from '../SqlFunctionSection' import { SqlSnippetSection } from '../SqlSnippetSection' import { + DEFAULT_TIMEOUT, FormSchema, type CreateCronJobForm, type CronJobType, @@ -268,6 +269,7 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro endpoint, method, // for some reason, the httpHeaders are not memoized and cause the useEffect to trigger even when the value is the same + // eslint-disable-next-line react-hooks/exhaustive-deps JSON.stringify(httpHeaders), httpBody, timeoutMs, @@ -334,7 +336,16 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro name="function_type" value={field.value} disabled={field.disabled} - onValueChange={(value) => field.onChange(value)} + onValueChange={(value) => { + field.onChange(value) + + if (value === 'http_request' || value === 'edge_function') { + form.setValue('values.timeoutMs', DEFAULT_TIMEOUT, { + shouldDirty: false, + shouldTouch: false, + }) + } + }} > {CRONJOB_DEFINITIONS.map((definition) => ( Date: Tue, 21 Jul 2026 10:08:00 -0400 Subject: [PATCH 09/17] chore(studio): polish vercel deploy-button new project interstitial (#48113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Studio UI polish for the Vercel deploy-button new-project connect flow (DEPR-616 follow-up). ## What is the current behavior? - Deploy-button project creation still used `VercelIntegrationWindowLayout` (top bar + Docs/Support footer) while install and choose-project already use shared `InterstitialLayout` - The remove-integration note sat as a floating admonition above the form - Advanced / internal-only config sections could show double dividers, and the Oriole radio bottom border could clip inside the collapsible ## What is the new behavior? - Deploy-button new-project uses `InterstitialLayout` with `VercelIntegrationLogo` and `VercelIntegrationFooter`, matching the other Vercel connect surfaces - Regular `/new` project creation is unchanged; Panel chrome is only flattened when `isVercelIntegrationFlow` is set - Removes unused `VercelIntegrationWindowLayout` / `IntegrationWindowLayout` - Fixes Advanced/Internal-only dividers and collapsible border clipping | Before | After | | --- | --- | | Supabase | Create Vercel Project Supabase | ## Additional context ### Testing With Studio running locally and while signed in: 1. Open `http://localhost:8082/integrations/vercel//deploy-button/new-project` 2. Confirm the interstitial card: Vercel + Supabase logo pair, “Create a new project” title, form fields, and the muted remove-integration footer under the card 3. Confirm there is no old window chrome (no “Marketplace Connector” / “Deploy Button” top bar, no Docs/Support footer) 4. Optionally submit and confirm project creation still works 5. Spot-check `/new` to confirm the normal project creation form is unchanged If you have advanced config enabled, expand Advanced Configuration and confirm single dividers (not doubles) and that the Oriole option’s bottom border is not clipped. ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added configurable width to the shared interstitial layout for Vercel project creation. * Updated the Vercel “new project” flow to use the interstitial UI. * **Bug Fixes** * Prevented child borders/shadows from being clipped in expandable configuration sections. * **Refactor** * Removed legacy Vercel/window layout components and updated routing to rely on the interstitial flow. * Switched advanced and internal-only configuration sections to render inside panel content. * **Documentation** * Refreshed Vercel integration route guidance in the migration checklist and inline route comments. --------- Co-authored-by: Joshen Lim --- .../studio/.github/eslint-rule-baselines.json | 2 - apps/studio/TANSTACK_MIGRATION.md | 2 +- .../ProjectCreation/AdvancedConfiguration.tsx | 177 +++++++++--------- .../InternalOnlyConfiguration.tsx | 133 +++++++------ .../ProjectCreation/ProjectCreationForm.tsx | 24 ++- .../IntegrationWindowLayout.tsx | 97 ---------- .../VercelIntegrationWindowLayout.tsx | 36 ---- .../components/layouts/InterstitialLayout.tsx | 7 +- .../[slug]/deploy-button/new-project.tsx | 47 +++-- apps/studio/routes/integrations/vercel.tsx | 8 +- .../$slug/deploy-button/new-project.tsx | 9 +- .../src/CollapsibleCardSection.tsx | 3 +- 12 files changed, 209 insertions(+), 336 deletions(-) delete mode 100644 apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx delete mode 100644 apps/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 97ba9ff3652f2..47a1447a26bf0 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -742,8 +742,6 @@ "components/layouts/DatabaseLayout/DatabaseLayout.tsx": 1, "components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx": 1, "components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx": 1, - "components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx": 1, - "components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx": 1, "components/layouts/JWTKeys/JWTKeysLayout.tsx": 1, "components/layouts/LogsLayout/LogsLayout.tsx": 1, "components/layouts/Navigation/NavigationBar/MobileNavigationBar.tsx": 1, diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 728290838b28c..5afb7202e9e54 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -78,7 +78,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] `routes/_app/account.tsx` — AccountLayout (reads `accountLayoutTitle` from leaf `staticData`) - [x] `routes/_app/org.tsx` — OrganizationLayout (reads `orgLayoutTitle` from leaf `staticData`). **Delta vs plan:** placed at `_app/org.tsx` (wraps both `/org/` index and `/org/$slug/*`) instead of `_app/org/$slug.tsx`. PageLayout stays inline on `/org/$slug/index.tsx` since only that one route uses it. - [x] `routes/_app/new.tsx` — skipped; only `_app/new/index.tsx` lives under \_app (inlines WizardLayout). `new/$slug` is top-level (no AppLayout) so a sub-shell would not actually share state. -- [x] `routes/integrations/vercel.tsx` — VercelIntegrationWindowLayout. **Delta vs plan:** placed at top-level rather than under `_app/` — Next getLayout for all three leaves wraps only in VercelIntegrationWindowLayout, no AppLayout/DefaultLayout. +- [x] `routes/integrations/vercel.tsx` — passthrough `Outlet` only (no shared window layout). **Delta vs plan:** placed at top-level rather than under `_app/`. All three Vercel leaves (install, marketplace choose-project, deploy-button new-project) render their own `InterstitialLayout` inline; the old `VercelIntegrationWindowLayout` was removed. ### Project shell diff --git a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx index 9651283d52311..c9a621a4d72c1 100644 --- a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx @@ -2,8 +2,6 @@ import { useFlag } from 'common' import { UseFormReturn } from 'react-hook-form' import { Badge, - Card, - CardContent, cn, FormControl, FormField, @@ -20,6 +18,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateProjectForm } from './ProjectCreation.schema' import { DocsButton } from '@/components/ui/DocsButton' +import Panel from '@/components/ui/Panel' import { DOCS_URL } from '@/lib/constants' interface AdvancedConfigurationProps { @@ -30,93 +29,91 @@ export const AdvancedConfiguration = ({ form }: AdvancedConfigurationProps) => { const disableOrioleProjectCreation = useFlag('disableOrioleProjectCreation') return ( - - - - ( - <> - - - field.onChange(value === 'true')} - defaultValue={field.value.toString()} - > - - - - Postgres - Default - - } - description="Recommended for production workloads" - className="[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>label]:flex [&>div>div>label]:items-center [&>div>div>label]:gap-x-2" - /> - - - - - - - - Postgres with OrioleDB - Alpha - - } - description="Not recommended for production workloads" - className={cn( - '[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>label]:flex [&>div>div>label]:items-center [&>div>div>label]:gap-x-2', - form.getValues('useOrioleDb') ? 'rounded-b-none!' : '' - )} - disabled={disableOrioleProjectCreation} - /> - - {disableOrioleProjectCreation && ( - - OrioleDB is temporarily disabled for new projects. Please try again - later. - - )} - - - - - - {form.getValues('useOrioleDb') && ( - - - - )} - - - )} - /> - - - + + + ( + <> + + + field.onChange(value === 'true')} + defaultValue={field.value.toString()} + > + + + + Postgres + Default + + } + description="Recommended for production workloads" + className="[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>label]:flex [&>div>div>label]:items-center [&>div>div>label]:gap-x-2" + /> + + + + + + + + Postgres with OrioleDB + Alpha + + } + description="Not recommended for production workloads" + className={cn( + '[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>label]:flex [&>div>div>label]:items-center [&>div>div>label]:gap-x-2', + form.getValues('useOrioleDb') ? 'rounded-b-none!' : '' + )} + disabled={disableOrioleProjectCreation} + /> + + {disableOrioleProjectCreation && ( + + OrioleDB is temporarily disabled for new projects. Please try again + later. + + )} + + + + + + {form.getValues('useOrioleDb') && ( + + + + )} + + + )} + /> + + ) } diff --git a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx index 3737650ec106b..12858f3df5fc0 100644 --- a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { UseFormReturn } from 'react-hook-form' import { type CloudProvider } from 'shared-data' -import { Card, CardContent, FormControl, FormField, Input } from 'ui' +import { FormControl, FormField, Input } from 'ui' import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -9,6 +9,7 @@ import { CloudProviderSelector } from './CloudProviderSelector' import { HighAvailabilityInput } from './HighAvailabilityInput' import { PostgresVersionSelector } from './PostgresVersionSelector' import { CreateProjectForm } from './ProjectCreation.schema' +import Panel from '@/components/ui/Panel' interface InternalOnlyConfigurationProps { form: UseFormReturn @@ -19,76 +20,74 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' return ( - - - -

-
- ( - - )} - /> + + +
+
+ ( + + )} + /> - -
+ +
- {showNonProdFields && ( -
-

- The settings below are only applicable for local/staging projects -

-
- + {showNonProdFields && ( +
+

+ The settings below are only applicable for local/staging projects +

+
+ - ( - - - - - - )} - /> + ( + + + + + + )} + /> - ( - - - - - - )} - /> -
+ ( + + + + + + )} + />
- )} -
- - - +
+ )} +
+ + ) } diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 75516031101ad..cd45552820493 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -8,7 +8,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useForm, useFormState } from 'react-hook-form' import { type CloudProvider } from 'shared-data' import { toast } from 'sonner' -import { Button, Form, useWatch } from 'ui' +import { Button, cn, Form, useWatch } from 'ui' import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { z } from 'zod' @@ -92,6 +92,8 @@ interface ProjectCreationFormProps { * - "Cancel" button * - Shows the following: * - "Data seeding" section + * - When embedded in the Vercel interstitial, flattens Panel chrome so the shared + * form fields sit inside InterstitialLayout without a nested card * Eventually we could looking into reducing the differences more, e.g having data seeding * for both ways, and showing GitHub repository field for Vercel integration */ @@ -581,14 +583,20 @@ export const ProjectCreationForm = ({ > -

Create a new project

-

- Your project will have its own dedicated instance and full Postgres database. An API - will be set up so you can easily interact with your new database. -

-
+ !isVercelIntegrationFlow && ( +
+

Create a new project

+

+ Your project will have its own dedicated instance and full Postgres database. An + API will be set up so you can easily interact with your new database. +

+
+ ) } footer={ ) => { - return ( -
-
- -
{children}
- - {docsHref && ( - - - Docs - - )} - - - Support - - -
- ) -} - -const INTEGRATION_LAYOUT_MAX_WIDTH = '' // 'max-w-[720px]' - -export default withAuth(IntegrationWindowLayout) - -export const IntegrationWindowLayoutWithoutAuth = IntegrationWindowLayout - -export type HeaderProps = { - title: string - integrationIcon: ReactNode -} - -const Header = ({ title, integrationIcon }: HeaderProps) => { - return ( -
- -
-
-
- Supabase -
- - {integrationIcon} -
- - {title} - -
-
-
- ) -} - -const maxWidthClasses = 'mx-auto w-full max-w-[1600px]' -const paddingClasses = 'px-6 lg:px-14 xl:px-28 2xl:px-32' - -export const IntegrationScaffoldContainer = forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - return
-}) - -IntegrationScaffoldContainer.displayName = 'IntegrationScaffoldContainer' diff --git a/apps/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx b/apps/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx deleted file mode 100644 index 96df39b67b1af..0000000000000 --- a/apps/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useParams } from 'common' -import { PropsWithChildren } from 'react' -import InlineSVG from 'react-inlinesvg' - -import IntegrationWindowLayout from './IntegrationWindowLayout' -import { BASE_PATH } from '@/lib/constants' -import { useIntegrationInstallationSnapshot } from '@/state/integration-installation' - -const VERCEL_ICON = ( -
- -
-) - -const VercelIntegrationWindowLayout = ({ children }: PropsWithChildren<{}>) => { - const { externalId } = useParams() - - const snapshot = useIntegrationInstallationSnapshot() - - const title = externalId - ? 'Supabase + Vercel Deploy Button' - : 'Supabase + Vercel Integration Marketplace Connector' - - return ( - - {children} - - ) -} - -export default VercelIntegrationWindowLayout diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index fa61322feee7c..d17b469956ec7 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -16,6 +16,8 @@ interface InterstitialLayoutProps { footer?: ReactNode containerClassName?: string cardClassName?: string + /** Shared max-width for the card and footer column. Defaults to `max-w-[400px]`. */ + widthClassName?: string titleClassName?: string descriptionClassName?: string } @@ -34,6 +36,7 @@ export const InterstitialLayout = ({ footer, containerClassName, cardClassName, + widthClassName = 'max-w-[400px]', titleClassName, descriptionClassName, children, @@ -67,7 +70,7 @@ export const InterstitialLayout = ({ {(logo || title || description) && ( @@ -92,7 +95,7 @@ export const InterstitialLayout = ({ )} > {footer ? ( -
+
{card}
{footer}
diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 2e1f3b8d2778a..02e1c050ab21e 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -1,19 +1,29 @@ import { useParams } from 'common' +import Head from 'next/head' import { useEffect, useState } from 'react' -import { Admonition } from 'ui-patterns/admonition' import { isVercelUrl } from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils' +import { + VercelIntegrationFooter, + VercelIntegrationLogo, +} from '@/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial' import { ProjectCreationForm } from '@/components/interfaces/ProjectCreation/ProjectCreationForm' -import VercelIntegrationWindowLayout from '@/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' -import { ScaffoldColumn, ScaffoldContainer } from '@/components/layouts/Scaffold' +import { InterstitialLayout } from '@/components/layouts/InterstitialLayout' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useIntegrationsQuery } from '@/data/integrations/integrations-query' import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integrations/integrations-vercel-connections-create-mutation' import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { withAuth } from '@/hooks/misc/withAuth' +import { buildStudioPageTitle } from '@/lib/page-title' import { useIntegrationInstallationSnapshot } from '@/state/integration-installation' import type { NextPageWithLayout } from '@/types' +const PAGE_TITLE = buildStudioPageTitle({ + section: 'Create Vercel Project', + brand: 'Supabase', +}) + const VercelIntegration: NextPageWithLayout = () => { const { slug, next, currentProjectId: foreignProjectId } = useParams() const snapshot = useIntegrationInstallationSnapshot() @@ -91,23 +101,22 @@ const VercelIntegration: NextPageWithLayout = () => { }, [data, isSuccess]) return ( - - - - + <> + + {PAGE_TITLE} + + + } + title="Create a new project" + description="Your project will have its own dedicated instance and full Postgres database. An API will be set up so you can easily interact with your new database." + footer={} + widthClassName="max-w-2xl" + > - - + + ) } -VercelIntegration.getLayout = (page) => ( - {page} -) - -export default VercelIntegration +export default withAuth(VercelIntegration) diff --git a/apps/studio/routes/integrations/vercel.tsx b/apps/studio/routes/integrations/vercel.tsx index e31fc665ab917..8e9bcd49623c0 100644 --- a/apps/studio/routes/integrations/vercel.tsx +++ b/apps/studio/routes/integrations/vercel.tsx @@ -4,11 +4,9 @@ export const Route = createFileRoute('/integrations/vercel')({ component: VercelIntegrationPassthrough, }) -// No shared layout here. Since #47623, the install and -// marketplace/choose-project pages render their own InterstitialLayout and -// have no Next getLayout, so the Next runtime shows them without any window -// chrome. Only deploy-button/new-project still uses -// VercelIntegrationWindowLayout, and its route wraps it at the leaf. +// No shared layout here. Since #47623, the Vercel install, marketplace +// choose-project, and deploy-button/new-project pages each render their own +// InterstitialLayout and have no Next getLayout / window chrome. function VercelIntegrationPassthrough() { return } diff --git a/apps/studio/routes/integrations/vercel/$slug/deploy-button/new-project.tsx b/apps/studio/routes/integrations/vercel/$slug/deploy-button/new-project.tsx index 0b09a4cf7e9c7..11f685c82081e 100644 --- a/apps/studio/routes/integrations/vercel/$slug/deploy-button/new-project.tsx +++ b/apps/studio/routes/integrations/vercel/$slug/deploy-button/new-project.tsx @@ -1,18 +1,11 @@ import { createFileRoute } from '@tanstack/react-router' -import VercelIntegrationWindowLayout from '@/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' import VercelIntegration from '@/pages/integrations/vercel/[slug]/deploy-button/new-project' export const Route = createFileRoute('/integrations/vercel/$slug/deploy-button/new-project')({ component: VercelDeployButtonNewProjectRoute, }) -// Mirrors the page's Next getLayout, which wraps this leaf (and only this -// leaf) in VercelIntegrationWindowLayout. function VercelDeployButtonNewProjectRoute() { - return ( - - - - ) + return } diff --git a/packages/ui-patterns/src/CollapsibleCardSection.tsx b/packages/ui-patterns/src/CollapsibleCardSection.tsx index 17e3268a76adf..a0d3df4ceb1ac 100644 --- a/packages/ui-patterns/src/CollapsibleCardSection.tsx +++ b/packages/ui-patterns/src/CollapsibleCardSection.tsx @@ -25,7 +25,8 @@ export const CollapsibleCardSection = ({ {description &&

{description}

} From 8a0b324dff71d0b273d11bec04ac765e64b8bc1a Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:08:32 -0400 Subject: [PATCH 10/17] docs(design-system): add connect interstitials pattern (#45356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds design-system guidance for the shared Connect interstitial layout used by authorisation, invite, marketplace, CLI, and credit flows - Includes a glanceable example showing the centred 400px card for partner authorise and wrong-account invite states - Documents Studio helpers (`InterstitialLayout`, logo helpers, account row, `OrganizationSelector`) so future surfaces reuse one pattern instead of bespoke shells ## Context Most of the Studio Connect UI work from this effort has already landed. This PR keeps the documentation and design-system example so the pattern stays discoverable. Related: [Shared Connect UI for Authorization and Partner Flows](https://linear.app/supabase/project/shared-connect-ui-for-authorization-and-partner-flows-94587ac29d38) ## Test plan - [ ] Open `/docs/ui-patterns/connect-interstitials` in the design system - [ ] Confirm the page appears under UI Patterns in the nav - [ ] Confirm the example renders the authorise and wrong-account cards side by side - [ ] Skim the guidance for accuracy against current Studio `InterstitialLayout` usage ## Summary by CodeRabbit * **New Features** * Added new design-system “Connect Interstitials” example demos, including branding variations (single vs dual logos) and a complete connect-card flow with account row and sign-out action. * Registered the new Connect Interstitials examples in the design-system example registry. * **Documentation** * Added a “Connect Interstitials” UI Patterns page covering when to use the pattern, recommended card/layout structure, branding/logo guidance, and conventions for states, actions, and copy. * Updated the documentation sidebar to include the new page. --- apps/design-system/__registry__/index.tsx | 33 ++++ apps/design-system/config/docs.ts | 5 + .../ui-patterns/connect-interstitials.mdx | 161 ++++++++++++++++++ .../example/connect-interstitial-demo.tsx | 30 ++++ .../connect-interstitial-logo-pair.tsx | 27 +++ .../connect-interstitial-logo-single.tsx | 26 +++ .../example/connect-interstitial-shared.tsx | 125 ++++++++++++++ apps/design-system/registry/examples.ts | 15 ++ 8 files changed, 422 insertions(+) create mode 100644 apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx create mode 100644 apps/design-system/registry/default/example/connect-interstitial-demo.tsx create mode 100644 apps/design-system/registry/default/example/connect-interstitial-logo-pair.tsx create mode 100644 apps/design-system/registry/default/example/connect-interstitial-logo-single.tsx create mode 100644 apps/design-system/registry/default/example/connect-interstitial-shared.tsx diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 12c8f4c965be6..84d94abdbc4ae 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -2601,6 +2601,39 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "connect-interstitial-demo": { + name: "connect-interstitial-demo", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/connect-interstitial-demo")), + source: "", + files: ["registry/default/example/connect-interstitial-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "connect-interstitial-logo-pair": { + name: "connect-interstitial-logo-pair", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-pair")), + source: "", + files: ["registry/default/example/connect-interstitial-logo-pair.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "connect-interstitial-logo-single": { + name: "connect-interstitial-logo-single", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-single")), + source: "", + files: ["registry/default/example/connect-interstitial-logo-single.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "page-layout-auth-emails": { name: "page-layout-auth-emails", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 6e72373cf3900..df52dd81aa1b3 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -70,6 +70,11 @@ export const docsConfig: DocsConfig = { href: '/docs/ui-patterns/charts', items: [], }, + { + title: 'Connect Interstitials', + href: '/docs/ui-patterns/connect-interstitials', + items: [], + }, { title: 'Empty States', href: '/docs/ui-patterns/empty-states', diff --git a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx new file mode 100644 index 0000000000000..b5f1514af02b6 --- /dev/null +++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx @@ -0,0 +1,161 @@ +--- +title: Connect Interstitials +description: Shared layout guidance for focused authorisation, invite, marketplace, CLI, and credit redemption flows. +--- + +Connect interstitials are focused, single-card flows that sit outside the main +Studio shell. Use the shared `InterstitialLayout` family instead of building +bespoke centered cards, logos, account rows, or organisation selectors. + + + +## Use this pattern for + +This pattern fits short-lived connect flows: partner authorisation and consent +(OAuth, MCP, Stripe Projects), organisation invites, marketplace and billing +connections (AWS Marketplace, Vercel install, credit redemption), and CLI or +device-code sign-in. Use the same shell for their loading, error, success, and +wrong-account states. + +Do not use it for normal authenticated Studio pages. Those should use the +standard [page layout](./layout) patterns. + +## Source of truth + +```tsx +import { OrganizationSelector } from '@/components/interfaces/Connect/OrganizationSelector' +import { + InterstitialAccountRow, + InterstitialLayout, + LogoBox, + LogoPair, + PartnerLogo, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' +``` + +## Basic shape + +Use `InterstitialLayout` for the outer card, then put route-specific content in +`px-6 pb-6`. Widen the card only when the flow embeds a real tool, such as +project linking. + +```tsx +} + right={} + /> + } + title="Authorize Stripe Projects" + description="This will create an organization on your behalf in Supabase" +> +
+ + +
+
+``` + +```tsx +} right={} />} + title="Connect Vercel project" + containerClassName="items-start" + cardClassName="max-w-[900px]" +> +
{projectLinker}
+
+``` + +## Logos + +Use `LogoPair` when the user is connecting two services, and `SupabaseLogo` +alone for first-party flows. `PartnerLogo` fills the 48px box edge-to-edge; +`LogoBox` is for custom inset marks or logos that need their own background. +Store new partner icons in `apps/studio/public/img/icons`. + + + + + +```tsx +const AwsLogo = () => ( + + AWS + +) + +} right={} /> +``` + +## Account row + +Use `InterstitialAccountRow` for signed-in context. Do not recreate it locally. + +```tsx + +``` + +## Organisation selection + +Use `OrganizationSelector` when the flow needs an organisation pick. Extend it +for new states instead of inventing a parallel card style. + +```tsx + setShowOrgCreationDialog(true)} +/> +``` + +## Actions + +Prefer one full-width primary action. A full-width text button is fine for a +secondary action that still belongs in the flow. + +## States + +Keep loading, invalid, error, and success states inside the same card when the +route can explain them. Use `ShimmeringLoader` for loading, and `Admonition` +for warning, error, note, and success copy. + +## Copy + +Use sentence case. Prefer `sign in` over `login`. Titles and primary actions +should follow `Verb -> Thing`, for example `Authorize Stripe Projects` or +`Install Vercel`. + +Keep the layout title static across states and put state-specific copy in the +body. Header descriptions should stay short and should not end with a full +stop. diff --git a/apps/design-system/registry/default/example/connect-interstitial-demo.tsx b/apps/design-system/registry/default/example/connect-interstitial-demo.tsx new file mode 100644 index 0000000000000..4bee00df445ba --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-demo.tsx @@ -0,0 +1,30 @@ +import { Button } from 'ui' + +import { + AccountRow, + InterstitialShell, + LogoPair, + SignOutButton, + StripeLogo, + SupabaseLogo, +} from './connect-interstitial-shared' + +export default function ConnectInterstitialDemo() { + return ( + } right={} />} + title="Authorize Stripe Projects" + description="This will create an organization on your behalf in Supabase" + > +
+ } /> + + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-logo-pair.tsx b/apps/design-system/registry/default/example/connect-interstitial-logo-pair.tsx new file mode 100644 index 0000000000000..a75e0d1f653c8 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-logo-pair.tsx @@ -0,0 +1,27 @@ +import { Button } from 'ui' + +import { + AccountRow, + InterstitialShell, + LogoPair, + SignOutButton, + StripeLogo, + SupabaseLogo, +} from './connect-interstitial-shared' + +export default function ConnectInterstitialLogoPair() { + return ( + } right={} />} + title="Authorize Stripe Projects" + description="This will create an organization on your behalf in Supabase" + > +
+ } /> + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-logo-single.tsx b/apps/design-system/registry/default/example/connect-interstitial-logo-single.tsx new file mode 100644 index 0000000000000..7e8fb7b0c5af6 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-logo-single.tsx @@ -0,0 +1,26 @@ +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { AccountRow, InterstitialShell, SupabaseLogo } from './connect-interstitial-shared' + +export default function ConnectInterstitialLogoSingle() { + return ( + } + title="Join organization" + description="You have been invited to Acme Labs" + > +
+ + + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx new file mode 100644 index 0000000000000..24c35e4da0856 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx @@ -0,0 +1,125 @@ +import { ArrowRightLeft, LogOut } from 'lucide-react' +import { Avatar, AvatarFallback, Button, Card, CardContent, CardHeader, cn } from 'ui' + +export function LogoBox({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +export function LogoPair({ left, right }: { left: React.ReactNode; right: React.ReactNode }) { + return ( +
+ {left} + + {right} +
+ ) +} + +export function StripeLogo() { + return ( + + + + + + ) +} + +export function SupabaseLogo() { + return ( + + + + + + + ) +} + +export function AccountRow({ + displayName, + action, +}: { + displayName: string + action?: React.ReactNode +}) { + return ( + + + + A + +
+

Signed in as

+

{displayName}

+
+ {action} +
+
+ ) +} + +export function InterstitialShell({ + logo, + title, + description, + children, +}: { + logo: React.ReactNode + title: string + description?: string + children: React.ReactNode +}) { + return ( +
+ + +
{logo}
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+
+
{children}
+
+
+ ) +} + +export function SignOutButton() { + return
diff --git a/apps/studio/styles/globals.css b/apps/studio/styles/globals.css index 23eab4d6eb1d9..b2640fe007573 100644 --- a/apps/studio/styles/globals.css +++ b/apps/studio/styles/globals.css @@ -474,3 +474,9 @@ div[data-radix-portal]:not(.portal--toast) { .prose.text-sm ul > li::before { top: 0.65rem; } + +.prose :where(strong, b):not(:where([class~='not-prose'], [class~='not-prose'] *)), +strong, +b { + @apply font-bold; +} From b883b102b4fb38236a0b0b0fe605c3f8c03796ae Mon Sep 17 00:00:00 2001 From: Luiz Felipe Machado <56140722+luizfelmach@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:22:41 -0300 Subject: [PATCH 14/17] fix(studio): gate user logs tab behind feature flag (#48122) --- .../interfaces/Auth/Users/UserPanel.test.tsx | 91 +++++++++++++++++++ .../interfaces/Auth/Users/UserPanel.tsx | 24 +++-- 2 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/Users/UserPanel.test.tsx diff --git a/apps/studio/components/interfaces/Auth/Users/UserPanel.test.tsx b/apps/studio/components/interfaces/Auth/Users/UserPanel.test.tsx new file mode 100644 index 0000000000000..23b4caea738d3 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UserPanel.test.tsx @@ -0,0 +1,91 @@ +import { screen } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { ResizablePanelGroup } from 'ui' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { UserPanel } from './UserPanel' +import type { User } from '@/data/auth/users-infinite-query' +import { BASE_PATH } from '@/lib/constants' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, mswServer } from '@/tests/lib/msw' +import { createMockProfileContext } from '@/tests/lib/profile-helpers' + +const { mockUser } = vi.hoisted(() => ({ + mockUser: { + id: '11111111-1111-1111-1111-111111111111', + email: 'user@example.com', + providers: ['email'], + } as unknown as User, +})) + +// Project resolution is pure scaffolding here (it only supplies `ref` to the +// user query); mocking the whole project query chain over the network would add +// noise without testing anything this spec cares about. +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: vi + .fn() + .mockReturnValue({ data: { ref: 'project-ref', connectionString: 'postgres://' } }), +})) + +// Heavy tab bodies — the sanctioned use of vi.mock. This spec only asserts tab +// visibility, not what the tabs render. +vi.mock('./UserOverview', () => ({ + UserOverview: () =>
, +})) +vi.mock('./UserLogs', () => ({ + UserLogs: () =>
, +})) + +const renderPanel = (disabledFeatures: string[] = []) => { + // The user query flows through pg-meta SQL; mock it at the network boundary. + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json([mockUser] as any), + }) + + // `/api/enabled-features-overrides` is a Next.js route, not an OpenAPI path, + // so addAPIMock can't type it — register a raw handler. Feature state is + // driven by the profile's disabled_features below. + mswServer.use( + http.get(`${BASE_PATH}/api/enabled-features-overrides`, () => + HttpResponse.json({ disabled_features: [] }) + ) + ) + + return customRender( + + + , + { + nuqs: { searchParams: `?show=${mockUser.id}` }, + profileContext: createMockProfileContext({ + profile: { + disabled_features: disabledFeatures as any, + } as any, + }), + } + ) +} + +describe('UserPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the Logs tab when logs:all is enabled', async () => { + renderPanel([]) + + expect(await screen.findByRole('tab', { name: 'Overview' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Logs' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Raw JSON' })).toBeInTheDocument() + }) + + it('hides the Logs tab when logs:all is disabled', async () => { + renderPanel(['logs:all']) + + expect(await screen.findByRole('tab', { name: 'Overview' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'Logs' })).not.toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Raw JSON' })).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx index 5e9723437f07c..f9d7342f6ed3b 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx @@ -20,10 +20,12 @@ import { UserOverview } from './UserOverview' import { PANEL_PADDING } from './Users.constants' import { useUserQuery } from '@/data/auth/user-query' import { User } from '@/data/auth/users-infinite-query' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' export const UserPanel = () => { const { data: project } = useSelectedProjectQuery() + const showLogs = useIsFeatureEnabled('logs:all') const [selectedId, setSelectedId] = useQueryState( 'show', @@ -85,12 +87,14 @@ export const UserPanel = () => { > Overview - - Logs - + {showLogs && ( + + Logs + + )} { setSelectedId(null)} /> )} - - {selectedUser && } - + {showLogs && ( + + {selectedUser && } + + )} Date: Tue, 21 Jul 2026 21:04:50 +0530 Subject: [PATCH 15/17] fix: empty search_path (#48151) ## TL;DR Restores handling for functions with `search_path` set to `''` editing them in the UI was failing with a Postgres `zero-length delimited identifier` error since the SafeSql refactor dropped the empty-string sentinel conversion ## ref - closes #48149 ## Summary by CodeRabbit * **Bug Fixes** * Preserved empty `search_path` configuration values when updating database functions. * Prevented empty configuration values from being altered or lost during function updates. * **Tests** * Added coverage verifying that function definitions can be updated without changing an existing empty `search_path` setting. --- packages/pg-meta/src/pg-meta-functions.ts | 2 +- packages/pg-meta/test/functions.test.ts | 32 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/pg-meta/src/pg-meta-functions.ts b/packages/pg-meta/src/pg-meta-functions.ts index 45054a18dc8b3..473dd066d4f90 100644 --- a/packages/pg-meta/src/pg-meta-functions.ts +++ b/packages/pg-meta/src/pg-meta-functions.ts @@ -260,7 +260,7 @@ function _generateCreateFunctionSql( Object.entries(config_params).map(([param, value]) => value === 'FROM CURRENT' ? safeSql`SET ${qualifiedIdent(param)} FROM CURRENT` - : safeSql`SET ${qualifiedIdent(param)} TO ${value}` + : safeSql`SET ${qualifiedIdent(param)} TO ${value === '""' ? literal('') : value}` ), '\n' ) diff --git a/packages/pg-meta/test/functions.test.ts b/packages/pg-meta/test/functions.test.ts index bc7c38d892fe0..2d5120534e5a9 100644 --- a/packages/pg-meta/test/functions.test.ts +++ b/packages/pg-meta/test/functions.test.ts @@ -349,6 +349,38 @@ withTestDatabase('create function with various config_params values', async ({ e await executeQuery(removeSql1) }) +withTestDatabase('update function with empty string search_path', async ({ executeQuery }) => { + const { sql: createSql } = pgMeta.functions.create({ + name: 'test_func_empty_search_path', + schema: 'public', + definition: 'select 1', + return_type: safeSql`integer`, + language: 'sql', + config_params: { search_path: safeSql`''` }, + }) + await executeQuery(createSql) + + const { sql: retrieveSql, zod: retrieveZod } = pgMeta.functions.retrieve({ + name: 'test_func_empty_search_path', + schema: 'public', + args: [], + }) + const result = retrieveZod.parse((await executeQuery(retrieveSql))[0]) + expect(result!.config_params).toEqual({ search_path: '""' }) + + const { sql: updateSql } = pgMeta.functions.update(asSavedFunction(result!), { + definition: 'select 2', + }) + await executeQuery(updateSql) + + const resultUpdated = retrieveZod.parse((await executeQuery(retrieveSql))[0]) + expect(resultUpdated!.definition).toBe('select 2') + expect(resultUpdated!.config_params).toEqual({ search_path: '""' }) + + const { sql: removeSql } = pgMeta.functions.remove(asSavedFunction(resultUpdated!)) + await executeQuery(removeSql) +}) + withTestDatabase( 'create function with namespaced custom GUC config_params', async ({ executeQuery }) => { From c13cb81e76d8f4f1fccdc99ed0708054479097f5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:40:36 +0000 Subject: [PATCH 16/17] chore: remove noisy dashboard PR-reminder workflow (#48142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Ivan Vasilov** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1784635673434979)_ ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Chore / cleanup — removes a scheduled GitHub Actions workflow. ## What is the current behavior? The `Dashboard PR Reminder` workflow (`.github/workflows/dashboard-pr-reminder.yml`) runs on a schedule and posts a "Dashboard PRs Older Than 24 Hours" reminder to Slack. It has become too noisy — Jordi flagged that it fired 5 times in 3 days. The #team-frontend team agreed to remove it rather than reschedule it. ## What is the new behavior? The workflow and its exclusively-used supporting scripts are deleted, so the Slack reminder no longer runs. Files removed (each used exclusively by this workflow): - `.github/workflows/dashboard-pr-reminder.yml` — the reminder workflow itself. - `scripts/actions/find-stale-dashboard-prs.ts` — helper invoked only by this workflow's run step; not referenced anywhere else in the repo. - `scripts/actions/send-slack-pr-notification.ts` — helper invoked only by this workflow's run step; not referenced anywhere else in the repo. (This leaves `scripts/actions/` empty, so the directory is removed too.) No shared files were touched. The workflow's `sparse-checkout` of `scripts`/`patches`, `.nvmrc`, and `pnpm-lock.yaml` are repo-wide and remain in place. ## Additional context Verified via a full-repo grep that the two scripts and the workflow file are referenced nowhere outside this workflow before deleting them. --- _Generated by [Claude Code](https://claude.ai/code/session_01RynCtzP874KrpN8CPf7n7n)_ Co-authored-by: Claude Co-authored-by: Ali Waseem --- .github/workflows/dashboard-pr-reminder.yml | 45 --- scripts/actions/find-stale-dashboard-prs.ts | 272 ------------------ scripts/actions/send-slack-pr-notification.ts | 140 --------- 3 files changed, 457 deletions(-) delete mode 100644 .github/workflows/dashboard-pr-reminder.yml delete mode 100644 scripts/actions/find-stale-dashboard-prs.ts delete mode 100644 scripts/actions/send-slack-pr-notification.ts diff --git a/.github/workflows/dashboard-pr-reminder.yml b/.github/workflows/dashboard-pr-reminder.yml deleted file mode 100644 index 9b4470882f269..0000000000000 --- a/.github/workflows/dashboard-pr-reminder.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Dashboard PR Reminder - -on: - schedule: - # Run at 10am Singapore Time (2am UTC) - - cron: '0 2 * * *' - # Run at 10am US Eastern Time (2pm UTC = 10am EDT / 9am EST) - - cron: '0 14 * * *' - workflow_dispatch: # Allow manual trigger for testing - -permissions: - pull-requests: read - contents: read - -jobs: - check-dashboard-prs: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - sparse-checkout: | - scripts - patches - - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Install pnpm - with: - run_install: false - - - name: Use Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - - name: Install deps - run: pnpm install --frozen-lockfile - - - name: Find stale Dashboard PRs and notify Slack - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }} - run: pnpm tsx scripts/actions/find-stale-dashboard-prs.ts | pnpm tsx scripts/actions/send-slack-pr-notification.ts diff --git a/scripts/actions/find-stale-dashboard-prs.ts b/scripts/actions/find-stale-dashboard-prs.ts deleted file mode 100644 index d6e8df7d54ead..0000000000000 --- a/scripts/actions/find-stale-dashboard-prs.ts +++ /dev/null @@ -1,272 +0,0 @@ -const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000) -const DASHBOARD_PATH = 'apps/studio/' -const REPO_OWNER = 'supabase' -const REPO_NAME = 'supabase' - -const GITHUB_TOKEN = process.env.GITHUB_TOKEN - -class RateLimitError extends Error { - constructor(resetAt: string) { - super(`GitHub API rate limit exceeded. Resets at ${resetAt}`) - } -} - -async function githubApi(path: string) { - const headers: Record = { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - } - if (GITHUB_TOKEN) { - headers.Authorization = `Bearer ${GITHUB_TOKEN}` - } - - const response = await fetch(`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}${path}`, { - headers, - }) - - if ( - response.status === 429 || - (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') - ) { - const resetEpoch = response.headers.get('x-ratelimit-reset') - const resetAt = resetEpoch ? new Date(Number(resetEpoch) * 1000).toISOString() : 'unknown' - throw new RateLimitError(resetAt) - } - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n${errorText}`) - } - - return response.json() -} - -interface StalePR { - number: number - title: string - url: string - author: string - createdAt: string - hoursOld: number - daysOld: number - fileCount: number - reviewStatus: string - reviewEmoji: string - mergeableStatus: string - mergeableEmoji: string -} - -async function findStalePRs(): Promise { - console.error(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`) - - const stalePRs: StalePR[] = [] - let page = 1 - let hasMore = true - - outer: while (hasMore && page <= 10) { - console.error(`Fetching page ${page}...`) - - let prs: any[] - try { - prs = await githubApi( - `/pulls?state=open&sort=created&direction=desc&per_page=100&page=${page}` - ) - } catch (error: any) { - if (error instanceof RateLimitError) { - console.error(`Rate limited while listing PRs. ${error.message}`) - break - } - throw error - } - - if (prs.length === 0) { - hasMore = false - break - } - - for (const pr of prs) { - // Skip PRs from forks - if (pr.head.repo && pr.head.repo.full_name !== `${REPO_OWNER}/${REPO_NAME}`) { - console.error(`PR #${pr.number} is from a fork, skipping...`) - continue - } - - // Skip dependabot PRs - if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') { - console.error(`PR #${pr.number} is from dependabot, skipping...`) - continue - } - - // Skip draft PRs - if (pr.draft) { - console.error(`PR #${pr.number} is a draft, skipping...`) - continue - } - - // Skip closed PRs - if (pr.state === 'closed') { - console.error(`PR #${pr.number} is closed, skipping...`) - continue - } - - const createdAt = new Date(pr.created_at) - - if (createdAt > TWENTY_FOUR_HOURS_AGO) { - console.error(`PR #${pr.number} is too new, skipping...`) - continue - } - - console.error(`Checking PR #${pr.number}: ${pr.title}`) - - let files: any[] - try { - files = await githubApi(`/pulls/${pr.number}/files?per_page=100`) - } catch (error: any) { - if (error instanceof RateLimitError) { - console.error(`Rate limited while fetching files. ${error.message}`) - break outer - } - throw error - } - - const touchesDashboard = files.some((file: any) => file.filename.startsWith(DASHBOARD_PATH)) - - if (!touchesDashboard) continue - - const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60)) - const daysOld = Math.floor(hoursOld / 24) - - // Fetch review status - let reviewStatus = 'no-reviews' - let reviewEmoji = ':eyes:' - try { - const reviews = await githubApi(`/pulls/${pr.number}/reviews?per_page=100`) - - if (reviews.length > 0) { - const latestReviews: Record = {} - reviews.forEach((review: any) => { - if ( - !latestReviews[review.user.login] || - new Date(review.submitted_at) > - new Date(latestReviews[review.user.login].submitted_at) - ) { - latestReviews[review.user.login] = review - } - }) - - const states = Object.values(latestReviews).map((r) => r.state) - if (states.includes('CHANGES_REQUESTED')) { - reviewStatus = 'changes-requested' - reviewEmoji = ':warning:' - } else if (states.includes('APPROVED')) { - reviewStatus = 'approved' - reviewEmoji = ':heavy_check_mark:' - } - } - } catch (error: any) { - if (error instanceof RateLimitError) { - console.error(`Rate limited while fetching reviews. ${error.message}`) - break outer - } - console.error( - `Warning: Could not fetch review status for PR #${pr.number}: ${error.message}` - ) - } - - // Get mergeable state - let mergeableStatus = 'unknown' - let mergeableEmoji = ':grey_question:' - try { - const fullPR = await githubApi(`/pulls/${pr.number}`) - const mergeableState = fullPR.mergeable_state - - switch (mergeableState) { - case 'clean': - mergeableStatus = 'ready' - mergeableEmoji = ':rocket:' - break - case 'dirty': - mergeableStatus = 'conflicts' - mergeableEmoji = ':collision:' - break - case 'blocked': - mergeableStatus = 'blocked' - mergeableEmoji = ':no_entry:' - break - case 'unstable': - mergeableStatus = 'unstable' - mergeableEmoji = ':warning:' - break - case 'behind': - mergeableStatus = 'behind' - mergeableEmoji = ':arrow_down:' - break - case 'draft': - mergeableStatus = 'draft' - mergeableEmoji = ':pencil2:' - break - default: - mergeableStatus = mergeableState || 'unknown' - mergeableEmoji = ':grey_question:' - } - } catch (error: any) { - if (error instanceof RateLimitError) { - console.error(`Rate limited while fetching mergeable state. ${error.message}`) - break outer - } - console.error( - `Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}` - ) - } - - // Skip PRs that have already been reviewed - if (reviewStatus !== 'no-reviews') { - console.error(`PR #${pr.number} has already been reviewed (${reviewStatus}), skipping...`) - continue - } - - // Skip PRs with merge conflicts - if (mergeableStatus === 'conflicts') { - console.error(`PR #${pr.number} has merge conflicts, skipping...`) - continue - } - - stalePRs.push({ - number: pr.number, - title: pr.title, - url: pr.html_url, - author: pr.user.login, - createdAt: pr.created_at, - hoursOld, - daysOld, - fileCount: files.filter((f: any) => f.filename.startsWith(DASHBOARD_PATH)).length, - reviewStatus, - reviewEmoji, - mergeableStatus, - mergeableEmoji, - }) - - console.error( - `Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})` - ) - } - - page++ - } - - console.error(`Found ${stalePRs.length} stale Dashboard PRs`) - - stalePRs.sort((a, b) => a.hoursOld - b.hoursOld) - - return stalePRs -} - -findStalePRs() - .then((stalePRs) => { - // Output JSON to stdout for piping to the next script - console.log(JSON.stringify(stalePRs)) - }) - .catch((error) => { - console.error('Error:', error.message) - process.exit(1) - }) diff --git a/scripts/actions/send-slack-pr-notification.ts b/scripts/actions/send-slack-pr-notification.ts deleted file mode 100644 index 7396c8a7af749..0000000000000 --- a/scripts/actions/send-slack-pr-notification.ts +++ /dev/null @@ -1,140 +0,0 @@ -const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL - -if (!SLACK_WEBHOOK_URL) { - console.error('SLACK_WEBHOOK_URL environment variable is required') - process.exit(1) -} - -interface StalePR { - number: number - title: string - url: string - author: string - createdAt: string - hoursOld: number - daysOld: number - fileCount: number - reviewStatus: string - reviewEmoji: string - mergeableStatus: string - mergeableEmoji: string -} - -function escapeSlack(text: string) { - return text.replace(/&/g, '&').replace(//g, '>') -} - -async function sendSlackNotification(stalePRs: StalePR[]) { - const count = stalePRs.length - - const prBlocks = stalePRs.map((pr) => { - const remainingHours = pr.hoursOld % 24 - const ageText = pr.daysOld > 0 ? `${pr.daysOld}d ${remainingHours}h` : `${pr.hoursOld}h` - - const maxTitleLength = 200 - const safeTitle = - pr.title.length > maxTitleLength - ? escapeSlack(pr.title.substring(0, maxTitleLength) + '...') - : escapeSlack(pr.title) - - const reviewStatusText = - pr.reviewStatus === 'approved' - ? 'Approved' - : pr.reviewStatus === 'changes-requested' - ? 'Changes Requested' - : pr.reviewStatus === 'commented' - ? 'Commented' - : 'Needs Review' - - const mergeableStatusText = - pr.mergeableStatus === 'ready' - ? 'Ready to Merge' - : pr.mergeableStatus === 'conflicts' - ? 'Has Conflicts' - : pr.mergeableStatus === 'blocked' - ? 'Blocked' - : pr.mergeableStatus === 'unstable' - ? 'Unstable' - : pr.mergeableStatus === 'behind' - ? 'Behind Base' - : pr.mergeableStatus === 'draft' - ? 'Draft' - : 'Unknown' - - return { - type: 'section', - text: { - type: 'mrkdwn', - text: `*<${pr.url}|#${pr.number}: ${safeTitle}>*\n:bust_in_silhouette: @${pr.author} • :clock3: ${ageText} old • :file_folder: ${pr.fileCount} Dashboard files\n${pr.reviewEmoji} ${reviewStatusText} • ${pr.mergeableEmoji} ${mergeableStatusText}`, - }, - } - }) - - const MAX_PRS_TO_SHOW = 47 - const prBlocksToShow = prBlocks.slice(0, MAX_PRS_TO_SHOW) - const hasMorePRs = prBlocks.length > MAX_PRS_TO_SHOW - - const slackMessage = { - text: 'Dashboard PRs needing attention', - blocks: [ - { - type: 'header', - text: { - type: 'plain_text', - text: 'Dashboard PRs Older Than 24 Hours', - }, - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: `There are *${count}* open PRs affecting /apps/studio/ that are older than 24 hours:${hasMorePRs ? ` (showing first ${MAX_PRS_TO_SHOW})` : ''}`, - }, - }, - { - type: 'divider', - }, - ...prBlocksToShow, - ], - } - - const response = await fetch(SLACK_WEBHOOK_URL!, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(slackMessage), - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error( - `Slack notification failed: ${response.status} ${response.statusText}\n${errorText}` - ) - } - - console.error('Slack notification sent successfully!') -} - -// Read JSON from stdin -async function readStdin(): Promise { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(chunk) - } - return Buffer.concat(chunks).toString('utf-8') -} - -readStdin() - .then(async (input) => { - const stalePRs: StalePR[] = JSON.parse(input) - - if (stalePRs.length === 0) { - console.error('No stale PRs to notify about') - return - } - - await sendSlackNotification(stalePRs) - }) - .catch((error) => { - console.error('Error:', error.message) - process.exit(1) - }) From 94f2f5a4a3f578124f2c561b4dee04a9936a6712 Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Tue, 21 Jul 2026 17:57:30 +0200 Subject: [PATCH 17/17] feat(pipelines): Adjust blog post naming (#48154) --- ...-07-21-supabase-pipelines-public-alpha.mdx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx b/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx index 3b67988f0845b..a3dd01b0ea199 100644 --- a/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx +++ b/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx @@ -1,6 +1,6 @@ --- title: 'Supabase Pipelines is now in Public Alpha' -description: 'Supabase Pipelines is now in public alpha with schema change support, a faster initial copy, and a new destination request form for ClickHouse, Snowflake, and DuckLake.' +description: 'Supabase Pipelines is now in public alpha with schema change support, a faster initial sync, and a new destination request form for ClickHouse, Snowflake, and DuckLake.' author: riccardo_busetti imgSocial: supabase-pipelines-public-alpha/og.png imgThumb: supabase-pipelines-public-alpha/thumb.png @@ -14,7 +14,7 @@ date: '2026-07-21' toc_depth: 2 --- -Today we're moving **Supabase Pipelines** into public alpha. The biggest addition is schema change support: Pipelines can now detect supported schema changes in your source tables and apply them to the destination automatically. This release also brings a faster initial copy and a new destination request form for ClickHouse, Snowflake, and DuckLake. +Today we're moving **Supabase Pipelines** into public alpha. The biggest addition is schema change support: Pipelines can now detect supported schema changes in your source tables and apply them to the destination automatically. This release also brings a faster initial sync and a new destination request form for ClickHouse, Snowflake, and DuckLake. Supabase Pipelines replicates your Supabase Postgres data to external analytical systems in near real time. It is powered by [Supabase ETL](https://github.com/supabase/etl), our open source change-data-capture pipeline written in Rust. Supabase ETL reads changes from Postgres through logical replication, copies existing table data, streams inserts, updates, deletes, and truncates, and writes them to your destination. @@ -30,8 +30,8 @@ Supabase Pipelines gives you a reliable way to move production data into systems You get: -- A complete initial copy of your selected tables. -- Near real-time replication after the initial copy finishes. +- A complete initial sync of your selected tables. +- Near real-time replication after the initial sync finishes. - At-least-once delivery for database changes. - Destination-side tables that stay aligned with your source schema. - A managed Dashboard experience for creating, monitoring, and controlling pipelines. @@ -45,7 +45,7 @@ Since introducing Supabase Pipelines, we've been focused on making it faster, mo We improved performance across the board, reduced memory usage, and lowered replication latency. This work is ongoing. Supabase Pipelines is still in alpha, and we're continuing to tune throughput, batching, and backpressure as we learn from more workloads. -We also improved the initial sync. It can now be parallelized across tables and within a table, which lets pipelines load existing data much faster before switching into streaming mode. +We also improved the initial sync. It can now be parallelized across tables and within a table, which lets pipelines load existing data much faster before switching to ongoing replication. The biggest new feature is **schema change support**. @@ -75,7 +75,7 @@ When you create a pipeline: 1. You choose the tables you want to replicate. 2. Supabase Pipelines creates an initial sync of the selected tables. 3. The initial sync runs with parallel workers based on the configured parallelism level. -4. After the sync completes, the pipeline switches to streaming mode. +4. After the sync completes, the pipeline switches to ongoing replication. 5. New changes are read from the replication slot, batched, and written to the destination. 6. Supported schema changes are detected and applied to the destination schema. @@ -107,11 +107,11 @@ We're always looking to add more destinations. If there is another warehouse, la Supabase Pipelines uses a pipeline-based and usage-based pricing model: -- **$0.053 per hour per active pipeline** -- **$0.60 per GB** for the initial table copy -- **$3 per GB** for replicated data after the initial copy +- **$0.053 per hour** per configured pipeline +- **$0.60 per GB** of initial sync data +- **$3 per GB** of ongoing replication data -A pipeline becomes billable as soon as it is created and is charged hourly, including while replication is paused or inactive. Initial sync usage covers the first load of existing data, while ongoing replication usage covers changes processed after that initial sync. Pricing may change during the public alpha, and we will provide advance notice before any updates take effect. +A pipeline becomes billable as soon as it is created and is charged hourly, including while replication is paused or inactive. Initial sync data covers the first load of existing data, while ongoing replication data covers changes processed after that initial sync. Pricing may change during the public alpha, and we will provide advance notice before any updates take effect. ## Things to know during public alpha