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 `<ConnectSheet />` component their is no deep linking like
the previous `<Connect />` 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



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
hallidayo
2026-04-13 10:00:19 +01:00
committed by GitHub
parent e541719345
commit 9791f65a18
2 changed files with 207 additions and 6 deletions

View File

@@ -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 = () => {
<ConnectConfigSection
state={state}
activeFields={activeFields}
onFieldChange={updateField}
onFieldChange={handleFieldChange}
getFieldOptions={getFieldOptions}
/>
</div>

View File

@@ -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/)
})
})