mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## Context Adds a "source" param in the new Connect sheet to allow selecting read replicas - this was present in the old Connect dialog <img width="962" height="713" alt="image" src="https://github.com/user-attachments/assets/3c805e41-e8df-431b-83d0-e5f227d68475" /> Selecting sources will update the connect steps at the bottom - connection string, host etc
625 lines
19 KiB
TypeScript
625 lines
19 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
getActiveFields,
|
|
getDefaultState,
|
|
resetDependentFields,
|
|
resolveConditional,
|
|
resolveSteps,
|
|
} from './connect.resolver'
|
|
import type { ConditionalValue, ConnectSchema, ConnectState, StepTree } from './Connect.types'
|
|
|
|
// ============================================================================
|
|
// resolveConditional Tests
|
|
// ============================================================================
|
|
|
|
describe('connect.resolver:resolveConditional', () => {
|
|
test('should return primitive values directly', () => {
|
|
expect(resolveConditional('hello', { mode: 'framework' })).toBe('hello')
|
|
expect(resolveConditional(42, { mode: 'framework' })).toBe(42)
|
|
expect(resolveConditional(null, { mode: 'framework' })).toBe(null)
|
|
expect(resolveConditional(true, { mode: 'framework' })).toBe(true)
|
|
})
|
|
|
|
test('should return arrays directly', () => {
|
|
const arr = ['a', 'b', 'c']
|
|
expect(resolveConditional(arr, { mode: 'framework' })).toBe(arr)
|
|
})
|
|
|
|
test('should resolve single-level conditional based on mode', () => {
|
|
const conditional: ConditionalValue<string> = {
|
|
framework: 'Framework Content',
|
|
direct: 'Direct Content',
|
|
DEFAULT: 'Default Content',
|
|
}
|
|
|
|
expect(resolveConditional(conditional, { mode: 'framework' })).toBe('Framework Content')
|
|
expect(resolveConditional(conditional, { mode: 'direct' })).toBe('Direct Content')
|
|
expect(resolveConditional(conditional, { mode: 'orm' })).toBe('Default Content')
|
|
})
|
|
|
|
test('should resolve nested conditionals (mode -> framework)', () => {
|
|
const conditional: ConditionalValue<string> = {
|
|
framework: {
|
|
nextjs: 'Next.js Content',
|
|
react: 'React Content',
|
|
DEFAULT: 'Generic Framework',
|
|
},
|
|
direct: 'Direct Content',
|
|
}
|
|
|
|
expect(resolveConditional(conditional, { mode: 'framework', framework: 'nextjs' })).toBe(
|
|
'Next.js Content'
|
|
)
|
|
expect(resolveConditional(conditional, { mode: 'framework', framework: 'react' })).toBe(
|
|
'React Content'
|
|
)
|
|
expect(resolveConditional(conditional, { mode: 'framework', framework: 'vue' })).toBe(
|
|
'Generic Framework'
|
|
)
|
|
expect(resolveConditional(conditional, { mode: 'direct' })).toBe('Direct Content')
|
|
})
|
|
|
|
test('should resolve deeply nested conditionals (mode -> framework -> variant)', () => {
|
|
const conditional: ConditionalValue<string> = {
|
|
framework: {
|
|
nextjs: {
|
|
app: 'App Router Content',
|
|
pages: 'Pages Router Content',
|
|
DEFAULT: 'Generic Next.js',
|
|
},
|
|
DEFAULT: 'Generic Framework',
|
|
},
|
|
}
|
|
|
|
expect(
|
|
resolveConditional(conditional, {
|
|
mode: 'framework',
|
|
framework: 'nextjs',
|
|
frameworkVariant: 'app',
|
|
})
|
|
).toBe('App Router Content')
|
|
expect(
|
|
resolveConditional(conditional, {
|
|
mode: 'framework',
|
|
framework: 'nextjs',
|
|
frameworkVariant: 'pages',
|
|
})
|
|
).toBe('Pages Router Content')
|
|
expect(
|
|
resolveConditional(conditional, {
|
|
mode: 'framework',
|
|
framework: 'nextjs',
|
|
frameworkVariant: 'unknown',
|
|
})
|
|
).toBe('Generic Next.js')
|
|
})
|
|
|
|
test('should resolve MCP client-specific content', () => {
|
|
const conditional: ConditionalValue<string> = {
|
|
mcp: {
|
|
cursor: 'Cursor Config',
|
|
codex: 'Codex Config',
|
|
'claude-code': 'Claude Code Config',
|
|
DEFAULT: 'Generic MCP',
|
|
},
|
|
}
|
|
|
|
expect(resolveConditional(conditional, { mode: 'mcp', mcpClient: 'cursor' })).toBe(
|
|
'Cursor Config'
|
|
)
|
|
expect(resolveConditional(conditional, { mode: 'mcp', mcpClient: 'codex' })).toBe(
|
|
'Codex Config'
|
|
)
|
|
expect(resolveConditional(conditional, { mode: 'mcp', mcpClient: 'claude-code' })).toBe(
|
|
'Claude Code Config'
|
|
)
|
|
})
|
|
|
|
test('should return undefined when no match and no DEFAULT', () => {
|
|
const conditional: ConditionalValue<string> = {
|
|
framework: 'Framework',
|
|
direct: 'Direct',
|
|
}
|
|
|
|
expect(resolveConditional(conditional, { mode: 'orm' })).toBe(undefined)
|
|
})
|
|
|
|
test('should handle frameworkUi boolean state (as string "true"/"false")', () => {
|
|
const conditional: ConditionalValue<string> = {
|
|
framework: {
|
|
nextjs: {
|
|
true: 'Shadcn Steps',
|
|
DEFAULT: 'Regular Steps',
|
|
},
|
|
},
|
|
}
|
|
|
|
// When frameworkUi is true (boolean), it gets converted to string "true"
|
|
expect(
|
|
resolveConditional(conditional, { mode: 'framework', framework: 'nextjs', frameworkUi: true })
|
|
).toBe('Shadcn Steps')
|
|
expect(
|
|
resolveConditional(conditional, {
|
|
mode: 'framework',
|
|
framework: 'nextjs',
|
|
frameworkUi: false,
|
|
})
|
|
).toBe('Regular Steps')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// resolveSteps Tests
|
|
// ============================================================================
|
|
|
|
describe('connect.resolver:resolveSteps', () => {
|
|
const createMockSchema = (steps: StepTree): ConnectSchema => ({
|
|
modes: [
|
|
{ id: 'framework', label: 'Framework', description: '', fields: [] },
|
|
{ id: 'direct', label: 'Direct', description: '', fields: [] },
|
|
],
|
|
fields: {},
|
|
steps,
|
|
})
|
|
|
|
test('should resolve steps array for framework mode', () => {
|
|
const schema = createMockSchema({
|
|
mode: {
|
|
framework: [
|
|
{ id: 'step1', title: 'Install', description: 'Install pkg', content: 'install-content' },
|
|
{ id: 'step2', title: 'Configure', description: 'Configure', content: 'config-content' },
|
|
],
|
|
direct: [
|
|
{
|
|
id: 'connection',
|
|
title: 'Connection',
|
|
description: 'Connect',
|
|
content: 'direct-content',
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const steps = resolveSteps(schema, { mode: 'framework' })
|
|
expect(steps).toHaveLength(2)
|
|
expect(steps[0].id).toBe('step1')
|
|
expect(steps[0].title).toBe('Install')
|
|
expect(steps[1].id).toBe('step2')
|
|
})
|
|
|
|
test('should resolve steps for direct mode', () => {
|
|
const schema = createMockSchema({
|
|
mode: {
|
|
framework: [{ id: 'step1', title: 'Install', description: 'Install', content: 'install' }],
|
|
direct: [
|
|
{ id: 'connection', title: 'Connection', description: 'Connect', content: 'direct' },
|
|
],
|
|
},
|
|
})
|
|
|
|
const steps = resolveSteps(schema, { mode: 'direct' })
|
|
expect(steps).toHaveLength(1)
|
|
expect(steps[0].id).toBe('connection')
|
|
})
|
|
|
|
test('should filter out steps with empty content', () => {
|
|
const schema = createMockSchema({
|
|
mode: {
|
|
framework: [
|
|
{ id: 'step1', title: 'Valid', description: 'Valid step', content: 'valid-content' },
|
|
{ id: 'step2', title: 'Empty', description: 'Empty step', content: '' },
|
|
{ id: 'step3', title: 'Null', description: 'Null step', content: null },
|
|
],
|
|
},
|
|
})
|
|
|
|
const steps = resolveSteps(schema, { mode: 'framework' })
|
|
expect(steps).toHaveLength(1)
|
|
expect(steps[0].id).toBe('step1')
|
|
})
|
|
|
|
test('should resolve conditional step content based on state', () => {
|
|
const schema = createMockSchema({
|
|
mode: {
|
|
framework: [
|
|
{
|
|
id: 'configure',
|
|
title: 'Configure',
|
|
description: 'Configure',
|
|
content: {
|
|
nextjs: 'nextjs-content',
|
|
react: 'react-content',
|
|
DEFAULT: 'generic-content',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
const nextjsSteps = resolveSteps(schema, { mode: 'framework', framework: 'nextjs' })
|
|
expect(nextjsSteps[0].content).toBe('nextjs-content')
|
|
|
|
const reactSteps = resolveSteps(schema, { mode: 'framework', framework: 'react' })
|
|
expect(reactSteps[0].content).toBe('react-content')
|
|
|
|
const vueSteps = resolveSteps(schema, { mode: 'framework', framework: 'vue' })
|
|
expect(vueSteps[0].content).toBe('generic-content')
|
|
})
|
|
|
|
test('should return empty array when no steps resolve', () => {
|
|
const schema = createMockSchema({
|
|
mode: {
|
|
framework: [{ id: 'step1', title: 'Step', description: 'Desc', content: '' }],
|
|
},
|
|
})
|
|
|
|
const steps = resolveSteps(schema, { mode: 'framework' })
|
|
expect(steps).toEqual([])
|
|
})
|
|
|
|
test('should return empty array when steps is not an array', () => {
|
|
const schema = createMockSchema({
|
|
mode: {
|
|
framework: {
|
|
framework: {},
|
|
},
|
|
},
|
|
} as any)
|
|
|
|
const steps = resolveSteps(schema, { mode: 'framework' })
|
|
expect(steps).toEqual([])
|
|
})
|
|
|
|
test('should append steps from multiple field conditions in order', () => {
|
|
const schema = createMockSchema({
|
|
mode: {
|
|
framework: {
|
|
framework: {
|
|
nextjs: [{ id: 'base', title: 'Base', description: 'Base step', content: 'base' }],
|
|
},
|
|
frameworkUi: {
|
|
true: [{ id: 'ui', title: 'UI', description: 'UI step', content: 'ui' }],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const steps = resolveSteps(schema, {
|
|
mode: 'framework',
|
|
framework: 'nextjs',
|
|
frameworkUi: true,
|
|
})
|
|
expect(steps.map((step) => step.id)).toEqual(['base', 'ui'])
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// getActiveFields Tests
|
|
// ============================================================================
|
|
|
|
describe('connect.resolver:getActiveFields', () => {
|
|
const createSchemaWithFields = (
|
|
modes: ConnectSchema['modes'],
|
|
fields: ConnectSchema['fields']
|
|
): ConnectSchema => ({
|
|
modes,
|
|
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'] },
|
|
],
|
|
{
|
|
framework: {
|
|
id: 'framework',
|
|
type: 'radio-grid',
|
|
label: 'Framework',
|
|
defaultValue: 'nextjs',
|
|
},
|
|
library: {
|
|
id: 'library',
|
|
type: 'select',
|
|
label: 'Library',
|
|
defaultValue: 'supabasejs',
|
|
},
|
|
connectionType: {
|
|
id: 'connectionType',
|
|
type: 'select',
|
|
label: 'Type',
|
|
defaultValue: 'uri',
|
|
},
|
|
}
|
|
)
|
|
|
|
const frameworkFields = getActiveFields(schema, { mode: 'framework' })
|
|
expect(frameworkFields).toHaveLength(2)
|
|
expect(frameworkFields.map((f) => f.id)).toEqual(['framework', 'library'])
|
|
|
|
const directFields = getActiveFields(schema, { mode: 'direct' })
|
|
expect(directFields).toHaveLength(1)
|
|
expect(directFields[0].id).toBe('connectionType')
|
|
})
|
|
|
|
test('should filter fields by dependsOn conditions', () => {
|
|
const schema = createSchemaWithFields(
|
|
[
|
|
{
|
|
id: 'framework',
|
|
label: 'Framework',
|
|
description: '',
|
|
fields: ['framework', 'frameworkVariant', 'frameworkUi'],
|
|
},
|
|
],
|
|
{
|
|
framework: {
|
|
id: 'framework',
|
|
type: 'radio-grid',
|
|
label: 'Framework',
|
|
defaultValue: 'nextjs',
|
|
},
|
|
frameworkVariant: {
|
|
id: 'frameworkVariant',
|
|
type: 'select',
|
|
label: 'Variant',
|
|
dependsOn: { framework: ['nextjs', 'react'] },
|
|
},
|
|
frameworkUi: {
|
|
id: 'frameworkUi',
|
|
type: 'switch',
|
|
label: 'Shadcn',
|
|
dependsOn: { framework: ['nextjs', 'react'] },
|
|
},
|
|
}
|
|
)
|
|
|
|
// With nextjs - should show all fields
|
|
const nextjsFields = getActiveFields(schema, { mode: 'framework', framework: 'nextjs' })
|
|
expect(nextjsFields).toHaveLength(3)
|
|
|
|
// 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')
|
|
})
|
|
|
|
test('should handle multiple dependsOn conditions', () => {
|
|
const schema = createSchemaWithFields(
|
|
[
|
|
{
|
|
id: 'direct',
|
|
label: 'Direct',
|
|
description: '',
|
|
fields: ['connectionMethod', 'useSharedPooler'],
|
|
},
|
|
],
|
|
{
|
|
connectionMethod: {
|
|
id: 'connectionMethod',
|
|
type: 'radio-list',
|
|
label: 'Method',
|
|
defaultValue: 'direct',
|
|
},
|
|
useSharedPooler: {
|
|
id: 'useSharedPooler',
|
|
type: 'switch',
|
|
label: 'Use Shared Pooler',
|
|
dependsOn: { connectionMethod: ['transaction'] },
|
|
},
|
|
}
|
|
)
|
|
|
|
// Transaction mode - show shared pooler option
|
|
const transactionFields = getActiveFields(schema, {
|
|
mode: 'direct',
|
|
connectionMethod: 'transaction',
|
|
})
|
|
expect(transactionFields).toHaveLength(2)
|
|
|
|
// Direct mode - hide shared pooler option
|
|
const directFields = getActiveFields(schema, { mode: 'direct', connectionMethod: 'direct' })
|
|
expect(directFields).toHaveLength(1)
|
|
expect(directFields[0].id).toBe('connectionMethod')
|
|
})
|
|
|
|
test('should return empty array for invalid mode', () => {
|
|
const schema = createSchemaWithFields(
|
|
[{ id: 'framework', label: 'Framework', description: '', fields: ['framework'] }],
|
|
{ framework: { id: 'framework', type: 'radio-grid', label: 'Framework' } }
|
|
)
|
|
|
|
const fields = getActiveFields(schema, { mode: 'invalid' as any })
|
|
expect(fields).toEqual([])
|
|
})
|
|
|
|
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
|
|
},
|
|
}
|
|
)
|
|
|
|
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([])
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// getDefaultState Tests
|
|
// ============================================================================
|
|
|
|
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: {
|
|
framework: {
|
|
id: 'framework',
|
|
type: 'radio-grid',
|
|
label: 'Framework',
|
|
defaultValue: 'nextjs',
|
|
},
|
|
library: {
|
|
id: 'library',
|
|
type: 'select',
|
|
label: 'Library',
|
|
defaultValue: 'supabasejs',
|
|
},
|
|
mcpReadonly: {
|
|
id: 'mcpReadonly',
|
|
type: 'switch',
|
|
label: 'Readonly',
|
|
defaultValue: false,
|
|
},
|
|
},
|
|
steps: [],
|
|
}
|
|
|
|
const state = getDefaultState({ schema })
|
|
expect(state.framework).toBe('nextjs')
|
|
expect(state.library).toBe('supabasejs')
|
|
expect(state.mcpReadonly).toBe(false)
|
|
})
|
|
|
|
test('should fallback to "direct" if no modes defined', () => {
|
|
const schema: ConnectSchema = {
|
|
modes: [],
|
|
fields: {},
|
|
steps: [],
|
|
}
|
|
|
|
const state = getDefaultState({ schema })
|
|
expect(state.mode).toBe('direct')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// 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'] },
|
|
},
|
|
frameworkUi: {
|
|
id: 'frameworkUi',
|
|
type: 'switch',
|
|
label: 'Shadcn',
|
|
dependsOn: { framework: ['nextjs', 'react'] },
|
|
},
|
|
connectionMethod: {
|
|
id: 'connectionMethod',
|
|
type: 'radio-list',
|
|
label: 'Method',
|
|
defaultValue: 'direct',
|
|
},
|
|
},
|
|
steps: [],
|
|
})
|
|
|
|
test('should reset dependent fields when dependency no longer satisfied', () => {
|
|
const schema = createSchemaForReset()
|
|
const state: ConnectState = {
|
|
mode: 'framework',
|
|
framework: 'vue', // Changed from nextjs to vue
|
|
frameworkVariant: 'app', // This should be reset
|
|
frameworkUi: true, // This should be reset
|
|
}
|
|
|
|
const newState = resetDependentFields(state, 'framework', schema)
|
|
|
|
expect(newState.frameworkVariant).toBeUndefined()
|
|
expect(newState.frameworkUi).toBeUndefined()
|
|
})
|
|
|
|
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',
|
|
}
|
|
|
|
// Note: The current implementation of resetDependentFields for mode changes
|
|
// looks for fields not in the current mode, but the logic compares against previous mode
|
|
const newState = resetDependentFields(state, 'mode', schema)
|
|
|
|
// Mode-specific field reset logic is handled
|
|
expect(newState.mode).toBe('direct')
|
|
})
|
|
|
|
test('should not modify state for fields without dependencies', () => {
|
|
const schema = createSchemaForReset()
|
|
const state: ConnectState = {
|
|
mode: 'framework',
|
|
framework: 'nextjs',
|
|
}
|
|
|
|
const newState = resetDependentFields(state, 'framework', schema)
|
|
|
|
expect(newState.mode).toBe('framework')
|
|
expect(newState.framework).toBe('nextjs')
|
|
})
|
|
})
|