simplify all the things

This commit is contained in:
Saxon Fletcher committed 2026-02-04 14:12:15 +10:00
1 parent 82747fbe8a
commit a7e63f2ad0
35 files changed
+465 -1239

No files matched your search

@@ -12,6 +12,15 @@ export type DatabaseConnectionType =
| 'python'
| 'sqlalchemy'
export const INSTALL_COMMANDS: Record<string, string> = {
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
@@ -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<T> =
// 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<FieldOption[]>
// Options can be static, conditional, or resolved from state
options?: FieldOption[] | ConditionalValue<FieldOption[]> | FieldOptionsResolver
// Only show this field when these state conditions are met
dependsOn?: Record<string, string[]>
// Default value for this field
@@ -140,7 +112,6 @@ export type StepFieldValueMap = {
// ============================================================================
export interface ConnectSchema {
modes: ModeDefinition[]
fields: Record<string, FieldDefinition>
// 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
}
@@ -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')
})
})
@@ -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
}
@@ -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<string, React.ReactNode> = {
framework: <Box size={16} strokeWidth={1.5} />,
direct: <Database size={16} strokeWidth={1.5} />,
orm: <Cable size={16} strokeWidth={1.5} />,
mcp: <Sparkles size={16} strokeWidth={1.5} />,
}
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 (
<div className="space-y-6">
{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 (
<FormLayout
key={field.id}
layout="flex-row-reverse"
label={field.label}
description="What is your preferred coding agent"
className={formLayoutClassName}
>
<FrameworkSelector
value={String(value ?? '')}
onChange={(v) => onFieldChange(field.id, v)}
items={allFrameworks}
className="w-full"
size="small"
/>
</FormLayout>
)
}
// Default radio-grid behavior for other fields
return (
<FormLayout
key={field.id}
@@ -162,30 +123,6 @@ export function ConnectConfigSection({
)
case 'select':
// Special case for MCP client - use the custom dropdown
if (field.id === 'mcpClient') {
const selectedClient = MCP_CLIENTS.find((c) => c.key === value) ?? MCP_CLIENTS[0]
return (
<FormLayout
key={field.id}
layout="flex-row-reverse"
label={field.label}
description={field.description}
className={formLayoutClassName}
>
<ClientSelectDropdown
basePath={BASE_PATH}
theme={(resolvedTheme ?? 'dark') as 'light' | 'dark'}
label=""
className="w-full"
clients={MCP_CLIENTS}
selectedClient={selectedClient}
onClientChange={(v) => onFieldChange(field.id, v)}
/>
</FormLayout>
)
}
return (
<FormLayout
key={field.id}
@@ -275,39 +212,3 @@ export function ConnectConfigSection({
</div>
)
}
// ============================================================================
// 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 (
<div className="grid grid-cols-4 rounded-lg border">
{modes.map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => onChange(mode.id)}
className={cn(
'flex flex-col items-center gap-2 p-4 transition-colors border-r last:border-r-0',
selected === mode.id
? 'bg-surface-200'
: 'border-default hover:border-strong hover:bg-surface-100 '
)}
>
<span className="text-foreground-light">{MODE_ICONS[mode.id]}</span>
<div>
<p className="heading-default text-center">{mode.label}</p>
<p className="text-sm text-foreground-lighter text-center">{mode.description}</p>
</div>
</button>
))}
</div>
)
}
@@ -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 = () => {
<Sheet open={showConnect} onOpenChange={handleOpenChange}>
<SheetContent size="lg" className="flex flex-col gap-0 p-0 space-y-0" tabIndex={undefined}>
<SheetHeader className={cn('text-left border-b shrink-0 py-6 px-8')}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<SheetTitle>Connect to your project</SheetTitle>
<SheetDescription>Choose how you want to use Supabase</SheetDescription>
</div>
</div>
<SheetTitle>Connect to your project</SheetTitle>
<SheetDescription>Choose how you want to use Supabase</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Configuration Section */}
<div className="space-y-6 border-b p-8 shrink-0">
{availableModes.length > 1 && (
<ModeSelector modes={availableModes} selected={state.mode} onChange={setMode} />
)}
<div className="border-t pt-8">
<div>
<ConnectConfigSection
activeFields={activeFields}
state={state}
@@ -1,6 +1,3 @@
import dynamic from 'next/dynamic'
import { useMemo, useRef } from 'react'
import { useParams } from 'common'
import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
@@ -9,18 +6,20 @@ import { useSupavisorConfigurationQuery } from 'data/database/supavisor-configur
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { pluckObjectFields } from 'lib/helpers'
import dynamic from 'next/dynamic'
import { useMemo, useRef } from 'react'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import type {
ConnectionStringPooler,
ConnectState,
ConnectionStringPooler,
ProjectKeys,
ResolvedStep,
StepContentProps,
} from './Connect.types'
import { ConnectSheetStep } from './ConnectSheetStep'
import { CopyPromptAdmonition } from './CopyPromptAdmonition'
import { getConnectionStrings } from './DatabaseSettings.utils'
import { getConnectionStringPooler } from './DatabaseSettings.utils'
interface ConnectStepsSectionProps {
steps: ResolvedStep[]
@@ -68,7 +67,7 @@ function useConnectionStringPooler(): ConnectionStringPooler {
const poolingConfigurationShared = supavisorConfig?.find((x) => 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]
)
}
@@ -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}'` : ''}"
}
}`
@@ -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 (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<div className={cn('flex', className)}>
<PopoverTrigger_Shadcn_ asChild>
<Button
size={size}
type="default"
className={cn('gap-0 justify-between', className?.includes('w-full') && 'w-full')}
iconRight={<ChevronDown strokeWidth={1.5} />}
>
<div className="flex items-center gap-2">
{selectedItem?.icon ? <ConnectionIcon icon={selectedItem.icon} /> : <Box size={12} />}
{selectedItem?.label}
</div>
</Button>
</PopoverTrigger_Shadcn_>
</div>
<PopoverContent_Shadcn_
className="p-0 w-[var(--radix-popover-trigger-width)] min-w-48"
side="bottom"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{items.map((item) => (
<CommandItem_Shadcn_
key={item.key}
value={item.key}
onSelect={() => handleSelect(item.key)}
className="flex gap-2 items-center"
>
{item.icon ? <ConnectionIcon icon={item.icon} /> : <Box size={12} />}
{item.label}
<Check
size={15}
className={cn('ml-auto', item.key === value ? 'opacity-100' : 'opacity-0')}
/>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -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')
})
})
@@ -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<string, unknown> {
* 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<string, unknown> {
export function resolveConditional<T>(
value: ConditionalValue<T>,
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<T>(
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<string | null>(step.content, state)
const content = resolveConditional<string | null>(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<typeof field> => !!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<FieldOption[]>(
field.options as ConditionalValue<FieldOption[]>,
state
)
return resolved ?? []
if (typeof field.options === 'object') {
const resolved = resolveConditional<FieldOption[]>(
field.options as ConditionalValue<FieldOption[]>,
state,
stateKeys
)
return resolved ?? []
}
return []
}
/**
* Normalizes state values based on schema defaults, options, and dependencies.
*/
export function resolveState(
schema: ConnectSchema,
inputState: Partial<ConnectState>
): 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, {})
}
@@ -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', () => {
@@ -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<string, string> = {
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'] },
},
},
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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]
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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()
})
})
})
@@ -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<ConnectState>): UseConnectStateReturn {
const [state, setState] = useState<ConnectState>(() => {
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<ConnectState>): 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,