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/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/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/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..d2e7f1cc31de1 --- /dev/null +++ b/apps/docs/internals/markdown-schema/AiSkillsIndex.ts @@ -0,0 +1,33 @@ +import { existsSync, 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 => { + // `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 + .map( + (skill) => + `### ${skill.name} + +${skill.description} + +\`\`\`sh +${skill.installCommand} +\`\`\`` + ) + .join('\n\n') +} 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'] diff --git a/apps/docs/scripts/federated-content/fetch-federated-content.ts b/apps/docs/scripts/federated-content/fetch-federated-content.ts index 50b8e10bdd4fc..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' @@ -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) } @@ -161,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( 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/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 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 diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 8a8bd15bd1628..47a1447a26bf0 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": { @@ -739,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, @@ -871,7 +872,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 +1059,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/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/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 && } + + )} {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/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) => ( {`Revoke access for ${selectedApp?.name}?`} -
+
-
    -
  • +
    +
    Before you remove this app, consider: @@ -73,10 +74,20 @@ export const RevokeAppModal = ({ Restoring access will require an organization administrator to re-authorize the application.
  • +
  • + The application may also have a Secret API key with access. + Go to the{' '} + + Integration's Settings + + , and remove any listed Secret API key to fully revoke its access. +
- - +
+
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/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

@@ -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/components/ui/ComputeBadgeWrapper.tsx b/apps/studio/components/ui/ComputeBadgeWrapper.tsx index 671791b97ac27..6621861829840 100644 --- a/apps/studio/components/ui/ComputeBadgeWrapper.tsx +++ b/apps/studio/components/ui/ComputeBadgeWrapper.tsx @@ -9,7 +9,6 @@ import { ProjectDetail } from '@/data/projects/project-detail-query' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { ResourceWarning } from '@/data/usage/resource-warnings-query' -import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils' import { useTrack } from '@/lib/telemetry/track' export const ChevronsUpAnimated = () => ( @@ -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/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/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/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/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/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/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/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/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/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; +} 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' })) 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() + }) +}) 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 new file mode 100644 index 0000000000000..a3dd01b0ea199 --- /dev/null +++ b/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx @@ -0,0 +1,138 @@ +--- +title: 'Supabase Pipelines is now in Public Alpha' +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 +categories: + - product +tags: + - pipelines + - bigquery + - postgres +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 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. + +The first destination available to everyone in public alpha is **Google BigQuery**. + +## Why Supabase Pipelines? + +Postgres is excellent for transactional workloads: reading a user profile, inserting an order, updating a subscription, or serving your application. + +Analytics workloads are different. They often scan large amounts of data, aggregate across many rows, and power dashboards, reports, notebooks, and downstream systems. Running those queries directly on your production database can add load to the same system your application depends on. + +Supabase Pipelines gives you a reliable way to move production data into systems built for analytics, while keeping your application workload on Postgres. + +You get: + +- 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. +- The ability to add and remove tables to your replication pipeline without having to restart replication from scratch. + +If you are deciding between Supabase Realtime and Supabase Pipelines, read [Realtime or Pipelines? How to choose the right tool](https://supabase.com/blog/realtime-or-pipelines-how-to-choose-the-right-tool). The short version: Realtime is for live user experiences over WebSocket. Supabase Pipelines is for reliable data movement into analytical systems. + +## What changed since private alpha + +Since introducing Supabase Pipelines, we've been focused on making it faster, more predictable, and easier to operate. + +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 to ongoing replication. + +The biggest new feature is **schema change support**. + +## Schema changes are now replicated + +When your Postgres schema changes in a supported way, Pipelines applies the same change to your destination automatically. + +That means your BigQuery schema can stay up to date as your Postgres schema changes, without requiring a manual destination migration for every supported change. + +The current version supports: + +- Adding columns. +- Removing columns. +- Renaming columns. +- Changing column nullability and defaults. + +This is an important step toward one-to-one replication between your operational database and your analytical destination. + +There is more to do. We're working on expanding schema change support to cover more cases, including additional type changes and other table-level changes. Some destinations may also support the operations above in different ways due to their inherent limitations. For now, unsupported schema changes may still require manual handling depending on the change and destination. + +## How it works + +Supabase Pipelines uses Postgres logical replication under the hood. + +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 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. + +The pipeline is designed for reliable data movement. If a destination is temporarily unavailable or the pipeline restarts, replication resumes from the last acknowledged position. It can also detect and recover from many transient failures automatically, while surfacing issues that require intervention. + +## Available destinations + +BigQuery is the first destination available to everyone in public alpha. + +BigQuery is Google's serverless data warehouse. It works well for large-scale analytics, integrates with BI tools, and gives teams a familiar place to query operational data without running heavy analytical workloads against Postgres. + +When you replicate to BigQuery, Supabase Pipelines keeps a destination representation of your source tables up to date. You can query the replicated data in BigQuery while your application continues using Postgres. + +## Request new destinations + +We want Supabase Pipelines to connect Supabase Postgres to the systems your team already uses. + +We're opening a [destination request form](https://supabase.com/go/supabase-pipelines-new-destinations) for: + +- ClickHouse +- Snowflake +- DuckLake + +This form helps us prioritize the next destinations based on real use cases: scale, data volume, expected latency, destination setup, and how teams want to query replicated data. + +We're always looking to add more destinations. If there is another warehouse, lakehouse, database, or local analytics engine you want to see supported, let us know. + +## Pricing + +Supabase Pipelines uses a pipeline-based and usage-based pricing model: + +- **$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 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 + +Supabase Pipelines is now available in public alpha, which means more teams can start using it, but we're still improving the product quickly. + +A few things to keep in mind: + +- BigQuery is currently the only destination, available on all paid plans. +- More schema changes, including broader type change support, are on the roadmap. +- Performance, memory usage, and latency will continue to improve. +- Destination support will expand based on customer demand and product feedback. +- Multi-region support for reduced latency is being evaluated. + +We're especially interested in feedback from teams using Supabase Pipelines for analytics, reporting, audit trails, ML workflows, and workload isolation. + +## Get started + +![Supabase Pipelines dashboard](/images/blog/supabase-pipelines-public-alpha/pipelines-screenshot-1.png) + +Supabase Pipelines is available in public alpha on all paid plans, from the Supabase Dashboard. + +Create a pipeline, choose BigQuery as your destination, select the tables you want to replicate, and start moving your Postgres data into your warehouse. + +To explore the underlying technology, visit the open-source [Supabase ETL repository](https://github.com/supabase/etl). If you want to understand when to use Supabase Pipelines instead of Realtime, read [Realtime or Pipelines? How to choose the right tool](https://supabase.com/blog/realtime-or-etl-how-to-choose-the-right-tool). diff --git a/apps/www/public/images/blog/supabase-pipelines-public-alpha/og.png b/apps/www/public/images/blog/supabase-pipelines-public-alpha/og.png new file mode 100644 index 0000000000000..660a30675d638 Binary files /dev/null and b/apps/www/public/images/blog/supabase-pipelines-public-alpha/og.png differ diff --git a/apps/www/public/images/blog/supabase-pipelines-public-alpha/pipelines-screenshot-1.png b/apps/www/public/images/blog/supabase-pipelines-public-alpha/pipelines-screenshot-1.png new file mode 100644 index 0000000000000..087d5b8c4489a Binary files /dev/null and b/apps/www/public/images/blog/supabase-pipelines-public-alpha/pipelines-screenshot-1.png differ diff --git a/apps/www/public/images/blog/supabase-pipelines-public-alpha/thumb.png b/apps/www/public/images/blog/supabase-pipelines-public-alpha/thumb.png new file mode 100644 index 0000000000000..8a36f5db8914e Binary files /dev/null and b/apps/www/public/images/blog/supabase-pipelines-public-alpha/thumb.png differ 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 }) => { 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}

} 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: {} 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) - })