From a7e63f2ad0bcbae044d4d09c3a593d92907798e2 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 4 Feb 2026 14:12:15 +1000 Subject: [PATCH] simplify all the things --- .../ConnectSheet/Connect.constants.ts | 9 + .../interfaces/ConnectSheet/Connect.types.ts | 45 +-- .../ConnectSheet/Connect.utils.test.ts | 223 ------------ .../interfaces/ConnectSheet/Connect.utils.ts | 37 -- .../ConnectSheet/ConnectConfigSection.tsx | 101 +----- .../interfaces/ConnectSheet/ConnectSheet.tsx | 45 +-- .../ConnectSheet/ConnectStepsSection.tsx | 27 +- .../ConnectSheet/DatabaseSettings.utils.ts | 12 +- .../ConnectSheet/FrameworkSelector.tsx | 93 ----- .../ConnectSheet/connect.resolver.test.ts | 330 +++++++++--------- .../ConnectSheet/connect.resolver.ts | 208 ++++++----- .../ConnectSheet/connect.schema.test.ts | 74 ++-- .../interfaces/ConnectSheet/connect.schema.ts | 103 ++++-- .../androidkotlin/supabasekt/content.tsx | 4 +- .../content/astro/supabasejs/content.tsx | 4 +- .../exporeactnative/supabasejs/content.tsx | 4 +- .../content/flask/supabasepy/content.tsx | 4 +- .../flutter/supabaseflutter/content.tsx | 4 +- .../ionicangular/supabasejs/content.tsx | 4 +- .../content/ionicreact/supabasejs/content.tsx | 4 +- .../content/nextjs/app/supabasejs/content.tsx | 4 +- .../nextjs/pages/supabasejs/content.tsx | 4 +- .../content/nuxt/supabasejs/content.tsx | 4 +- .../create-react-app/supabasejs/content.tsx | 4 +- .../content/react/vite/supabasejs/content.tsx | 4 +- .../content/refine/supabasejs/content.tsx | 4 +- .../content/remix/supabasejs/content.tsx | 4 +- .../content/solidjs/supabasejs/content.tsx | 4 +- .../content/steps/install/content.tsx | 5 +- .../content/sveltekit/supabasejs/content.tsx | 4 +- .../content/swift/supabaseswift/content.tsx | 4 +- .../content/tanstack/supabasejs/content.tsx | 4 +- .../content/vuejs/supabasejs/content.tsx | 4 +- .../ConnectSheet/useConnectState.test.ts | 51 +-- .../ConnectSheet/useConnectState.ts | 265 +------------- 35 files changed, 465 insertions(+), 1239 deletions(-) delete mode 100644 apps/studio/components/interfaces/ConnectSheet/Connect.utils.test.ts delete mode 100644 apps/studio/components/interfaces/ConnectSheet/Connect.utils.ts delete mode 100644 apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.constants.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.constants.ts index 51ec0adf146..bb35a22bf84 100644 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.constants.ts +++ b/apps/studio/components/interfaces/ConnectSheet/Connect.constants.ts @@ -12,6 +12,15 @@ export type DatabaseConnectionType = | 'python' | 'sqlalchemy' +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")', +} + export const DATABASE_CONNECTION_TYPES: { id: DatabaseConnectionType label: string diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts index 32d27e6a231..f559f34f627 100644 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts +++ b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts @@ -24,28 +24,6 @@ export interface ConnectionStringPooler { direct?: string } -/** @deprecated Use ConnectionStringPooler instead */ -export type ConnectionStrings = ConnectionStringPooler - -/** @deprecated Use StepContentProps instead */ -export interface ContentFileProps { - projectKeys: { - apiUrl: string - anonKey?: string - publishableKey?: string - } - connectionStringPooler: { - transactionShared: string - sessionShared: string - transactionDedicated?: string - sessionDedicated?: string - ipv4SupportedForDedicatedPooler: boolean - direct?: string - } - connectionTab: 'App Frameworks' | 'Mobile Frameworks' | 'ORMs' - onCopy?: () => void -} - // ============================================================================ // Schema Types - Conditional Resolution // ============================================================================ @@ -76,15 +54,7 @@ export type ConditionalValue = // Schema Types - Modes // ============================================================================ -export type ConnectMode = 'framework' | 'direct' | 'orm' | 'mcp' - -export interface ModeDefinition { - id: ConnectMode - label: string - description: string - icon?: string - fields: string[] // References to field IDs -} +export type ConnectMode = string // ============================================================================ // Schema Types - Fields @@ -99,13 +69,15 @@ export interface FieldOption { description?: string } +export type FieldOptionsResolver = (state: ConnectState) => FieldOption[] + export interface FieldDefinition { id: string type: FieldType label: string description?: string - // Options can be static, or reference a data source, or be conditional - options?: FieldOption[] | { source: string } | ConditionalValue + // Options can be static, conditional, or resolved from state + options?: FieldOption[] | ConditionalValue | FieldOptionsResolver // Only show this field when these state conditions are met dependsOn?: Record // Default value for this field @@ -140,7 +112,6 @@ export type StepFieldValueMap = { // ============================================================================ export interface ConnectSchema { - modes: ModeDefinition[] fields: Record // Steps are fully conditional based on state steps: StepTree @@ -179,9 +150,3 @@ export interface StepContentProps { projectKeys: ProjectKeys connectionStringPooler: ConnectionStringPooler } - -/** @deprecated Use StepContentProps instead */ -export interface StepComponentProps { - state: ConnectState - projectKeys: ProjectKeys -} diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.utils.test.ts deleted file mode 100644 index 62d04774378..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.utils.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { resolveFrameworkLibraryKey } from './Connect.utils' - -describe('Connect.utils:resolveFrameworkLibraryKey', () => { - test('should return null if no framework provided', () => { - const result = resolveFrameworkLibraryKey({ - framework: undefined, - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBeNull() - }) - - test('should return null for empty framework string', () => { - const result = resolveFrameworkLibraryKey({ - framework: '', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBeNull() - }) - - test('should return explicit library if provided', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'nextjs', - frameworkVariant: 'app', - library: 'custom-library', - }) - expect(result).toBe('custom-library') - }) - - test('should resolve library for Next.js App Router', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'nextjs', - frameworkVariant: 'app', - library: undefined, - }) - // Next.js App Router has supabasejs as its library - expect(result).toBe('supabasejs') - }) - - test('should resolve library for Next.js Pages Router', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'nextjs', - frameworkVariant: 'pages', - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should resolve library for React with Vite variant', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'react', - frameworkVariant: 'vite', - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should resolve library for React with Create React App variant', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'react', - frameworkVariant: 'create-react-app', - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should resolve library for framework without variants (Remix)', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'remix', - frameworkVariant: undefined, - library: undefined, - }) - // Remix has single child which is supabasejs - expect(result).toBe('supabasejs') - }) - - test('should resolve library for Flutter', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'flutter', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabaseflutter') - }) - - test('should resolve library for Swift', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'swift', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabaseswift') - }) - - test('should resolve library for Android Kotlin', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'androidkotlin', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasekt') - }) - - test('should resolve library for Flask (Python)', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'flask', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasepy') - }) - - test('should fallback to first variant library when variant not specified for multi-variant framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'nextjs', - frameworkVariant: undefined, - library: undefined, - }) - // Should get library from first variant (app router) - expect(result).toBe('supabasejs') - }) - - test('should return null for unknown framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'unknown-framework', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBeNull() - }) - - test('should handle SvelteKit framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'sveltekit', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Nuxt framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'nuxt', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Vue.js framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'vuejs', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Solid.js framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'solidjs', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Astro framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'astro', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Expo React Native', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'exporeactnative', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Ionic React', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'ionicreact', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Ionic Angular', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'ionicangular', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle Refine framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'refine', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) - - test('should handle TanStack Start framework', () => { - const result = resolveFrameworkLibraryKey({ - framework: 'tanstack', - frameworkVariant: undefined, - library: undefined, - }) - expect(result).toBe('supabasejs') - }) -}) diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.utils.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.utils.ts deleted file mode 100644 index ca4f3a69d55..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { FRAMEWORKS, MOBILES } from './Connect.constants' - -type FieldValue = string | boolean | string[] | undefined - -interface FrameworkLibraryInput { - framework?: FieldValue - frameworkVariant?: FieldValue - library?: FieldValue - [key: string]: FieldValue -} - -export function resolveFrameworkLibraryKey(state: FrameworkLibraryInput): string | null { - const { framework, frameworkVariant, library } = state - - if (!framework) return null - - if (library) return String(library) - - const allFrameworks = [...FRAMEWORKS, ...MOBILES] - const selectedFramework = allFrameworks.find((f) => f.key === framework) - - if (!selectedFramework?.children?.length) return null - - if (frameworkVariant) { - const variant = selectedFramework.children.find((c) => c.key === frameworkVariant) - if (variant?.children?.length) { - return variant.children[0].key - } - } - - const firstChild = selectedFramework.children[0] - if (firstChild?.children?.length) { - return firstChild.children[0].key - } - - return firstChild?.key ?? null -} diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx index 03fa0a57af4..4f4c895890b 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx @@ -1,5 +1,3 @@ -import { BASE_PATH } from 'lib/constants' -import { Box, Cable, Database, Sparkles } from 'lucide-react' import { useTheme } from 'next-themes' import { RadioGroupStacked, @@ -10,9 +8,7 @@ import { SelectValue_Shadcn_, Select_Shadcn_, Switch, - cn, } from 'ui' -import { ClientSelectDropdown, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' import { MultiSelector, @@ -22,18 +18,8 @@ import { MultiSelectorTrigger, } from 'ui-patterns/multi-select' -import { FRAMEWORKS, MOBILES } from './Connect.constants' -import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types' +import type { FieldOption, ResolvedField } from './Connect.types' import { ConnectionIcon } from './ConnectionIcon' -import { FrameworkSelector } from './FrameworkSelector' - -// Icon mapping for modes -const MODE_ICONS: Record = { - framework: , - direct: , - orm: , - mcp: , -} interface ConnectConfigSectionProps { activeFields: ResolvedField[] @@ -54,9 +40,6 @@ export function ConnectConfigSection({ if (activeFields.length === 0) return null - // Get all frameworks for the combobox - const allFrameworks = [...FRAMEWORKS, ...MOBILES] - return (
{activeFields.map((field) => { @@ -72,28 +55,6 @@ export function ConnectConfigSection({ switch (field.type) { case 'radio-grid': - // For framework field, use the combobox selector - if (field.id === 'framework') { - return ( - - onFieldChange(field.id, v)} - items={allFrameworks} - className="w-full" - size="small" - /> - - ) - } - - // Default radio-grid behavior for other fields return ( c.key === value) ?? MCP_CLIENTS[0] - return ( - - onFieldChange(field.id, v)} - /> - - ) - } - return ( ) } - -// ============================================================================ -// Mode Selector -// ============================================================================ - -interface ModeSelectorProps { - modes: Array<{ id: ConnectMode; label: string; description: string }> - selected: ConnectMode - onChange: (mode: ConnectMode) => void -} - -export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) { - return ( -
- {modes.map((mode) => ( - - ))} -
- ) -} diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index 038b0324687..5801a927b8c 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -3,13 +3,12 @@ import { useParams } from 'common' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' import { useMemo } from 'react' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, cn } from 'ui' import type { ProjectKeys } from './Connect.types' -import { ConnectConfigSection, ModeSelector } from './ConnectConfigSection' +import { ConnectConfigSection } from './ConnectConfigSection' import { ConnectStepsSection } from './ConnectStepsSection' import { useConnectState } from './useConnectState' @@ -27,34 +26,7 @@ export const ConnectSheet = () => { setShowConnect(sheetOpen) } - const { - projectConnectionShowAppFrameworks: showAppFrameworks, - projectConnectionShowMobileFrameworks: showMobileFrameworks, - } = useIsFeatureEnabled([ - 'project_connection:show_app_frameworks', - 'project_connection:show_mobile_frameworks', - ]) - - const handleSourceChange = (databaseId: string) => { - // Database selection is handled by the DatabaseSelector's internal state - // We just need to trigger a re-render of connection strings - } - - // Filter available modes based on feature flags - const availableModeIds = useMemo(() => { - const showFrameworks = showAppFrameworks || showMobileFrameworks - - return showFrameworks ? ['framework'] : [] - }, [showAppFrameworks, showMobileFrameworks]) - - const { state, updateField, setMode, activeFields, resolvedSteps, getFieldOptions, schema } = - useConnectState() - - // Filter modes based on feature flags - const availableModes = useMemo( - () => schema.modes.filter((m) => availableModeIds.includes(m.id)), - [schema.modes, availableModeIds] - ) + const { state, updateField, activeFields, resolvedSteps, getFieldOptions } = useConnectState() // Project keys for step components const { ref: projectRef } = useParams() @@ -90,21 +62,14 @@ export const ConnectSheet = () => { -
-
- Connect to your project - Choose how you want to use Supabase -
-
+ Connect to your project + Choose how you want to use Supabase
{/* Configuration Section */}
- {availableModes.length > 1 && ( - - )} -
+
x.database_type === 'PRIMARY') const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined - const connectionStringsShared = getConnectionStrings({ + const ConnectionStringPoolerShared = getConnectionStringPooler({ connectionInfo, poolingInfo: { connectionString: poolingConfigurationShared?.connection_string ?? '', @@ -80,9 +79,9 @@ function useConnectionStringPooler(): ConnectionStringPooler { metadata: { projectRef }, }) - const connectionStringsDedicated = + const ConnectionStringPoolerDedicated = poolingConfigurationDedicated !== undefined - ? getConnectionStrings({ + ? getConnectionStringPooler({ connectionInfo, poolingInfo: { connectionString: poolingConfigurationDedicated.connection_string, @@ -97,14 +96,14 @@ function useConnectionStringPooler(): ConnectionStringPooler { return useMemo( () => ({ - transactionShared: connectionStringsShared.pooler.uri, - sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'), - transactionDedicated: connectionStringsDedicated?.pooler.uri, - sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'), + transactionShared: ConnectionStringPoolerShared.pooler.uri, + sessionShared: ConnectionStringPoolerShared.pooler.uri.replace('6543', '5432'), + transactionDedicated: ConnectionStringPoolerDedicated?.pooler.uri, + sessionDedicated: ConnectionStringPoolerDedicated?.pooler.uri.replace('6543', '5432'), ipv4SupportedForDedicatedPooler: !!ipv4Addon, - direct: connectionStringsShared.direct.uri, + direct: ConnectionStringPoolerShared.direct.uri, }), - [connectionStringsShared, connectionStringsDedicated, ipv4Addon] + [ConnectionStringPoolerShared, ConnectionStringPoolerDedicated, ipv4Addon] ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts index 5db61dd26fb..2f91d6fe92a 100644 --- a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts @@ -1,4 +1,4 @@ -type ConnectionStrings = { +type ConnectionStringPooler = { psql: string uri: string golang: string @@ -10,7 +10,7 @@ type ConnectionStrings = { sqlalchemy: string } -export const getConnectionStrings = ({ +export const getConnectionStringPooler = ({ connectionInfo, poolingInfo, metadata, @@ -33,8 +33,8 @@ export const getConnectionStrings = ({ pgVersion?: string } }): { - direct: ConnectionStrings - pooler: ConnectionStrings + direct: ConnectionStringPooler + pooler: ConnectionStringPooler } => { const isMd5 = poolingInfo?.connectionString.includes('options=reference') const { projectRef } = metadata @@ -65,14 +65,14 @@ export const getConnectionStrings = ({ // User Id=${directUser};Password=${password};Server=${directHost};Port=${directPort};Database=${directName}` const directDotNetString = `{ - "ConnectionStrings": { + "ConnectionStringPooler": { "DefaultConnection": "Host=${directHost};Database=${directName};Username=${directUser};Password=${password};SSL Mode=Require;Trust Server Certificate=true" } }` // `User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}` const poolerDotNetString = `{ - "ConnectionStrings": { + "ConnectionStringPooler": { "DefaultConnection": "User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}" } }` diff --git a/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx b/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx deleted file mode 100644 index 2892cf6906d..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/FrameworkSelector.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 ( - -
- - - -
- 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 0b196767a12..80aa3c42a60 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'vitest' -import type { ConditionalValue, ConnectSchema, ConnectState, StepTree } from './Connect.types' +import type { ConditionalValue, ConnectSchema, StepTree } from './Connect.types' import { getActiveFields, getDefaultState, - resetDependentFields, resolveConditional, + resolveState, resolveSteps, } from './connect.resolver' @@ -155,10 +155,6 @@ 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, }) @@ -299,116 +295,118 @@ describe('connect.resolver:resolveSteps', () => { // ============================================================================ describe('connect.resolver:getActiveFields', () => { - const createSchemaWithFields = ( - modes: ConnectSchema['modes'], - fields: ConnectSchema['fields'] - ): ConnectSchema => ({ - modes, + const createSchemaWithFields = (fields: ConnectSchema['fields']): ConnectSchema => ({ fields, steps: [], }) test('should return fields for the current mode', () => { const schema = createSchemaWithFields( - [ - { id: 'framework', label: 'Framework', description: '', fields: ['framework', 'library'] }, - { id: 'direct', label: 'Direct', description: '', fields: ['connectionType'] }, - ], { + 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 frameworkFields = getActiveFields(schema, { mode: 'framework' }) - expect(frameworkFields).toHaveLength(2) - expect(frameworkFields.map((f) => f.id)).toEqual(['framework', 'library']) + expect(frameworkFields.map((f) => f.id)).toEqual(['mode', 'framework', 'library']) const directFields = getActiveFields(schema, { mode: 'direct' }) - expect(directFields).toHaveLength(1) - expect(directFields[0].id).toBe('connectionType') + expect(directFields.map((f) => f.id)).toEqual(['mode', 'connectionType']) }) test('should filter fields by dependsOn conditions', () => { const schema = createSchemaWithFields( - [ - { - id: 'framework', - label: 'Framework', - description: '', - fields: ['framework', 'frameworkVariant', 'frameworkUi'], - }, - ], { + 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: { framework: ['nextjs', 'react'] }, + dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] }, }, frameworkUi: { id: 'frameworkUi', type: 'switch', label: 'Shadcn', - dependsOn: { framework: ['nextjs', 'react'] }, + dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] }, }, } ) // With nextjs - should show all fields const nextjsFields = getActiveFields(schema, { mode: 'framework', framework: 'nextjs' }) - expect(nextjsFields).toHaveLength(3) + expect(nextjsFields).toHaveLength(4) // With vue - should hide frameworkVariant and frameworkUi const vueFields = getActiveFields(schema, { mode: 'framework', framework: 'vue' }) - expect(vueFields).toHaveLength(1) - expect(vueFields[0].id).toBe('framework') + expect(vueFields.map((field) => field.id)).toEqual(['mode', 'framework']) }) test('should handle multiple dependsOn conditions', () => { const schema = createSchemaWithFields( - [ - { - id: 'direct', - label: 'Direct', - description: '', - fields: ['connectionMethod', 'useSharedPooler'], - }, - ], { + 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: { connectionMethod: ['transaction'] }, + dependsOn: { mode: ['direct'], connectionMethod: ['transaction'] }, }, } ) @@ -418,41 +416,55 @@ describe('connect.resolver:getActiveFields', () => { mode: 'direct', connectionMethod: 'transaction', }) - expect(transactionFields).toHaveLength(2) + expect(transactionFields.map((field) => field.id)).toEqual([ + 'mode', + 'connectionMethod', + 'useSharedPooler', + ]) // Direct mode - hide shared pooler option const directFields = getActiveFields(schema, { mode: 'direct', connectionMethod: 'direct' }) - expect(directFields).toHaveLength(1) - expect(directFields[0].id).toBe('connectionMethod') + expect(directFields.map((field) => field.id)).toEqual(['mode', 'connectionMethod']) }) - test('should return empty array for invalid mode', () => { + test('should return only mode field when dependsOn does not match', () => { const schema = createSchemaWithFields( - [{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }], - { framework: { id: 'framework', type: 'radio-grid', label: 'Framework' } } + { + 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'] }, + }, + } ) const fields = getActiveFields(schema, { mode: 'invalid' as any }) - expect(fields).toEqual([]) + expect(fields.map((field) => field.id)).toEqual(['mode']) }) test('should include resolvedOptions for each field', () => { 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 + options: () => [{ value: 'nextjs', label: 'Next.js' }], }, } ) const fields = getActiveFields(schema, { mode: 'framework' }) expect(fields[0]).toHaveProperty('resolvedOptions') - // Source options are resolved by the hook, not the resolver - expect(fields[0].resolvedOptions).toEqual([]) + expect(fields[0].resolvedOptions).toEqual([{ value: 'nextjs', label: 'Next.js' }]) }) }) @@ -461,35 +473,29 @@ 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', @@ -506,119 +512,113 @@ describe('connect.resolver:getDefaultState', () => { expect(state.library).toBe('supabasejs') expect(state.mcpReadonly).toBe(false) }) +}) - test('should fallback to "direct" if no modes defined', () => { +// ============================================================================ +// resolveState Tests +// ============================================================================ + +describe('connect.resolver:resolveState', () => { + test('should apply defaults from options when valid', () => { const schema: ConnectSchema = { - modes: [], - fields: {}, + 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'] }, + }, + }, steps: [], } - const state = getDefaultState(schema) - expect(state.mode).toBe('direct') + const state = resolveState(schema, {}) + expect(state.mode).toBe('framework') + expect(state.framework).toBe('react') }) -}) -// ============================================================================ -// resetDependentFields Tests -// ============================================================================ - -describe('connect.resolver:resetDependentFields', () => { - const createSchemaForReset = (): ConnectSchema => ({ - modes: [ - { - id: 'framework', - label: 'Framework', - description: '', - fields: ['framework', 'frameworkVariant', 'frameworkUi'], - }, - { 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'] }, + 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'] }, + }, }, - 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 + steps: [], } - - const newState = resetDependentFields(state, 'framework', schema) - expect(newState.frameworkVariant).toBeUndefined() - expect(newState.frameworkUi).toBeUndefined() + const state = resolveState(schema, {}) + expect(state.framework).toBe('nextjs') }) - 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 newState = resetDependentFields(state, 'framework', schema) - - expect(newState.frameworkVariant).toBe('vite') - expect(newState.frameworkUi).toBe(true) - }) - - test('should handle mode changes', () => { - const schema = createSchemaForReset() - const state: ConnectState = { - mode: 'direct', // Changed mode - framework: 'nextjs', - frameworkVariant: 'app', + 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: [], } - // 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 = { + const state = resolveState(schema, { mode: 'framework', - framework: 'nextjs', - } - - const newState = resetDependentFields(state, 'framework', schema) + framework: 'react', + variant: 'app', + }) - expect(newState.mode).toBe('framework') - expect(newState.framework).toBe('nextjs') + expect(state.variant).toBe('vite') }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts index 2baeb7940c1..ccf359c9512 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts @@ -10,22 +10,6 @@ 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) */ @@ -38,7 +22,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 ['mode', 'framework', ..., 'mcpClient'] + * and stateKeys derived from schema field order * * 1. Look up 'mcp' (state.mode value) in tree -> found, continue * 2. At mcp subtree { codex: [...], DEFAULT: [...] }, skip irrelevant keys @@ -49,7 +33,7 @@ function isConditionalObject(value: unknown): value is Record { export function resolveConditional( value: ConditionalValue, state: ConnectState, - stateKeys: readonly string[] = STATE_KEY_ORDER + stateKeys: readonly string[] = Object.keys(state) ): T | undefined { // Base case: we've reached a leaf value (string, array, null, boolean, etc.) if (!isConditionalObject(value)) { @@ -87,10 +71,11 @@ 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) return steps .map((step) => { - const content = resolveConditional(step.content, state) + const content = resolveConditional(step.content, state, stateKeys) return { id: step.id, title: step.title, @@ -144,10 +129,9 @@ function resolveStepBranch( * Gets the active fields for the current mode, filtering by dependsOn conditions. */ export function getActiveFields(schema: ConnectSchema, state: ConnectState): ResolvedField[] { - const currentMode = schema.modes.find((m) => m.id === state.mode) - if (!currentMode) return [] + const stateKeys = Object.keys(schema.fields) - return currentMode.fields + return stateKeys .map((fieldId) => schema.fields[fieldId]) .filter((field): field is NonNullable => !!field) .filter((field) => { @@ -160,14 +144,18 @@ export function getActiveFields(schema: ConnectSchema, state: ConnectState): Res }) .map((field) => ({ ...field, - resolvedOptions: resolveFieldOptions(field, state), + resolvedOptions: resolveFieldOptions(field, state, stateKeys), })) } /** * Resolves field options based on current state. */ -function resolveFieldOptions(field: { options?: unknown }, state: ConnectState): FieldOption[] { +function resolveFieldOptions( + field: { options?: unknown }, + state: ConnectState, + stateKeys: readonly string[] +): FieldOption[] { if (!field.options) return [] // Static options array @@ -175,80 +163,118 @@ function resolveFieldOptions(field: { options?: unknown }, state: ConnectState): return field.options } - // 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 [] + if (typeof field.options === 'function') { + return (field.options as (state: ConnectState) => FieldOption[])(state) } // Conditional options - const resolved = resolveConditional( - field.options as ConditionalValue, - state - ) - return resolved ?? [] + if (typeof field.options === 'object') { + const resolved = resolveConditional( + field.options as ConditionalValue, + state, + stateKeys + ) + return resolved ?? [] + } + + return [] +} + +/** + * Normalizes state values based on schema defaults, options, and dependencies. + */ +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) + + for (let iteration = 0; iteration < maxIterations; iteration++) { + let changed = false + const activeFields = getActiveFields(schema, next) + + for (const field of activeFields) { + const currentValue = next[field.id] + const optionValues = field.resolvedOptions.map((option) => option.value) + const hasOptions = optionValues.length > 0 + + if (field.type === 'switch') { + if (typeof currentValue !== 'boolean' && typeof field.defaultValue === 'boolean') { + next[field.id] = field.defaultValue + changed = true + } + continue + } + + 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 + } + + 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 (hasOptions && !optionValues.includes(currentValue)) { + let nextValue: string | undefined + + if (typeof field.defaultValue === 'string' && optionValues.includes(field.defaultValue)) { + nextValue = field.defaultValue + } else { + nextValue = optionValues[0] + } + + if (nextValue !== currentValue) { + next[field.id] = nextValue + changed = true + } + } + } + + 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 { - const defaultMode = schema.modes[0]?.id ?? 'direct' - - const state: ConnectState = { mode: defaultMode } - - // Set default values for all fields - Object.values(schema.fields).forEach((field) => { - if (field.defaultValue !== undefined) { - state[field.id] = field.defaultValue - } - }) - - return state -} - -/** - * 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 (!dependencySatisfied) { - delete newState[field.id] - } - } - }) - - // 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) - - // Reset fields from previous mode that aren't in current mode - previousMode?.fields.forEach((fieldId) => { - if (!currentMode?.fields.includes(fieldId)) { - delete newState[fieldId] - } - }) - } - - return newState + return resolveState(schema, {}) } diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts index 358be913d0d..750d08f6976 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.test.ts @@ -1,60 +1,22 @@ -import { describe, test, expect } from 'vitest' -import { connectSchema, INSTALL_COMMANDS } from './connect.schema' -import { resolveSteps } from './connect.resolver' +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' // ============================================================================ // Schema Structure Tests // ============================================================================ describe('connect.schema:structure', () => { - test('should have all required modes', () => { - const modeIds = connectSchema.modes.map((m) => m.id) - expect(modeIds).toEqual(['framework']) - }) - - 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 be removed', () => { - const directMode = connectSchema.modes.find((m) => m.id === 'direct') - expect(directMode).toBeUndefined() - }) - - test('orm mode should be removed', () => { - const ormMode = connectSchema.modes.find((m) => m.id === 'orm') - expect(ormMode).toBeUndefined() - }) - - test('mcp mode should be removed', () => { - const mcpMode = connectSchema.modes.find((m) => m.id === 'mcp') - expect(mcpMode).toBeUndefined() - }) - - 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() - }) - }) + 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) }) }) @@ -65,21 +27,25 @@ describe('connect.schema:structure', () => { describe('connect.schema:fields', () => { test('framework field should have correct type', () => { const field = connectSchema.fields.framework - expect(field.type).toBe('radio-grid') - expect(field.options).toEqual({ source: 'frameworks' }) + 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.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({ framework: ['nextjs', 'react'] }) + expect(field.dependsOn).toEqual({ mode: ['framework'], framework: ['nextjs', 'react'] }) + expect(typeof field.options).toBe('function') }) 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({ framework: ['nextjs', 'react'] }) + expect(field.dependsOn).toEqual({ mode: ['framework'], framework: ['nextjs', 'react'] }) }) test('connectionMethod field should be removed', () => { diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts index a33703373df..791fefa0aba 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts @@ -1,15 +1,63 @@ -import type { ConnectSchema, StepDefinition } from './Connect.types' +import { FRAMEWORKS, MOBILES } from './Connect.constants' +import type { ConnectSchema, ConnectState, FieldOption, 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")', +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 [] } // ============================================================================ @@ -73,44 +121,41 @@ const skillsInstallStep: StepDefinition = { // ============================================================================ export const connectSchema: ConnectSchema = { - // ------------------------------------------------------------------------- - // Mode Definitions - // ------------------------------------------------------------------------- - modes: [ - { - id: 'framework', - label: 'Framework', - description: 'Use a client library', - fields: ['framework', 'frameworkVariant', 'library', 'frameworkUi'], - }, - ], - // ------------------------------------------------------------------------- // Field Definitions // ------------------------------------------------------------------------- fields: { + mode: { + id: 'mode', + type: 'radio-list', + label: 'Mode', + options: modeOptions, + defaultValue: 'framework', + }, // Framework fields framework: { id: 'framework', - type: 'radio-grid', + type: 'select', label: 'Framework', - options: { source: 'frameworks' }, + options: frameworkOptions, defaultValue: 'nextjs', + dependsOn: { mode: ['framework'] }, }, frameworkVariant: { id: 'frameworkVariant', type: 'select', label: 'Variant', - options: { source: 'frameworkVariants' }, + options: getFrameworkVariantOptions, defaultValue: 'app', - dependsOn: { framework: ['nextjs', 'react'] }, // Only show for frameworks with multiple variants + dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] }, // Only show for frameworks with multiple variants }, library: { id: 'library', type: 'select', label: 'Library', - options: { source: 'libraries' }, + options: getLibraryOptions, defaultValue: 'supabasejs', + dependsOn: { mode: ['framework'] }, }, frameworkUi: { id: 'frameworkUi', @@ -118,7 +163,7 @@ export const connectSchema: ConnectSchema = { label: 'Shadcn', description: 'Install components via the Supabase shadcn registry.', defaultValue: false, - dependsOn: { framework: ['nextjs', 'react'] }, + dependsOn: { mode: ['framework'], framework: ['nextjs', 'react'] }, }, }, diff --git a/apps/studio/components/interfaces/ConnectSheet/content/androidkotlin/supabasekt/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/androidkotlin/supabasekt/content.tsx index 297f2f0a600..455506fce0f 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/androidkotlin/supabasekt/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/androidkotlin/supabasekt/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: 'MainActivity.kt', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/astro/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/astro/supabasejs/content.tsx index e7d2bd836ec..f30bcf0cdd6 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/astro/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/astro/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/exporeactnative/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/exporeactnative/supabasejs/content.tsx index a9a1b023115..085a6d35423 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/exporeactnative/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/exporeactnative/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/flask/supabasepy/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/flask/supabasepy/content.tsx index ddb9c9d2795..644ceb769e4 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/flask/supabasepy/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/flask/supabasepy/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/flutter/supabaseflutter/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/flutter/supabaseflutter/content.tsx index acdbe11bcf9..4f993384316 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/flutter/supabaseflutter/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/flutter/supabaseflutter/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: 'lib/main.dart', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/ionicangular/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/ionicangular/supabasejs/content.tsx index 76d00b74ecf..7096712ee7d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/ionicangular/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/ionicangular/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: 'environments/environment.ts', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/ionicreact/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/ionicreact/supabasejs/content.tsx index 0a9c68e23fd..48966b3e38d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/ionicreact/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/ionicreact/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx index dfaabf13010..a0c79e7f043 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/nextjs/pages/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/nextjs/pages/supabasejs/content.tsx index ea3c36e2220..34c5a24b472 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/nextjs/pages/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/nextjs/pages/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/nuxt/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/nuxt/supabasejs/content.tsx index 1c232591d28..e9a147411c3 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/nuxt/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/nuxt/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/react/create-react-app/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/react/create-react-app/supabasejs/content.tsx index d2eed05bc20..3bd354e2460 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/react/create-react-app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/react/create-react-app/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/react/vite/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/react/vite/supabasejs/content.tsx index 657d9c90fc9..e07d0e0a3bb 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/react/vite/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/react/vite/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/refine/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/refine/supabasejs/content.tsx index 0078a408100..97b65d09327 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/refine/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/refine/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/remix/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/remix/supabasejs/content.tsx index 8390ebe4818..64aa639f5cc 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/remix/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/remix/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/solidjs/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/solidjs/supabasejs/content.tsx index 0d60deb0cad..0e598e5d25d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/solidjs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/solidjs/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', 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 76c94087ff6..15d84d9a93a 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx @@ -2,15 +2,14 @@ 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 { resolveFrameworkLibraryKey } from '../../../Connect.utils' -import { INSTALL_COMMANDS } from '../../../connect.schema' /** * Gets the install command for the current framework selection. */ function getInstallCommand(state: StepContentProps['state']): string | null { - const libraryKey = resolveFrameworkLibraryKey(state) + const libraryKey = typeof state.library === 'string' ? state.library : null if (libraryKey && INSTALL_COMMANDS[libraryKey]) return INSTALL_COMMANDS[libraryKey] diff --git a/apps/studio/components/interfaces/ConnectSheet/content/sveltekit/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/sveltekit/supabasejs/content.tsx index e6497996818..608c3e7f7b4 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/sveltekit/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/sveltekit/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/swift/supabaseswift/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/swift/supabaseswift/content.tsx index 1b811e8abea..5610cb21469 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/swift/supabaseswift/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/swift/supabaseswift/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: 'Supabase.swift', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/tanstack/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/tanstack/supabasejs/content.tsx index dfa556d143c..cfe42dffbc5 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/tanstack/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/tanstack/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/vuejs/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/vuejs/supabasejs/content.tsx index e385251e49d..70873c1e07d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/vuejs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/vuejs/supabasejs/content.tsx @@ -1,8 +1,8 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' -const ContentFile = ({ projectKeys }: ContentFileProps) => { +const ContentFile = ({ projectKeys }: StepContentProps) => { const files = [ { name: '.env.local', diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts index 80f3c128a8b..652ea244b76 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts @@ -44,28 +44,6 @@ describe('useConnectState', () => { }) }) - // ============================================================================ - // Mode Switching Tests - // ============================================================================ - - describe('setMode', () => { - test('should preserve framework state when setting framework mode', () => { - const { result } = renderHook(() => useConnectState()) - - // Change framework - act(() => { - result.current.updateField('framework', 'react') - }) - - // Set framework mode again - act(() => { - result.current.setMode('framework') - }) - - expect(result.current.state.framework).toBe('react') - }) - }) - // ============================================================================ // Field Update Tests // ============================================================================ @@ -205,25 +183,15 @@ describe('useConnectState', () => { // ============================================================================ describe('getFieldOptions', () => { - test('should return framework options', () => { + test('should return framework options from schema', () => { 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 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', () => { + test('should return empty array for inactive field', () => { const { result } = renderHook(() => useConnectState({ framework: 'remix' })) const options = result.current.getFieldOptions('frameworkVariant') @@ -236,15 +204,6 @@ describe('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) - }) }) // ============================================================================ @@ -256,16 +215,14 @@ 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 have all expected modes in schema', () => { + test('should include mode field in schema', () => { const { result } = renderHook(() => useConnectState()) - const modeIds = result.current.schema.modes.map((m) => m.id) - expect(modeIds).toEqual(['framework']) + expect(result.current.schema.fields.mode).toBeDefined() }) }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts index c45fc3e5fe4..23c7f724473 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts @@ -1,153 +1,13 @@ import { useCallback, useMemo, useState } from 'react' -import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder' -import { - connectionStringMethodOptions, - DATABASE_CONNECTION_TYPES, - FRAMEWORKS, - MOBILES, - ORMS, -} from './Connect.constants' -import type { - ConnectMode, - ConnectSchema, - ConnectState, - FieldOption, - ResolvedField, - ResolvedStep, -} from './Connect.types' -import { resolveFrameworkLibraryKey } from './Connect.utils' +import type { ConnectSchema, ConnectState, FieldOption, ResolvedField, ResolvedStep } from './Connect.types' import { getActiveFields, - getDefaultState, - resetDependentFields, + resolveState, resolveSteps, } from './connect.resolver' import { connectSchema } from './connect.schema' -// ============================================================================ -// 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 // ============================================================================ @@ -155,7 +15,6 @@ function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState 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[] @@ -164,124 +23,13 @@ export interface UseConnectStateReturn { export function useConnectState(initialState?: Partial): UseConnectStateReturn { const [state, setState] = useState(() => { - 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 + return resolveState(connectSchema, initialState ?? {}) }) const updateField = useCallback((fieldId: string, value: string | boolean | string[]) => { setState((prev) => { const next = { ...prev, [fieldId]: value } - - // 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 - delete next.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') { - delete next.library - const libraryKey = resolveFrameworkLibraryKey({ - framework: next.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 ?? '' - } - delete next.library - 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 + return resolveState(connectSchema, next) }) }, []) @@ -293,15 +41,14 @@ export function useConnectState(initialState?: Partial): UseConnec (fieldId: string): FieldOption[] => { const field = activeFields.find((f) => f.id === fieldId) if (!field) return [] - return resolveFieldOptionsWithSource(field, state) + return field.resolvedOptions }, - [activeFields, state] + [activeFields] ) return { state, updateField, - setMode, activeFields, resolvedSteps, getFieldOptions,