From 9791f65a185b9c26fa6e49a5c7d9813d9f1778d7 Mon Sep 17 00:00:00 2001 From: hallidayo <22655069+Hallidayo@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:00:19 +0100 Subject: [PATCH] feat: connect sheet deep linking (#44021) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Supabase Dashboard - Connect ## What is the current behavior? On the new `` component their is no deep linking like the previous `` component ## What is the new behavior? Deep linking added onto framework and other options, Example local links: http://localhost:8082/project/default?showConnect=true&connectTab=framework&framework=nextjs&using=pages http://localhost:8082/project/default?showConnect=true&connectTab=mcp&mcpClient=goose ## Summary by CodeRabbit * **New Features** * Connect Sheet supports URL query parameters for pre-configuring connection settings (framework, using, method, type, mcpClient). * Legacy tab identifiers are accepted for compatibility. * **Improvements** * Opening, switching, and closing the Connect Sheet now more reliably syncs and clears related parameters to avoid stale state. * **Tests** * Added end-to-end tests covering deep-linking, legacy aliases, and parameter clearing on close/mode change. --------- Co-authored-by: Joshen Lim --- .../interfaces/ConnectSheet/ConnectSheet.tsx | 83 ++++++++++- e2e/studio/features/connect.spec.ts | 130 ++++++++++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index 16844bd955..d01013598f 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -20,6 +20,19 @@ function isConnectMode(value: string): value is ConnectMode { return CONNECT_MODES.some((mode) => mode === value) } +function mapConnectTabToMode(tab: string | null): ConnectMode | null { + if (!tab) return null + switch (tab) { + case 'frameworks': + case 'mobiles': + return 'framework' + case 'orms': + return 'orm' + default: + return isConnectMode(tab) ? tab : null + } +} + export const ConnectSheet = () => { const { ref: projectRef } = useParams() @@ -30,6 +43,11 @@ export const ConnectSheet = () => { parseAsBoolean.withDefault(false) ) const [connectTab, setConnectTab] = useQueryState('connectTab', parseAsString) + const [queryFramework, setQueryFramework] = useQueryState('framework', parseAsString) + const [queryUsing, setQueryUsing] = useQueryState('using', parseAsString) + const [queryMethod, setQueryMethod] = useQueryState('method', parseAsString) + const [queryType, setQueryType] = useQueryState('type', parseAsString) + const [queryMcpClient, setQueryMcpClient] = useQueryState('mcpClient', parseAsString) const { connectSheetSource, setConnectSheetSource } = useAppStateSnapshot() const track = useTrack() const prevShowConnect = useRef(false) @@ -46,23 +64,51 @@ export const ConnectSheet = () => { track('connect_sheet_opened', { source: connectSheetSource }) setConnectSheetSource('header_button') - if (connectTab && isConnectMode(connectTab) && availableModeIds.includes(connectTab)) { - setMode(connectTab) + const mappedMode = mapConnectTabToMode(connectTab) + if (mappedMode && availableModeIds.includes(mappedMode)) { + setMode(mappedMode) + } + + if (mappedMode === 'framework') { + if (queryFramework) { + updateField('framework', queryFramework) + if (queryUsing) updateField('frameworkVariant', queryUsing) + } + } else if (mappedMode === 'orm') { + if (queryFramework) updateField('orm', queryFramework) + } else if (mappedMode === 'direct') { + if (queryMethod) updateField('connectionMethod', queryMethod) + if (queryType) updateField('connectionType', queryType) + } else if (mappedMode === 'mcp') { + if (queryMcpClient) updateField('mcpClient', queryMcpClient) } }, [ showConnect, connectSheetSource, connectTab, + queryFramework, + queryUsing, + queryMethod, + queryType, + queryMcpClient, availableModeIds, track, setConnectSheetSource, setMode, + updateField, ]) + const clearAllQueryParams = () => { + setConnectTab(null) + setQueryFramework(null) + setQueryUsing(null) + setQueryMethod(null) + setQueryType(null) + setQueryMcpClient(null) + } + const handleOpenChange = (sheetOpen: boolean) => { - if (!sheetOpen) { - setConnectTab(null) - } + if (!sheetOpen) clearAllQueryParams() setShowConnect(sheetOpen) } @@ -93,6 +139,31 @@ export const ConnectSheet = () => { const handleModeChange = (mode: ConnectMode) => { setMode(mode) setConnectTab(mode) + setQueryFramework(null) + setQueryUsing(null) + setQueryMethod(null) + setQueryType(null) + setQueryMcpClient(null) + } + + const handleFieldChange = (fieldId: string, value: string | boolean | string[]) => { + updateField(fieldId, value) + const str = String(value) + if (fieldId === 'framework') { + setQueryFramework(str) + setQueryUsing(null) + } else if (fieldId === 'frameworkVariant') { + setQueryUsing(str) + } else if (fieldId === 'orm') { + setQueryFramework(str) + } else if (fieldId === 'connectionMethod') { + setQueryMethod(str) + setQueryType(null) + } else if (fieldId === 'connectionType') { + setQueryType(str) + } else if (fieldId === 'mcpClient') { + setQueryMcpClient(str) + } } return ( @@ -116,7 +187,7 @@ export const ConnectSheet = () => { diff --git a/e2e/studio/features/connect.spec.ts b/e2e/studio/features/connect.spec.ts index 893657634f..0cb908f774 100644 --- a/e2e/studio/features/connect.spec.ts +++ b/e2e/studio/features/connect.spec.ts @@ -54,3 +54,133 @@ test.describe('Connect', async () => { await expect(page).toHaveURL(/showConnect=true/) }) }) + +test.describe('Connect Sheet deep linking', async () => { + test('pre-selects framework and variant from URL params', async ({ page, ref }) => { + await page.goto( + toUrl( + `/project/${ref}?showConnect=true&connectTab=framework&framework=nextjs&using=pages` + ) + ) + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should open from deep link' + ).toBeVisible({ timeout: 30000 }) + + await expect( + page.getByRole('combobox').filter({ hasText: 'Next.js' }), + 'Framework select should show Next.js' + ).toBeVisible() + + await expect( + page.getByRole('combobox').filter({ hasText: 'Pages Router' }), + 'Variant select should show Pages Router' + ).toBeVisible() + }) + + test('supports legacy frameworks tab alias', async ({ page, ref }) => { + await page.goto( + toUrl(`/project/${ref}?showConnect=true&connectTab=frameworks&framework=nextjs`) + ) + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should open with legacy tab alias' + ).toBeVisible({ timeout: 30000 }) + + await expect( + page.getByRole('combobox').filter({ hasText: 'Next.js' }), + 'Framework select should show Next.js via legacy connectTab alias' + ).toBeVisible() + }) + + test('pre-selects ORM from URL params', async ({ page, ref }) => { + // Use drizzle (non-default) to verify the param takes effect + await page.goto(toUrl(`/project/${ref}?showConnect=true&connectTab=orm&framework=drizzle`)) + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should open from deep link' + ).toBeVisible({ timeout: 30000 }) + + await expect( + page.locator('[data-state="checked"]').filter({ hasText: 'Drizzle' }), + 'Drizzle radio should be selected' + ).toBeVisible() + }) + + test('pre-selects MCP client from URL params', async ({ page, ref }) => { + await page.goto(toUrl(`/project/${ref}?showConnect=true&connectTab=mcp&mcpClient=goose`)) + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should open from deep link' + ).toBeVisible({ timeout: 30000 }) + + await expect( + page.getByRole('combobox').filter({ hasText: 'Goose' }), + 'MCP client select should show Goose' + ).toBeVisible() + }) + + test('pre-selects direct connection method from URL params', async ({ page, ref }) => { + await page.goto( + toUrl(`/project/${ref}?showConnect=true&connectTab=direct&method=transaction`) + ) + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should open from deep link' + ).toBeVisible({ timeout: 30000 }) + + await expect( + page.locator('[data-state="checked"]').filter({ hasText: 'Transaction pooler' }), + 'Transaction pooler radio should be selected' + ).toBeVisible() + }) + + test('closing the sheet clears all deep-link params from URL', async ({ page, ref }) => { + await page.goto( + toUrl( + `/project/${ref}?showConnect=true&connectTab=framework&framework=nextjs&using=pages` + ) + ) + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should open' + ).toBeVisible({ timeout: 30000 }) + + await page.keyboard.press('Escape') + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should close' + ).not.toBeVisible({ timeout: 10000 }) + + await expect(page, 'connectTab param should be removed').not.toHaveURL(/connectTab/) + await expect(page, 'framework param should be removed').not.toHaveURL(/[?&]framework=/) + await expect(page, 'using param should be removed').not.toHaveURL(/using=/) + }) + + test('changing mode clears previous mode params from URL', async ({ page, ref }) => { + await page.goto( + toUrl(`/project/${ref}?showConnect=true&connectTab=framework&framework=nextjs`) + ) + + await expect( + page.getByRole('heading', { name: 'Connect to your project' }), + 'ConnectSheet should open' + ).toBeVisible({ timeout: 30000 }) + + await expect(page, 'framework param should be in URL initially').toHaveURL(/framework=nextjs/) + + await page.getByRole('button', { name: /ORM/ }).click() + + await expect(page, 'framework param should be cleared after mode change').not.toHaveURL( + /framework=nextjs/ + ) + await expect(page, 'connectTab should update to orm').toHaveURL(/connectTab=orm/) + }) +})