diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx
index 55288932b698a..06241ac97d620 100644
--- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx
+++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx
@@ -19,7 +19,7 @@ import type {
} from './Connect.types'
import { ConnectSheetStep } from './ConnectSheetStep'
import { CopyPromptAdmonition } from './CopyPromptAdmonition'
-import { getConnectionStringPooler } from './DatabaseSettings.utils'
+import { getConnectionStrings } from './DatabaseSettings.utils'
interface ConnectStepsSectionProps {
steps: ResolvedStep[]
@@ -67,7 +67,7 @@ function useConnectionStringPooler(): ConnectionStringPooler {
const poolingConfigurationShared = supavisorConfig?.find((x) => x.database_type === 'PRIMARY')
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
- const ConnectionStringPoolerShared = getConnectionStringPooler({
+ const connectionStringsShared = getConnectionStrings({
connectionInfo,
poolingInfo: {
connectionString: poolingConfigurationShared?.connection_string ?? '',
@@ -79,9 +79,9 @@ function useConnectionStringPooler(): ConnectionStringPooler {
metadata: { projectRef },
})
- const ConnectionStringPoolerDedicated =
+ const connectionStringsDedicated =
poolingConfigurationDedicated !== undefined
- ? getConnectionStringPooler({
+ ? getConnectionStrings({
connectionInfo,
poolingInfo: {
connectionString: poolingConfigurationDedicated.connection_string,
@@ -96,14 +96,14 @@ function useConnectionStringPooler(): ConnectionStringPooler {
return useMemo(
() => ({
- transactionShared: ConnectionStringPoolerShared.pooler.uri,
- sessionShared: ConnectionStringPoolerShared.pooler.uri.replace('6543', '5432'),
- transactionDedicated: ConnectionStringPoolerDedicated?.pooler.uri,
- sessionDedicated: ConnectionStringPoolerDedicated?.pooler.uri.replace('6543', '5432'),
+ transactionShared: connectionStringsShared.pooler.uri,
+ sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'),
+ transactionDedicated: connectionStringsDedicated?.pooler.uri,
+ sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'),
ipv4SupportedForDedicatedPooler: !!ipv4Addon,
- direct: ConnectionStringPoolerShared.direct.uri,
+ direct: connectionStringsShared.direct.uri,
}),
- [ConnectionStringPoolerShared, ConnectionStringPoolerDedicated, ipv4Addon]
+ [connectionStringsShared, connectionStringsDedicated, ipv4Addon]
)
}
diff --git a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts
index 402be85432336..60d5c88df0465 100644
--- a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts
@@ -1,4 +1,4 @@
-type ConnectionStringPooler = {
+type ConnectionStrings = {
psql: string
uri: string
golang: string
@@ -10,7 +10,7 @@ type ConnectionStringPooler = {
sqlalchemy: string
}
-export const getConnectionStringPooler = ({
+export const getConnectionStrings = ({
connectionInfo,
poolingInfo,
metadata,
@@ -33,10 +33,10 @@ export const getConnectionStringPooler = ({
pgVersion?: string
}
}): {
- direct: ConnectionStringPooler
- pooler: ConnectionStringPooler
+ direct: ConnectionStrings
+ pooler: ConnectionStrings
} => {
- const isMd5 = poolingInfo?.connectionString?.includes('options=reference')
+ const isMd5 = poolingInfo?.connectionString.includes('options=reference')
const { projectRef } = metadata
const password = '[YOUR-PASSWORD]'
diff --git a/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx b/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx
new file mode 100644
index 0000000000000..2892cf6906d06
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx
@@ -0,0 +1,93 @@
+import { Box, Check, ChevronDown } from 'lucide-react'
+import { useState } from 'react'
+import {
+ Button,
+ CommandEmpty_Shadcn_,
+ CommandGroup_Shadcn_,
+ CommandInput_Shadcn_,
+ CommandItem_Shadcn_,
+ CommandList_Shadcn_,
+ Command_Shadcn_,
+ PopoverContent_Shadcn_,
+ PopoverTrigger_Shadcn_,
+ Popover_Shadcn_,
+ cn,
+} from 'ui'
+
+import { ConnectionType } from '@/components/interfaces/ConnectSheet/Connect.constants'
+import { ConnectionIcon } from '@/components/interfaces/ConnectSheet/ConnectionIcon'
+
+interface FrameworkSelectorProps {
+ value: string
+ onChange: (value: string) => void
+ items: ConnectionType[]
+ className?: string
+ size?: 'tiny' | 'small'
+}
+
+export const FrameworkSelector = ({
+ value,
+ onChange,
+ items,
+ className,
+ size = 'tiny',
+}: FrameworkSelectorProps) => {
+ const [open, setOpen] = useState(false)
+
+ const selectedItem = items.find((item) => item.key === value)
+
+ function handleSelect(key: string) {
+ onChange(key)
+ setOpen(false)
+ }
+
+ return (
+
+
+
+ }
+ >
+
+ {selectedItem?.icon ? : }
+ {selectedItem?.label}
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+
+ No results found.
+
+ {items.map((item) => (
+ handleSelect(item.key)}
+ className="flex gap-2 items-center"
+ >
+ {item.icon ? : }
+ {item.label}
+
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts
index 30f264907c476..57d7fdb067242 100644
--- a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts
@@ -1,13 +1,13 @@
import { describe, expect, test } from 'vitest'
-import type { ConditionalValue, ConnectSchema, StepTree } from './Connect.types'
import {
getActiveFields,
getDefaultState,
+ resetDependentFields,
resolveConditional,
- resolveState,
resolveSteps,
} from './connect.resolver'
+import type { ConditionalValue, ConnectSchema, ConnectState, StepTree } from './Connect.types'
// ============================================================================
// resolveConditional Tests
@@ -155,6 +155,10 @@ describe('connect.resolver:resolveConditional', () => {
describe('connect.resolver:resolveSteps', () => {
const createMockSchema = (steps: StepTree): ConnectSchema => ({
+ modes: [
+ { id: 'framework', label: 'Framework', description: '', fields: [] },
+ { id: 'direct', label: 'Direct', description: '', fields: [] },
+ ],
fields: {},
steps,
})
@@ -295,166 +299,160 @@ describe('connect.resolver:resolveSteps', () => {
// ============================================================================
describe('connect.resolver:getActiveFields', () => {
- const createSchemaWithFields = (fields: ConnectSchema['fields']): ConnectSchema => ({
+ const createSchemaWithFields = (
+ modes: ConnectSchema['modes'],
+ fields: ConnectSchema['fields']
+ ): ConnectSchema => ({
+ modes,
fields,
steps: [],
})
test('should return fields for the current mode', () => {
- const schema = createSchemaWithFields({
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'framework',
- options: () => [
- { value: 'framework', label: 'Framework' },
- { value: 'direct', label: 'Direct' },
- ],
- },
- framework: {
- id: 'framework',
- type: 'radio-grid',
- label: 'Framework',
- defaultValue: 'nextjs',
- dependsOn: { mode: ['framework'] },
- },
- library: {
- id: 'library',
- type: 'select',
- label: 'Library',
- defaultValue: 'supabasejs',
- dependsOn: { mode: ['framework'] },
- },
- connectionType: {
- id: 'connectionType',
- type: 'select',
- label: 'Type',
- defaultValue: 'uri',
- dependsOn: { mode: ['direct'] },
- },
- })
+ const schema = createSchemaWithFields(
+ [
+ { id: 'framework', label: 'Framework', description: '', fields: ['framework', 'library'] },
+ { id: 'direct', label: 'Direct', description: '', fields: ['connectionType'] },
+ ],
+ {
+ framework: {
+ id: 'framework',
+ type: 'radio-grid',
+ label: 'Framework',
+ defaultValue: 'nextjs',
+ },
+ library: {
+ id: 'library',
+ type: 'select',
+ label: 'Library',
+ defaultValue: 'supabasejs',
+ },
+ connectionType: {
+ id: 'connectionType',
+ type: 'select',
+ label: 'Type',
+ defaultValue: 'uri',
+ },
+ }
+ )
const frameworkFields = getActiveFields(schema, { mode: 'framework' })
- expect(frameworkFields.map((f) => f.id)).toEqual(['mode', 'framework', 'library'])
+ expect(frameworkFields).toHaveLength(2)
+ expect(frameworkFields.map((f) => f.id)).toEqual(['framework', 'library'])
const directFields = getActiveFields(schema, { mode: 'direct' })
- expect(directFields.map((f) => f.id)).toEqual(['mode', 'connectionType'])
+ expect(directFields).toHaveLength(1)
+ expect(directFields[0].id).toBe('connectionType')
})
test('should filter fields by dependsOn conditions', () => {
- const schema = createSchemaWithFields({
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'framework',
- options: () => [{ value: 'framework', label: 'Framework' }],
- },
- framework: {
- id: 'framework',
- type: 'radio-grid',
- label: 'Framework',
- defaultValue: 'nextjs',
- dependsOn: { mode: ['framework'] },
- },
- frameworkVariant: {
- id: 'frameworkVariant',
- type: 'select',
- label: 'Variant',
- dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] },
- },
- frameworkUi: {
- id: 'frameworkUi',
- type: 'switch',
- label: 'Shadcn',
- dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] },
- },
- })
+ const schema = createSchemaWithFields(
+ [
+ {
+ id: 'framework',
+ label: 'Framework',
+ description: '',
+ fields: ['framework', 'frameworkVariant', 'frameworkUi'],
+ },
+ ],
+ {
+ framework: {
+ id: 'framework',
+ type: 'radio-grid',
+ label: 'Framework',
+ defaultValue: 'nextjs',
+ },
+ frameworkVariant: {
+ id: 'frameworkVariant',
+ type: 'select',
+ label: 'Variant',
+ dependsOn: { framework: ['nextjs', 'react'] },
+ },
+ frameworkUi: {
+ id: 'frameworkUi',
+ type: 'switch',
+ label: 'Shadcn',
+ dependsOn: { framework: ['nextjs', 'react'] },
+ },
+ }
+ )
// With nextjs - should show all fields
const nextjsFields = getActiveFields(schema, { mode: 'framework', framework: 'nextjs' })
- expect(nextjsFields).toHaveLength(4)
+ expect(nextjsFields).toHaveLength(3)
// With vue - should hide frameworkVariant and frameworkUi
const vueFields = getActiveFields(schema, { mode: 'framework', framework: 'vue' })
- expect(vueFields.map((field) => field.id)).toEqual(['mode', 'framework'])
+ expect(vueFields).toHaveLength(1)
+ expect(vueFields[0].id).toBe('framework')
})
test('should handle multiple dependsOn conditions', () => {
- const schema = createSchemaWithFields({
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'direct',
- options: () => [{ value: 'direct', label: 'Direct' }],
- },
- connectionMethod: {
- id: 'connectionMethod',
- type: 'radio-list',
- label: 'Method',
- defaultValue: 'direct',
- dependsOn: { mode: ['direct'] },
- },
- useSharedPooler: {
- id: 'useSharedPooler',
- type: 'switch',
- label: 'Use Shared Pooler',
- dependsOn: { mode: ['direct'], connectionMethod: ['transaction'] },
- },
- })
+ const schema = createSchemaWithFields(
+ [
+ {
+ id: 'direct',
+ label: 'Direct',
+ description: '',
+ fields: ['connectionMethod', 'useSharedPooler'],
+ },
+ ],
+ {
+ connectionMethod: {
+ id: 'connectionMethod',
+ type: 'radio-list',
+ label: 'Method',
+ defaultValue: 'direct',
+ },
+ useSharedPooler: {
+ id: 'useSharedPooler',
+ type: 'switch',
+ label: 'Use Shared Pooler',
+ dependsOn: { connectionMethod: ['transaction'] },
+ },
+ }
+ )
// Transaction mode - show shared pooler option
const transactionFields = getActiveFields(schema, {
mode: 'direct',
connectionMethod: 'transaction',
})
- expect(transactionFields.map((field) => field.id)).toEqual([
- 'mode',
- 'connectionMethod',
- 'useSharedPooler',
- ])
+ expect(transactionFields).toHaveLength(2)
// Direct mode - hide shared pooler option
const directFields = getActiveFields(schema, { mode: 'direct', connectionMethod: 'direct' })
- expect(directFields.map((field) => field.id)).toEqual(['mode', 'connectionMethod'])
+ expect(directFields).toHaveLength(1)
+ expect(directFields[0].id).toBe('connectionMethod')
})
- test('should return only mode field when dependsOn does not match', () => {
- const schema = createSchemaWithFields({
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'framework',
- options: () => [{ value: 'framework', label: 'Framework' }],
- },
- framework: {
- id: 'framework',
- type: 'radio-grid',
- label: 'Framework',
- dependsOn: { mode: ['framework'] },
- },
- })
+ test('should return empty array for invalid mode', () => {
+ const schema = createSchemaWithFields(
+ [{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }],
+ { framework: { id: 'framework', type: 'radio-grid', label: 'Framework' } }
+ )
const fields = getActiveFields(schema, { mode: 'invalid' as any })
- expect(fields.map((field) => field.id)).toEqual(['mode'])
+ expect(fields).toEqual([])
})
test('should include resolvedOptions for each field', () => {
- const schema = createSchemaWithFields({
- framework: {
- id: 'framework',
- type: 'radio-grid',
- label: 'Framework',
- options: () => [{ value: 'nextjs', label: 'Next.js' }],
- },
- })
+ const schema = createSchemaWithFields(
+ [{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }],
+ {
+ framework: {
+ id: 'framework',
+ type: 'radio-grid',
+ label: 'Framework',
+ options: { source: 'frameworks' }, // Source reference - resolved elsewhere
+ },
+ }
+ )
const fields = getActiveFields(schema, { mode: 'framework' })
expect(fields[0]).toHaveProperty('resolvedOptions')
- expect(fields[0].resolvedOptions).toEqual([{ value: 'nextjs', label: 'Next.js' }])
+ // Source options are resolved by the hook, not the resolver
+ expect(fields[0].resolvedOptions).toEqual([])
})
})
@@ -463,29 +461,35 @@ describe('connect.resolver:getActiveFields', () => {
// ============================================================================
describe('connect.resolver:getDefaultState', () => {
+ test('should return default state with first mode', () => {
+ const schema: ConnectSchema = {
+ modes: [
+ { id: 'framework', label: 'Framework', description: '', fields: [] },
+ { id: 'direct', label: 'Direct', description: '', fields: [] },
+ ],
+ fields: {},
+ steps: [],
+ }
+
+ const state = getDefaultState(schema)
+ expect(state.mode).toBe('framework')
+ })
+
test('should include default values from fields', () => {
const schema: ConnectSchema = {
+ modes: [{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }],
fields: {
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'framework',
- options: () => [{ value: 'framework', label: 'Framework' }],
- },
framework: {
id: 'framework',
type: 'radio-grid',
label: 'Framework',
defaultValue: 'nextjs',
- dependsOn: { mode: ['framework'] },
},
library: {
id: 'library',
type: 'select',
label: 'Library',
defaultValue: 'supabasejs',
- dependsOn: { mode: ['framework'] },
},
mcpReadonly: {
id: 'mcpReadonly',
@@ -502,113 +506,119 @@ describe('connect.resolver:getDefaultState', () => {
expect(state.library).toBe('supabasejs')
expect(state.mcpReadonly).toBe(false)
})
+
+ test('should fallback to "direct" if no modes defined', () => {
+ const schema: ConnectSchema = {
+ modes: [],
+ fields: {},
+ steps: [],
+ }
+
+ const state = getDefaultState(schema)
+ expect(state.mode).toBe('direct')
+ })
})
// ============================================================================
-// resolveState Tests
+// resetDependentFields Tests
// ============================================================================
-describe('connect.resolver:resolveState', () => {
- test('should apply defaults from options when valid', () => {
- const schema: ConnectSchema = {
- fields: {
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'framework',
- options: () => [{ value: 'framework', label: 'Framework' }],
- },
- framework: {
- id: 'framework',
- type: 'select',
- label: 'Framework',
- defaultValue: 'react',
- options: () => [
- { value: 'nextjs', label: 'Next.js' },
- { value: 'react', label: 'React' },
- ],
- dependsOn: { mode: ['framework'] },
- },
+describe('connect.resolver:resetDependentFields', () => {
+ const createSchemaForReset = (): ConnectSchema => ({
+ modes: [
+ {
+ id: 'framework',
+ label: 'Framework',
+ description: '',
+ fields: ['framework', 'frameworkVariant', 'frameworkUi'],
},
- steps: [],
+ { id: 'direct', label: 'Direct', description: '', fields: ['connectionMethod'] },
+ ],
+ fields: {
+ framework: {
+ id: 'framework',
+ type: 'radio-grid',
+ label: 'Framework',
+ defaultValue: 'nextjs',
+ },
+ frameworkVariant: {
+ id: 'frameworkVariant',
+ type: 'select',
+ label: 'Variant',
+ dependsOn: { framework: ['nextjs', 'react'] },
+ },
+ frameworkUi: {
+ id: 'frameworkUi',
+ type: 'switch',
+ label: 'Shadcn',
+ dependsOn: { framework: ['nextjs', 'react'] },
+ },
+ connectionMethod: {
+ id: 'connectionMethod',
+ type: 'radio-list',
+ label: 'Method',
+ defaultValue: 'direct',
+ },
+ },
+ steps: [],
+ })
+
+ test('should reset dependent fields when dependency no longer satisfied', () => {
+ const schema = createSchemaForReset()
+ const state: ConnectState = {
+ mode: 'framework',
+ framework: 'vue', // Changed from nextjs to vue
+ frameworkVariant: 'app', // This should be reset
+ frameworkUi: true, // This should be reset
}
- const state = resolveState(schema, {})
- expect(state.mode).toBe('framework')
- expect(state.framework).toBe('react')
+ const newState = resetDependentFields(state, 'framework', schema)
+
+ expect(newState.frameworkVariant).toBeUndefined()
+ expect(newState.frameworkUi).toBeUndefined()
})
- test('should fall back to first option when default is invalid', () => {
- const schema: ConnectSchema = {
- fields: {
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'framework',
- options: () => [{ value: 'framework', label: 'Framework' }],
- },
- framework: {
- id: 'framework',
- type: 'select',
- label: 'Framework',
- defaultValue: 'angular',
- options: () => [
- { value: 'nextjs', label: 'Next.js' },
- { value: 'react', label: 'React' },
- ],
- dependsOn: { mode: ['framework'] },
- },
- },
- steps: [],
+ test('should keep dependent fields when dependency still satisfied', () => {
+ const schema = createSchemaForReset()
+ const state: ConnectState = {
+ mode: 'framework',
+ framework: 'react', // Still in the allowed list
+ frameworkVariant: 'vite',
+ frameworkUi: true,
}
- const state = resolveState(schema, {})
- expect(state.framework).toBe('nextjs')
+ const newState = resetDependentFields(state, 'framework', schema)
+
+ expect(newState.frameworkVariant).toBe('vite')
+ expect(newState.frameworkUi).toBe(true)
})
- test('should refresh dependent values when options change', () => {
- const schema: ConnectSchema = {
- fields: {
- mode: {
- id: 'mode',
- type: 'select',
- label: 'Mode',
- defaultValue: 'framework',
- options: () => [{ value: 'framework', label: 'Framework' }],
- },
- framework: {
- id: 'framework',
- type: 'select',
- label: 'Framework',
- defaultValue: 'nextjs',
- options: () => [
- { value: 'nextjs', label: 'Next.js' },
- { value: 'react', label: 'React' },
- ],
- dependsOn: { mode: ['framework'] },
- },
- variant: {
- id: 'variant',
- type: 'select',
- label: 'Variant',
- options: (state) =>
- state.framework === 'react'
- ? [{ value: 'vite', label: 'Vite' }]
- : [{ value: 'app', label: 'App' }],
- dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] },
- },
- },
- steps: [],
+ test('should handle mode changes', () => {
+ const schema = createSchemaForReset()
+ const state: ConnectState = {
+ mode: 'direct', // Changed mode
+ framework: 'nextjs',
+ frameworkVariant: 'app',
}
- const state = resolveState(schema, {
+ // Note: The current implementation of resetDependentFields for mode changes
+ // looks for fields not in the current mode, but the logic compares against previous mode
+ const newState = resetDependentFields(state, 'mode', schema)
+
+ // Mode-specific field reset logic is handled
+ expect(newState.mode).toBe('direct')
+ })
+
+ test('should not modify state for fields without dependencies', () => {
+ const schema = createSchemaForReset()
+ const state: ConnectState = {
mode: 'framework',
- framework: 'react',
- variant: 'app',
- })
+ framework: 'nextjs',
+ }
+
+ const newState = resetDependentFields(state, 'framework', schema)
- expect(state.variant).toBe('vite')
+ expect(newState.mode).toBe('framework')
+ expect(newState.framework).toBe('nextjs')
})
})
diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts
index 019224ffe1468..2baeb7940c163 100644
--- a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts
@@ -10,6 +10,22 @@ import type {
StepTree,
} from './Connect.types'
+/**
+ * The order in which state keys are checked during conditional value resolution.
+ * Used for ConditionalValue (value-keyed) resolution, not for step trees.
+ */
+const STATE_KEY_ORDER = [
+ 'mode',
+ 'framework',
+ 'frameworkVariant',
+ 'library',
+ 'frameworkUi',
+ 'orm',
+ 'connectionMethod',
+ 'connectionType',
+ 'mcpClient',
+] as const
+
/**
* Check if a value is a conditional object (has nested state keys or DEFAULT)
*/
@@ -22,7 +38,7 @@ function isConditionalObject(value: unknown): value is Record
{
* Walks the tree using stateKeys in order, falling back to DEFAULT at each level.
*
* Example: Given state { mode: 'mcp', mcpClient: 'codex' }
- * and stateKeys derived from schema field order
+ * and stateKeys ['mode', 'framework', ..., 'mcpClient']
*
* 1. Look up 'mcp' (state.mode value) in tree -> found, continue
* 2. At mcp subtree { codex: [...], DEFAULT: [...] }, skip irrelevant keys
@@ -33,7 +49,7 @@ function isConditionalObject(value: unknown): value is Record {
export function resolveConditional(
value: ConditionalValue,
state: ConnectState,
- stateKeys: readonly string[] = Object.keys(state)
+ stateKeys: readonly string[] = STATE_KEY_ORDER
): T | undefined {
// Base case: we've reached a leaf value (string, array, null, boolean, etc.)
if (!isConditionalObject(value)) {
@@ -71,12 +87,10 @@ export function resolveConditional(
export function resolveSteps(schema: ConnectSchema, state: ConnectState): ResolvedStep[] {
const steps = resolveStepTree(schema.steps, state)
if (steps.length === 0) return []
- const stateKeys = Object.keys(schema.fields)
- const resolutionKeys = stateKeys.length > 0 ? stateKeys : Object.keys(state)
return steps
.map((step) => {
- const content = resolveConditional(step.content, state, resolutionKeys)
+ const content = resolveConditional(step.content, state)
return {
id: step.id,
title: step.title,
@@ -130,9 +144,10 @@ function resolveStepBranch(
* Gets the active fields for the current mode, filtering by dependsOn conditions.
*/
export function getActiveFields(schema: ConnectSchema, state: ConnectState): ResolvedField[] {
- const stateKeys = Object.keys(schema.fields)
+ const currentMode = schema.modes.find((m) => m.id === state.mode)
+ if (!currentMode) return []
- return stateKeys
+ return currentMode.fields
.map((fieldId) => schema.fields[fieldId])
.filter((field): field is NonNullable => !!field)
.filter((field) => {
@@ -145,18 +160,14 @@ export function getActiveFields(schema: ConnectSchema, state: ConnectState): Res
})
.map((field) => ({
...field,
- resolvedOptions: resolveFieldOptions(field, state, stateKeys),
+ resolvedOptions: resolveFieldOptions(field, state),
}))
}
/**
* Resolves field options based on current state.
*/
-function resolveFieldOptions(
- field: { options?: unknown },
- state: ConnectState,
- stateKeys: readonly string[]
-): FieldOption[] {
+function resolveFieldOptions(field: { options?: unknown }, state: ConnectState): FieldOption[] {
if (!field.options) return []
// Static options array
@@ -164,118 +175,80 @@ function resolveFieldOptions(
return field.options
}
- if (typeof field.options === 'function') {
- return (field.options as (state: ConnectState) => FieldOption[])(state)
+ // Reference to data source (handled elsewhere)
+ if (
+ typeof field.options === 'object' &&
+ 'source' in field.options &&
+ typeof field.options.source === 'string'
+ ) {
+ // This will be resolved by the component using getFieldOptionsFromSource
+ return []
}
// Conditional options
- if (typeof field.options === 'object') {
- const resolved = resolveConditional(
- field.options as ConditionalValue,
- state,
- stateKeys
- )
- return resolved ?? []
- }
-
- return []
+ const resolved = resolveConditional(
+ field.options as ConditionalValue,
+ state
+ )
+ return resolved ?? []
}
/**
- * Normalizes state values based on schema defaults, options, and dependencies.
+ * Gets default state for the schema, using first mode and default field values.
*/
-export function resolveState(
- schema: ConnectSchema,
- inputState: Partial
-): ConnectState {
- const next: ConnectState = { ...(inputState as ConnectState) }
-
- const maxIterations = Math.max(1, Object.keys(schema.fields).length + 1)
+export function getDefaultState(schema: ConnectSchema): ConnectState {
+ const defaultMode = schema.modes[0]?.id ?? 'direct'
- for (let iteration = 0; iteration < maxIterations; iteration++) {
- let changed = false
- const activeFields = getActiveFields(schema, next)
+ const state: ConnectState = { mode: defaultMode }
- for (const field of activeFields) {
- const currentValue = next[field.id]
- const optionValues = field.resolvedOptions.map((option) => option.value)
- const hasOptions = optionValues.length > 0
+ // Set default values for all fields
+ Object.values(schema.fields).forEach((field) => {
+ if (field.defaultValue !== undefined) {
+ state[field.id] = field.defaultValue
+ }
+ })
- if (field.type === 'switch') {
- if (typeof currentValue !== 'boolean' && typeof field.defaultValue === 'boolean') {
- next[field.id] = field.defaultValue
- changed = true
- }
- continue
- }
+ return state
+}
- if (field.type === 'multi-select') {
- if (Array.isArray(currentValue)) {
- if (hasOptions) {
- const filtered = currentValue.filter((value) => optionValues.includes(String(value)))
- if (filtered.length !== currentValue.length) {
- next[field.id] = filtered
- changed = true
- }
- }
- } else if (Array.isArray(field.defaultValue)) {
- next[field.id] = field.defaultValue
- changed = true
- }
- continue
- }
+/**
+ * Resets dependent fields when a parent field changes.
+ * For example, changing framework should reset frameworkVariant.
+ */
+export function resetDependentFields(
+ state: ConnectState,
+ changedFieldId: string,
+ schema: ConnectSchema
+): ConnectState {
+ const newState = { ...state }
+
+ // Find fields that depend on the changed field
+ Object.values(schema.fields).forEach((field) => {
+ if (field.dependsOn && changedFieldId in field.dependsOn) {
+ // Only reset if dependency conditions are no longer satisfied
+ const dependencySatisfied = Object.entries(field.dependsOn).every(([key, values]) => {
+ const stateValue = String(newState[key] ?? '')
+ return values.includes(stateValue)
+ })
- if (typeof currentValue !== 'string') {
- let nextValue: string | undefined
-
- if (
- typeof field.defaultValue === 'string' &&
- (!hasOptions || optionValues.includes(field.defaultValue))
- ) {
- nextValue = field.defaultValue
- } else if (hasOptions) {
- nextValue = optionValues[0]
- }
-
- if (nextValue !== undefined) {
- next[field.id] = nextValue
- changed = true
- }
- continue
+ if (!dependencySatisfied) {
+ delete newState[field.id]
}
+ }
+ })
- if (hasOptions && !optionValues.includes(currentValue)) {
- let nextValue: string | undefined
-
- if (typeof field.defaultValue === 'string' && optionValues.includes(field.defaultValue)) {
- nextValue = field.defaultValue
- } else {
- nextValue = optionValues[0]
- }
+ // Special case: changing mode resets all mode-specific fields
+ if (changedFieldId === 'mode') {
+ const previousMode = schema.modes.find((m) => m.id !== state.mode)
+ const currentMode = schema.modes.find((m) => m.id === state.mode)
- if (nextValue !== currentValue) {
- next[field.id] = nextValue
- changed = true
- }
+ // Reset fields from previous mode that aren't in current mode
+ previousMode?.fields.forEach((fieldId) => {
+ if (!currentMode?.fields.includes(fieldId)) {
+ delete newState[fieldId]
}
- }
-
- if (!changed) break
+ })
}
- const activeIds = new Set(getActiveFields(schema, next).map((field) => field.id))
- Object.keys(schema.fields).forEach((fieldId) => {
- if (!activeIds.has(fieldId)) {
- delete next[fieldId]
- }
- })
-
- return next
-}
-
-/**
- * Gets default state for the schema, using first mode and default field values.
- */
-export function getDefaultState(schema: ConnectSchema): ConnectState {
- return resolveState(schema, {})
+ return newState
}
diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts
index 750d08f6976c1..e7eb1d9fab86e 100644
--- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts
@@ -1,22 +1,68 @@
import { describe, expect, test } from 'vitest'
-import { INSTALL_COMMANDS } from './Connect.constants'
-import type { ConnectState } from './Connect.types'
import { resolveSteps } from './connect.resolver'
-import { connectSchema } from './connect.schema'
+import { connectSchema, INSTALL_COMMANDS } from './connect.schema'
+import type { ConnectState } from './Connect.types'
// ============================================================================
// Schema Structure Tests
// ============================================================================
describe('connect.schema:structure', () => {
- test('should define a mode field', () => {
- const field = connectSchema.fields.mode
- expect(field).toBeDefined()
- expect(field.type).toBe('radio-list')
- expect(field.defaultValue).toBe('framework')
- const options = Array.isArray(field.options) ? field.options : []
- expect(options.some((option) => option.value === 'framework')).toBe(true)
+ test('should have all required modes', () => {
+ const modeIds = connectSchema.modes.map((m) => m.id)
+ expect(modeIds).toContain('framework')
+ expect(modeIds).toContain('direct')
+ expect(modeIds).toContain('orm')
+ expect(modeIds).toContain('mcp')
+ })
+
+ test('each mode should have required properties', () => {
+ connectSchema.modes.forEach((mode) => {
+ expect(mode.id).toBeDefined()
+ expect(mode.label).toBeDefined()
+ expect(mode.description).toBeDefined()
+ expect(mode.fields).toBeDefined()
+ expect(Array.isArray(mode.fields)).toBe(true)
+ })
+ })
+
+ test('framework mode should have correct fields', () => {
+ const frameworkMode = connectSchema.modes.find((m) => m.id === 'framework')
+ expect(frameworkMode?.fields).toContain('framework')
+ expect(frameworkMode?.fields).toContain('frameworkVariant')
+ expect(frameworkMode?.fields).toContain('library')
+ expect(frameworkMode?.fields).toContain('frameworkUi')
+ })
+
+ test('direct mode should have correct fields', () => {
+ const directMode = connectSchema.modes.find((m) => m.id === 'direct')
+ expect(directMode?.fields).toContain('connectionMethod')
+ expect(directMode?.fields).toContain('useSharedPooler')
+ expect(directMode?.fields).toContain('connectionType')
+ })
+
+ test('orm mode should have correct fields', () => {
+ const ormMode = connectSchema.modes.find((m) => m.id === 'orm')
+ expect(ormMode?.fields).toContain('orm')
+ })
+
+ test('mcp mode should have correct fields', () => {
+ const mcpMode = connectSchema.modes.find((m) => m.id === 'mcp')
+ expect(mcpMode?.fields).toContain('mcpClient')
+ expect(mcpMode?.fields).toContain('mcpReadonly')
+ expect(mcpMode?.fields).toContain('mcpFeatures')
+ })
+
+ test('all mode fields should exist in fields definition', () => {
+ connectSchema.modes.forEach((mode) => {
+ mode.fields.forEach((fieldId) => {
+ expect(
+ connectSchema.fields[fieldId],
+ `Field "${fieldId}" in mode "${mode.id}" should exist in fields definition`
+ ).toBeDefined()
+ })
+ })
})
})
@@ -28,49 +74,53 @@ describe('connect.schema:fields', () => {
test('framework field should have correct type', () => {
const field = connectSchema.fields.framework
expect(field.type).toBe('select')
- expect(Array.isArray(field.options)).toBe(true)
- const options = Array.isArray(field.options) ? field.options : []
- expect(options.some((option) => option.value === 'nextjs')).toBe(true)
+ expect(field.options).toEqual({ source: 'frameworks' })
expect(field.defaultValue).toBe('nextjs')
- expect(field.dependsOn).toEqual({ mode: ['framework'] })
})
test('frameworkVariant field should depend on framework', () => {
const field = connectSchema.fields.frameworkVariant
- expect(field.dependsOn).toEqual({ mode: ['framework'], framework: ['nextjs', 'react'] })
- expect(typeof field.options).toBe('function')
+ expect(field.dependsOn).toEqual({ framework: ['nextjs', 'react'] })
})
test('frameworkUi field should be a switch type', () => {
const field = connectSchema.fields.frameworkUi
expect(field.type).toBe('switch')
expect(field.defaultValue).toBe(false)
- expect(field.dependsOn).toEqual({ mode: ['framework'], framework: ['nextjs', 'react'] })
+ expect(field.dependsOn).toEqual({ framework: ['nextjs', 'react'] })
})
- test('connectionMethod field should be removed', () => {
+ test('connectionMethod field should have radio-list type', () => {
const field = connectSchema.fields.connectionMethod
- expect(field).toBeUndefined()
+ expect(field.type).toBe('radio-list')
+ expect(field.options).toEqual({ source: 'connectionMethods' })
+ expect(field.defaultValue).toBe('direct')
})
- test('useSharedPooler field should be removed', () => {
+ test('useSharedPooler field should depend on transaction connection method', () => {
const field = connectSchema.fields.useSharedPooler
- expect(field).toBeUndefined()
+ expect(field.type).toBe('switch')
+ expect(field.dependsOn).toEqual({ connectionMethod: ['transaction'] })
})
- test('orm field should be removed', () => {
+ test('orm field should have radio-list type', () => {
const field = connectSchema.fields.orm
- expect(field).toBeUndefined()
+ expect(field.type).toBe('radio-list')
+ expect(field.options).toEqual({ source: 'orms' })
+ expect(field.defaultValue).toBe('prisma')
})
- test('mcpClient field should be removed', () => {
+ test('mcpClient field should have select type', () => {
const field = connectSchema.fields.mcpClient
- expect(field).toBeUndefined()
+ expect(field.type).toBe('select')
+ expect(field.options).toEqual({ source: 'mcpClients' })
+ expect(field.defaultValue).toBe('cursor')
})
- test('mcpFeatures field should be removed', () => {
+ test('mcpFeatures field should have multi-select type', () => {
const field = connectSchema.fields.mcpFeatures
- expect(field).toBeUndefined()
+ expect(field.type).toBe('multi-select')
+ expect(field.options).toEqual({ source: 'mcpFeatures' })
})
})
@@ -149,35 +199,101 @@ describe('connect.schema:steps resolution', () => {
})
describe('direct mode steps', () => {
- test('should not resolve steps for direct mode', () => {
+ test('should resolve connection step for default direct mode', () => {
const state: ConnectState = { mode: 'direct' }
const steps = resolveSteps(connectSchema, state)
- expect(steps.length).toBe(0)
+ expect(steps.find((s) => s.id === 'connection')).toBeDefined()
+ })
+
+ test('should resolve install and files steps for nodejs connection type', () => {
+ const state: ConnectState = { mode: 'direct', connectionType: 'nodejs' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
+ expect(steps.find((s) => s.id === 'direct-files')).toBeDefined()
+ })
+
+ test('should resolve install and files steps for golang connection type', () => {
+ const state: ConnectState = { mode: 'direct', connectionType: 'golang' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
+ expect(steps.find((s) => s.id === 'direct-files')).toBeDefined()
+ })
+
+ test('should resolve install and files steps for python connection type', () => {
+ const state: ConnectState = { mode: 'direct', connectionType: 'python' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
+ })
+
+ test('should resolve install and files steps for dotnet connection type', () => {
+ const state: ConnectState = { mode: 'direct', connectionType: 'dotnet' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
})
})
describe('orm mode steps', () => {
- test('should not resolve steps for orm mode', () => {
+ test('should resolve install and configure steps for prisma', () => {
const state: ConnectState = { mode: 'orm', orm: 'prisma' }
const steps = resolveSteps(connectSchema, state)
- expect(steps.length).toBe(0)
+ expect(steps.find((s) => s.id === 'install')).toBeDefined()
+ expect(steps.find((s) => s.id === 'configure')).toBeDefined()
+ expect(steps.find((s) => s.id === 'install-skills')).toBeDefined()
+ })
+
+ test('should resolve install and configure steps for drizzle', () => {
+ const state: ConnectState = { mode: 'orm', orm: 'drizzle' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'install')).toBeDefined()
+ expect(steps.find((s) => s.id === 'configure')).toBeDefined()
})
})
describe('mcp mode steps', () => {
- test('should not resolve steps for mcp mode', () => {
+ test('should resolve configure step for cursor client', () => {
const state: ConnectState = { mode: 'mcp', mcpClient: 'cursor' }
const steps = resolveSteps(connectSchema, state)
- expect(steps.length).toBe(0)
+ expect(steps.find((s) => s.id === 'configure-mcp')).toBeDefined()
+ expect(steps.find((s) => s.id === 'install-skills')).toBeDefined()
+ })
+
+ test('should resolve codex-specific steps for codex client', () => {
+ const state: ConnectState = { mode: 'mcp', mcpClient: 'codex' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'codex-add-server')).toBeDefined()
+ expect(steps.find((s) => s.id === 'codex-enable-remote')).toBeDefined()
+ expect(steps.find((s) => s.id === 'codex-authenticate')).toBeDefined()
+ expect(steps.find((s) => s.id === 'codex-verify')).toBeDefined()
+ })
+
+ test('should resolve claude-code-specific steps for claude-code client', () => {
+ const state: ConnectState = { mode: 'mcp', mcpClient: 'claude-code' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'claude-add-server')).toBeDefined()
+ expect(steps.find((s) => s.id === 'claude-authenticate')).toBeDefined()
+ })
+
+ test('should resolve default mcp steps for other clients', () => {
+ const state: ConnectState = { mode: 'mcp', mcpClient: 'unknown-client' }
+ const steps = resolveSteps(connectSchema, state)
+
+ expect(steps.find((s) => s.id === 'configure-mcp')).toBeDefined()
})
})
describe('skills install step', () => {
test('should include skills install step in all modes', () => {
- const modes: ConnectState['mode'][] = ['framework']
+ const modes: ConnectState['mode'][] = ['framework', 'direct', 'orm', 'mcp']
modes.forEach((mode) => {
const state: ConnectState = { mode }
@@ -221,11 +337,12 @@ describe('connect.schema:step content paths', () => {
expect(exploreStep?.content).toBe('steps/shadcn/explore')
})
- test('direct connection step should not be resolved', () => {
+ test('direct connection step should have valid content path', () => {
const state: ConnectState = { mode: 'direct' }
const steps = resolveSteps(connectSchema, state)
+ const connectionStep = steps.find((s) => s.id === 'connection')
- expect(steps.length).toBe(0)
+ expect(connectionStep?.content).toBe('steps/direct-connection')
})
test('skills install step should have valid content path', () => {
@@ -236,31 +353,50 @@ describe('connect.schema:step content paths', () => {
expect(skillsStep?.content).toBe('steps/skills-install')
})
- test('orm configure step should not be resolved', () => {
+ test('orm configure step should use template content path', () => {
+ // The ORM configure step uses a template {{orm}} that gets resolved
+ // by the dynamic import system, not the resolver
const state: ConnectState = { mode: 'orm', orm: 'prisma' }
const steps = resolveSteps(connectSchema, state)
+ const configureStep = steps.find((s) => s.id === 'configure')
- expect(steps.length).toBe(0)
+ // The content path uses template syntax for the component loader
+ expect(configureStep?.content).toBe('{{orm}}')
})
- test('mcp cursor configure step should not be resolved', () => {
+ test('mcp cursor configure step should have valid content path', () => {
const state: ConnectState = { mode: 'mcp', mcpClient: 'cursor' }
const steps = resolveSteps(connectSchema, state)
+ const configureStep = steps.find((s) => s.id === 'configure-mcp')
- expect(steps.length).toBe(0)
+ expect(configureStep?.content).toBe('steps/mcp/cursor')
})
- test('codex steps should not be resolved', () => {
+ test('codex steps should have valid content paths', () => {
const state: ConnectState = { mode: 'mcp', mcpClient: 'codex' }
const steps = resolveSteps(connectSchema, state)
- expect(steps.length).toBe(0)
+ expect(steps.find((s) => s.id === 'codex-add-server')?.content).toBe(
+ 'steps/mcp/codex/add-server'
+ )
+ expect(steps.find((s) => s.id === 'codex-enable-remote')?.content).toBe(
+ 'steps/mcp/codex/enable-remote'
+ )
+ expect(steps.find((s) => s.id === 'codex-authenticate')?.content).toBe(
+ 'steps/mcp/codex/authenticate'
+ )
+ expect(steps.find((s) => s.id === 'codex-verify')?.content).toBe('steps/mcp/codex/verify')
})
- test('claude-code steps should not be resolved', () => {
+ test('claude-code steps should have valid content paths', () => {
const state: ConnectState = { mode: 'mcp', mcpClient: 'claude-code' }
const steps = resolveSteps(connectSchema, state)
- expect(steps.length).toBe(0)
+ expect(steps.find((s) => s.id === 'claude-add-server')?.content).toBe(
+ 'steps/mcp/claude-code/add-server'
+ )
+ expect(steps.find((s) => s.id === 'claude-authenticate')?.content).toBe(
+ 'steps/mcp/claude-code/authenticate'
+ )
})
})
diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts
index ed4f9d83ee9c8..76ecd8ecc1c11 100644
--- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts
@@ -1,63 +1,15 @@
-import { FRAMEWORKS, MOBILES } from './Connect.constants'
-import type { ConnectSchema, ConnectState, FieldOption, StepDefinition } from './Connect.types'
-
-const frameworkOptions: FieldOption[] = [...FRAMEWORKS, ...MOBILES].map((framework) => ({
- value: framework.key,
- label: framework.label,
- icon: framework.icon,
-}))
-
-const modeOptions: FieldOption[] = [
- {
- value: 'framework',
- label: 'Framework',
- description: 'Use a client library',
- },
-]
-
-const getFrameworkVariantOptions = (state: ConnectState): FieldOption[] => {
- const allFrameworks = [...FRAMEWORKS, ...MOBILES]
- const selected = allFrameworks.find((framework) => framework.key === state.framework)
- if (!selected?.children?.length) return []
- if (selected.children.length <= 1) return []
-
- return selected.children.map((variant) => ({
- value: variant.key,
- label: variant.label,
- icon: variant.icon,
- }))
-}
-
-const getLibraryOptions = (state: ConnectState): FieldOption[] => {
- const allFrameworks = [...FRAMEWORKS, ...MOBILES]
- const selectedFramework = allFrameworks.find((framework) => framework.key === state.framework)
- if (!selectedFramework) return []
-
- if (selectedFramework.children?.length > 1 && state.frameworkVariant) {
- const variant = selectedFramework.children.find((child) => child.key === state.frameworkVariant)
- if (variant?.children?.length) {
- return variant.children.map((child) => ({
- value: child.key,
- label: child.label,
- icon: child.icon,
- }))
- }
- }
-
- if (selectedFramework.children?.length === 1) {
- const child = selectedFramework.children[0]
- if (child.children?.length) {
- return child.children.map((library) => ({
- value: library.key,
- label: library.label,
- icon: library.icon,
- }))
- }
-
- return [{ value: child.key, label: child.label, icon: child.icon }]
- }
-
- return []
+import type { ConnectSchema, StepDefinition } from './Connect.types'
+
+/**
+ * Install commands for different packages
+ */
+export const INSTALL_COMMANDS: Record = {
+ supabasejs: 'npm install @supabase/supabase-js',
+ supabasepy: 'pip install supabase',
+ supabaseflutter: 'flutter pub add supabase_flutter',
+ supabaseswift:
+ 'swift package add-dependency https://github.com/supabase-community/supabase-swift',
+ supabasekt: 'implementation("io.github.jan-tennert.supabase:supabase-kt:VERSION")',
}
// ============================================================================
@@ -108,6 +60,92 @@ const frameworkShadcnExploreStep: StepDefinition = {
content: 'steps/shadcn/explore',
}
+const directConnectionStep: StepDefinition = {
+ id: 'connection',
+ title: 'Connection string',
+ description: 'Copy the connection details for your database.',
+ content: 'steps/direct-connection',
+}
+
+const directInstallStep: StepDefinition = {
+ id: 'direct-install',
+ title: 'Install dependencies',
+ description: 'Run this command to install the required dependencies.',
+ content: 'steps/direct-install',
+}
+
+const directFilesStep: StepDefinition = {
+ id: 'direct-files',
+ title: 'Add files',
+ description: 'Add the following files to your project.',
+ content: 'steps/direct-files',
+}
+
+const mcpConfigureStep: StepDefinition = {
+ id: 'configure-mcp',
+ title: 'Configure MCP',
+ description: 'Set up your MCP client.',
+ content: 'steps/mcp/cursor',
+}
+
+// Codex-specific MCP steps
+const codexAddServerStep: StepDefinition = {
+ id: 'codex-add-server',
+ title: 'Add the Supabase MCP server to Codex',
+ description: 'Run this command to add the server.',
+ content: 'steps/mcp/codex/add-server',
+}
+
+const codexEnableRemoteStep: StepDefinition = {
+ id: 'codex-enable-remote',
+ title: 'Enable remote MCP client support',
+ description: 'Add this to your ~/.codex/config.toml file.',
+ content: 'steps/mcp/codex/enable-remote',
+}
+
+const codexAuthenticateStep: StepDefinition = {
+ id: 'codex-authenticate',
+ title: 'Authenticate',
+ description: 'Run the authentication command.',
+ content: 'steps/mcp/codex/authenticate',
+}
+
+const codexVerifyStep: StepDefinition = {
+ id: 'codex-verify',
+ title: 'Verify authentication',
+ description: 'Run /mcp inside Codex to verify.',
+ content: 'steps/mcp/codex/verify',
+}
+
+const claudeAddServerStep: StepDefinition = {
+ id: 'claude-add-server',
+ title: 'Add MCP server',
+ description: 'Add the MCP server to your project config using the command line.',
+ content: 'steps/mcp/claude-code/add-server',
+}
+
+const claudeAuthenticateStep: StepDefinition = {
+ id: 'claude-authenticate',
+ title: 'Authenticate',
+ description:
+ 'After configuring the MCP server, you need to authenticate. In a regular terminal (not the IDE extension) run:',
+ content: 'steps/mcp/claude-code/authenticate',
+}
+
+const ormInstallStep: StepDefinition = {
+ id: 'install',
+ title: 'Install ORM',
+ description: 'Add the ORM to your project.',
+ content: 'steps/orm-install',
+}
+
+const ormConfigureStep: StepDefinition = {
+ id: 'configure',
+ title: 'Configure ORM',
+ description: 'Set up your ORM configuration.',
+ content: '{{orm}}',
+}
+
const skillsInstallStep: StepDefinition = {
id: 'install-skills',
title: 'Install Agent Skills (Optional)',
@@ -121,41 +159,62 @@ const skillsInstallStep: StepDefinition = {
// ============================================================================
export const connectSchema: ConnectSchema = {
+ // -------------------------------------------------------------------------
+ // Mode Definitions
+ // -------------------------------------------------------------------------
+ modes: [
+ {
+ id: 'framework',
+ label: 'Framework',
+ description: 'Use a client library',
+ fields: ['framework', 'frameworkVariant', 'library', 'frameworkUi'],
+ },
+ {
+ id: 'direct',
+ label: 'Direct',
+ description: 'Connection string',
+ fields: ['connectionMethod', 'useSharedPooler', 'connectionType'],
+ },
+ {
+ id: 'orm',
+ label: 'ORM',
+ description: 'Third-party library',
+ fields: ['orm'],
+ },
+ {
+ id: 'mcp',
+ label: 'MCP',
+ description: 'Connect your agent',
+ fields: ['mcpClient', 'mcpReadonly', 'mcpFeatures'],
+ },
+ ],
+
// -------------------------------------------------------------------------
// Field Definitions
// -------------------------------------------------------------------------
fields: {
- mode: {
- id: 'mode',
- type: 'radio-list',
- label: 'Mode',
- options: modeOptions,
- defaultValue: 'framework',
- },
// Framework fields
framework: {
id: 'framework',
type: 'select',
label: 'Framework',
- options: frameworkOptions,
+ options: { source: 'frameworks' },
defaultValue: 'nextjs',
- dependsOn: { mode: ['framework'] },
},
frameworkVariant: {
id: 'frameworkVariant',
type: 'select',
label: 'Variant',
- options: getFrameworkVariantOptions,
- defaultValue: 'vite',
- dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] }, // Only show for frameworks with multiple variants
+ options: { source: 'frameworkVariants' },
+ defaultValue: 'app',
+ dependsOn: { framework: ['nextjs', 'react'] }, // Only show for frameworks with multiple variants
},
library: {
id: 'library',
type: 'select',
label: 'Library',
- options: getLibraryOptions,
+ options: { source: 'libraries' },
defaultValue: 'supabasejs',
- dependsOn: { mode: ['framework'] },
},
frameworkUi: {
id: 'frameworkUi',
@@ -163,7 +222,65 @@ export const connectSchema: ConnectSchema = {
label: 'Shadcn',
description: 'Install components via the Supabase shadcn registry.',
defaultValue: false,
- dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] },
+ dependsOn: { framework: ['nextjs', 'react'] },
+ },
+
+ // Direct connection fields
+ connectionMethod: {
+ id: 'connectionMethod',
+ type: 'radio-list',
+ label: 'Connection Method',
+ options: { source: 'connectionMethods' },
+ defaultValue: 'direct',
+ },
+ useSharedPooler: {
+ id: 'useSharedPooler',
+ type: 'switch',
+ label: 'Use IPv4 connection (Shared Pooler)',
+ description: 'Recommended when your network does not support IPv6',
+ defaultValue: false,
+ dependsOn: { connectionMethod: ['transaction'] },
+ },
+ connectionType: {
+ id: 'connectionType',
+ type: 'select',
+ label: 'Type',
+ options: { source: 'connectionTypes' },
+ defaultValue: 'uri',
+ },
+
+ // ORM fields
+ orm: {
+ id: 'orm',
+ type: 'radio-list',
+ label: 'ORM',
+ options: { source: 'orms' },
+ defaultValue: 'prisma',
+ },
+
+ // MCP fields
+ mcpClient: {
+ id: 'mcpClient',
+ type: 'select',
+ label: 'Client',
+ description: 'Choose the MCP client you are using.',
+ options: { source: 'mcpClients' },
+ defaultValue: 'cursor',
+ },
+ mcpReadonly: {
+ id: 'mcpReadonly',
+ type: 'switch',
+ label: 'Read-only',
+ description: 'Only allow read operations on your database',
+ defaultValue: false,
+ },
+ mcpFeatures: {
+ id: 'mcpFeatures',
+ type: 'multi-select',
+ label: 'Feature groups',
+ description:
+ 'Only enable a subset of features. Helps keep the number of tools within MCP client limits.',
+ options: { source: 'mcpFeatures' },
},
},
@@ -200,6 +317,31 @@ export const connectSchema: ConnectSchema = {
DEFAULT: [frameworkInstallStep, frameworkConfigureStep, skillsInstallStep],
},
},
+ direct: {
+ connectionType: {
+ nodejs: [directInstallStep, directFilesStep, skillsInstallStep],
+ golang: [directInstallStep, directFilesStep, skillsInstallStep],
+ dotnet: [directInstallStep, directFilesStep, skillsInstallStep],
+ python: [directInstallStep, directFilesStep, skillsInstallStep],
+ sqlalchemy: [directInstallStep, directFilesStep, skillsInstallStep],
+ DEFAULT: [directConnectionStep, skillsInstallStep],
+ },
+ },
+ orm: [ormInstallStep, ormConfigureStep, skillsInstallStep],
+ mcp: {
+ mcpClient: {
+ codex: [
+ codexAddServerStep,
+ codexEnableRemoteStep,
+ codexAuthenticateStep,
+ codexVerifyStep,
+ skillsInstallStep,
+ ],
+ 'claude-code': [claudeAddServerStep, claudeAuthenticateStep, skillsInstallStep],
+ DEFAULT: [mcpConfigureStep, skillsInstallStep],
+ },
+ },
+ DEFAULT: [skillsInstallStep],
},
},
}
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx
new file mode 100644
index 0000000000000..9be84484d59e8
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx
@@ -0,0 +1,64 @@
+import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+const ContentFile = ({ connectionStringPooler }: StepContentProps) => {
+ const files = [
+ {
+ name: '.env',
+ language: 'bash',
+ code:
+ connectionStringPooler.ipv4SupportedForDedicatedPooler &&
+ connectionStringPooler.transactionDedicated
+ ? `
+DATABASE_URL="${connectionStringPooler.transactionDedicated}"
+ `
+ : connectionStringPooler.transactionDedicated &&
+ !connectionStringPooler.ipv4SupportedForDedicatedPooler
+ ? `
+# Use Shared connection pooler (supports both IPv4/IPv6)
+DATABASE_URL="${connectionStringPooler.transactionShared}"
+
+# If your network supports IPv6 or you purchased IPv4 addon, use dedicated pooler
+# DATABASE_URL="${connectionStringPooler.transactionDedicated}"
+ `
+ : `
+DATABASE_URL="${connectionStringPooler.transactionShared}"
+`,
+ },
+ {
+ name: 'drizzle/schema.ts',
+ language: 'tsx',
+ code: `
+import { pgTable, serial, text, varchar } from "drizzle-orm/pg-core";
+
+export const users = pgTable('users', {
+ id: serial('id').primaryKey(),
+ fullName: text('full_name'),
+ phone: varchar('phone', { length: 256 }),
+});
+ `,
+ },
+ {
+ name: 'index.tsx',
+ language: 'tsx',
+ code: `
+import { drizzle } from 'drizzle-orm/postgres-js'
+import postgres from 'postgres'
+import { users } from './drizzle/schema'
+
+const connectionString = process.env.DATABASE_URL
+
+// Disable prefetch as it is not supported for "Transaction" pool mode
+const client = postgres(connectionString, { prepare: false })
+const db = drizzle(client);
+
+const allUsers = await db.select().from(users);
+ `,
+ },
+ ]
+
+ return
+}
+
+export default ContentFile
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx
new file mode 100644
index 0000000000000..9ff6346fe5517
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx
@@ -0,0 +1,62 @@
+import { IS_PLATFORM } from 'lib/constants'
+import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+const ContentFile = ({ connectionStringPooler }: StepContentProps) => {
+ const files = [
+ {
+ name: '.env.local',
+ language: 'bash',
+ code:
+ connectionStringPooler.ipv4SupportedForDedicatedPooler &&
+ connectionStringPooler.transactionDedicated
+ ? `
+# Connect to Supabase via connection pooling.
+DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true"
+
+# Direct connection to the database. Used for migrations.
+DIRECT_URL="${connectionStringPooler.sessionDedicated}"
+ `
+ : connectionStringPooler.transactionDedicated &&
+ !connectionStringPooler.ipv4SupportedForDedicatedPooler
+ ? `
+# Connect to Supabase via Shared Connection Pooler
+DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true"
+
+# Direct connection to the database through Shared Pooler (supports IPv4/IPv6). Used for migrations.
+DIRECT_URL="${connectionStringPooler.sessionShared}"
+
+# If your network supports IPv6 or you purchased IPv4 addon, use dedicated pooler
+# DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true"
+# DIRECT_URL="${connectionStringPooler.sessionDedicated}"
+ `
+ : `
+# Connect to Supabase ${IS_PLATFORM ? 'via connection pooling' : ''}
+DATABASE_URL="${IS_PLATFORM ? `${connectionStringPooler.transactionShared}?pgbouncer=true` : connectionStringPooler.direct}"
+
+# Direct connection to the database. Used for migrations
+DIRECT_URL="${IS_PLATFORM ? connectionStringPooler.sessionShared : connectionStringPooler.direct}"
+`,
+ },
+ {
+ name: 'prisma/schema.prisma',
+ language: 'bash',
+ code: `
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+ directUrl = env("DIRECT_URL")
+}
+ `,
+ },
+ ]
+
+ return
+}
+
+export default ContentFile
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx
new file mode 100644
index 0000000000000..261fdb5899790
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx
@@ -0,0 +1,93 @@
+import { useMemo } from 'react'
+import { CodeBlock } from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+
+import {
+ type ConnectionStringMethod,
+ type DatabaseConnectionType,
+} from '@/components/interfaces/ConnectSheet/Connect.constants'
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters'
+import {
+ buildConnectionParameters,
+ buildSafeConnectionString,
+ parseConnectionParams,
+ PASSWORD_PLACEHOLDER,
+ resolveConnectionString,
+} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
+
+const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) =>
+ `psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}`
+
+const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) =>
+ `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}`
+
+/**
+ * Step component for direct database connections.
+ * Uses state to determine which connection string to show.
+ */
+function DirectConnectionContent({ state, connectionStringPooler }: StepContentProps) {
+ const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
+ const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
+ const useSharedPooler = Boolean(state.useSharedPooler)
+
+ // Determine which connection string to use
+ const resolvedConnectionString = useMemo(
+ () =>
+ resolveConnectionString({
+ connectionMethod,
+ useSharedPooler,
+ connectionStringPooler,
+ }),
+ [connectionMethod, useSharedPooler, connectionStringPooler]
+ )
+
+ const connectionParams = useMemo(
+ () => parseConnectionParams(resolvedConnectionString),
+ [resolvedConnectionString]
+ )
+
+ const safeConnectionString = useMemo(
+ () => buildSafeConnectionString(resolvedConnectionString, connectionParams),
+ [resolvedConnectionString, connectionParams]
+ )
+
+ const connectionString = useMemo(() => {
+ switch (connectionType) {
+ case 'psql':
+ return buildPsqlCommand(connectionParams)
+ case 'jdbc':
+ return buildJdbcString(connectionParams)
+ case 'php':
+ return `DATABASE_URL=${safeConnectionString}`
+ case 'uri':
+ default:
+ return safeConnectionString
+ }
+ }, [connectionType, connectionParams, safeConnectionString])
+
+ if (!resolvedConnectionString) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ {connectionString}
+
+
+
+ )
+}
+
+export default DirectConnectionContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-files/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-files/content.tsx
new file mode 100644
index 0000000000000..e77eea6f6af8f
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-files/content.tsx
@@ -0,0 +1,264 @@
+import { useEffect, useMemo, useState } from 'react'
+import { CodeBlock } from 'ui'
+import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock'
+import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+
+import {
+ type ConnectionStringMethod,
+ type DatabaseConnectionType,
+} from '@/components/interfaces/ConnectSheet/Connect.constants'
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters'
+import {
+ buildConnectionParameters,
+ buildSafeConnectionString,
+ parseConnectionParams,
+ PASSWORD_PLACEHOLDER,
+ resolveConnectionString,
+} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
+
+const DOTNET_CONFIG_COMMAND =
+ 'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION'
+
+type DirectFilesConfig = {
+ files: {
+ name: string
+ language?: string
+ code: string
+ }[]
+ connectionStringFile?: string
+ postCommands?: { label: string; command: string }[]
+}
+
+function DirectFilesContent({ state, connectionStringPooler }: StepContentProps) {
+ const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
+ const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
+ const useSharedPooler = Boolean(state.useSharedPooler)
+
+ const resolvedConnectionString = useMemo(
+ () =>
+ resolveConnectionString({
+ connectionMethod,
+ useSharedPooler,
+ connectionStringPooler,
+ }),
+ [connectionMethod, useSharedPooler, connectionStringPooler]
+ )
+
+ const connectionParams = useMemo(
+ () => parseConnectionParams(resolvedConnectionString),
+ [resolvedConnectionString]
+ )
+
+ const safeConnectionString = useMemo(
+ () => buildSafeConnectionString(resolvedConnectionString, connectionParams),
+ [resolvedConnectionString, connectionParams]
+ )
+
+ const config: DirectFilesConfig | null = useMemo(() => {
+ const envFile = {
+ name: '.env',
+ language: 'bash',
+ code: `DATABASE_URL=${safeConnectionString}`,
+ }
+
+ switch (connectionType) {
+ case 'nodejs':
+ return {
+ files: [
+ {
+ name: 'db.js',
+ language: 'js',
+ code: `import postgres from 'postgres'
+
+const connectionString = process.env.DATABASE_URL
+const sql = postgres(connectionString)
+
+export default sql`,
+ },
+ envFile,
+ ],
+ connectionStringFile: envFile.name,
+ }
+
+ case 'golang':
+ return {
+ files: [
+ {
+ name: 'main.go',
+ language: 'go',
+ code: `package main
+
+import (
+\t"context"
+\t"log"
+\t"os"
+\t"github.com/jackc/pgx/v5"
+)
+
+func main() {
+\tconn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL"))
+\tif err != nil {
+\t\tlog.Fatalf("Failed to connect to the database: %v", err)
+\t}
+\tdefer conn.Close(context.Background())
+
+\t// Example query to test connection
+\tvar version string
+\tif err := conn.QueryRow(context.Background(), "SELECT version()").Scan(&version); err != nil {
+\t\tlog.Fatalf("Query failed: %v", err)
+\t}
+
+\tlog.Println("Connected to:", version)
+}`,
+ },
+ envFile,
+ ],
+ connectionStringFile: envFile.name,
+ }
+
+ case 'dotnet':
+ return {
+ files: [
+ {
+ name: 'appsettings.json',
+ language: 'json',
+ code: `{
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=${connectionParams.host};Database=${connectionParams.database};Username=${connectionParams.user};Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true"
+ }
+}`,
+ },
+ ],
+ connectionStringFile: 'appsettings.json',
+ postCommands: [
+ {
+ label: 'Add the configuration package to read the settings.',
+ command: DOTNET_CONFIG_COMMAND,
+ },
+ ],
+ }
+
+ case 'python':
+ return {
+ files: [
+ {
+ name: 'main.py',
+ language: 'python',
+ code: `import psycopg2
+from dotenv import load_dotenv
+import os
+
+# Load environment variables from .env
+load_dotenv()
+
+# Fetch variables
+DATABASE_URL = os.getenv("DATABASE_URL")
+
+# Connect to the database
+connection = psycopg2.connect(DATABASE_URL)`,
+ },
+ envFile,
+ ],
+ connectionStringFile: envFile.name,
+ }
+
+ case 'sqlalchemy':
+ return {
+ files: [
+ {
+ name: 'main.py',
+ language: 'python',
+ code: `from sqlalchemy import create_engine
+# from sqlalchemy.pool import NullPool
+from dotenv import load_dotenv
+import os
+
+# Load environment variables from .env
+load_dotenv()
+
+# Fetch variables
+USER = os.getenv("user")
+PASSWORD = os.getenv("password")
+HOST = os.getenv("host")
+PORT = os.getenv("port")
+DBNAME = os.getenv("dbname")
+
+# Construct the SQLAlchemy connection string
+DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require"
+
+# Create the SQLAlchemy engine
+engine = create_engine(DATABASE_URL)
+# If using Transaction Pooler or Session Pooler, we want to ensure we disable SQLAlchemy client side pooling -
+# https://docs.sqlalchemy.org/en/20/core/pooling.html#switching-pool-implementations
+# engine = create_engine(DATABASE_URL, poolclass=NullPool)
+
+# Test the connection
+try:
+ with engine.connect() as connection:
+ print("Connection successful!")
+except Exception as e:
+ print(f"Failed to connect: {e}")`,
+ },
+ {
+ name: '.env',
+ language: 'bash',
+ code: [
+ `user=${connectionParams.user}`,
+ `password=${PASSWORD_PLACEHOLDER}`,
+ `host=${connectionParams.host}`,
+ `port=${connectionParams.port}`,
+ `dbname=${connectionParams.database}`,
+ ].join('\n'),
+ },
+ ],
+ connectionStringFile: '.env',
+ }
+
+ default:
+ return null
+ }
+ }, [connectionType, safeConnectionString, connectionParams])
+
+ const defaultFile = config?.files[0]?.name ?? ''
+ const [activeFile, setActiveFile] = useState(defaultFile)
+
+ useEffect(() => {
+ setActiveFile(defaultFile)
+ }, [connectionType, defaultFile])
+
+ if (!resolvedConnectionString) {
+ return (
+
+
+
+ )
+ }
+
+ if (!config?.files.length) {
+ return null
+ }
+
+ return (
+
+
+
+ {(config.postCommands ?? []).map((command) => (
+
+
{command.label}
+
+ {command.command}
+
+
+ ))}
+
+ )
+}
+
+export default DirectFilesContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-install/content.tsx
new file mode 100644
index 0000000000000..67244a567036d
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-install/content.tsx
@@ -0,0 +1,36 @@
+import { CodeBlock } from 'ui'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+import examples from '@/components/interfaces/ConnectSheet/DirectConnectionExamples'
+
+/**
+ * Step component for direct connection install commands.
+ */
+function DirectInstallContent({ state }: StepContentProps) {
+ const connectionType = (state.connectionType as string) ?? 'uri'
+ const example = examples[connectionType as keyof typeof examples]
+ const exampleInstallCommands = example?.installCommands ?? []
+
+ if (exampleInstallCommands.length === 0) {
+ return null
+ }
+
+ return (
+
+ {exampleInstallCommands.map((cmd) => (
+
+ {cmd}
+
+ ))}
+
+ )
+}
+
+export default DirectInstallContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx
index 15d84d9a93a59..5ed41d483cb42 100644
--- a/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx
@@ -2,14 +2,15 @@ import { Copy } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Button, copyToClipboard } from 'ui'
-import { INSTALL_COMMANDS } from '../../../Connect.constants'
-import type { StepContentProps } from '../../../Connect.types'
+import { INSTALL_COMMANDS } from '@/components/interfaces/ConnectSheet/connect.schema'
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+import { resolveFrameworkLibraryKey } from '@/components/interfaces/ConnectSheet/Connect.utils'
/**
* Gets the install command for the current framework selection.
*/
function getInstallCommand(state: StepContentProps['state']): string | null {
- const libraryKey = typeof state.library === 'string' ? state.library : null
+ const libraryKey = resolveFrameworkLibraryKey(state)
if (libraryKey && INSTALL_COMMANDS[libraryKey]) return INSTALL_COMMANDS[libraryKey]
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/add-server/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/add-server/content.tsx
new file mode 100644
index 0000000000000..9a392c17cbd98
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/add-server/content.tsx
@@ -0,0 +1,20 @@
+import { CodeBlock } from 'ui'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+import { useMcpUrl } from '@/components/interfaces/ConnectSheet/useMcpUrl'
+
+function ClaudeAddServerContent({ state, projectKeys }: StepContentProps) {
+ const mcpUrl = useMcpUrl(state, projectKeys)
+ const command = `claude mcp add --scope project --transport http supabase "${mcpUrl}"`
+
+ return (
+
+ )
+}
+
+export default ClaudeAddServerContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/authenticate/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/authenticate/content.tsx
new file mode 100644
index 0000000000000..c8f91c803a508
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/authenticate/content.tsx
@@ -0,0 +1,22 @@
+import { CodeBlock } from 'ui'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+function ClaudeAuthenticateContent(_props: StepContentProps) {
+ return (
+
+
+
+ Select the supabase{' '}
+ server, then Authenticate to begin the flow.
+
+
+ )
+}
+
+export default ClaudeAuthenticateContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/add-server/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/add-server/content.tsx
new file mode 100644
index 0000000000000..c6e40aba273e2
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/add-server/content.tsx
@@ -0,0 +1,20 @@
+import { CodeBlock } from 'ui'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+import { useMcpUrl } from '@/components/interfaces/ConnectSheet/useMcpUrl'
+
+function CodexAddServerContent({ state, projectKeys }: StepContentProps) {
+ const mcpUrl = useMcpUrl(state, projectKeys)
+ const command = `codex mcp add supabase --url ${mcpUrl}`
+
+ return (
+
+ )
+}
+
+export default CodexAddServerContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/authenticate/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/authenticate/content.tsx
new file mode 100644
index 0000000000000..ad41be2d3fca5
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/authenticate/content.tsx
@@ -0,0 +1,18 @@
+import { CodeBlock } from 'ui'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+function CodexAuthenticateContent(_props: StepContentProps) {
+ const command = 'codex mcp login supabase'
+
+ return (
+
+ )
+}
+
+export default CodexAuthenticateContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/enable-remote/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/enable-remote/content.tsx
new file mode 100644
index 0000000000000..072c056f9416f
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/enable-remote/content.tsx
@@ -0,0 +1,19 @@
+import { CodeBlock } from 'ui'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+function CodexEnableRemoteContent(_props: StepContentProps) {
+ const configContent = `[mcp]
+remote_mcp_client_enabled = true`
+
+ return (
+
+ )
+}
+
+export default CodexEnableRemoteContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/verify/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/verify/content.tsx
new file mode 100644
index 0000000000000..6c05f23d66a69
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/verify/content.tsx
@@ -0,0 +1,14 @@
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+function CodexVerifyContent(_props: StepContentProps) {
+ return (
+
+
+ Run /mcp inside Codex to
+ verify authentication.
+
+
+ )
+}
+
+export default CodexVerifyContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/cursor/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/cursor/content.tsx
new file mode 100644
index 0000000000000..86d669119952d
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/cursor/content.tsx
@@ -0,0 +1,90 @@
+import { useParams } from 'common'
+import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import { useTrack } from 'lib/telemetry/track'
+import { useMemo } from 'react'
+import {
+ createMcpCopyHandler,
+ FEATURE_GROUPS_NON_PLATFORM,
+ FEATURE_GROUPS_PLATFORM,
+ getMcpUrl,
+ MCP_CLIENTS,
+ McpConfigurationDisplay,
+} from 'ui-patterns/McpUrlBuilder'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+function McpCursorContent({ state, projectKeys }: StepContentProps) {
+ const { ref: projectRef } = useParams()
+
+ if (!projectRef) {
+ return (
+
+
+
+
+ )
+ }
+
+ return
+}
+
+function McpCursorContentInner({
+ projectRef,
+ projectKeys,
+ state,
+}: {
+ projectRef: string
+ projectKeys: StepContentProps['projectKeys']
+ state: StepContentProps['state']
+}) {
+ const track = useTrack()
+
+ const selectedClient = useMemo(() => {
+ const clientKey = String(state.mcpClient ?? '')
+ return MCP_CLIENTS.find((c) => c.key === clientKey) ?? MCP_CLIENTS[0]
+ }, [state.mcpClient])
+
+ const readonly = Boolean(state.mcpReadonly)
+ const selectedFeatures = Array.isArray(state.mcpFeatures) ? state.mcpFeatures : []
+
+ const selectedFeaturesSupported = useMemo(() => {
+ const supportedFeatures = IS_PLATFORM ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM
+ return selectedFeatures.filter((feature) =>
+ supportedFeatures.some((group) => group.id === feature)
+ )
+ }, [selectedFeatures])
+
+ const handleCopy = useMemo(
+ () =>
+ createMcpCopyHandler({
+ selectedClient,
+ source: 'studio',
+ onTrack: (event) => track(event.action, event.properties, event.groups),
+ projectRef,
+ }),
+ [selectedClient, track, projectRef]
+ )
+
+ const { clientConfig } = getMcpUrl({
+ projectRef,
+ isPlatform: IS_PLATFORM,
+ apiUrl: projectKeys.apiUrl ?? undefined,
+ readonly,
+ features: selectedFeaturesSupported,
+ selectedClient,
+ })
+
+ return (
+
+ )
+}
+
+export default McpCursorContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/orm-install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/orm-install/content.tsx
new file mode 100644
index 0000000000000..09610bde4289a
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/orm-install/content.tsx
@@ -0,0 +1,36 @@
+import { CodeBlock } from 'ui'
+
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
+
+const ORM_INSTALL_COMMANDS: Record = {
+ prisma: ['npm install prisma --save-dev', 'npx prisma init'],
+ drizzle: ['npm install drizzle-orm', 'npm install drizzle-kit --save-dev'],
+}
+
+function OrmInstallContent({ state }: StepContentProps) {
+ const ormKey = String(state.orm ?? '')
+ const commands = ORM_INSTALL_COMMANDS[ormKey]
+
+ if (!commands?.length) {
+ return null
+ }
+
+ return (
+
+ {commands.map((cmd, index) => (
+
+ {cmd}
+
+ ))}
+
+ )
+}
+
+export default OrmInstallContent
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx
index c8c15e50f90dc..57f8a86b99c8a 100644
--- a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx
@@ -2,7 +2,7 @@ import { Copy } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Button, copyToClipboard } from 'ui'
-import type { StepContentProps } from '../../../../Connect.types'
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
function getShadcnCommand(state: StepContentProps['state']): string | null {
if (state.framework === 'nextjs') {
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/explore/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/explore/content.tsx
index ee3d721072801..4e557636466f2 100644
--- a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/explore/content.tsx
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/explore/content.tsx
@@ -1,7 +1,7 @@
import { ExternalLink } from 'lucide-react'
import { Button } from 'ui'
-import type { StepContentProps } from '../../../../Connect.types'
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
function ShadcnExploreContent(_props: StepContentProps) {
return (
diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx
index 2a0bdfc8f8365..93a844b47086f 100644
--- a/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx
+++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx
@@ -2,7 +2,7 @@ import { Copy } from 'lucide-react'
import { useState } from 'react'
import { Button, copyToClipboard } from 'ui'
-import type { StepContentProps } from '../../../Connect.types'
+import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types'
const SKILLS_COMMAND = 'npx skills add supabase/agent-skills'
diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts
index 652ea244b76f2..d37f7cd731e7c 100644
--- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts
@@ -1,5 +1,6 @@
-import { describe, test, expect } from 'vitest'
-import { renderHook, act } from '@testing-library/react'
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, test } from 'vitest'
+
import { useConnectState } from './useConnectState'
describe('useConnectState', () => {
@@ -30,11 +31,10 @@ describe('useConnectState', () => {
test('should accept initial state override', () => {
const { result } = renderHook(() =>
- useConnectState({ framework: 'react', frameworkVariant: 'vite' })
+ useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
)
- expect(result.current.state.mode).toBe('framework')
- expect(result.current.state.framework).toBe('react')
- expect(result.current.state.frameworkVariant).toBe('vite')
+ expect(result.current.state.mode).toBe('direct')
+ expect(result.current.state.connectionMethod).toBe('transaction')
})
test('should merge initial state with defaults', () => {
@@ -44,6 +44,76 @@ describe('useConnectState', () => {
})
})
+ // ============================================================================
+ // Mode Switching Tests
+ // ============================================================================
+
+ describe('setMode', () => {
+ test('should switch to direct mode', () => {
+ const { result } = renderHook(() => useConnectState())
+
+ act(() => {
+ result.current.setMode('direct')
+ })
+
+ expect(result.current.state.mode).toBe('direct')
+ })
+
+ test('should initialize direct mode defaults when switching', () => {
+ const { result } = renderHook(() => useConnectState())
+
+ act(() => {
+ result.current.setMode('direct')
+ })
+
+ expect(result.current.state.connectionMethod).toBeDefined()
+ expect(result.current.state.connectionType).toBeDefined()
+ })
+
+ test('should switch to orm mode and initialize defaults', () => {
+ const { result } = renderHook(() => useConnectState())
+
+ act(() => {
+ result.current.setMode('orm')
+ })
+
+ expect(result.current.state.mode).toBe('orm')
+ expect(result.current.state.orm).toBe('prisma')
+ })
+
+ test('should switch to mcp mode and initialize defaults', () => {
+ const { result } = renderHook(() => useConnectState())
+
+ act(() => {
+ result.current.setMode('mcp')
+ })
+
+ expect(result.current.state.mode).toBe('mcp')
+ expect(result.current.state.mcpClient).toBeDefined()
+ })
+
+ test('should preserve framework state when switching back to framework mode', () => {
+ const { result } = renderHook(() => useConnectState())
+
+ // Change framework
+ act(() => {
+ result.current.updateField('framework', 'react')
+ })
+
+ // Switch to direct
+ act(() => {
+ result.current.setMode('direct')
+ })
+
+ // Switch back to framework
+ act(() => {
+ result.current.setMode('framework')
+ })
+
+ expect(result.current.state.framework).toBe('react')
+ })
+ })
+
// ============================================================================
// Field Update Tests
// ============================================================================
@@ -98,6 +168,44 @@ describe('useConnectState', () => {
expect(result.current.state.library).toBe('supabasejs')
})
+ test('should update connection method', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
+
+ act(() => {
+ result.current.updateField('connectionMethod', 'transaction')
+ })
+
+ expect(result.current.state.connectionMethod).toBe('transaction')
+ })
+
+ test('should clear useSharedPooler when connectionMethod changes to direct', () => {
+ const { result } = renderHook(() =>
+ useConnectState({
+ mode: 'direct',
+ connectionMethod: 'transaction',
+ useSharedPooler: true,
+ })
+ )
+
+ act(() => {
+ result.current.updateField('connectionMethod', 'direct')
+ })
+
+ // useSharedPooler is cleared because it depends on connectionMethod: ['transaction']
+ // When the dependency is not satisfied, the field is removed from state
+ expect(result.current.state.useSharedPooler).toBeUndefined()
+ })
+
+ test('should update MCP client', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
+
+ act(() => {
+ result.current.updateField('mcpClient', 'codex')
+ })
+
+ expect(result.current.state.mcpClient).toBe('codex')
+ })
+
test('should update boolean fields', () => {
const { result } = renderHook(() => useConnectState())
@@ -107,6 +215,16 @@ describe('useConnectState', () => {
expect(result.current.state.frameworkUi).toBe(true)
})
+
+ test('should update ORM selection', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
+
+ act(() => {
+ result.current.updateField('orm', 'drizzle')
+ })
+
+ expect(result.current.state.orm).toBe('drizzle')
+ })
})
// ============================================================================
@@ -141,6 +259,47 @@ describe('useConnectState', () => {
const fieldIds = result.current.activeFields.map((f) => f.id)
expect(fieldIds).not.toContain('frameworkUi')
})
+
+ test('should return direct mode fields', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
+
+ const fieldIds = result.current.activeFields.map((f) => f.id)
+ expect(fieldIds).toContain('connectionMethod')
+ expect(fieldIds).toContain('connectionType')
+ })
+
+ test('should show useSharedPooler only for transaction connection method', () => {
+ const { result } = renderHook(() =>
+ useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
+ )
+
+ const fieldIds = result.current.activeFields.map((f) => f.id)
+ expect(fieldIds).toContain('useSharedPooler')
+ })
+
+ test('should hide useSharedPooler for direct connection method', () => {
+ const { result } = renderHook(() =>
+ useConnectState({ mode: 'direct', connectionMethod: 'direct' })
+ )
+
+ const fieldIds = result.current.activeFields.map((f) => f.id)
+ expect(fieldIds).not.toContain('useSharedPooler')
+ })
+
+ test('should return orm mode fields', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
+
+ const fieldIds = result.current.activeFields.map((f) => f.id)
+ expect(fieldIds).toContain('orm')
+ })
+
+ test('should return mcp mode fields', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
+
+ const fieldIds = result.current.activeFields.map((f) => f.id)
+ expect(fieldIds).toContain('mcpClient')
+ expect(fieldIds).toContain('mcpReadonly')
+ })
})
// ============================================================================
@@ -161,6 +320,28 @@ describe('useConnectState', () => {
expect(stepIds).toContain('install')
})
+ test('should resolve different steps for mcp mode', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
+
+ const stepIds = result.current.resolvedSteps.map((s) => s.id)
+ // MCP mode should have configure step
+ expect(stepIds.some((id) => id.includes('configure') || id.includes('mcp'))).toBe(true)
+ })
+
+ test('should resolve different steps for different mcp clients', () => {
+ const { result: cursorResult } = renderHook(() =>
+ useConnectState({ mode: 'mcp', mcpClient: 'cursor' })
+ )
+ const { result: codexResult } = renderHook(() =>
+ useConnectState({ mode: 'mcp', mcpClient: 'codex' })
+ )
+
+ // Codex has more steps than cursor
+ expect(codexResult.current.resolvedSteps.length).toBeGreaterThanOrEqual(
+ cursorResult.current.resolvedSteps.length
+ )
+ })
+
test('should include skills install step', () => {
const { result } = renderHook(() => useConnectState())
@@ -168,6 +349,21 @@ describe('useConnectState', () => {
expect(stepIds).toContain('install-skills')
})
+ test('should resolve steps for direct mode', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
+
+ expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
+ })
+
+ test('should resolve steps for orm mode', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
+
+ expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
+ const stepIds = result.current.resolvedSteps.map((s) => s.id)
+ expect(stepIds).toContain('install')
+ expect(stepIds).toContain('configure')
+ })
+
test('should resolve shadcn steps when frameworkUi is true', () => {
const { result } = renderHook(() =>
useConnectState({ framework: 'nextjs', frameworkUi: true })
@@ -183,27 +379,81 @@ describe('useConnectState', () => {
// ============================================================================
describe('getFieldOptions', () => {
- test('should return framework options from schema', () => {
+ test('should return framework options', () => {
const { result } = renderHook(() => useConnectState())
const options = result.current.getFieldOptions('framework')
+ expect(options.length).toBeGreaterThan(0)
expect(options.some((o) => o.value === 'nextjs')).toBe(true)
expect(options.some((o) => o.value === 'react')).toBe(true)
})
- test('should return empty array for inactive field', () => {
+ test('should return variant options for nextjs', () => {
+ const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
+
+ const options = result.current.getFieldOptions('frameworkVariant')
+ expect(options.length).toBeGreaterThan(0)
+ expect(options.some((o) => o.value === 'app')).toBe(true)
+ expect(options.some((o) => o.value === 'pages')).toBe(true)
+ })
+
+ test('should return empty variant options for frameworks without variants', () => {
const { result } = renderHook(() => useConnectState({ framework: 'remix' }))
const options = result.current.getFieldOptions('frameworkVariant')
expect(options).toEqual([])
})
+ test('should return connection method options', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
+
+ const options = result.current.getFieldOptions('connectionMethod')
+ expect(options.length).toBeGreaterThan(0)
+ expect(options.some((o) => o.value === 'direct')).toBe(true)
+ expect(options.some((o) => o.value === 'transaction')).toBe(true)
+ })
+
+ test('should return connection type options', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
+
+ const options = result.current.getFieldOptions('connectionType')
+ expect(options.length).toBeGreaterThan(0)
+ expect(options.some((o) => o.value === 'uri')).toBe(true)
+ expect(options.some((o) => o.value === 'psql')).toBe(true)
+ })
+
+ test('should return ORM options', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
+
+ const options = result.current.getFieldOptions('orm')
+ expect(options.length).toBeGreaterThan(0)
+ expect(options.some((o) => o.value === 'prisma')).toBe(true)
+ expect(options.some((o) => o.value === 'drizzle')).toBe(true)
+ })
+
+ test('should return MCP client options', () => {
+ const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
+
+ const options = result.current.getFieldOptions('mcpClient')
+ expect(options.length).toBeGreaterThan(0)
+ expect(options.some((o) => o.value === 'cursor')).toBe(true)
+ })
+
test('should return empty array for unknown field', () => {
const { result } = renderHook(() => useConnectState())
const options = result.current.getFieldOptions('unknownField')
expect(options).toEqual([])
})
+
+ test('should return library options for selected framework', () => {
+ const { result } = renderHook(() =>
+ useConnectState({ framework: 'nextjs', frameworkVariant: 'app' })
+ )
+
+ const options = result.current.getFieldOptions('library')
+ expect(options.length).toBeGreaterThan(0)
+ })
})
// ============================================================================
@@ -215,14 +465,19 @@ describe('useConnectState', () => {
const { result } = renderHook(() => useConnectState())
expect(result.current.schema).toBeDefined()
+ expect(result.current.schema.modes).toBeDefined()
expect(result.current.schema.fields).toBeDefined()
expect(result.current.schema.steps).toBeDefined()
})
- test('should include mode field in schema', () => {
+ test('should have all expected modes in schema', () => {
const { result } = renderHook(() => useConnectState())
- expect(result.current.schema.fields.mode).toBeDefined()
+ const modeIds = result.current.schema.modes.map((m) => m.id)
+ expect(modeIds).toContain('framework')
+ expect(modeIds).toContain('direct')
+ expect(modeIds).toContain('orm')
+ expect(modeIds).toContain('mcp')
})
})
})
diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts
index 3135891b15798..b330928fede3a 100644
--- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts
+++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts
@@ -1,18 +1,161 @@
import { useCallback, useMemo, useState } from 'react'
+import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
-import { getActiveFields, resolveState, resolveSteps } from './connect.resolver'
+import {
+ connectionStringMethodOptions,
+ DATABASE_CONNECTION_TYPES,
+ FRAMEWORKS,
+ MOBILES,
+ ORMS,
+} from './Connect.constants'
+import {
+ getActiveFields,
+ getDefaultState,
+ resetDependentFields,
+ resolveSteps,
+} from './connect.resolver'
import { connectSchema } from './connect.schema'
import type {
+ ConnectMode,
ConnectSchema,
ConnectState,
FieldOption,
ResolvedField,
ResolvedStep,
} from './Connect.types'
+import { resolveFrameworkLibraryKey } from './Connect.utils'
+
+// ============================================================================
+// Data Source Helpers
+// ============================================================================
+
+/**
+ * Get field options from a data source reference.
+ * This maps source names to actual data.
+ */
+function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOption[] {
+ switch (source) {
+ case 'frameworks':
+ return [...FRAMEWORKS, ...MOBILES].map((f) => ({
+ value: f.key,
+ label: f.label,
+ icon: f.icon,
+ }))
+
+ case 'frameworkVariants': {
+ // Get variants for the selected framework
+ const allFrameworks = [...FRAMEWORKS, ...MOBILES]
+ const selected = allFrameworks.find((f) => f.key === state.framework)
+ if (!selected?.children?.length) return []
+ // Only return if there are multiple children (variants)
+ if (selected.children.length <= 1) return []
+ return selected.children.map((c) => ({
+ value: c.key,
+ label: c.label,
+ icon: c.icon,
+ }))
+ }
+
+ case 'libraries': {
+ // Get libraries for the selected framework and variant
+ const allFrameworks = [...FRAMEWORKS, ...MOBILES]
+ const selectedFramework = allFrameworks.find((f) => f.key === state.framework)
+ if (!selectedFramework) return []
+
+ // If framework has variants, look in the variant
+ if (selectedFramework.children?.length > 1 && state.frameworkVariant) {
+ const variant = selectedFramework.children.find((c) => c.key === state.frameworkVariant)
+ if (variant?.children?.length) {
+ return variant.children.map((c) => ({
+ value: c.key,
+ label: c.label,
+ icon: c.icon,
+ }))
+ }
+ }
+
+ // Otherwise look directly in framework children
+ if (selectedFramework.children?.length === 1) {
+ const child = selectedFramework.children[0]
+ if (child.children?.length) {
+ return child.children.map((c) => ({
+ value: c.key,
+ label: c.label,
+ icon: c.icon,
+ }))
+ }
+ // The child itself is the library
+ return [{ value: child.key, label: child.label, icon: child.icon }]
+ }
+
+ return []
+ }
+
+ case 'connectionMethods':
+ return Object.values(connectionStringMethodOptions).map((m) => ({
+ value: m.value,
+ label: m.label,
+ description: m.description,
+ }))
+
+ case 'connectionTypes':
+ return DATABASE_CONNECTION_TYPES.map((t) => ({
+ value: t.id,
+ label: t.label,
+ }))
+
+ case 'orms':
+ return ORMS.map((o) => ({
+ value: o.key,
+ label: o.label,
+ icon: o.icon,
+ }))
+
+ case 'mcpClients':
+ return MCP_CLIENTS.map((c) => ({
+ value: c.key,
+ label: c.label,
+ icon: c.icon,
+ }))
+
+ case 'mcpFeatures':
+ return FEATURE_GROUPS_PLATFORM.map((f) => ({
+ value: f.id,
+ label: f.name,
+ description: f.description,
+ }))
+
+ default:
+ return []
+ }
+}
+
+/**
+ * Resolve field options, handling both static options and data source references.
+ */
+function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState): FieldOption[] {
+ // If already resolved (from conditional resolution)
+ if (field.resolvedOptions.length > 0) {
+ return field.resolvedOptions
+ }
+
+ // Check if it's a source reference
+ const options = connectSchema.fields[field.id]?.options
+ if (options && typeof options === 'object' && 'source' in options) {
+ return getFieldOptionsFromSource(options.source as string, state)
+ }
+
+ return []
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
export interface UseConnectStateReturn {
state: ConnectState
updateField: (fieldId: string, value: string | boolean | string[]) => void
+ setMode: (mode: ConnectMode) => void
activeFields: ResolvedField[]
resolvedSteps: ResolvedStep[]
getFieldOptions: (fieldId: string) => FieldOption[]
@@ -21,13 +164,121 @@ export interface UseConnectStateReturn {
export function useConnectState(initialState?: Partial): UseConnectStateReturn {
const [state, setState] = useState(() => {
- return resolveState(connectSchema, initialState ?? {})
+ const defaults = getDefaultState(connectSchema)
+
+ // Set initial framework if mode is framework
+ if (defaults.mode === 'framework' && !defaults.framework) {
+ const firstFramework = FRAMEWORKS[0]
+ defaults.framework = firstFramework?.key ?? ''
+
+ // Set initial variant if framework has variants
+ if (firstFramework?.children?.length > 1) {
+ defaults.frameworkVariant = firstFramework.children[0]?.key ?? ''
+ }
+
+ // Set initial library
+ const libraryKey = resolveFrameworkLibraryKey({
+ framework: defaults.framework,
+ frameworkVariant: defaults.frameworkVariant,
+ library: defaults.library,
+ })
+ if (libraryKey) defaults.library = libraryKey
+ }
+
+ // Set initial ORM if mode is orm
+ if (defaults.mode === 'orm' && !defaults.orm) {
+ defaults.orm = ORMS[0]?.key ?? ''
+ }
+
+ // Set initial MCP client if mode is mcp
+ if (defaults.mode === 'mcp' && !defaults.mcpClient) {
+ defaults.mcpClient = MCP_CLIENTS[0]?.key ?? ''
+ }
+
+ return { ...defaults, ...initialState } as ConnectState
})
const updateField = useCallback((fieldId: string, value: string | boolean | string[]) => {
setState((prev) => {
const next = { ...prev, [fieldId]: value }
- return resolveState(connectSchema, next)
+
+ // Handle cascading updates for framework selection
+ if (fieldId === 'framework') {
+ const allFrameworks = [...FRAMEWORKS, ...MOBILES]
+ const selected = allFrameworks.find((f) => f.key === value)
+
+ // Reset variant if framework changed
+ if (selected?.children && selected.children.length > 1) {
+ next.frameworkVariant = selected.children[0]?.key ?? ''
+ } else {
+ delete next.frameworkVariant
+ }
+
+ // Reset library
+ const libraryKey = resolveFrameworkLibraryKey({
+ framework: next.framework,
+ frameworkVariant: next.frameworkVariant,
+ library: next.library,
+ })
+ if (libraryKey) {
+ next.library = libraryKey
+ } else {
+ delete next.library
+ }
+ }
+
+ // Handle cascading updates for variant selection
+ if (fieldId === 'frameworkVariant') {
+ const libraryKey = resolveFrameworkLibraryKey({
+ framework: prev.framework,
+ frameworkVariant: String(value),
+ library: next.library,
+ })
+ if (libraryKey) next.library = libraryKey
+ }
+
+ // Reset useSharedPooler when connectionMethod changes to 'direct'
+ if (fieldId === 'connectionMethod' && value === 'direct') {
+ next.useSharedPooler = false
+ }
+
+ return resetDependentFields(next, fieldId, connectSchema)
+ })
+ }, [])
+
+ const setMode = useCallback((mode: ConnectMode) => {
+ setState((prev) => {
+ const next: ConnectState = { ...prev, mode }
+
+ // Initialize mode-specific defaults
+ if (mode === 'framework' && !next.framework) {
+ const firstFramework = FRAMEWORKS[0]
+ next.framework = firstFramework?.key ?? ''
+ if (firstFramework?.children?.length > 1) {
+ next.frameworkVariant = firstFramework.children[0]?.key ?? ''
+ }
+ const libraryKey = resolveFrameworkLibraryKey({
+ framework: next.framework,
+ frameworkVariant: next.frameworkVariant,
+ library: next.library,
+ })
+ if (libraryKey) next.library = libraryKey
+ }
+
+ if (mode === 'direct') {
+ next.connectionMethod = next.connectionMethod ?? 'direct'
+ next.connectionType = next.connectionType ?? 'uri'
+ }
+
+ if (mode === 'orm' && !next.orm) {
+ next.orm = ORMS[0]?.key ?? ''
+ }
+
+ if (mode === 'mcp' && !next.mcpClient) {
+ next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
+ }
+
+ return next
})
}, [])
@@ -39,14 +290,15 @@ export function useConnectState(initialState?: Partial): UseConnec
(fieldId: string): FieldOption[] => {
const field = activeFields.find((f) => f.id === fieldId)
if (!field) return []
- return field.resolvedOptions
+ return resolveFieldOptionsWithSource(field, state)
},
- [activeFields]
+ [activeFields, state]
)
return {
state,
updateField,
+ setMode,
activeFields,
resolvedSteps,
getFieldOptions,
diff --git a/apps/studio/components/interfaces/ConnectSheet/useMcpUrl.ts b/apps/studio/components/interfaces/ConnectSheet/useMcpUrl.ts
new file mode 100644
index 0000000000000..1843c93595fbf
--- /dev/null
+++ b/apps/studio/components/interfaces/ConnectSheet/useMcpUrl.ts
@@ -0,0 +1,30 @@
+import { IS_PLATFORM } from 'lib/constants'
+import { useMemo } from 'react'
+import { FEATURE_GROUPS_NON_PLATFORM, FEATURE_GROUPS_PLATFORM } from 'ui-patterns/McpUrlBuilder'
+
+import { StepContentProps } from './Connect.types'
+
+export function useMcpUrl(
+ state: StepContentProps['state'],
+ projectKeys: StepContentProps['projectKeys']
+): string {
+ const readonly = Boolean(state.mcpReadonly)
+ const baseUrl = IS_PLATFORM ? 'https://mcp.supabase.com' : projectKeys.apiUrl ?? ''
+
+ return useMemo(() => {
+ const params = new URLSearchParams()
+ if (readonly) params.set('readonly', 'true')
+
+ const selectedFeatures = Array.isArray(state.mcpFeatures) ? state.mcpFeatures : []
+ const supportedFeatures = IS_PLATFORM ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM
+ const validFeatures = selectedFeatures.filter((f) =>
+ supportedFeatures.some((group) => group.id === f)
+ )
+ if (validFeatures.length > 0) {
+ params.set('features', validFeatures.join(','))
+ }
+
+ const queryString = params.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ }, [baseUrl, readonly, state.mcpFeatures])
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
index 78fa236088ed0..74c63ccddcd64 100644
--- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
@@ -24,10 +24,10 @@ import {
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import {
+ getStatusName,
PIPELINE_DISABLE_ALLOWED_FROM,
PIPELINE_ENABLE_ALLOWED_FROM,
PIPELINE_ERROR_MESSAGES,
- getStatusName,
} from './Pipeline.utils'
import { PipelineStatusName } from './Replication.constants'
@@ -57,7 +57,7 @@ export const RowMenu = ({
const { ref: projectRef } = useParams()
const statusName = getStatusName(pipelineStatus)
- const [_, setEdit] = useQueryState(
+ const [, setEdit] = useQueryState(
'edit',
parseAsInteger.withOptions({ history: 'push', clearOnDefault: true })
)
diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx
index 067c075c4d244..b85805474e32b 100644
--- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx
+++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx
@@ -1,15 +1,13 @@
import type { PostgresSchema } from '@supabase/postgres-meta'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
import { toPng, toSvg } from 'html-to-image'
import { Check, Copy, Download, Loader2, Plus } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow'
+
import 'reactflow/dist/style.css'
-import { toast } from 'sonner'
-import { Button } from 'ui'
-import { Admonition } from 'ui-patterns/admonition'
-import { PermissionAction } from '@supabase/shared-types/out/constants'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import AlertError from 'components/ui/AlertError'
@@ -17,22 +15,27 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTablesQuery } from 'data/tables/tables-query'
+import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
-import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
import { tablesToSQL } from 'lib/helpers'
+import { toast } from 'sonner'
import {
+ Button,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
+import { Admonition } from 'ui-patterns/admonition'
+
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
import { TableNode } from './SchemaTableNode'
+
// [Joshen] Persisting logic: Only save positions to local storage WHEN a node is moved OR when explicitly clicked to reset layout
export const SchemaGraph = () => {
@@ -88,7 +91,7 @@ export const SchemaGraph = () => {
})
const schema = (schemas ?? []).find((s) => s.name === selectedSchema)
- const [_, setStoredPositions] = useLocalStorage(
+ const [, setStoredPositions] = useLocalStorage(
LOCAL_STORAGE_KEYS.SCHEMA_VISUALIZER_POSITIONS(ref as string, schema?.id ?? 0),
{}
)
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
index ca5e231a6b1ae..655eb68ea64e9 100644
--- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
+++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
@@ -1,15 +1,15 @@
import { PostgresTrigger } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { includes, sortBy } from 'lodash'
-import { Check, Copy, Edit, Edit2, MoreVertical, Trash, X } from 'lucide-react'
-import Link from 'next/link'
-
import { useParams } from 'common'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { includes, sortBy } from 'lodash'
+import { Check, Copy, Edit, Edit2, MoreVertical, Trash, X } from 'lucide-react'
+import Link from 'next/link'
+import { parseAsJson, parseAsString, useQueryState } from 'nuqs'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import {
@@ -23,25 +23,19 @@ import {
TableCell,
TableRow,
} from 'ui'
+
import { generateTriggerCreateSQL } from './TriggerList.utils'
+import { selectFilterSchema } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
+import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
+import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
interface TriggerListProps {
- schema: string
- filterString: string
- isLocked: boolean
editTrigger: (trigger: PostgresTrigger) => void
duplicateTrigger: (trigger: PostgresTrigger) => void
deleteTrigger: (trigger: PostgresTrigger) => void
}
-export const TriggerList = ({
- schema,
- filterString,
- isLocked,
- editTrigger,
- duplicateTrigger,
- deleteTrigger,
-}: TriggerListProps) => {
+export const TriggerList = ({ editTrigger, duplicateTrigger, deleteTrigger }: TriggerListProps) => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const aiSnap = useAiAssistantStateSnapshot()
@@ -52,15 +46,27 @@ export const TriggerList = ({
'triggers'
)
+ const { selectedSchema: schema } = useQuerySchemaState()
+ const { isSchemaLocked: isLocked } = useIsProtectedSchema({ schema })
+ const [filterString] = useQueryState('search', parseAsString.withDefault(''))
+ const [tablesFilter] = useQueryState(
+ 'tables',
+ parseAsJson(selectFilterSchema.parse).withDefault([])
+ )
+
const { data: triggers } = useDatabaseTriggersQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
- const filteredTriggers = (triggers ?? []).filter(
- (x) =>
- includes(x.name.toLowerCase(), filterString.toLowerCase()) ||
- (x.function_name && includes(x.function_name.toLowerCase(), filterString.toLowerCase()))
- )
+ const filteredTriggers = (triggers ?? []).filter((x) => {
+ const search = filterString?.toLowerCase()
+ const matchesSearch =
+ !search ||
+ x.name.toLowerCase().includes(search) ||
+ (!!x.function_name && includes(x.function_name.toLowerCase(), search))
+ const matchesTables = !tablesFilter?.length || tablesFilter.includes(x.table)
+ return matchesSearch && matchesTables
+ })
const _triggers = sortBy(
filteredTriggers.filter((x) => x.schema == schema),
(trigger) => trigger.name.toLocaleLowerCase()
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx
index 44566dee8b936..9860261e3d588 100644
--- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx
+++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx
@@ -1,17 +1,12 @@
import type { PostgresTrigger } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { DatabaseZap, Search } from 'lucide-react'
-import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
-import { useRef, useState } from 'react'
-import { toast } from 'sonner'
-
import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings'
import { ProtectedSchemaWarning } from 'components/interfaces/Database/ProtectedSchemaWarning'
import { DeleteTrigger } from 'components/interfaces/Database/Triggers/DeleteTrigger'
import { TriggerSheet } from 'components/interfaces/Database/Triggers/TriggerSheet'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
-
import AlertError from 'components/ui/AlertError'
+import { DocsButton } from 'components/ui/DocsButton'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useDatabaseTriggerDeleteMutation } from 'data/database-triggers/database-trigger-delete-mutation'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
@@ -22,28 +17,35 @@ import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsProtectedSchema, useProtectedSchemas } from 'hooks/useProtectedSchemas'
import { DOCS_URL } from 'lib/constants'
-import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
+import { DatabaseZap, Search } from 'lucide-react'
+import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs'
+import { useRef, useState } from 'react'
+import { toast } from 'sonner'
import { useEditorPanelStateSnapshot } from 'state/editor-panel-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { Card, Input, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
import { EmptyStatePresentational } from 'ui-patterns'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
-import { DocsButton } from 'components/ui/DocsButton'
+
import { CreateTriggerButtons } from './CreateTriggerButtons'
import { TriggerList } from './TriggerList'
import { generateTriggerCreateSQL } from './TriggerList.utils'
+import {
+ ReportsSelectFilter,
+ selectFilterSchema,
+} from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
export const TriggersList = () => {
const [selectedTrigger, setSelectedTrigger] = useState()
const deletingTriggerIdRef = useRef(null)
const { data: project } = useSelectedProjectQuery()
- const aiSnap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
- const [filterString, setFilterString] = useQueryState(
- 'search',
- parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
+ const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
+ const [tablesFilter, setTablesFilter] = useQueryState(
+ 'tables',
+ parseAsJson(selectFilterSchema.parse).withDefault([])
)
const isInlineEditorEnabled = useIsInlineEditorEnabled()
@@ -176,6 +178,7 @@ execute function function_name();`)
}
const schemaTriggers = triggers.filter((x) => x.schema === selectedSchema)
+ const tables = Array.from(new Set(schemaTriggers.map((x) => x.table))).sort()
return (
<>
@@ -197,6 +200,13 @@ execute function function_name();`)
className="w-full lg:w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
+ ({ label: type, value: type }))}
+ value={tablesFilter ?? []}
+ onChange={setTablesFilter}
+ showSearch
+ />
)
- const imageUrl = isCMS
- ? blogMetaData.imgThumb ?? ''
- : blogMetaData.imgThumb
- ? blogMetaData.imgThumb.startsWith('/') || blogMetaData.imgThumb.startsWith('http')
- ? blogMetaData.imgThumb
- : `/images/blog/${blogMetaData.imgThumb}`
- : ''
+ const imageUrl = blogMetaData.imgThumb
+ ? blogMetaData.imgThumb.startsWith('/') || blogMetaData.imgThumb.startsWith('http')
+ ? blogMetaData.imgThumb
+ : `/images/blog/${blogMetaData.imgThumb}`
+ : ''
return (
<>
@@ -203,11 +175,7 @@ const BlogPostRenderer = ({