mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 15:49:09 +03:00
just the button
This commit is contained in:
1 parent
0a7ae0d1d8
commit
dbbbfed3f8
58 files changed
+8
-7326
No files matched your search
@@ -1,412 +0,0 @@
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { CodeBlockLang } from 'ui'
|
||||
|
||||
export type DatabaseConnectionType =
|
||||
| 'uri'
|
||||
| 'psql'
|
||||
| 'golang'
|
||||
| 'jdbc'
|
||||
| 'dotnet'
|
||||
| 'nodejs'
|
||||
| 'php'
|
||||
| 'python'
|
||||
| 'sqlalchemy'
|
||||
|
||||
export const DATABASE_CONNECTION_TYPES: {
|
||||
id: DatabaseConnectionType
|
||||
label: string
|
||||
contentType: 'input' | 'code'
|
||||
lang: CodeBlockLang
|
||||
fileTitle: string | undefined
|
||||
}[] = [
|
||||
{ id: 'uri', label: 'URI', contentType: 'input', lang: 'bash', fileTitle: undefined },
|
||||
{ id: 'psql', label: 'PSQL', contentType: 'code', lang: 'bash', fileTitle: undefined },
|
||||
{ id: 'golang', label: 'Golang', contentType: 'code', lang: 'go', fileTitle: '.env' },
|
||||
{ id: 'jdbc', label: 'JDBC', contentType: 'input', lang: 'bash', fileTitle: undefined },
|
||||
{
|
||||
id: 'dotnet',
|
||||
label: '.NET',
|
||||
contentType: 'code',
|
||||
lang: 'csharp',
|
||||
fileTitle: 'appsettings.json',
|
||||
},
|
||||
{ id: 'nodejs', label: 'Node.js', contentType: 'code', lang: 'js', fileTitle: '.env' },
|
||||
{ id: 'php', label: 'PHP', contentType: 'code', lang: 'php', fileTitle: '.env' },
|
||||
{ id: 'python', label: 'Python', contentType: 'code', lang: 'python', fileTitle: '.env' },
|
||||
{ id: 'sqlalchemy', label: 'SQLAlchemy', contentType: 'code', lang: 'python', fileTitle: '.env' },
|
||||
]
|
||||
|
||||
export const CONNECTION_PARAMETERS = {
|
||||
host: {
|
||||
key: 'host',
|
||||
description: 'The hostname of your database',
|
||||
},
|
||||
port: {
|
||||
key: 'port',
|
||||
description: 'Port number for the connection',
|
||||
},
|
||||
database: {
|
||||
key: 'database',
|
||||
description: 'Default database name',
|
||||
},
|
||||
user: {
|
||||
key: 'user',
|
||||
description: 'Database user',
|
||||
},
|
||||
pool_mode: {
|
||||
key: 'pool_mode',
|
||||
description: 'Connection pooling behavior',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type ConnectionType = {
|
||||
key: string
|
||||
icon: string
|
||||
label: string
|
||||
guideLink?: string
|
||||
children: ConnectionType[]
|
||||
files?: {
|
||||
name: string
|
||||
content: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export const FRAMEWORKS: ConnectionType[] = [
|
||||
{
|
||||
key: 'nextjs',
|
||||
label: 'Next.js',
|
||||
icon: 'nextjs',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/nextjs`,
|
||||
children: [
|
||||
{
|
||||
key: 'app',
|
||||
label: 'App Router',
|
||||
icon: '',
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'supabase-js',
|
||||
icon: 'supabase',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pages',
|
||||
label: 'Pages Router',
|
||||
icon: '',
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'remix',
|
||||
label: 'Remix',
|
||||
icon: 'remix',
|
||||
guideLink: `${DOCS_URL}/guides/auth/server-side/creating-a-client?framework=remix&environment=remix-loader`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'react',
|
||||
label: 'React',
|
||||
icon: 'react',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/reactjs`,
|
||||
children: [
|
||||
{
|
||||
key: 'create-react-app',
|
||||
label: 'Create React App',
|
||||
icon: 'react',
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'supabase-js',
|
||||
icon: 'supabase',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vite',
|
||||
label: 'Vite',
|
||||
icon: 'vite',
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'nuxt',
|
||||
label: 'Nuxt',
|
||||
icon: 'nuxt',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/nuxtjs`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vuejs',
|
||||
label: 'Vue.JS',
|
||||
icon: 'vuejs',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/vue`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'sveltekit',
|
||||
label: 'SvelteKit',
|
||||
icon: 'sveltekit',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/sveltekit`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'solidjs',
|
||||
label: 'Solid.js',
|
||||
icon: 'solidjs',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/solidjs`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'astro',
|
||||
label: 'Astro',
|
||||
icon: 'astro',
|
||||
guideLink: 'https://docs.astro.build/en/guides/backend/supabase/',
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'refine',
|
||||
label: 'Refine',
|
||||
icon: 'refine',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/refine`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tanstack',
|
||||
label: 'TanStack Start',
|
||||
icon: 'tanstack',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/tanstack`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'flask',
|
||||
label: 'Flask (Python)',
|
||||
icon: 'python',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/flask`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasepy',
|
||||
label: 'supabase-py',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const MOBILES: ConnectionType[] = [
|
||||
{
|
||||
key: 'exporeactnative',
|
||||
label: 'Expo React Native',
|
||||
icon: 'expo',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/quickstarts/expo-react-native`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'flutter',
|
||||
label: 'Flutter',
|
||||
icon: 'flutter',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/tutorials/with-flutter`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabaseflutter',
|
||||
label: 'supabase-flutter',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ionicreact',
|
||||
label: 'Ionic React',
|
||||
icon: 'react',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/tutorials/with-ionic-react`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'swift',
|
||||
label: 'Swift',
|
||||
icon: 'swift',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/tutorials/with-swift`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabaseswift',
|
||||
label: 'supabase-swift',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'androidkotlin',
|
||||
label: 'Android Kotlin',
|
||||
icon: 'kotlin',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/tutorials/with-kotlin`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasekt',
|
||||
label: 'supabase-kt',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ionicangular',
|
||||
label: 'Ionic Angular',
|
||||
icon: 'ionic-angular',
|
||||
guideLink: `${DOCS_URL}/guides/getting-started/tutorials/with-ionic-angular`,
|
||||
children: [
|
||||
{
|
||||
key: 'supabasejs',
|
||||
label: 'Supabase-js',
|
||||
children: [],
|
||||
icon: 'supabase',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const ORMS: ConnectionType[] = [
|
||||
{
|
||||
key: 'prisma',
|
||||
label: 'Prisma',
|
||||
icon: 'prisma',
|
||||
guideLink: 'https://supabase.com/partners/integrations/prisma',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
key: 'drizzle',
|
||||
label: 'Drizzle',
|
||||
icon: 'drizzle',
|
||||
guideLink: `${DOCS_URL}/guides/database/connecting-to-postgres#connecting-with-drizzle`,
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
|
||||
export const CONNECTION_TYPES = [
|
||||
{ key: 'direct', label: 'Connection String', obj: [] },
|
||||
{ key: 'frameworks', label: 'App Frameworks', obj: FRAMEWORKS },
|
||||
{ key: 'mobiles', label: 'Mobile Frameworks', obj: MOBILES },
|
||||
{ key: 'orms', label: 'ORMs', obj: ORMS },
|
||||
{ key: 'mcp', label: 'MCP', obj: [] },
|
||||
]
|
||||
|
||||
export const PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT =
|
||||
'Purchase IPv4 add-on or use Shared Pooler if on a IPv4 network'
|
||||
export const IPV4_ADDON_TEXT = 'Connections are IPv4 proxied with IPv4 add-on'
|
||||
|
||||
export type ConnectionStringMethod = 'direct' | 'transaction' | 'session'
|
||||
|
||||
export const connectionStringMethodOptions: Record<
|
||||
ConnectionStringMethod,
|
||||
{ value: string; label: string; description: string }
|
||||
> = {
|
||||
direct: {
|
||||
value: 'direct',
|
||||
label: 'Direct connection',
|
||||
description:
|
||||
'Ideal for applications with persistent and long-lived connections, such as those running on virtual machines or long-standing containers.',
|
||||
},
|
||||
transaction: {
|
||||
value: 'transaction',
|
||||
label: 'Transaction pooler',
|
||||
description:
|
||||
'Ideal for stateless applications like serverless functions where each interaction with Postgres is brief and isolated.',
|
||||
},
|
||||
session: {
|
||||
value: 'session',
|
||||
label: 'Session pooler',
|
||||
description:
|
||||
'Only recommended as an alternative to Direct Connection, when connecting via an IPv4 network.',
|
||||
},
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// ============================================================================
|
||||
// Project Keys (existing)
|
||||
// ============================================================================
|
||||
|
||||
export type ProjectKeys = {
|
||||
apiUrl: string | null
|
||||
anonKey: string | null
|
||||
publishableKey: string | null
|
||||
}
|
||||
|
||||
/** @deprecated Use ProjectKeys instead */
|
||||
export type projectKeys = ProjectKeys
|
||||
|
||||
// ============================================================================
|
||||
// Connection Strings
|
||||
// ============================================================================
|
||||
|
||||
export interface ConnectionStringPooler {
|
||||
transactionShared: string
|
||||
sessionShared: string
|
||||
transactionDedicated?: string
|
||||
sessionDedicated?: string
|
||||
ipv4SupportedForDedicatedPooler: boolean
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A value that can be resolved conditionally based on state.
|
||||
* Resolution walks the tree using state values, falling back to DEFAULT.
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* framework: {
|
||||
* nextjs: 'NextJsContent',
|
||||
* react: 'ReactContent',
|
||||
* DEFAULT: 'GenericFrameworkContent'
|
||||
* },
|
||||
* direct: 'DirectContent',
|
||||
* DEFAULT: null
|
||||
* }
|
||||
*/
|
||||
export type ConditionalValue<T> =
|
||||
| T
|
||||
| {
|
||||
[stateValue: string]: ConditionalValue<T> | undefined
|
||||
DEFAULT?: 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema Types - Fields
|
||||
// ============================================================================
|
||||
|
||||
export type FieldType = 'select' | 'radio-grid' | 'radio-list' | 'switch' | 'multi-select'
|
||||
|
||||
export interface FieldOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
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[]>
|
||||
// Only show this field when these state conditions are met
|
||||
dependsOn?: Record<string, string[]>
|
||||
// Default value for this field
|
||||
defaultValue?: string | boolean | string[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema Types - Steps
|
||||
// ============================================================================
|
||||
|
||||
export interface StepDefinition {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
// Component identifier or content file path, can be conditional
|
||||
content: ConditionalValue<string | null>
|
||||
}
|
||||
|
||||
export type StepTree =
|
||||
| StepDefinition[]
|
||||
| {
|
||||
[fieldId: string]: StepFieldValueMap
|
||||
}
|
||||
|
||||
export type StepFieldValueMap = {
|
||||
[fieldValue: string]: StepTree | undefined
|
||||
DEFAULT?: StepTree
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema Types - Main Schema
|
||||
// ============================================================================
|
||||
|
||||
export interface ConnectSchema {
|
||||
modes: ModeDefinition[]
|
||||
fields: Record<string, FieldDefinition>
|
||||
// Steps are fully conditional based on state
|
||||
steps: StepTree
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ConnectState {
|
||||
mode: ConnectMode
|
||||
[fieldId: string]: string | boolean | string[]
|
||||
}
|
||||
|
||||
export interface ResolvedStep {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
content: string // Resolved component identifier
|
||||
}
|
||||
|
||||
export interface ResolvedField extends FieldDefinition {
|
||||
resolvedOptions: FieldOption[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Step Content Props - Unified props for all step content components
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Props passed to all step content components.
|
||||
* Components receive full state and can conditionally render whatever they need.
|
||||
*/
|
||||
export interface StepContentProps {
|
||||
state: ConnectState
|
||||
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,313 +0,0 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Box, Cable, Database, Sparkles } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
cn,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
|
||||
import { ClientSelectDropdown, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
|
||||
import { FrameworkSelector } from '../HomeNew/GettingStarted/FrameworkSelector'
|
||||
import { FRAMEWORKS, MOBILES } from './Connect.constants'
|
||||
import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types'
|
||||
import { ConnectionIcon } from './ConnectionIcon'
|
||||
|
||||
// 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[]
|
||||
state: Record<string, string | boolean | string[]>
|
||||
onFieldChange: (fieldId: string, value: string | boolean | string[]) => void
|
||||
getFieldOptions: (fieldId: string) => FieldOption[]
|
||||
}
|
||||
|
||||
export function ConnectConfigSection({
|
||||
activeFields,
|
||||
state,
|
||||
onFieldChange,
|
||||
getFieldOptions,
|
||||
}: ConnectConfigSectionProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const formLayoutClassName =
|
||||
'md:gap-8 md:[&>div:first-child]:!w-[calc(50%-16px)] md:[&_[data-formlayout-id=labelContainer]]:!w-[calc(50%-16px)]'
|
||||
|
||||
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) => {
|
||||
const options = getFieldOptions(field.id)
|
||||
const value = state[field.id]
|
||||
|
||||
// Skip fields with no options (or single option that's auto-selected)
|
||||
// Exception: switch and multi-select fields don't require options
|
||||
if (field.type !== 'switch' && field.type !== 'multi-select') {
|
||||
if (options.length === 0) return null
|
||||
if (options.length === 1) return null
|
||||
}
|
||||
|
||||
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}
|
||||
layout="flex-row-reverse"
|
||||
label={field.label}
|
||||
className={formLayoutClassName}
|
||||
>
|
||||
<RadioGroupStacked
|
||||
value={String(value ?? '')}
|
||||
onValueChange={(v) => onFieldChange(field.id, v)}
|
||||
className="flex-row gap-3 space-y-0"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<RadioGroupStackedItem
|
||||
key={option.value}
|
||||
id={`connect-${field.id}-${option.value}`}
|
||||
value={option.value}
|
||||
label=""
|
||||
className="flex-1 rounded-lg text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon && <ConnectionIcon icon={option.icon} />}
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
))}
|
||||
</RadioGroupStacked>
|
||||
</FormLayout>
|
||||
)
|
||||
|
||||
case 'radio-list':
|
||||
return (
|
||||
<FormLayout
|
||||
key={field.id}
|
||||
layout="flex-row-reverse"
|
||||
label={field.label}
|
||||
className={formLayoutClassName}
|
||||
>
|
||||
<RadioGroupStacked
|
||||
value={String(value ?? '')}
|
||||
onValueChange={(v) => onFieldChange(field.id, v)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<RadioGroupStackedItem
|
||||
key={option.value}
|
||||
id={`connect-${field.id}-${option.value}`}
|
||||
value={option.value}
|
||||
label=""
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon && <ConnectionIcon icon={option.icon} />}
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
<span className="text-sm text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
))}
|
||||
</RadioGroupStacked>
|
||||
</FormLayout>
|
||||
)
|
||||
|
||||
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}
|
||||
layout="flex-row-reverse"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
className={formLayoutClassName}
|
||||
>
|
||||
<Select_Shadcn_
|
||||
value={String(value ?? '')}
|
||||
onValueChange={(v) => onFieldChange(field.id, v)}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ size="small" className="w-full">
|
||||
<SelectValue_Shadcn_ />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{options.map((option) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormLayout>
|
||||
)
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<FormLayout
|
||||
key={field.id}
|
||||
layout="flex-row-reverse"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
className={formLayoutClassName}
|
||||
>
|
||||
<Switch
|
||||
id={field.id}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(v) => onFieldChange(field.id, v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
)
|
||||
|
||||
case 'multi-select':
|
||||
return (
|
||||
<FormLayout
|
||||
key={field.id}
|
||||
layout="flex-row-reverse"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
className={formLayoutClassName}
|
||||
>
|
||||
<MultiSelector
|
||||
values={Array.isArray(value) ? value : []}
|
||||
onValuesChange={(v) => onFieldChange(field.id, v)}
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
className="w-full"
|
||||
label="All features except Storage enabled by default"
|
||||
badgeLimit="wrap"
|
||||
showIcon={true}
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{options.map((option) => (
|
||||
<MultiSelectorItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-xs text-foreground-light">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</FormLayout>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { DatabaseSelector } from 'components/ui/DatabaseSelector'
|
||||
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 { useEffect, useMemo } from 'react'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, cn } from 'ui'
|
||||
|
||||
import type { ProjectKeys } from './Connect.types'
|
||||
import { ConnectConfigSection, ModeSelector } from './ConnectConfigSection'
|
||||
import { ConnectStepsSection } from './ConnectStepsSection'
|
||||
import { useConnectState } from './useConnectState'
|
||||
|
||||
interface ConnectSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -20,83 +7,6 @@ interface ConnectSheetProps {
|
||||
}
|
||||
|
||||
export const ConnectSheet = ({ open, onOpenChange, connectTab }: ConnectSheetProps) => {
|
||||
const {
|
||||
projectConnectionShowAppFrameworks: showAppFrameworks,
|
||||
projectConnectionShowMobileFrameworks: showMobileFrameworks,
|
||||
projectConnectionShowOrms: showOrms,
|
||||
} = useIsFeatureEnabled([
|
||||
'project_connection:show_app_frameworks',
|
||||
'project_connection:show_mobile_frameworks',
|
||||
'project_connection:show_orms',
|
||||
])
|
||||
|
||||
// Filter available modes based on feature flags
|
||||
const availableModeIds = useMemo(() => {
|
||||
const modes: string[] = []
|
||||
const showFrameworks = showAppFrameworks || showMobileFrameworks
|
||||
|
||||
if (showFrameworks) modes.push('framework')
|
||||
modes.push('direct')
|
||||
if (showOrms) modes.push('orm')
|
||||
modes.push('mcp')
|
||||
|
||||
return modes
|
||||
}, [showAppFrameworks, showMobileFrameworks, showOrms])
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
// Project keys for step components
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef }, { enabled: open })
|
||||
const { can: canReadAPIKeys } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'service_api_keys'
|
||||
)
|
||||
const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
|
||||
const { anonKey, publishableKey } = canReadAPIKeys
|
||||
? getKeys(apiKeys)
|
||||
: { anonKey: null, publishableKey: null }
|
||||
|
||||
const projectKeys: ProjectKeys = useMemo(() => {
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint ?? ''
|
||||
const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : ''
|
||||
|
||||
return {
|
||||
apiUrl: apiHost ?? null,
|
||||
anonKey: anonKey?.api_key ?? null,
|
||||
publishableKey: publishableKey?.api_key ?? null,
|
||||
}
|
||||
}, [
|
||||
settings?.app_config?.protocol,
|
||||
settings?.app_config?.endpoint,
|
||||
canReadAPIKeys,
|
||||
anonKey?.api_key,
|
||||
publishableKey?.api_key,
|
||||
])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (typeof connectTab === 'string') {
|
||||
if (connectTab === 'mcp') setMode('mcp')
|
||||
else if (connectTab === 'orms') setMode('orm')
|
||||
else if (connectTab === 'direct') setMode('direct')
|
||||
else if (connectTab === 'frameworks' || connectTab === 'mobiles') setMode('framework')
|
||||
}
|
||||
}, [connectTab, open, setMode])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent size="lg" className="flex flex-col gap-0 p-0 space-y-0" tabIndex={undefined}>
|
||||
@@ -106,36 +16,12 @@ export const ConnectSheet = ({ open, onOpenChange, connectTab }: ConnectSheetPro
|
||||
<SheetTitle>Connect to your project</SheetTitle>
|
||||
<SheetDescription>Choose how you want to use Supabase</SheetDescription>
|
||||
</div>
|
||||
<div className="w-full lg:w-auto">
|
||||
<DatabaseSelector
|
||||
align="end"
|
||||
buttonProps={{
|
||||
size: 'small',
|
||||
className: 'w-full lg:w-auto justify-between pr-2.5 [&_svg]:h-4',
|
||||
}}
|
||||
className="w-full [&>span]:w-1/2 [&>span]:md:w-auto"
|
||||
onSelectId={handleSourceChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* Configuration Section */}
|
||||
<div className="space-y-6 border-b p-8 shrink-0">
|
||||
<ModeSelector modes={availableModes} selected={state.mode} onChange={setMode} />
|
||||
<div className="border-t pt-8">
|
||||
<ConnectConfigSection
|
||||
activeFields={activeFields}
|
||||
state={state}
|
||||
onFieldChange={updateField}
|
||||
getFieldOptions={getFieldOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps Section */}
|
||||
<ConnectStepsSection steps={resolvedSteps} state={state} projectKeys={projectKeys} />
|
||||
<div className="space-y-6 border-b p-8 shrink-0"></div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
interface ConnectSheetStepProps {
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
isFirst?: boolean
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export const ConnectSheetStep = ({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
}: ConnectSheetStepProps) => {
|
||||
const lineTopClass = isFirst ? 'top-1/2' : '-top-6'
|
||||
const lineBottomClass = isLast ? 'bottom-1/2' : '-bottom-6'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-6', className)}
|
||||
data-connect-step
|
||||
data-step-title={title}
|
||||
data-step-description={description}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8 items-start">
|
||||
{/* Left column: number, title, description */}
|
||||
<div className="flex items-start gap-6 self-stretch">
|
||||
<div className="relative self-stretch shrink-0 w-6">
|
||||
<div className="absolute inset-0 flex items-start justify-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute left-[calc(50%-1px)] w-px bg-border opacity-60',
|
||||
lineTopClass,
|
||||
lineBottomClass
|
||||
)}
|
||||
/>
|
||||
<div className="relative z-10 flex font-mono text-xs items-center justify-center min-w-6 w-6 h-6 border border-default rounded-md bg-surface-100 text-foreground-light">
|
||||
{number}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
<p className="text-sm text-foreground-light">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: content */}
|
||||
<div className="lg:pl-0 [&_pre.code-block]:!bg-surface-75" data-step-content>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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'
|
||||
import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from 'data/database/supavisor-configuration-query'
|
||||
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { pluckObjectFields } from 'lib/helpers'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import type {
|
||||
ConnectionStringPooler,
|
||||
ConnectState,
|
||||
ProjectKeys,
|
||||
ResolvedStep,
|
||||
StepContentProps,
|
||||
} from './Connect.types'
|
||||
import { ConnectSheetStep } from './ConnectSheetStep'
|
||||
import { CopyPromptAdmonition } from './CopyPromptAdmonition'
|
||||
import { getConnectionStrings } from './DatabaseSettings.utils'
|
||||
|
||||
interface ConnectStepsSectionProps {
|
||||
steps: ResolvedStep[]
|
||||
state: ConnectState
|
||||
projectKeys: ProjectKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a content path template by replacing {{key}} placeholders with state values.
|
||||
* Empty segments are filtered out to handle optional state values like frameworkVariant.
|
||||
*
|
||||
* Examples:
|
||||
* - '{{framework}}/{{frameworkVariant}}/{{library}}' with state {framework: 'nextjs', frameworkVariant: 'app', library: 'supabasejs'}
|
||||
* → 'nextjs/app/supabasejs'
|
||||
* - '{{orm}}' with state {orm: 'prisma'}
|
||||
* → 'prisma'
|
||||
* - 'steps/install' (no templates)
|
||||
* → 'steps/install'
|
||||
*/
|
||||
function resolveContentPath(template: string, state: ConnectState): string {
|
||||
return template
|
||||
.replace(/\{\{(\w+)\}\}/g, (_, key) => String(state[key] ?? ''))
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and prepare connection strings for step content.
|
||||
*/
|
||||
function useConnectionStringPooler(): ConnectionStringPooler {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: selectedOrg } = useSelectedOrganizationQuery()
|
||||
const allowPgBouncerSelection = useMemo(() => selectedOrg?.plan.id !== 'free', [selectedOrg])
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef })
|
||||
const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef })
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
|
||||
|
||||
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
|
||||
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
|
||||
const connectionInfo = pluckObjectFields(settings || emptyState, DB_FIELDS)
|
||||
const poolingConfigurationShared = supavisorConfig?.find((x) => x.database_type === 'PRIMARY')
|
||||
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
|
||||
|
||||
const connectionStringsShared = getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationShared?.connection_string ?? '',
|
||||
db_host: poolingConfigurationShared?.db_host ?? '',
|
||||
db_name: poolingConfigurationShared?.db_name ?? '',
|
||||
db_port: poolingConfigurationShared?.db_port ?? 0,
|
||||
db_user: poolingConfigurationShared?.db_user ?? '',
|
||||
},
|
||||
metadata: { projectRef },
|
||||
})
|
||||
|
||||
const connectionStringsDedicated =
|
||||
poolingConfigurationDedicated !== undefined
|
||||
? getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationDedicated.connection_string,
|
||||
db_host: poolingConfigurationDedicated.db_host,
|
||||
db_name: poolingConfigurationDedicated.db_name,
|
||||
db_port: poolingConfigurationDedicated.db_port,
|
||||
db_user: poolingConfigurationDedicated.db_user,
|
||||
},
|
||||
metadata: { projectRef },
|
||||
})
|
||||
: undefined
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
transactionShared: connectionStringsShared.pooler.uri,
|
||||
sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'),
|
||||
transactionDedicated: connectionStringsDedicated?.pooler.uri,
|
||||
sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'),
|
||||
ipv4SupportedForDedicatedPooler: !!ipv4Addon,
|
||||
direct: connectionStringsShared.direct.uri,
|
||||
}),
|
||||
[connectionStringsShared, connectionStringsDedicated, ipv4Addon]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically loads and renders a content component from the content directory.
|
||||
* All step content uses this unified loader - no built-in component registry needed.
|
||||
*/
|
||||
function StepContent({
|
||||
contentId,
|
||||
state,
|
||||
projectKeys,
|
||||
connectionStringPooler,
|
||||
}: {
|
||||
contentId: string
|
||||
state: ConnectState
|
||||
projectKeys: ProjectKeys
|
||||
connectionStringPooler: ConnectionStringPooler
|
||||
}) {
|
||||
// Resolve any template placeholders in the content path
|
||||
const filePath = useMemo(() => resolveContentPath(contentId, state), [contentId, state])
|
||||
|
||||
// Dynamically import the content component
|
||||
const ContentComponent = useMemo(() => {
|
||||
return dynamic<StepContentProps>(() => import(`./content/${filePath}/content`), {
|
||||
loading: () => (
|
||||
<div className="p-4 min-h-[200px]">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}, [filePath])
|
||||
|
||||
return (
|
||||
<ContentComponent
|
||||
state={state}
|
||||
projectKeys={projectKeys}
|
||||
connectionStringPooler={connectionStringPooler}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsSectionProps) {
|
||||
const stepsContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const connectionStringPooler = useConnectionStringPooler()
|
||||
|
||||
if (steps.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 flex-1">
|
||||
<div className="p-8">
|
||||
<h3 className="heading-subTitle mb-6">Connect your app</h3>
|
||||
|
||||
<CopyPromptAdmonition stepsContainerRef={stepsContainerRef} />
|
||||
|
||||
<div className="space-y-0" ref={stepsContainerRef}>
|
||||
{steps.map((step, index) => (
|
||||
<ConnectSheetStep
|
||||
key={step.id}
|
||||
number={index + 1}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
isFirst={index === 0}
|
||||
isLast={index === steps.length - 1}
|
||||
>
|
||||
<StepContent
|
||||
contentId={step.content}
|
||||
state={state}
|
||||
projectKeys={projectKeys}
|
||||
connectionStringPooler={connectionStringPooler}
|
||||
/>
|
||||
</ConnectSheetStep>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useTheme } from 'next-themes'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { cn } from 'ui'
|
||||
|
||||
interface ConnectionIconProps {
|
||||
icon: string
|
||||
iconFolder?: string
|
||||
supportsDarkMode?: boolean
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ConnectionIcon = ({
|
||||
icon,
|
||||
iconFolder,
|
||||
supportsDarkMode,
|
||||
size = 14,
|
||||
className,
|
||||
}: ConnectionIconProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const imageFolder =
|
||||
iconFolder || (['ionic-angular'].includes(icon) ? 'icons/frameworks' : 'libraries')
|
||||
|
||||
const imageExtension = imageFolder === 'icons/frameworks' ? '' : '-icon'
|
||||
|
||||
const shouldUseDarkMode =
|
||||
supportsDarkMode ||
|
||||
['expo', 'nextjs', 'prisma', 'drizzle', 'astro', 'remix', 'refine'].includes(icon.toLowerCase())
|
||||
|
||||
const iconImgSrc = icon.startsWith('http')
|
||||
? icon
|
||||
: `${BASE_PATH}/img/${imageFolder}/${icon.toLowerCase()}${
|
||||
shouldUseDarkMode && resolvedTheme?.includes('dark') ? '-dark' : ''
|
||||
}${imageExtension}.svg`
|
||||
|
||||
return (
|
||||
<Image
|
||||
className={cn('transition-all group-hover:scale-110', className)}
|
||||
src={iconImgSrc}
|
||||
alt={`${icon} logo`}
|
||||
width={size}
|
||||
height={size}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { cn, copyToClipboard } from 'ui'
|
||||
|
||||
interface Parameter {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ConnectionParametersProps {
|
||||
parameters: Parameter[]
|
||||
}
|
||||
|
||||
export const ConnectionParameters = ({ parameters }: ConnectionParametersProps) => {
|
||||
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
|
||||
|
||||
return (
|
||||
<div className="bg-surface-75 rounded-lg border font-mono text-sm p-4">
|
||||
{parameters.map((param) => (
|
||||
<div key={param.key} className="py-0.5 group/param">
|
||||
<div className="text-xs flex items-center">
|
||||
<span className="text-foreground-lighter">{param.key}: </span>
|
||||
<span className="ml-1 text-foreground">{param.value}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(param.value, () => {
|
||||
setCopiedMap((prev) => ({ ...prev, [param.key]: true }))
|
||||
setTimeout(() => {
|
||||
setCopiedMap((prev) => ({ ...prev, [param.key]: false }))
|
||||
}, 1000)
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
'text-foreground-lighter',
|
||||
'ml-2 opacity-0 group-hover/param:opacity-100',
|
||||
'hover:text-foreground rounded-sm p-1',
|
||||
copiedMap[param.key] && 'opacity-100',
|
||||
'transition-all'
|
||||
)}
|
||||
>
|
||||
{copiedMap[param.key] ? (
|
||||
<Check size={12} strokeWidth={1.5} />
|
||||
) : (
|
||||
<Copy size={12} strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { ConnectionStringPooler } from './Connect.types'
|
||||
import type { ConnectionStringMethod } from './Connect.constants'
|
||||
|
||||
export const DEFAULT_PORT = '5432'
|
||||
export const PASSWORD_PLACEHOLDER = '[YOUR-PASSWORD]'
|
||||
|
||||
export type ConnectionParams = {
|
||||
host: string
|
||||
port: string
|
||||
user: string
|
||||
database: string
|
||||
}
|
||||
|
||||
export const resolveConnectionString = ({
|
||||
connectionMethod,
|
||||
useSharedPooler,
|
||||
connectionStringPooler,
|
||||
}: {
|
||||
connectionMethod: ConnectionStringMethod
|
||||
useSharedPooler: boolean
|
||||
connectionStringPooler: ConnectionStringPooler
|
||||
}) => {
|
||||
if (connectionMethod === 'direct') {
|
||||
return connectionStringPooler.direct ?? ''
|
||||
}
|
||||
|
||||
if (connectionMethod === 'session') {
|
||||
return connectionStringPooler.sessionShared ?? ''
|
||||
}
|
||||
|
||||
if (useSharedPooler || !connectionStringPooler.transactionDedicated) {
|
||||
return connectionStringPooler.transactionShared ?? ''
|
||||
}
|
||||
|
||||
return connectionStringPooler.transactionDedicated ?? ''
|
||||
}
|
||||
|
||||
export const parseConnectionParams = (connectionString: string): ConnectionParams => {
|
||||
if (!connectionString) {
|
||||
return {
|
||||
host: 'hidden',
|
||||
port: DEFAULT_PORT,
|
||||
user: 'hidden',
|
||||
database: 'hidden',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(connectionString)
|
||||
return {
|
||||
host: parsed.hostname || 'hidden',
|
||||
port: parsed.port || DEFAULT_PORT,
|
||||
user: parsed.username || 'hidden',
|
||||
database: parsed.pathname?.replace(/^\//, '') || 'hidden',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
host: 'hidden',
|
||||
port: DEFAULT_PORT,
|
||||
user: 'hidden',
|
||||
database: 'hidden',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSafeConnectionString = (
|
||||
connectionString: string,
|
||||
params: ConnectionParams
|
||||
): string => {
|
||||
if (!connectionString) return ''
|
||||
|
||||
const search = (() => {
|
||||
try {
|
||||
return new URL(connectionString).search
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
})()
|
||||
|
||||
return `postgresql://${params.user}:${PASSWORD_PLACEHOLDER}@${params.host}:${params.port}/${params.database}${search}`
|
||||
}
|
||||
|
||||
export const buildConnectionParameters = (params: ConnectionParams) => [
|
||||
{ key: 'host', value: params.host },
|
||||
{ key: 'port', value: params.port },
|
||||
{ key: 'database', value: params.database },
|
||||
{ key: 'user', value: params.user },
|
||||
]
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Badge, Button, copyToClipboard } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
interface CopyPromptAdmonitionProps {
|
||||
stepsContainerRef: RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function CopyPromptAdmonition({ stepsContainerRef }: CopyPromptAdmonitionProps) {
|
||||
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [hasCopiedPrompt, setHasCopiedPrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const normalizeTextLines = (value: string) => {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const getStepTextContent = (contentElement: HTMLElement) => {
|
||||
const clone = contentElement.cloneNode(true) as HTMLElement
|
||||
clone
|
||||
.querySelectorAll('pre, button, svg, input, textarea, select, [aria-hidden="true"]')
|
||||
.forEach((element) => element.remove())
|
||||
|
||||
const text = clone.textContent ?? ''
|
||||
return normalizeTextLines(text)
|
||||
}
|
||||
|
||||
const getStepCodeSnippets = (contentElement: HTMLElement) => {
|
||||
const snippets: Array<{ label: string; snippet: string }> = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const addSnippet = (label: string, snippet: string) => {
|
||||
if (!snippet || seen.has(snippet)) return
|
||||
seen.add(snippet)
|
||||
snippets.push({ label, snippet })
|
||||
}
|
||||
|
||||
const tabContents = Array.from(
|
||||
contentElement.querySelectorAll('[data-connect-tab-content]')
|
||||
) as HTMLElement[]
|
||||
|
||||
tabContents.forEach((tabContent) => {
|
||||
const label = tabContent.getAttribute('data-tab-label') || 'Code'
|
||||
const tabSnippets = Array.from(tabContent.querySelectorAll('pre'))
|
||||
.map((pre) => pre.textContent?.trim())
|
||||
.filter((snippet): snippet is string => Boolean(snippet))
|
||||
|
||||
if (tabSnippets.length === 0) {
|
||||
const inlineSnippets = Array.from(tabContent.querySelectorAll('code'))
|
||||
.filter((code) => !code.closest('pre') && code.closest('.font-mono'))
|
||||
.map((code) => code.textContent?.trim())
|
||||
.filter((snippet): snippet is string => Boolean(snippet))
|
||||
inlineSnippets.forEach((snippet, index) => {
|
||||
const inlineLabel = inlineSnippets.length > 1 ? `${label} (part ${index + 1})` : label
|
||||
addSnippet(inlineLabel, snippet)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tabSnippets.forEach((snippet, index) => {
|
||||
const tabLabel = tabSnippets.length > 1 ? `${label} (part ${index + 1})` : label
|
||||
addSnippet(tabLabel, snippet)
|
||||
})
|
||||
})
|
||||
|
||||
contentElement.querySelectorAll('pre').forEach((pre) => {
|
||||
if (pre.closest('[data-connect-tab-content]')) return
|
||||
const snippet = pre.textContent?.trim()
|
||||
if (snippet) addSnippet('Code', snippet)
|
||||
})
|
||||
|
||||
contentElement.querySelectorAll('code').forEach((code) => {
|
||||
if (code.closest('pre')) return
|
||||
if (code.closest('[data-connect-tab-content]')) return
|
||||
if (!code.closest('.font-mono')) return
|
||||
const snippet = code.textContent?.trim()
|
||||
if (snippet) addSnippet('Code', snippet)
|
||||
})
|
||||
|
||||
return snippets
|
||||
}
|
||||
|
||||
const handleCopyPrompt = () => {
|
||||
const stepElements = stepsContainerRef.current?.querySelectorAll('[data-connect-step]')
|
||||
if (!stepElements?.length) return
|
||||
|
||||
const promptContent = Array.from(stepElements)
|
||||
.map((stepElement, index) => {
|
||||
const title = stepElement.getAttribute('data-step-title') ?? `Step ${index + 1}`
|
||||
const description = stepElement.getAttribute('data-step-description') ?? ''
|
||||
const contentElement = stepElement.querySelector(
|
||||
'[data-step-content]'
|
||||
) as HTMLElement | null
|
||||
|
||||
const details = contentElement ? getStepTextContent(contentElement) : ''
|
||||
const codeSnippets = contentElement ? getStepCodeSnippets(contentElement) : []
|
||||
|
||||
const sections = [
|
||||
`${index + 1}. ${title}`,
|
||||
description,
|
||||
details ? `Details:\n${details}` : null,
|
||||
codeSnippets.length
|
||||
? `Code:\n${codeSnippets
|
||||
.map(({ label, snippet }) => `File: ${label}\n\`\`\`\n${snippet}\n\`\`\``)
|
||||
.join('\n\n')}`
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
|
||||
return sections.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
copyToClipboard(promptContent)
|
||||
setHasCopiedPrompt(true)
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current)
|
||||
}
|
||||
copyResetTimerRef.current = setTimeout(() => {
|
||||
setHasCopiedPrompt(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
type="tip"
|
||||
showIcon={false}
|
||||
layout="horizontal"
|
||||
className="mb-6"
|
||||
actions={
|
||||
<Button type="default" size="small" icon={<Copy size={14} />} onClick={handleCopyPrompt}>
|
||||
{hasCopiedPrompt ? 'Copied' : 'Copy prompt'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Background image */}
|
||||
<div className="absolute -inset-16 z-0 opacity-50">
|
||||
<img
|
||||
src={`${BASE_PATH}/img/reports/bg-grafana-dark.svg`}
|
||||
alt="Supabase Grafana"
|
||||
className="w-full h-full object-cover object-right hidden dark:block"
|
||||
/>
|
||||
<img
|
||||
src={`${BASE_PATH}/img/reports/bg-grafana-light.svg`}
|
||||
alt="Supabase Grafana"
|
||||
className="w-full h-full object-cover object-right dark:hidden"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-background-alternative to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center gap-y-2 md:gap-x-8 justify-between px-2">
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex flex-col gap-y-2 items-start">
|
||||
<Badge variant="success" className="-ml-0.5">
|
||||
Skip the steps
|
||||
</Badge>
|
||||
<p className="heading-default">Prompt your agent</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-lighter text-balance">
|
||||
Copy a prompt with everything your agent needs to connect your app for you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
type ConnectionStrings = {
|
||||
psql: string
|
||||
uri: string
|
||||
golang: string
|
||||
jdbc: string
|
||||
dotnet: string
|
||||
nodejs: string
|
||||
php: string
|
||||
python: string
|
||||
sqlalchemy: string
|
||||
}
|
||||
|
||||
export const getConnectionStrings = ({
|
||||
connectionInfo,
|
||||
poolingInfo,
|
||||
metadata,
|
||||
}: {
|
||||
connectionInfo: {
|
||||
db_user: string
|
||||
db_port: number
|
||||
db_host: string
|
||||
db_name: string
|
||||
}
|
||||
poolingInfo?: {
|
||||
connectionString: string
|
||||
db_user: string
|
||||
db_port: number
|
||||
db_host: string
|
||||
db_name: string
|
||||
}
|
||||
metadata: {
|
||||
projectRef?: string
|
||||
pgVersion?: string
|
||||
}
|
||||
}): {
|
||||
direct: ConnectionStrings
|
||||
pooler: ConnectionStrings
|
||||
} => {
|
||||
const isMd5 = poolingInfo?.connectionString.includes('options=reference')
|
||||
const { projectRef } = metadata
|
||||
const password = '[YOUR-PASSWORD]'
|
||||
|
||||
// Direct connection variables
|
||||
const directUser = connectionInfo.db_user
|
||||
const directPort = connectionInfo.db_port
|
||||
const directHost = connectionInfo.db_host
|
||||
const directName = connectionInfo.db_name
|
||||
|
||||
// Pooler connection variables
|
||||
const poolerUser = poolingInfo?.db_user
|
||||
const poolerPort = poolingInfo?.db_port
|
||||
const poolerHost = poolingInfo?.db_host
|
||||
const poolerName = poolingInfo?.db_name
|
||||
|
||||
// Direct connection strings
|
||||
const directPsqlString = isMd5
|
||||
? `psql "postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}"`
|
||||
: `psql -h ${directHost} -p ${directPort} -d ${directName} -U ${directUser}`
|
||||
|
||||
const directUriString = `postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}`
|
||||
|
||||
const directGolangString = `DATABASE_URL=${directUriString}`
|
||||
|
||||
const directJdbcString = `jdbc:postgresql://${directHost}:${directPort}/${directName}?user=${directUser}&password=${password}`
|
||||
|
||||
// User Id=${directUser};Password=${password};Server=${directHost};Port=${directPort};Database=${directName}`
|
||||
const directDotNetString = `{
|
||||
"ConnectionStrings": {
|
||||
"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": {
|
||||
"DefaultConnection": "User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}"
|
||||
}
|
||||
}`
|
||||
|
||||
const directNodejsString = `DATABASE_URL=${directUriString}`
|
||||
|
||||
// Pooler connection strings
|
||||
const poolerPsqlString = isMd5
|
||||
? `psql "postgresql://${poolerUser}:${password}@${poolerHost}:${poolerPort}/${poolerName}?options=reference%3D${projectRef}"`
|
||||
: `psql -h ${poolerHost} -p ${poolerPort} -d ${poolerName} -U ${poolerUser}`
|
||||
|
||||
const poolerUriString = poolingInfo?.connectionString ?? ''
|
||||
|
||||
const nodejsPoolerUriString = `DATABASE_URL=${poolingInfo?.connectionString}`
|
||||
|
||||
const poolerGolangString = `user=${poolerUser}
|
||||
password=${password}
|
||||
host=${poolerHost}
|
||||
port=${poolerPort}
|
||||
dbname=${poolerName}${isMd5 ? `options=reference=${projectRef}` : ''}`
|
||||
|
||||
const poolerJdbcString = `jdbc:postgresql://${poolerHost}:${poolerPort}/${poolerName}?user=${poolerUser}${isMd5 ? `&options=reference%3D${projectRef}` : ''}&password=${password}`
|
||||
|
||||
const sqlalchemyString = `user=${directUser}
|
||||
password=${password}
|
||||
host=${directHost}
|
||||
port=${directPort}
|
||||
dbname=${directName}`
|
||||
|
||||
const poolerSqlalchemyString = `user=${poolerUser}
|
||||
password=${password}
|
||||
host=${poolerHost}
|
||||
port=${poolerPort}
|
||||
dbname=${poolerName}`
|
||||
|
||||
return {
|
||||
direct: {
|
||||
psql: directPsqlString,
|
||||
uri: directUriString,
|
||||
golang: directGolangString,
|
||||
jdbc: directJdbcString,
|
||||
dotnet: directDotNetString,
|
||||
nodejs: directNodejsString,
|
||||
php: directGolangString,
|
||||
python: directGolangString,
|
||||
sqlalchemy: sqlalchemyString,
|
||||
},
|
||||
pooler: {
|
||||
psql: poolerPsqlString,
|
||||
uri: poolerUriString,
|
||||
golang: poolerGolangString,
|
||||
jdbc: poolerJdbcString,
|
||||
dotnet: poolerDotNetString,
|
||||
nodejs: nodejsPoolerUriString,
|
||||
php: poolerGolangString,
|
||||
python: poolerGolangString,
|
||||
sqlalchemy: poolerSqlalchemyString,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const DB_USER_DESC = 'Database user (e.g postgres)'
|
||||
const DB_PASS_DESC = 'Database password'
|
||||
const DB_NAME_DESC = 'Database name (e.g postgres)'
|
||||
const PROJECT_REF_DESC = "Project's reference ID"
|
||||
const PORT_NUMBER_DESC = 'Port number (Use 5432 if using prepared statements)'
|
||||
|
||||
// [Joshen] This is to the best of interpreting the syntax from the API response
|
||||
// // There's different format for PG13 (depending on authentication method being md5) and PG14
|
||||
export const constructConnStringSyntax = (
|
||||
connString: string,
|
||||
{
|
||||
selectedTab,
|
||||
usePoolerConnection,
|
||||
ref,
|
||||
cloudProvider,
|
||||
region,
|
||||
tld,
|
||||
portNumber,
|
||||
}: {
|
||||
selectedTab: 'uri' | 'psql' | 'golang' | 'jdbc' | 'dotnet' | 'nodejs' | 'php' | 'python'
|
||||
usePoolerConnection: boolean
|
||||
ref: string
|
||||
cloudProvider: string
|
||||
region: string
|
||||
tld: string
|
||||
portNumber: string
|
||||
}
|
||||
) => {
|
||||
const isMd5 = connString.includes('options=reference')
|
||||
const poolerHostDetails = [
|
||||
{ value: cloudProvider.toLocaleLowerCase(), tooltip: 'Cloud provider' },
|
||||
{ value: '-0-', tooltip: undefined },
|
||||
{ value: region, tooltip: "Project's region" },
|
||||
{ value: `.pooler.supabase.${tld}`, tooltip: undefined },
|
||||
]
|
||||
const dbHostDetails = [
|
||||
{ value: 'db.', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
{ value: `.supabase.${tld}`, tooltip: undefined },
|
||||
]
|
||||
|
||||
if (selectedTab === 'uri' || selectedTab === 'nodejs') {
|
||||
if (isMd5) {
|
||||
return [
|
||||
{ value: 'postgresql://', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
{ value: '@', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: '/', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: `?options=reference%3D`, tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'postgresql://', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: '.', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
{ value: '@', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: '/', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTab === 'psql') {
|
||||
if (isMd5) {
|
||||
return [
|
||||
{ value: 'psql "postgresql://', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
{ value: '@', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: '/', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: '?options=reference%3D', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'psql -h ', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ' -p ', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: ' -d ', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
{ value: ' -U ', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: '.', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTab === 'golang' || selectedTab === 'php' || selectedTab === 'python') {
|
||||
if (isMd5) {
|
||||
return [
|
||||
{ value: 'user=', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
{ value: ' password=', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
{ value: ' host=', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ' port=', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: ' dbname=', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: ' options=reference=', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'user=', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: '.', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
{ value: ' password=', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
{ value: ' host=', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ' port=', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: ' dbname=', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTab === 'jdbc') {
|
||||
if (isMd5) {
|
||||
return [
|
||||
{ value: 'jdbc:postgresql://', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: '/', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
{ value: '?user=', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
{ value: '&password=', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: '&options=reference%3D', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'jdbc:postgresql://', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: `:`, tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: '/', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
{ value: '?user=', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: '.', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
{ value: '&password=', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTab === 'dotnet') {
|
||||
if (isMd5) {
|
||||
return [
|
||||
{ value: 'User Id=', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
{ value: ';Password=', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
{ value: ';Server=', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ';Port=', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: ';Database=', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: ";Options='reference=", tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
{ value: "'", tooltip: undefined },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'User Id=', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
{ value: '.', tooltip: undefined },
|
||||
{ value: ref, tooltip: PROJECT_REF_DESC },
|
||||
]
|
||||
: []),
|
||||
{ value: ';Password=', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
{ value: ';Server=', tooltip: undefined },
|
||||
...(usePoolerConnection ? poolerHostDetails : dbHostDetails),
|
||||
{ value: ';Port=', tooltip: undefined },
|
||||
{ value: portNumber, tooltip: PORT_NUMBER_DESC },
|
||||
{ value: ';Database=', tooltip: undefined },
|
||||
{ value: '[db-name]', tooltip: DB_NAME_DESC },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
export type Example = {
|
||||
installCommands?: string[]
|
||||
postInstallCommands?: string[]
|
||||
files?: {
|
||||
name: string
|
||||
content: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const examples = {
|
||||
nodejs: {
|
||||
installCommands: ['npm install postgres'],
|
||||
files: [
|
||||
{
|
||||
name: 'db.js',
|
||||
content: `import postgres from 'postgres'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
const sql = postgres(connectionString)
|
||||
|
||||
export default sql`,
|
||||
},
|
||||
],
|
||||
},
|
||||
golang: {
|
||||
installCommands: ['go get github.com/jackc/pgx/v5'],
|
||||
files: [
|
||||
{
|
||||
name: 'main.go',
|
||||
content: `package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func main() {
|
||||
conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to the database: %v", err)
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
// Example query to test connection
|
||||
var version string
|
||||
if err := conn.QueryRow(context.Background(), "SELECT version()").Scan(&version); err != nil {
|
||||
log.Fatalf("Query failed: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Connected to:", version)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
dotnet: {
|
||||
installCommands: [
|
||||
'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION',
|
||||
],
|
||||
postInstallCommands: [
|
||||
'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION',
|
||||
],
|
||||
},
|
||||
python: {
|
||||
installCommands: ['pip install python-dotenv psycopg2'],
|
||||
files: [
|
||||
{
|
||||
name: 'main.py',
|
||||
content: `import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load environment variables from .env
|
||||
load_dotenv()
|
||||
|
||||
# Fetch variables
|
||||
USER = os.getenv("user")
|
||||
PASSWORD = os.getenv("password")
|
||||
HOST = os.getenv("host")
|
||||
PORT = os.getenv("port")
|
||||
DBNAME = os.getenv("dbname")
|
||||
|
||||
# Connect to the database
|
||||
try:
|
||||
connection = psycopg2.connect(
|
||||
user=USER,
|
||||
password=PASSWORD,
|
||||
host=HOST,
|
||||
port=PORT,
|
||||
dbname=DBNAME
|
||||
)
|
||||
print("Connection successful!")
|
||||
|
||||
# Create a cursor to execute SQL queries
|
||||
cursor = connection.cursor()
|
||||
|
||||
# Example query
|
||||
cursor.execute("SELECT NOW();")
|
||||
result = cursor.fetchone()
|
||||
print("Current Time:", result)
|
||||
|
||||
# Close the cursor and connection
|
||||
cursor.close()
|
||||
connection.close()
|
||||
print("Connection closed.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to connect: {e}")`,
|
||||
},
|
||||
],
|
||||
},
|
||||
sqlalchemy: {
|
||||
installCommands: ['pip install python-dotenv sqlalchemy psycopg2'],
|
||||
files: [
|
||||
{
|
||||
name: 'main.py',
|
||||
content: `from sqlalchemy import create_engine
|
||||
# from sqlalchemy.pool import NullPool
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load environment variables from .env
|
||||
load_dotenv()
|
||||
|
||||
# Fetch variables
|
||||
USER = os.getenv("user")
|
||||
PASSWORD = os.getenv("password")
|
||||
HOST = os.getenv("host")
|
||||
PORT = os.getenv("port")
|
||||
DBNAME = os.getenv("dbname")
|
||||
|
||||
# Construct the SQLAlchemy connection string
|
||||
DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require"
|
||||
|
||||
# Create the SQLAlchemy engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
# If using Transaction Pooler or Session Pooler, we want to ensure we disable SQLAlchemy client side pooling -
|
||||
# https://docs.sqlalchemy.org/en/20/core/pooling.html#switching-pool-implementations
|
||||
# engine = create_engine(DATABASE_URL, poolclass=NullPool)
|
||||
|
||||
# Test the connection
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
print("Connection successful!")
|
||||
except Exception as e:
|
||||
print(f"Failed to connect: {e}")`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default examples
|
||||
@@ -1,624 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { ConditionalValue, ConnectSchema, ConnectState, StepTree } from './Connect.types'
|
||||
import {
|
||||
getActiveFields,
|
||||
getDefaultState,
|
||||
resetDependentFields,
|
||||
resolveConditional,
|
||||
resolveSteps,
|
||||
} from './connect.resolver'
|
||||
|
||||
// ============================================================================
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
@@ -1,254 +0,0 @@
|
||||
import type {
|
||||
ConditionalValue,
|
||||
ConnectSchema,
|
||||
ConnectState,
|
||||
FieldOption,
|
||||
ResolvedField,
|
||||
ResolvedStep,
|
||||
StepDefinition,
|
||||
StepFieldValueMap,
|
||||
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)
|
||||
*/
|
||||
function isConditionalObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a conditional value based on current state.
|
||||
* 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']
|
||||
*
|
||||
* 1. Look up 'mcp' (state.mode value) in tree -> found, continue
|
||||
* 2. At mcp subtree { codex: [...], DEFAULT: [...] }, skip irrelevant keys
|
||||
* until we find a key whose state value matches an entry in the object
|
||||
* 3. Look up 'codex' (state.mcpClient value) in that subtree -> found, return value
|
||||
* 4. If no state key matches, try DEFAULT at that level
|
||||
*/
|
||||
export function resolveConditional<T>(
|
||||
value: ConditionalValue<T>,
|
||||
state: ConnectState,
|
||||
stateKeys: readonly string[] = STATE_KEY_ORDER
|
||||
): T | undefined {
|
||||
// Base case: we've reached a leaf value (string, array, null, boolean, etc.)
|
||||
if (!isConditionalObject(value)) {
|
||||
return value as T
|
||||
}
|
||||
|
||||
const conditionalObj = value as Record<string, ConditionalValue<T>>
|
||||
const objectKeys = Object.keys(conditionalObj).filter((k) => k !== 'DEFAULT')
|
||||
|
||||
// Try each state key in order to find one that matches an entry in the object
|
||||
for (let i = 0; i < stateKeys.length; i++) {
|
||||
const currentKey = stateKeys[i]
|
||||
const stateValue = String(state[currentKey] ?? '')
|
||||
|
||||
// If this state value matches a key in the conditional object, use it
|
||||
if (stateValue && objectKeys.includes(stateValue)) {
|
||||
const nextValue = conditionalObj[stateValue]
|
||||
// Continue resolving with remaining keys (after this one)
|
||||
return resolveConditional(nextValue, state, stateKeys.slice(i + 1))
|
||||
}
|
||||
}
|
||||
|
||||
// No state key matched - use DEFAULT if available
|
||||
if (conditionalObj.DEFAULT !== undefined) {
|
||||
return resolveConditional(conditionalObj.DEFAULT, state, stateKeys)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the steps array based on current state.
|
||||
* Returns only steps that have non-null content.
|
||||
*/
|
||||
export function resolveSteps(schema: ConnectSchema, state: ConnectState): ResolvedStep[] {
|
||||
const steps = resolveStepTree(schema.steps, state)
|
||||
if (steps.length === 0) return []
|
||||
|
||||
return steps
|
||||
.map((step) => {
|
||||
const content = resolveConditional<string | null>(step.content, state)
|
||||
return {
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
description: step.description,
|
||||
content: content ?? '',
|
||||
}
|
||||
})
|
||||
.filter((step) => step.content !== '' && step.content !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a step tree by evaluating field-specific branches in insertion order.
|
||||
* Each matching branch appends its steps to the final list.
|
||||
*/
|
||||
function resolveStepTree(tree: StepTree, state: ConnectState): StepDefinition[] {
|
||||
if (Array.isArray(tree)) return tree
|
||||
if (!isConditionalObject(tree)) return []
|
||||
|
||||
const resolved: StepDefinition[] = []
|
||||
|
||||
for (const [fieldId, valueMap] of Object.entries(tree)) {
|
||||
if (fieldId === 'DEFAULT') continue
|
||||
if (!isConditionalObject(valueMap)) continue
|
||||
|
||||
const branch = resolveStepBranch(valueMap as StepFieldValueMap, state[fieldId])
|
||||
if (!branch) continue
|
||||
|
||||
resolved.push(...resolveStepTree(branch, state))
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function resolveStepBranch(
|
||||
valueMap: StepFieldValueMap,
|
||||
stateValue: ConnectState[keyof ConnectState] | undefined
|
||||
): StepTree | undefined {
|
||||
const key = String(stateValue ?? '')
|
||||
if (key && Object.prototype.hasOwnProperty.call(valueMap, key)) {
|
||||
return valueMap[key]
|
||||
}
|
||||
|
||||
if (valueMap.DEFAULT !== undefined) {
|
||||
return valueMap.DEFAULT
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 []
|
||||
|
||||
return currentMode.fields
|
||||
.map((fieldId) => schema.fields[fieldId])
|
||||
.filter((field): field is NonNullable<typeof field> => !!field)
|
||||
.filter((field) => {
|
||||
// Check dependsOn conditions
|
||||
if (!field.dependsOn) return true
|
||||
return Object.entries(field.dependsOn).every(([key, values]) => {
|
||||
const stateValue = String(state[key] ?? '')
|
||||
return values.includes(stateValue)
|
||||
})
|
||||
})
|
||||
.map((field) => ({
|
||||
...field,
|
||||
resolvedOptions: resolveFieldOptions(field, state),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves field options based on current state.
|
||||
*/
|
||||
function resolveFieldOptions(field: { options?: unknown }, state: ConnectState): FieldOption[] {
|
||||
if (!field.options) return []
|
||||
|
||||
// Static options array
|
||||
if (Array.isArray(field.options)) {
|
||||
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 []
|
||||
}
|
||||
|
||||
// Conditional options
|
||||
const resolved = resolveConditional<FieldOption[]>(
|
||||
field.options as ConditionalValue<FieldOption[]>,
|
||||
state
|
||||
)
|
||||
return resolved ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { connectSchema, INSTALL_COMMANDS } from './connect.schema'
|
||||
import { resolveSteps } from './connect.resolver'
|
||||
import type { ConnectState } from './Connect.types'
|
||||
|
||||
// ============================================================================
|
||||
// Schema Structure Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('connect.schema:structure', () => {
|
||||
test('should have all required modes', () => {
|
||||
const modeIds = connectSchema.modes.map((m) => m.id)
|
||||
expect(modeIds).toContain('framework')
|
||||
expect(modeIds).toContain('direct')
|
||||
expect(modeIds).toContain('orm')
|
||||
expect(modeIds).toContain('mcp')
|
||||
})
|
||||
|
||||
test('each mode should have required properties', () => {
|
||||
connectSchema.modes.forEach((mode) => {
|
||||
expect(mode.id).toBeDefined()
|
||||
expect(mode.label).toBeDefined()
|
||||
expect(mode.description).toBeDefined()
|
||||
expect(mode.fields).toBeDefined()
|
||||
expect(Array.isArray(mode.fields)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('framework mode should have correct fields', () => {
|
||||
const frameworkMode = connectSchema.modes.find((m) => m.id === 'framework')
|
||||
expect(frameworkMode?.fields).toContain('framework')
|
||||
expect(frameworkMode?.fields).toContain('frameworkVariant')
|
||||
expect(frameworkMode?.fields).toContain('library')
|
||||
expect(frameworkMode?.fields).toContain('frameworkUi')
|
||||
})
|
||||
|
||||
test('direct mode should have correct fields', () => {
|
||||
const directMode = connectSchema.modes.find((m) => m.id === 'direct')
|
||||
expect(directMode?.fields).toContain('connectionMethod')
|
||||
expect(directMode?.fields).toContain('useSharedPooler')
|
||||
expect(directMode?.fields).toContain('connectionType')
|
||||
})
|
||||
|
||||
test('orm mode should have correct fields', () => {
|
||||
const ormMode = connectSchema.modes.find((m) => m.id === 'orm')
|
||||
expect(ormMode?.fields).toContain('orm')
|
||||
})
|
||||
|
||||
test('mcp mode should have correct fields', () => {
|
||||
const mcpMode = connectSchema.modes.find((m) => m.id === 'mcp')
|
||||
expect(mcpMode?.fields).toContain('mcpClient')
|
||||
expect(mcpMode?.fields).toContain('mcpReadonly')
|
||||
expect(mcpMode?.fields).toContain('mcpFeatures')
|
||||
})
|
||||
|
||||
test('all mode fields should exist in fields definition', () => {
|
||||
connectSchema.modes.forEach((mode) => {
|
||||
mode.fields.forEach((fieldId) => {
|
||||
expect(
|
||||
connectSchema.fields[fieldId],
|
||||
`Field "${fieldId}" in mode "${mode.id}" should exist in fields definition`
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Field Definition Tests
|
||||
// ============================================================================
|
||||
|
||||
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.defaultValue).toBe('nextjs')
|
||||
})
|
||||
|
||||
test('frameworkVariant field should depend on framework', () => {
|
||||
const field = connectSchema.fields.frameworkVariant
|
||||
expect(field.dependsOn).toEqual({ framework: ['nextjs', 'react'] })
|
||||
})
|
||||
|
||||
test('frameworkUi field should be a switch type', () => {
|
||||
const field = connectSchema.fields.frameworkUi
|
||||
expect(field.type).toBe('switch')
|
||||
expect(field.defaultValue).toBe(false)
|
||||
expect(field.dependsOn).toEqual({ framework: ['nextjs', 'react'] })
|
||||
})
|
||||
|
||||
test('connectionMethod field should have radio-list type', () => {
|
||||
const field = connectSchema.fields.connectionMethod
|
||||
expect(field.type).toBe('radio-list')
|
||||
expect(field.options).toEqual({ source: 'connectionMethods' })
|
||||
expect(field.defaultValue).toBe('direct')
|
||||
})
|
||||
|
||||
test('useSharedPooler field should depend on transaction connection method', () => {
|
||||
const field = connectSchema.fields.useSharedPooler
|
||||
expect(field.type).toBe('switch')
|
||||
expect(field.dependsOn).toEqual({ connectionMethod: ['transaction'] })
|
||||
})
|
||||
|
||||
test('orm field should have radio-list type', () => {
|
||||
const field = connectSchema.fields.orm
|
||||
expect(field.type).toBe('radio-list')
|
||||
expect(field.options).toEqual({ source: 'orms' })
|
||||
expect(field.defaultValue).toBe('prisma')
|
||||
})
|
||||
|
||||
test('mcpClient field should have select type', () => {
|
||||
const field = connectSchema.fields.mcpClient
|
||||
expect(field.type).toBe('select')
|
||||
expect(field.options).toEqual({ source: 'mcpClients' })
|
||||
expect(field.defaultValue).toBe('cursor')
|
||||
})
|
||||
|
||||
test('mcpFeatures field should have multi-select type', () => {
|
||||
const field = connectSchema.fields.mcpFeatures
|
||||
expect(field.type).toBe('multi-select')
|
||||
expect(field.options).toEqual({ source: 'mcpFeatures' })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Install Commands Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('connect.schema:INSTALL_COMMANDS', () => {
|
||||
test('should have install command for supabase-js', () => {
|
||||
expect(INSTALL_COMMANDS.supabasejs).toBe('npm install @supabase/supabase-js')
|
||||
})
|
||||
|
||||
test('should have install command for supabase-py', () => {
|
||||
expect(INSTALL_COMMANDS.supabasepy).toBe('pip install supabase')
|
||||
})
|
||||
|
||||
test('should have install command for supabase-flutter', () => {
|
||||
expect(INSTALL_COMMANDS.supabaseflutter).toBe('flutter pub add supabase_flutter')
|
||||
})
|
||||
|
||||
test('should have install command for supabase-swift', () => {
|
||||
expect(INSTALL_COMMANDS.supabaseswift).toContain('swift package add-dependency')
|
||||
})
|
||||
|
||||
test('should have install command for supabase-kt', () => {
|
||||
expect(INSTALL_COMMANDS.supabasekt).toContain('io.github.jan-tennert.supabase')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Steps Resolution Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('connect.schema:steps resolution', () => {
|
||||
describe('framework mode steps', () => {
|
||||
test('should resolve steps for nextjs without shadcn', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: false }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.length).toBeGreaterThan(0)
|
||||
expect(steps.find((s) => s.id === 'install')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'install-skills')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve shadcn steps for nextjs with frameworkUi true', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: true }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'shadcn-add')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'shadcn-explore')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve steps for react without shadcn', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'react', frameworkUi: false }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.length).toBeGreaterThan(0)
|
||||
expect(steps.find((s) => s.id === 'install')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve shadcn steps for react with frameworkUi true', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'react', frameworkUi: true }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'shadcn-add')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve default steps for other frameworks', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'remix' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.length).toBeGreaterThan(0)
|
||||
expect(steps.find((s) => s.id === 'install')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'configure')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct mode steps', () => {
|
||||
test('should resolve connection step for default direct mode', () => {
|
||||
const state: ConnectState = { mode: 'direct' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'connection')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve install and files steps for nodejs connection type', () => {
|
||||
const state: ConnectState = { mode: 'direct', connectionType: 'nodejs' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'direct-files')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve install and files steps for golang connection type', () => {
|
||||
const state: ConnectState = { mode: 'direct', connectionType: 'golang' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'direct-files')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve install and files steps for python connection type', () => {
|
||||
const state: ConnectState = { mode: 'direct', connectionType: 'python' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve install and files steps for dotnet connection type', () => {
|
||||
const state: ConnectState = { mode: 'direct', connectionType: 'dotnet' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'direct-install')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orm mode steps', () => {
|
||||
test('should resolve install and configure steps for prisma', () => {
|
||||
const state: ConnectState = { mode: 'orm', orm: 'prisma' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'install')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'configure')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'install-skills')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve install and configure steps for drizzle', () => {
|
||||
const state: ConnectState = { mode: 'orm', orm: 'drizzle' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'install')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'configure')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mcp mode steps', () => {
|
||||
test('should resolve configure step for cursor client', () => {
|
||||
const state: ConnectState = { mode: 'mcp', mcpClient: 'cursor' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'configure-mcp')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'install-skills')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve codex-specific steps for codex client', () => {
|
||||
const state: ConnectState = { mode: 'mcp', mcpClient: 'codex' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'codex-add-server')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'codex-enable-remote')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'codex-authenticate')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'codex-verify')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve claude-code-specific steps for claude-code client', () => {
|
||||
const state: ConnectState = { mode: 'mcp', mcpClient: 'claude-code' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'claude-add-server')).toBeDefined()
|
||||
expect(steps.find((s) => s.id === 'claude-authenticate')).toBeDefined()
|
||||
})
|
||||
|
||||
test('should resolve default mcp steps for other clients', () => {
|
||||
const state: ConnectState = { mode: 'mcp', mcpClient: 'unknown-client' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'configure-mcp')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills install step', () => {
|
||||
test('should include skills install step in all modes', () => {
|
||||
const modes: ConnectState['mode'][] = ['framework', 'direct', 'orm', 'mcp']
|
||||
|
||||
modes.forEach((mode) => {
|
||||
const state: ConnectState = { mode }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(
|
||||
steps.find((s) => s.id === 'install-skills'),
|
||||
`Mode "${mode}" should have skills install step`
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Step Content Path Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('connect.schema:step content paths', () => {
|
||||
test('install step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'nextjs' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const installStep = steps.find((s) => s.id === 'install')
|
||||
|
||||
expect(installStep?.content).toBe('steps/install')
|
||||
})
|
||||
|
||||
test('shadcn command step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: true }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const shadcnStep = steps.find((s) => s.id === 'shadcn-add')
|
||||
|
||||
expect(shadcnStep?.content).toBe('steps/shadcn/command')
|
||||
})
|
||||
|
||||
test('shadcn explore step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: true }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const exploreStep = steps.find((s) => s.id === 'shadcn-explore')
|
||||
|
||||
expect(exploreStep?.content).toBe('steps/shadcn/explore')
|
||||
})
|
||||
|
||||
test('direct connection step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'direct' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const connectionStep = steps.find((s) => s.id === 'connection')
|
||||
|
||||
expect(connectionStep?.content).toBe('steps/direct-connection')
|
||||
})
|
||||
|
||||
test('skills install step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'framework' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const skillsStep = steps.find((s) => s.id === 'install-skills')
|
||||
|
||||
expect(skillsStep?.content).toBe('steps/skills-install')
|
||||
})
|
||||
|
||||
test('orm configure step should use template content path', () => {
|
||||
// The ORM configure step uses a template {{orm}} that gets resolved
|
||||
// by the dynamic import system, not the resolver
|
||||
const state: ConnectState = { mode: 'orm', orm: 'prisma' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const configureStep = steps.find((s) => s.id === 'configure')
|
||||
|
||||
// The content path uses template syntax for the component loader
|
||||
expect(configureStep?.content).toBe('{{orm}}')
|
||||
})
|
||||
|
||||
test('mcp cursor configure step should have valid content path', () => {
|
||||
const state: ConnectState = { mode: 'mcp', mcpClient: 'cursor' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
const configureStep = steps.find((s) => s.id === 'configure-mcp')
|
||||
|
||||
expect(configureStep?.content).toBe('steps/mcp/cursor')
|
||||
})
|
||||
|
||||
test('codex steps should have valid content paths', () => {
|
||||
const state: ConnectState = { mode: 'mcp', mcpClient: 'codex' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'codex-add-server')?.content).toBe(
|
||||
'steps/mcp/codex/add-server'
|
||||
)
|
||||
expect(steps.find((s) => s.id === 'codex-enable-remote')?.content).toBe(
|
||||
'steps/mcp/codex/enable-remote'
|
||||
)
|
||||
expect(steps.find((s) => s.id === 'codex-authenticate')?.content).toBe(
|
||||
'steps/mcp/codex/authenticate'
|
||||
)
|
||||
expect(steps.find((s) => s.id === 'codex-verify')?.content).toBe('steps/mcp/codex/verify')
|
||||
})
|
||||
|
||||
test('claude-code steps should have valid content paths', () => {
|
||||
const state: ConnectState = { mode: 'mcp', mcpClient: 'claude-code' }
|
||||
const steps = resolveSteps(connectSchema, state)
|
||||
|
||||
expect(steps.find((s) => s.id === 'claude-add-server')?.content).toBe(
|
||||
'steps/mcp/claude-code/add-server'
|
||||
)
|
||||
expect(steps.find((s) => s.id === 'claude-authenticate')?.content).toBe(
|
||||
'steps/mcp/claude-code/authenticate'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,347 +0,0 @@
|
||||
import type { ConnectSchema, 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")',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Step Definitions (reusable)
|
||||
// All content paths use template syntax: {{stateKey}} is replaced with state values
|
||||
// ============================================================================
|
||||
|
||||
const frameworkInstallStep: StepDefinition = {
|
||||
id: 'install',
|
||||
title: 'Install package',
|
||||
description: 'Run this command to install the required dependencies.',
|
||||
content: 'steps/install',
|
||||
}
|
||||
|
||||
const frameworkConfigureStep: StepDefinition = {
|
||||
id: 'configure',
|
||||
title: 'Add files',
|
||||
description: 'Copy the following code into your project.',
|
||||
content: '{{framework}}/{{frameworkVariant}}/{{library}}',
|
||||
}
|
||||
|
||||
const frameworkNextJsFilesStep: StepDefinition = {
|
||||
id: 'configure-nextjs',
|
||||
title: 'Add files',
|
||||
description:
|
||||
'Add env variables, create Supabase client helpers, and set up middleware to keep sessions refreshed.',
|
||||
content: '{{framework}}/{{frameworkVariant}}/{{library}}',
|
||||
}
|
||||
|
||||
const frameworkReactFilesStep: StepDefinition = {
|
||||
id: 'configure-react',
|
||||
title: 'Add files',
|
||||
description: 'Add env variables, create a Supabase client, and use it in your app to query data.',
|
||||
content: '{{framework}}/{{frameworkVariant}}/{{library}}',
|
||||
}
|
||||
|
||||
const frameworkShadcnStep: StepDefinition = {
|
||||
id: 'shadcn-add',
|
||||
title: 'Add Supabase UI components',
|
||||
description: 'Run this command to install the Supabase shadcn components.',
|
||||
content: 'steps/shadcn/command',
|
||||
}
|
||||
|
||||
const frameworkShadcnExploreStep: StepDefinition = {
|
||||
id: 'shadcn-explore',
|
||||
title: 'Check out more UI components',
|
||||
description: 'Add auth, realtime and storage functionality to your project',
|
||||
content: 'steps/shadcn/explore',
|
||||
}
|
||||
|
||||
const directConnectionStep: StepDefinition = {
|
||||
id: 'connection',
|
||||
title: 'Connection string',
|
||||
description: 'Copy the connection details for your database.',
|
||||
content: 'steps/direct-connection',
|
||||
}
|
||||
|
||||
const directInstallStep: StepDefinition = {
|
||||
id: 'direct-install',
|
||||
title: 'Install dependencies',
|
||||
description: 'Run this command to install the required dependencies.',
|
||||
content: 'steps/direct-install',
|
||||
}
|
||||
|
||||
const directFilesStep: StepDefinition = {
|
||||
id: 'direct-files',
|
||||
title: 'Add files',
|
||||
description: 'Add the following files to your project.',
|
||||
content: 'steps/direct-files',
|
||||
}
|
||||
|
||||
const mcpConfigureStep: StepDefinition = {
|
||||
id: 'configure-mcp',
|
||||
title: 'Configure MCP',
|
||||
description: 'Set up your MCP client.',
|
||||
content: 'steps/mcp/cursor',
|
||||
}
|
||||
|
||||
// Codex-specific MCP steps
|
||||
const codexAddServerStep: StepDefinition = {
|
||||
id: 'codex-add-server',
|
||||
title: 'Add the Supabase MCP server to Codex',
|
||||
description: 'Run this command to add the server.',
|
||||
content: 'steps/mcp/codex/add-server',
|
||||
}
|
||||
|
||||
const codexEnableRemoteStep: StepDefinition = {
|
||||
id: 'codex-enable-remote',
|
||||
title: 'Enable remote MCP client support',
|
||||
description: 'Add this to your ~/.codex/config.toml file.',
|
||||
content: 'steps/mcp/codex/enable-remote',
|
||||
}
|
||||
|
||||
const codexAuthenticateStep: StepDefinition = {
|
||||
id: 'codex-authenticate',
|
||||
title: 'Authenticate',
|
||||
description: 'Run the authentication command.',
|
||||
content: 'steps/mcp/codex/authenticate',
|
||||
}
|
||||
|
||||
const codexVerifyStep: StepDefinition = {
|
||||
id: 'codex-verify',
|
||||
title: 'Verify authentication',
|
||||
description: 'Run /mcp inside Codex to verify.',
|
||||
content: 'steps/mcp/codex/verify',
|
||||
}
|
||||
|
||||
const claudeAddServerStep: StepDefinition = {
|
||||
id: 'claude-add-server',
|
||||
title: 'Add MCP server',
|
||||
description: 'Add the MCP server to your project config using the command line.',
|
||||
content: 'steps/mcp/claude-code/add-server',
|
||||
}
|
||||
|
||||
const claudeAuthenticateStep: StepDefinition = {
|
||||
id: 'claude-authenticate',
|
||||
title: 'Authenticate',
|
||||
description:
|
||||
'After configuring the MCP server, you need to authenticate. In a regular terminal (not the IDE extension) run:',
|
||||
content: 'steps/mcp/claude-code/authenticate',
|
||||
}
|
||||
|
||||
const ormInstallStep: StepDefinition = {
|
||||
id: 'install',
|
||||
title: 'Install ORM',
|
||||
description: 'Add the ORM to your project.',
|
||||
content: 'steps/orm-install',
|
||||
}
|
||||
|
||||
const ormConfigureStep: StepDefinition = {
|
||||
id: 'configure',
|
||||
title: 'Configure ORM',
|
||||
description: 'Set up your ORM configuration.',
|
||||
content: '{{orm}}',
|
||||
}
|
||||
|
||||
const skillsInstallStep: StepDefinition = {
|
||||
id: 'install-skills',
|
||||
title: 'Install Agent Skills (Optional)',
|
||||
description:
|
||||
'Agent Skills give AI coding tools ready-made instructions, scripts, and resources for working with Supabase more accurately and efficiently.',
|
||||
content: 'steps/skills-install',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Schema
|
||||
// ============================================================================
|
||||
|
||||
export const connectSchema: ConnectSchema = {
|
||||
// -------------------------------------------------------------------------
|
||||
// Mode Definitions
|
||||
// -------------------------------------------------------------------------
|
||||
modes: [
|
||||
{
|
||||
id: 'framework',
|
||||
label: 'Framework',
|
||||
description: 'Use a client library',
|
||||
fields: ['framework', 'frameworkVariant', 'library', 'frameworkUi'],
|
||||
},
|
||||
{
|
||||
id: 'direct',
|
||||
label: 'Direct',
|
||||
description: 'Connection string',
|
||||
fields: ['connectionMethod', 'useSharedPooler', 'connectionType'],
|
||||
},
|
||||
{
|
||||
id: 'orm',
|
||||
label: 'ORM',
|
||||
description: 'Third-party library',
|
||||
fields: ['orm'],
|
||||
},
|
||||
{
|
||||
id: 'mcp',
|
||||
label: 'MCP',
|
||||
description: 'Connect your agent',
|
||||
fields: ['mcpClient', 'mcpReadonly', 'mcpFeatures'],
|
||||
},
|
||||
],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Field Definitions
|
||||
// -------------------------------------------------------------------------
|
||||
fields: {
|
||||
// Framework fields
|
||||
framework: {
|
||||
id: 'framework',
|
||||
type: 'radio-grid',
|
||||
label: 'Framework',
|
||||
options: { source: 'frameworks' },
|
||||
defaultValue: 'nextjs',
|
||||
},
|
||||
frameworkVariant: {
|
||||
id: 'frameworkVariant',
|
||||
type: 'select',
|
||||
label: 'Variant',
|
||||
options: { source: 'frameworkVariants' },
|
||||
defaultValue: 'app',
|
||||
dependsOn: { framework: ['nextjs', 'react'] }, // Only show for frameworks with multiple variants
|
||||
},
|
||||
library: {
|
||||
id: 'library',
|
||||
type: 'select',
|
||||
label: 'Library',
|
||||
options: { source: 'libraries' },
|
||||
defaultValue: 'supabasejs',
|
||||
},
|
||||
frameworkUi: {
|
||||
id: 'frameworkUi',
|
||||
type: 'switch',
|
||||
label: 'Shadcn',
|
||||
description: 'Install components via the Supabase shadcn registry.',
|
||||
defaultValue: false,
|
||||
dependsOn: { framework: ['nextjs', 'react'] },
|
||||
},
|
||||
|
||||
// Direct connection fields
|
||||
connectionMethod: {
|
||||
id: 'connectionMethod',
|
||||
type: 'radio-list',
|
||||
label: 'Connection Method',
|
||||
options: { source: 'connectionMethods' },
|
||||
defaultValue: 'direct',
|
||||
},
|
||||
useSharedPooler: {
|
||||
id: 'useSharedPooler',
|
||||
type: 'switch',
|
||||
label: 'Use IPv4 connection (Shared Pooler)',
|
||||
description: 'Only recommended when your network does not support IPv6',
|
||||
defaultValue: false,
|
||||
dependsOn: { connectionMethod: ['transaction'] },
|
||||
},
|
||||
connectionType: {
|
||||
id: 'connectionType',
|
||||
type: 'select',
|
||||
label: 'Type',
|
||||
options: { source: 'connectionTypes' },
|
||||
defaultValue: 'uri',
|
||||
},
|
||||
|
||||
// ORM fields
|
||||
orm: {
|
||||
id: 'orm',
|
||||
type: 'radio-list',
|
||||
label: 'ORM',
|
||||
options: { source: 'orms' },
|
||||
defaultValue: 'prisma',
|
||||
},
|
||||
|
||||
// MCP fields
|
||||
mcpClient: {
|
||||
id: 'mcpClient',
|
||||
type: 'select',
|
||||
label: 'Client',
|
||||
description: 'Choose the MCP client you are using.',
|
||||
options: { source: 'mcpClients' },
|
||||
defaultValue: 'cursor',
|
||||
},
|
||||
mcpReadonly: {
|
||||
id: 'mcpReadonly',
|
||||
type: 'switch',
|
||||
label: 'Read-only',
|
||||
description: 'Only allow read operations on your database',
|
||||
defaultValue: false,
|
||||
},
|
||||
mcpFeatures: {
|
||||
id: 'mcpFeatures',
|
||||
type: 'multi-select',
|
||||
label: 'Feature groups',
|
||||
description:
|
||||
'Only enable a subset of features. Helps keep the number of tools within MCP client limits.',
|
||||
options: { source: 'mcpFeatures' },
|
||||
},
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Steps - Conditional based on mode and nested selections
|
||||
// -------------------------------------------------------------------------
|
||||
steps: {
|
||||
// Keys are field IDs; each field maps state values to step trees.
|
||||
mode: {
|
||||
framework: {
|
||||
framework: {
|
||||
nextjs: {
|
||||
frameworkUi: {
|
||||
true: [
|
||||
frameworkInstallStep,
|
||||
frameworkShadcnStep,
|
||||
frameworkShadcnExploreStep,
|
||||
skillsInstallStep,
|
||||
],
|
||||
DEFAULT: [frameworkInstallStep, frameworkNextJsFilesStep, skillsInstallStep],
|
||||
},
|
||||
},
|
||||
react: {
|
||||
frameworkUi: {
|
||||
true: [
|
||||
frameworkInstallStep,
|
||||
frameworkShadcnStep,
|
||||
frameworkShadcnExploreStep,
|
||||
skillsInstallStep,
|
||||
],
|
||||
DEFAULT: [frameworkInstallStep, frameworkReactFilesStep, skillsInstallStep],
|
||||
},
|
||||
},
|
||||
DEFAULT: [frameworkInstallStep, frameworkConfigureStep, skillsInstallStep],
|
||||
},
|
||||
},
|
||||
direct: {
|
||||
connectionType: {
|
||||
nodejs: [directInstallStep, directFilesStep, skillsInstallStep],
|
||||
golang: [directInstallStep, directFilesStep, skillsInstallStep],
|
||||
dotnet: [directInstallStep, directFilesStep, skillsInstallStep],
|
||||
python: [directInstallStep, directFilesStep, skillsInstallStep],
|
||||
sqlalchemy: [directInstallStep, directFilesStep, skillsInstallStep],
|
||||
DEFAULT: [directConnectionStep, skillsInstallStep],
|
||||
},
|
||||
},
|
||||
orm: [ormInstallStep, ormConfigureStep, skillsInstallStep],
|
||||
mcp: {
|
||||
mcpClient: {
|
||||
codex: [
|
||||
codexAddServerStep,
|
||||
codexEnableRemoteStep,
|
||||
codexAuthenticateStep,
|
||||
codexVerifyStep,
|
||||
skillsInstallStep,
|
||||
],
|
||||
'claude-code': [claudeAddServerStep, claudeAuthenticateStep, skillsInstallStep],
|
||||
DEFAULT: [mcpConfigureStep, skillsInstallStep],
|
||||
},
|
||||
},
|
||||
DEFAULT: [skillsInstallStep],
|
||||
},
|
||||
},
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: 'MainActivity.kt',
|
||||
language: 'kotlin',
|
||||
code: `
|
||||
val supabase = createSupabaseClient(
|
||||
supabaseUrl = "${projectKeys.apiUrl ?? 'your-project-url'}",
|
||||
supabaseKey = "${projectKeys.publishableKey ?? '<prefer publishable key instead of anon key for mobile apps>'}"
|
||||
) {
|
||||
install(Postgrest)
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
TodoList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TodoList() {
|
||||
var items by remember { mutableStateOf<List<TodoItem>>(listOf()) }
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
items = supabase.from("todos")
|
||||
.select().decodeList<TodoItem>()
|
||||
}
|
||||
}
|
||||
LazyColumn {
|
||||
items(
|
||||
items,
|
||||
key = { item -> item.id },
|
||||
) { item ->
|
||||
Text(
|
||||
item.name,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'TodoItem.kt',
|
||||
language: 'kotlin',
|
||||
code: `
|
||||
@Serializable
|
||||
data class TodoItem(val id: Int, val name: String)
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,53 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: `
|
||||
SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/db/supabase.js',
|
||||
language: 'js',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = import.meta.env.SUPABASE_URL;
|
||||
const supabaseKey = import.meta.env.SUPABASE_KEY;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/pages/index.astro',
|
||||
language: 'html',
|
||||
code: `
|
||||
---
|
||||
import { supabase } from '../db/supabase';
|
||||
|
||||
const { data, error } = await supabase.from("todos").select('*');
|
||||
---
|
||||
|
||||
{
|
||||
(
|
||||
<ul>
|
||||
{data.map((entry) => (
|
||||
<li>{entry.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,64 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ connectionStringPooler }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code:
|
||||
connectionStringPooler.ipv4SupportedForDedicatedPooler &&
|
||||
connectionStringPooler.transactionDedicated
|
||||
? `
|
||||
DATABASE_URL="${connectionStringPooler.transactionDedicated}"
|
||||
`
|
||||
: connectionStringPooler.transactionDedicated &&
|
||||
!connectionStringPooler.ipv4SupportedForDedicatedPooler
|
||||
? `
|
||||
# Use Shared connection pooler (supports both IPv4/IPv6)
|
||||
DATABASE_URL="${connectionStringPooler.transactionShared}"
|
||||
|
||||
# If your network supports IPv6 or you purchased IPv4 addon, use dedicated pooler
|
||||
# DATABASE_URL="${connectionStringPooler.transactionDedicated}"
|
||||
`
|
||||
: `
|
||||
DATABASE_URL="${connectionStringPooler.transactionShared}"
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'drizzle/schema.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { pgTable, serial, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: serial('id').primaryKey(),
|
||||
fullName: text('full_name'),
|
||||
phone: varchar('phone', { length: 256 }),
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'index.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import { users } from './schema'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
|
||||
// Disable prefetch as it is not supported for "Transaction" pool mode
|
||||
const client = postgres(connectionString, { prepare: false })
|
||||
const db = drizzle(client);
|
||||
|
||||
const allUsers = await db.select().from(users);
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: `
|
||||
EXPO_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
EXPO_PUBLIC_SUPABASE_KEY=${projectKeys.publishableKey ?? '<prefer publishable key instead of anon key for mobile and desktop apps>'}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { createClient, processLock } from '@supabase/supabase-js'
|
||||
|
||||
export const supabase = createClient(
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL!,
|
||||
process.env.EXPO_PUBLIC_SUPABASE_KEY!,
|
||||
{
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
lock: processLock,
|
||||
},
|
||||
})
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'App.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, FlatList } from 'react-native';
|
||||
import { supabase } from '../utils/supabase';
|
||||
|
||||
export default function App() {
|
||||
const [todos, setTodos] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const getTodos = async () => {
|
||||
try {
|
||||
const { data: todos, error } = await supabase.from('todos').select();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching todos:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (todos && todos.length > 0) {
|
||||
setTodos(todos);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching todos:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
getTodos();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text>Todo List</Text>
|
||||
<FlatList
|
||||
data={todos}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={({ item }) => <Text key={item.id}>{item.title}</Text>}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,54 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code: `
|
||||
SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'app.py',
|
||||
language: 'python',
|
||||
code: `
|
||||
import os
|
||||
from flask import Flask
|
||||
from supabase import create_client, Client
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
supabase: Client = create_client(
|
||||
os.environ.get("SUPABASE_URL"),
|
||||
os.environ.get("SUPABASE_KEY")
|
||||
)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
response = supabase.table('todos').select("*").execute()
|
||||
todos = response.data
|
||||
|
||||
html = '<h1>Todos</h1><ul>'
|
||||
for todo in todos:
|
||||
html += f'<li>{todo["name"]}</li>'
|
||||
html += '</ul>'
|
||||
|
||||
return html
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: 'lib/main.dart',
|
||||
language: 'dart',
|
||||
code: `
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
await Supabase.initialize(
|
||||
url: '${projectKeys.apiUrl ?? 'your-project-url'}',
|
||||
anonKey: '${projectKeys.publishableKey ?? '<prefer publishable key instead of anon key for mobile and desktop apps>'}',
|
||||
);
|
||||
runApp(MyApp());
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'lib/main.dart (app)',
|
||||
language: 'dart',
|
||||
code: `
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const MaterialApp(
|
||||
title: 'Todos',
|
||||
home: HomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _future = Supabase.instance.client
|
||||
.from('todos')
|
||||
.select();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: FutureBuilder(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final todos = snapshot.data!;
|
||||
return ListView.builder(
|
||||
itemCount: todos.length,
|
||||
itemBuilder: ((context, index) {
|
||||
final todo = todos[index];
|
||||
return ListTile(
|
||||
title: Text(todo['name']),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: 'environments/environment.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
export const environment = {
|
||||
supabaseUrl: '${projectKeys.apiUrl ?? 'your-project-url'}',
|
||||
supabaseKey: '${projectKeys.publishableKey ?? '<prefer publishable key instead of anon key for mobile apps>'}',
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/app/supabase.service.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { Injectable } from '@angular/core';
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SupabaseService {
|
||||
private supabase: SupabaseClient;
|
||||
constructor() {
|
||||
this.supabase = createClient(
|
||||
environment.supabaseUrl,
|
||||
environment.supabaseKey
|
||||
);
|
||||
}
|
||||
|
||||
getTodos() {
|
||||
return this.supabase.from('todos').select('*');
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/app/app.component.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { SupabaseService } from './supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
todos: any[] = [];
|
||||
|
||||
constructor(private supabaseService: SupabaseService) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadTodos();
|
||||
}
|
||||
|
||||
async loadTodos() {
|
||||
const { data, error } = await this.supabaseService.getTodos();
|
||||
if (error) {
|
||||
console.error('Error fetching todos:', error);
|
||||
} else {
|
||||
this.todos = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/app/app.component.html',
|
||||
language: 'html',
|
||||
code: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Todo List</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-list>
|
||||
<ion-item *ngFor="let todo of todos">
|
||||
<ion-label>{{ todo.title }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/app/app.module.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { SupabaseService } from './supabase.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
FormsModule,
|
||||
RouterModule.forRoot([]),
|
||||
IonicModule.forRoot({ mode: 'ios' }),
|
||||
],
|
||||
declarations: [AppComponent],
|
||||
providers: [SupabaseService],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code: `
|
||||
REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
REACT_APP_SUPABASE_KEY=${projectKeys.publishableKey ?? '<prefer publishable key instead of anon key for mobile or desktop apps>'}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/supabaseClient.tsx',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL
|
||||
const supabaseKey = process.env.REACT_APP_SUPABASE_KEY
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/App.tsx',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { setupIonicReact, IonApp } from '@ionic/react';
|
||||
import {
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
IonList,
|
||||
IonItem,
|
||||
} from '@ionic/react';
|
||||
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
import '@ionic/react/css/core.css';
|
||||
|
||||
/* Theme variables */
|
||||
import './theme/variables.css';
|
||||
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
setupIonicReact();
|
||||
|
||||
export default function App() {
|
||||
const [todos, setTodos] = useState([]);
|
||||
useEffect(() => {
|
||||
getTodos();
|
||||
}, []);
|
||||
|
||||
const getTodos = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.from('todos').select();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching todos:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setTodos(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching todos:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonApp>
|
||||
<>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Todos</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
||||
<IonList>
|
||||
{todos.map((todo) => (
|
||||
<IonItem key={todo.id}>{todo.title}</IonItem>
|
||||
))}
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</>
|
||||
</IonApp>
|
||||
);
|
||||
}
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys?.publishableKey
|
||||
? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'page.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export default async function Page() {
|
||||
const cookieStore = await cookies()
|
||||
const supabase = createClient(cookieStore)
|
||||
|
||||
const { data: todos } = await supabase.from('todos').select()
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{todos?.map((todo) => (
|
||||
<li>{todo}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase/server.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'};
|
||||
|
||||
export const createClient = (cookieStore: ReturnType<typeof cookies>) => {
|
||||
return createServerClient(
|
||||
supabaseUrl!,
|
||||
supabaseKey!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
|
||||
} catch {
|
||||
// The \`setAll\` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing
|
||||
// user sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase/client.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'};
|
||||
|
||||
export const createClient = () =>
|
||||
createBrowserClient(
|
||||
supabaseUrl!,
|
||||
supabaseKey!,
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase/middleware.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'};
|
||||
|
||||
export const createClient = (request: NextRequest) => {
|
||||
// Create an unmodified response
|
||||
let supabaseResponse = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const supabase = createServerClient(
|
||||
supabaseUrl!,
|
||||
supabaseKey!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
||||
supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return supabaseResponse
|
||||
};
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
// [Joshen] Used as a dynamic import
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default ContentFile
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys?.publishableKey
|
||||
? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'};
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '_app.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../utils/supabase'
|
||||
|
||||
function Page() {
|
||||
const [todos, setTodos] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
function getTodos() {
|
||||
const { data: todos } = await supabase.from('todos').select()
|
||||
|
||||
if (todos.length > 1) {
|
||||
setTodos(todos)
|
||||
}
|
||||
}
|
||||
|
||||
getTodos()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{todos.map((todo) => (
|
||||
<li key={todo}>{todo}</li>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Page
|
||||
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,58 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: `
|
||||
SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_KEY;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/App.vue',
|
||||
language: 'jsx',
|
||||
code: `
|
||||
<script setup>
|
||||
import { supabase } from '../utils/supabase'
|
||||
const todos = ref([])
|
||||
|
||||
async function getTodos() {
|
||||
const { data } = await supabase.from('todos').select()
|
||||
todos.value = data
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTodos()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
<li v-for="todo in todos" :key="todo.id">{{ todo.name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,62 +0,0 @@
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ connectionStringPooler }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code:
|
||||
connectionStringPooler.ipv4SupportedForDedicatedPooler &&
|
||||
connectionStringPooler.transactionDedicated
|
||||
? `
|
||||
# Connect to Supabase via connection pooling.
|
||||
DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true"
|
||||
|
||||
# Direct connection to the database. Used for migrations.
|
||||
DIRECT_URL="${connectionStringPooler.sessionDedicated}"
|
||||
`
|
||||
: connectionStringPooler.transactionDedicated &&
|
||||
!connectionStringPooler.ipv4SupportedForDedicatedPooler
|
||||
? `
|
||||
# Connect to Supabase via Shared Connection Pooler
|
||||
DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true"
|
||||
|
||||
# Direct connection to the database through Shared Pooler (supports IPv4/IPv6). Used for migrations.
|
||||
DIRECT_URL="${connectionStringPooler.sessionShared}"
|
||||
|
||||
# If your network supports IPv6 or you purchased IPv4 addon, use dedicated pooler
|
||||
# DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true"
|
||||
# DIRECT_URL="${connectionStringPooler.sessionDedicated}"
|
||||
`
|
||||
: `
|
||||
# Connect to Supabase ${IS_PLATFORM ? 'via connection pooling' : ''}
|
||||
DATABASE_URL="${IS_PLATFORM ? `${connectionStringPooler.transactionShared}?pgbouncer=true` : connectionStringPooler.direct}"
|
||||
|
||||
# Direct connection to the database. Used for migrations
|
||||
DIRECT_URL="${IS_PLATFORM ? connectionStringPooler.sessionShared : connectionStringPooler.direct}"
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'prisma/schema.prisma',
|
||||
language: 'bash',
|
||||
code: `
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directUrl = env("DIRECT_URL")
|
||||
}
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys?.publishableKey
|
||||
? `REACT_APP_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL;
|
||||
const supabaseKey = process.env.${projectKeys.publishableKey ? 'REACT_APP_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'REACT_APP_SUPABASE_ANON_KEY'};
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'App.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../utils/supabase'
|
||||
|
||||
function Page() {
|
||||
const [todos, setTodos] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
function getTodos() {
|
||||
const { data: todos } = await supabase.from('todos').select()
|
||||
|
||||
if (todos.length > 1) {
|
||||
setTodos(todos)
|
||||
}
|
||||
}
|
||||
|
||||
getTodos()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{todos.map((todo) => (
|
||||
<li key={todo}>{todo}</li>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Page
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`VITE_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys?.publishableKey
|
||||
? `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseKey = import.meta.env.${projectKeys.publishableKey ? 'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'VITE_SUPABASE_ANON_KEY'};
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
export default supabase
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'App.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../utils/supabase'
|
||||
|
||||
function Page() {
|
||||
const [todos, setTodos] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
function getTodos() {
|
||||
const { data: todos } = await supabase.from('todos').select()
|
||||
|
||||
if (todos.length > 1) {
|
||||
setTodos(todos)
|
||||
}
|
||||
}
|
||||
|
||||
getTodos()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{todos.map((todo) => (
|
||||
<li key={todo}>{todo}</li>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Page
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,102 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
`SUPABASE_KEY=${projectKeys?.publishableKey ?? projectKeys?.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'src/utility/supabaseClient.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from "@refinedev/supabase";
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const SUPABASE_KEY = process.env.SUPABASE_KEY;
|
||||
|
||||
export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, {
|
||||
db: {
|
||||
schema: "public",
|
||||
},
|
||||
auth: {
|
||||
persistSession: true,
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/App.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { Refine } from "@refinedev/core";
|
||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||
import routerProvider, {
|
||||
DocumentTitleHandler,
|
||||
NavigateToResource,
|
||||
UnsavedChangesNotifier,
|
||||
} from "@refinedev/react-router";
|
||||
import { dataProvider, liveProvider } from "@refinedev/supabase";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import "./App.css";
|
||||
import authProvider from "./authProvider";
|
||||
import { supabaseClient } from "./utility";
|
||||
import { CountriesCreate, CountriesEdit, CountriesList, CountriesShow } from "./pages/countries";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<RefineKbarProvider>
|
||||
<Refine
|
||||
dataProvider={dataProvider(supabaseClient)}
|
||||
liveProvider={liveProvider(supabaseClient)}
|
||||
authProvider={authProvider}
|
||||
routerProvider={routerProvider}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
resources={[{
|
||||
name: "countries",
|
||||
list: "/countries",
|
||||
create: "/countries/create",
|
||||
edit: "/countries/edit/:id",
|
||||
show: "/countries/show/:id"
|
||||
}]}>
|
||||
<Routes>
|
||||
<Route index
|
||||
element={<NavigateToResource resource="countries" />}
|
||||
/>
|
||||
<Route path="/countries">
|
||||
<Route index element={<CountriesList />} />
|
||||
<Route path="create" element={<CountriesCreate />} />
|
||||
<Route path="edit/:id" element={<CountriesEdit />} />
|
||||
<Route path="show/:id" element={<CountriesShow />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<RefineKbar />
|
||||
<UnsavedChangesNotifier />
|
||||
<DocumentTitleHandler />
|
||||
</Refine>
|
||||
</RefineKbarProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,91 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`VITE_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys?.publishableKey
|
||||
? `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'app/utils/supabase.server.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import {
|
||||
createServerClient,
|
||||
parseCookieHeader,
|
||||
serializeCookieHeader,
|
||||
} from "@supabase/ssr";
|
||||
|
||||
export function createClient(request: Request) {
|
||||
const headers = new Headers();
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.VITE_SUPABASE_URL!,
|
||||
process.env.VITE_${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'SUPABASE_ANON_KEY'};,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return parseCookieHeader(request.headers.get("Cookie") ?? "") as {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
headers.append(
|
||||
"Set-Cookie",
|
||||
serializeCookieHeader(name, value, options)
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { supabase, headers };
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'app/routes/_index.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import type { Route } from "./+types/home";
|
||||
import { createClient } from "~/utils/supabase.server";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { supabase } = createClient(request);
|
||||
const { data: todos } = await supabase.from("todos").select();
|
||||
|
||||
return { todos };
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<ul>
|
||||
{loaderData.todos?.map((todo) => (
|
||||
<li key={todo.id}>{todo.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,60 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys?.publishableKey
|
||||
? `SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseKey = process.env.${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'SUPABASE_ANON_KEY'};
|
||||
|
||||
export const supabase = createClient(supabaseUrl!, supabaseKey!);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/App.jsx',
|
||||
language: 'jsx',
|
||||
code: `
|
||||
import { supabase } from '../utils/supabase'
|
||||
import { createResource, For } from "solid-js";
|
||||
|
||||
async function getTodos() {
|
||||
const { data: todos } = await supabase.from("todos").select();
|
||||
return data;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [todos] = createResource(getTodos);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<For each={todos()}>{(country) => <li>{todo.name}</li>}</For>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { CodeBlock } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
} from '../../../Connect.constants'
|
||||
import type { StepContentProps } from '../../../Connect.types'
|
||||
import { ConnectionParameters } from '../../../ConnectionParameters'
|
||||
import {
|
||||
PASSWORD_PLACEHOLDER,
|
||||
buildConnectionParameters,
|
||||
buildSafeConnectionString,
|
||||
parseConnectionParams,
|
||||
resolveConnectionString,
|
||||
} from '../../../ConnectionString.utils'
|
||||
|
||||
const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) =>
|
||||
`psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}`
|
||||
|
||||
const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) =>
|
||||
`jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}`
|
||||
|
||||
/**
|
||||
* Step component for direct database connections.
|
||||
* Uses state to determine which connection string to show.
|
||||
*/
|
||||
function DirectConnectionContent({ state, connectionStringPooler }: StepContentProps) {
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
|
||||
// Determine which connection string to use
|
||||
const resolvedConnectionString = useMemo(
|
||||
() =>
|
||||
resolveConnectionString({
|
||||
connectionMethod,
|
||||
useSharedPooler,
|
||||
connectionStringPooler,
|
||||
}),
|
||||
[connectionMethod, useSharedPooler, connectionStringPooler]
|
||||
)
|
||||
|
||||
const connectionParams = useMemo(
|
||||
() => parseConnectionParams(resolvedConnectionString),
|
||||
[resolvedConnectionString]
|
||||
)
|
||||
|
||||
const safeConnectionString = useMemo(
|
||||
() => buildSafeConnectionString(resolvedConnectionString, connectionParams),
|
||||
[resolvedConnectionString, connectionParams]
|
||||
)
|
||||
|
||||
const connectionString = useMemo(() => {
|
||||
switch (connectionType) {
|
||||
case 'psql':
|
||||
return buildPsqlCommand(connectionParams)
|
||||
case 'jdbc':
|
||||
return buildJdbcString(connectionParams)
|
||||
case 'php':
|
||||
return `DATABASE_URL=${safeConnectionString}`
|
||||
case 'uri':
|
||||
default:
|
||||
return safeConnectionString
|
||||
}
|
||||
}, [connectionType, connectionParams, safeConnectionString])
|
||||
|
||||
if (!resolvedConnectionString) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
wrapperClassName="lg:col-span-2"
|
||||
value={connectionString}
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
>
|
||||
{connectionString}
|
||||
</CodeBlock>
|
||||
<ConnectionParameters parameters={buildConnectionParameters(connectionParams)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DirectConnectionContent
|
||||
-264
@@ -1,264 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CodeBlock } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import {
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
} from '../../../Connect.constants'
|
||||
import type { StepContentProps } from '../../../Connect.types'
|
||||
import { ConnectionParameters } from '../../../ConnectionParameters'
|
||||
import {
|
||||
PASSWORD_PLACEHOLDER,
|
||||
buildConnectionParameters,
|
||||
buildSafeConnectionString,
|
||||
parseConnectionParams,
|
||||
resolveConnectionString,
|
||||
} from '../../../ConnectionString.utils'
|
||||
|
||||
const DOTNET_CONFIG_COMMAND =
|
||||
'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION'
|
||||
|
||||
type DirectFilesConfig = {
|
||||
files: {
|
||||
name: string
|
||||
language?: string
|
||||
code: string
|
||||
}[]
|
||||
connectionStringFile?: string
|
||||
postCommands?: { label: string; command: string }[]
|
||||
}
|
||||
|
||||
function DirectFilesContent({ state, connectionStringPooler }: StepContentProps) {
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
|
||||
const resolvedConnectionString = useMemo(
|
||||
() =>
|
||||
resolveConnectionString({
|
||||
connectionMethod,
|
||||
useSharedPooler,
|
||||
connectionStringPooler,
|
||||
}),
|
||||
[connectionMethod, useSharedPooler, connectionStringPooler]
|
||||
)
|
||||
|
||||
const connectionParams = useMemo(
|
||||
() => parseConnectionParams(resolvedConnectionString),
|
||||
[resolvedConnectionString]
|
||||
)
|
||||
|
||||
const safeConnectionString = useMemo(
|
||||
() => buildSafeConnectionString(resolvedConnectionString, connectionParams),
|
||||
[resolvedConnectionString, connectionParams]
|
||||
)
|
||||
|
||||
const config: DirectFilesConfig | null = useMemo(() => {
|
||||
const envFile = {
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code: `DATABASE_URL=${safeConnectionString}`,
|
||||
}
|
||||
|
||||
switch (connectionType) {
|
||||
case 'nodejs':
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
name: 'db.js',
|
||||
language: 'js',
|
||||
code: `import postgres from 'postgres'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
const sql = postgres(connectionString)
|
||||
|
||||
export default sql`,
|
||||
},
|
||||
envFile,
|
||||
],
|
||||
connectionStringFile: envFile.name,
|
||||
}
|
||||
|
||||
case 'golang':
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
name: 'main.go',
|
||||
language: 'go',
|
||||
code: `package main
|
||||
|
||||
import (
|
||||
\t"context"
|
||||
\t"log"
|
||||
\t"os"
|
||||
\t"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func main() {
|
||||
\tconn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL"))
|
||||
\tif err != nil {
|
||||
\t\tlog.Fatalf("Failed to connect to the database: %v", err)
|
||||
\t}
|
||||
\tdefer conn.Close(context.Background())
|
||||
|
||||
\t// Example query to test connection
|
||||
\tvar version string
|
||||
\tif err := conn.QueryRow(context.Background(), "SELECT version()").Scan(&version); err != nil {
|
||||
\t\tlog.Fatalf("Query failed: %v", err)
|
||||
\t}
|
||||
|
||||
\tlog.Println("Connected to:", version)
|
||||
}`,
|
||||
},
|
||||
envFile,
|
||||
],
|
||||
connectionStringFile: envFile.name,
|
||||
}
|
||||
|
||||
case 'dotnet':
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
name: 'appsettings.json',
|
||||
language: 'json',
|
||||
code: `{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=${connectionParams.host};Database=${connectionParams.database};Username=${connectionParams.user};Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
],
|
||||
connectionStringFile: 'appsettings.json',
|
||||
postCommands: [
|
||||
{
|
||||
label: 'Add the configuration package to read the settings.',
|
||||
command: DOTNET_CONFIG_COMMAND,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
case 'python':
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
name: 'main.py',
|
||||
language: 'python',
|
||||
code: `import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load environment variables from .env
|
||||
load_dotenv()
|
||||
|
||||
# Fetch variables
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
# Connect to the database
|
||||
connection = psycopg2.connect(DATABASE_URL)`,
|
||||
},
|
||||
envFile,
|
||||
],
|
||||
connectionStringFile: envFile.name,
|
||||
}
|
||||
|
||||
case 'sqlalchemy':
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
name: 'main.py',
|
||||
language: 'python',
|
||||
code: `from sqlalchemy import create_engine
|
||||
# from sqlalchemy.pool import NullPool
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load environment variables from .env
|
||||
load_dotenv()
|
||||
|
||||
# Fetch variables
|
||||
USER = os.getenv("user")
|
||||
PASSWORD = os.getenv("password")
|
||||
HOST = os.getenv("host")
|
||||
PORT = os.getenv("port")
|
||||
DBNAME = os.getenv("dbname")
|
||||
|
||||
# Construct the SQLAlchemy connection string
|
||||
DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require"
|
||||
|
||||
# Create the SQLAlchemy engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
# If using Transaction Pooler or Session Pooler, we want to ensure we disable SQLAlchemy client side pooling -
|
||||
# https://docs.sqlalchemy.org/en/20/core/pooling.html#switching-pool-implementations
|
||||
# engine = create_engine(DATABASE_URL, poolclass=NullPool)
|
||||
|
||||
# Test the connection
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
print("Connection successful!")
|
||||
except Exception as e:
|
||||
print(f"Failed to connect: {e}")`,
|
||||
},
|
||||
{
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`user=${connectionParams.user}`,
|
||||
`password=${PASSWORD_PLACEHOLDER}`,
|
||||
`host=${connectionParams.host}`,
|
||||
`port=${connectionParams.port}`,
|
||||
`dbname=${connectionParams.database}`,
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
connectionStringFile: '.env',
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [connectionType, safeConnectionString, connectionParams])
|
||||
|
||||
const defaultFile = config?.files[0]?.name ?? ''
|
||||
const [activeFile, setActiveFile] = useState(defaultFile)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveFile(defaultFile)
|
||||
}, [connectionType, defaultFile])
|
||||
|
||||
if (!resolvedConnectionString) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config?.files.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<MultipleCodeBlock files={config.files} value={activeFile} onValueChange={setActiveFile} />
|
||||
<ConnectionParameters parameters={buildConnectionParameters(connectionParams)} />
|
||||
{(config.postCommands ?? []).map((command) => (
|
||||
<div key={command.command} className="flex flex-col gap-2">
|
||||
<p className="text-sm text-foreground-light">{command.label}</p>
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
wrapperClassName="lg:col-span-2"
|
||||
value={command.command}
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
>
|
||||
{command.command}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DirectFilesContent
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { CodeBlock } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../Connect.types'
|
||||
import examples from '../../../DirectConnectionExamples'
|
||||
|
||||
/**
|
||||
* Step component for direct connection install commands.
|
||||
*/
|
||||
function DirectInstallContent({ state }: StepContentProps) {
|
||||
const connectionType = (state.connectionType as string) ?? 'uri'
|
||||
const example = examples[connectionType as keyof typeof examples]
|
||||
const exampleInstallCommands = example?.installCommands ?? []
|
||||
|
||||
if (exampleInstallCommands.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{exampleInstallCommands.map((cmd) => (
|
||||
<CodeBlock
|
||||
key={`example-install-command-${cmd}`}
|
||||
className="[&_code]:text-foreground"
|
||||
wrapperClassName="lg:col-span-2"
|
||||
value={cmd}
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
>
|
||||
{cmd}
|
||||
</CodeBlock>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DirectInstallContent
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, copyToClipboard } from 'ui'
|
||||
|
||||
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)
|
||||
|
||||
if (libraryKey && INSTALL_COMMANDS[libraryKey]) return INSTALL_COMMANDS[libraryKey]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function InstallContent({ state }: StepContentProps) {
|
||||
const installCommand = useMemo(() => getInstallCommand(state), [state])
|
||||
const [copyLabel, setCopyLabel] = useState('Copy')
|
||||
|
||||
if (!installCommand) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(installCommand, () => {
|
||||
setCopyLabel('Copied')
|
||||
setTimeout(() => setCopyLabel('Copy'), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="bg-surface-75 border rounded-lg p-3 pr-20 font-mono text-sm text-foreground-light overflow-x-auto">
|
||||
<code>{installCommand}</code>
|
||||
</div>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{copyLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallContent
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { CodeBlock } from 'ui'
|
||||
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { FEATURE_GROUPS_NON_PLATFORM, FEATURE_GROUPS_PLATFORM } from 'ui-patterns/McpUrlBuilder'
|
||||
import type { StepContentProps } from '../../../../../Connect.types'
|
||||
|
||||
function useMcpUrl(
|
||||
state: StepContentProps['state'],
|
||||
projectKeys: StepContentProps['projectKeys']
|
||||
): string {
|
||||
const readonly = Boolean(state.mcpReadonly)
|
||||
const selectedFeatures = Array.isArray(state.mcpFeatures) ? state.mcpFeatures : []
|
||||
const baseUrl = IS_PLATFORM ? 'https://mcp.supabase.com' : projectKeys.apiUrl ?? ''
|
||||
|
||||
return useMemo(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (readonly) params.set('readonly', 'true')
|
||||
|
||||
const supportedFeatures = IS_PLATFORM ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM
|
||||
const validFeatures = selectedFeatures.filter((f) =>
|
||||
supportedFeatures.some((group) => group.id === f)
|
||||
)
|
||||
if (validFeatures.length > 0) {
|
||||
params.set('features', validFeatures.join(','))
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl
|
||||
}, [baseUrl, readonly, selectedFeatures])
|
||||
}
|
||||
|
||||
function ClaudeAddServerContent({ state, projectKeys }: StepContentProps) {
|
||||
const mcpUrl = useMcpUrl(state, projectKeys)
|
||||
const command = `claude mcp add --scope project --transport http supabase "${mcpUrl}"`
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
value={command}
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClaudeAddServerContent
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { CodeBlock } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../../../Connect.types'
|
||||
|
||||
function ClaudeAuthenticateContent(_props: StepContentProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
value="claude /mcp"
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Select the <code className="text-xs bg-surface-300 px-1 py-0.5 rounded">supabase</code>{' '}
|
||||
server, then <span className="font-medium">Authenticate</span> to begin the flow.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClaudeAuthenticateContent
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { CodeBlock } from 'ui'
|
||||
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { FEATURE_GROUPS_NON_PLATFORM, FEATURE_GROUPS_PLATFORM } from 'ui-patterns/McpUrlBuilder'
|
||||
import type { StepContentProps } from '../../../../../Connect.types'
|
||||
|
||||
function useMcpUrl(
|
||||
state: StepContentProps['state'],
|
||||
projectKeys: StepContentProps['projectKeys']
|
||||
): string {
|
||||
const readonly = Boolean(state.mcpReadonly)
|
||||
const selectedFeatures = Array.isArray(state.mcpFeatures) ? state.mcpFeatures : []
|
||||
const baseUrl = IS_PLATFORM ? 'https://mcp.supabase.com' : projectKeys.apiUrl ?? ''
|
||||
|
||||
return useMemo(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (readonly) params.set('readonly', 'true')
|
||||
|
||||
const supportedFeatures = IS_PLATFORM ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM
|
||||
const validFeatures = selectedFeatures.filter((f) =>
|
||||
supportedFeatures.some((group) => group.id === f)
|
||||
)
|
||||
if (validFeatures.length > 0) {
|
||||
params.set('features', validFeatures.join(','))
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl
|
||||
}, [baseUrl, readonly, selectedFeatures])
|
||||
}
|
||||
|
||||
function CodexAddServerContent({ state, projectKeys }: StepContentProps) {
|
||||
const mcpUrl = useMcpUrl(state, projectKeys)
|
||||
const command = `codex mcp add supabase --url ${mcpUrl}`
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
value={command}
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodexAddServerContent
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { CodeBlock } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../../../Connect.types'
|
||||
|
||||
function CodexAuthenticateContent(_props: StepContentProps) {
|
||||
const command = 'codex mcp login supabase'
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
value={command}
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodexAuthenticateContent
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import { CodeBlock } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../../../Connect.types'
|
||||
|
||||
function CodexEnableRemoteContent(_props: StepContentProps) {
|
||||
const configContent = `[mcp]
|
||||
remote_mcp_client_enabled = true`
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
className="[&_code]:text-foreground"
|
||||
value={configContent}
|
||||
hideLineNumbers
|
||||
language="toml"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodexEnableRemoteContent
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import type { StepContentProps } from '../../../../../Connect.types'
|
||||
|
||||
function CodexVerifyContent(_props: StepContentProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Run <code className="text-xs bg-surface-300 px-1 py-0.5 rounded">/mcp</code> inside Codex to
|
||||
verify authentication.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodexVerifyContent
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useParams } from 'common'
|
||||
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
createMcpCopyHandler,
|
||||
FEATURE_GROUPS_NON_PLATFORM,
|
||||
FEATURE_GROUPS_PLATFORM,
|
||||
getMcpUrl,
|
||||
MCP_CLIENTS,
|
||||
McpConfigurationDisplay,
|
||||
} from 'ui-patterns/McpUrlBuilder'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import type { StepContentProps } from '../../../../Connect.types'
|
||||
|
||||
function McpCursorContent({ state, projectKeys }: StepContentProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
if (!projectRef) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <McpCursorContentInner projectRef={projectRef} projectKeys={projectKeys} state={state} />
|
||||
}
|
||||
|
||||
function McpCursorContentInner({
|
||||
projectRef,
|
||||
projectKeys,
|
||||
state,
|
||||
}: {
|
||||
projectRef: string
|
||||
projectKeys: StepContentProps['projectKeys']
|
||||
state: StepContentProps['state']
|
||||
}) {
|
||||
const track = useTrack()
|
||||
|
||||
const selectedClient = useMemo(() => {
|
||||
const clientKey = String(state.mcpClient ?? '')
|
||||
return MCP_CLIENTS.find((c) => c.key === clientKey) ?? MCP_CLIENTS[0]
|
||||
}, [state.mcpClient])
|
||||
|
||||
const readonly = Boolean(state.mcpReadonly)
|
||||
const selectedFeatures = Array.isArray(state.mcpFeatures) ? state.mcpFeatures : []
|
||||
|
||||
const selectedFeaturesSupported = useMemo(() => {
|
||||
const supportedFeatures = IS_PLATFORM ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM
|
||||
return selectedFeatures.filter((feature) =>
|
||||
supportedFeatures.some((group) => group.id === feature)
|
||||
)
|
||||
}, [selectedFeatures])
|
||||
|
||||
const handleCopy = useMemo(
|
||||
() =>
|
||||
createMcpCopyHandler({
|
||||
selectedClient,
|
||||
source: 'studio',
|
||||
onTrack: (event) => track(event.action, event.properties, event.groups),
|
||||
projectRef,
|
||||
}),
|
||||
[selectedClient, track, projectRef]
|
||||
)
|
||||
|
||||
const { clientConfig } = getMcpUrl({
|
||||
projectRef,
|
||||
isPlatform: IS_PLATFORM,
|
||||
apiUrl: projectKeys.apiUrl ?? undefined,
|
||||
readonly,
|
||||
features: selectedFeaturesSupported,
|
||||
selectedClient,
|
||||
})
|
||||
|
||||
return (
|
||||
<McpConfigurationDisplay
|
||||
className="space-y-4"
|
||||
theme="dark"
|
||||
basePath={BASE_PATH}
|
||||
selectedClient={selectedClient}
|
||||
clientConfig={clientConfig}
|
||||
onCopyCallback={handleCopy}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpCursorContent
|
||||
@@ -1,36 +0,0 @@
|
||||
import { CodeBlock } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../Connect.types'
|
||||
|
||||
const ORM_INSTALL_COMMANDS: Record<string, string[]> = {
|
||||
prisma: ['npm install prisma --save-dev', 'npx prisma init'],
|
||||
drizzle: ['npm install drizzle-orm', 'npm install drizzle-kit --save-dev'],
|
||||
}
|
||||
|
||||
function OrmInstallContent({ state }: StepContentProps) {
|
||||
const ormKey = String(state.orm ?? '')
|
||||
const commands = ORM_INSTALL_COMMANDS[ormKey]
|
||||
|
||||
if (!commands?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{commands.map((cmd, index) => (
|
||||
<CodeBlock
|
||||
key={index}
|
||||
className="[&_code]:text-foreground"
|
||||
wrapperClassName="lg:col-span-2"
|
||||
value={cmd}
|
||||
hideLineNumbers
|
||||
language="bash"
|
||||
>
|
||||
{cmd}
|
||||
</CodeBlock>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrmInstallContent
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, copyToClipboard } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../../Connect.types'
|
||||
|
||||
function getShadcnCommand(state: StepContentProps['state']): string | null {
|
||||
if (state.framework === 'nextjs') {
|
||||
return 'npx shadcn@latest add @supabase/supabase-client-nextjs'
|
||||
}
|
||||
|
||||
if (state.framework === 'react') {
|
||||
return 'npx shadcn@latest add @supabase/supabase-client-react-router'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function ShadcnCommandContent({ state }: StepContentProps) {
|
||||
const command = useMemo(() => getShadcnCommand(state), [state])
|
||||
const [copyLabel, setCopyLabel] = useState('Copy')
|
||||
|
||||
if (!command) return null
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(command, () => {
|
||||
setCopyLabel('Copied')
|
||||
setTimeout(() => setCopyLabel('Copy'), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="bg-surface-75 border rounded-lg p-3 pr-20 font-mono text-sm text-foreground-light overflow-x-auto">
|
||||
<code>{command}</code>
|
||||
</div>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{copyLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShadcnCommandContent
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../../Connect.types'
|
||||
|
||||
function ShadcnExploreContent(_props: StepContentProps) {
|
||||
return (
|
||||
<Button asChild type="default" icon={<ExternalLink size={14} />}>
|
||||
<a href="https://supabase.com/ui" target="_blank" rel="noreferrer">
|
||||
Explore supabase.com/ui
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShadcnExploreContent
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button, copyToClipboard } from 'ui'
|
||||
|
||||
import type { StepContentProps } from '../../../Connect.types'
|
||||
|
||||
const SKILLS_COMMAND = 'npx skills add supabase/agent-skills'
|
||||
|
||||
function SkillsInstallContent(_props: StepContentProps) {
|
||||
const [copyLabel, setCopyLabel] = useState('Copy')
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(SKILLS_COMMAND, () => {
|
||||
setCopyLabel('Copied')
|
||||
setTimeout(() => setCopyLabel('Copy'), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="bg-surface-75 border rounded-lg p-3 pr-20 font-mono text-sm text-foreground-light overflow-x-auto">
|
||||
<code>{SKILLS_COMMAND}</code>
|
||||
</div>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{copyLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SkillsInstallContent
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: [
|
||||
`PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`,
|
||||
projectKeys?.publishableKey
|
||||
? `PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}`
|
||||
: `PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: 'src/lib/supabaseClient.js',
|
||||
language: 'js',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { PUBLIC_SUPABASE_URL, ${projectKeys.publishableKey ? 'PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'PUBLIC_SUPABASE_ANON_KEY'} } from "$env/static/public"
|
||||
|
||||
const supabaseUrl = PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = ${projectKeys.publishableKey ? 'PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'PUBLIC_SUPABASE_ANON_KEY'};
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/routes/+page.server.js',
|
||||
language: 'js',
|
||||
code: `
|
||||
import { supabase } from "$lib/supabaseClient";
|
||||
|
||||
export async function load() {
|
||||
const { data } = await supabase.from("countries").select();
|
||||
return {
|
||||
countries: data ?? [],
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/routes/+page.svelte',
|
||||
language: 'html',
|
||||
code: `
|
||||
<script>
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each data.countries as country}
|
||||
<li>{country.name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: 'Supabase.swift',
|
||||
language: 'swift',
|
||||
code: `
|
||||
import Foundation
|
||||
import Supabase
|
||||
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: URL(string: "${projectKeys.apiUrl ?? 'your-project-url'}")!,
|
||||
supabaseKey: "${projectKeys.publishableKey ?? '<prefer publishable key for native apps instead of anon key>'}"
|
||||
)
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'Todo.swift',
|
||||
language: 'swift',
|
||||
code: `
|
||||
import Foundation
|
||||
|
||||
struct Todo: Identifiable, Decodable {
|
||||
var id: Int
|
||||
var title: String
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'ContentView.swift',
|
||||
language: 'swift',
|
||||
code: `
|
||||
import Supabase
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State var todos: [Todo] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(todos) { todo in
|
||||
Text(todo.title)
|
||||
}
|
||||
.navigationTitle("Todos")
|
||||
.task {
|
||||
do {
|
||||
todos = try await supabase.from("todos").select().execute().value
|
||||
} catch {
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env',
|
||||
language: 'bash',
|
||||
code: `
|
||||
VITE_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
VITE_SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const supabase = createClient(
|
||||
import.meta.env.VITE_SUPABASE_URL,
|
||||
import.meta.env.VITE_SUPABASE_KEY
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'src/routes/index.tsx',
|
||||
language: 'tsx',
|
||||
code: `
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { supabase } from '../utils/supabase'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
loader: async () => {
|
||||
const { data: todos } = await supabase.from('todos').select()
|
||||
return { todos }
|
||||
},
|
||||
component: Home,
|
||||
})
|
||||
|
||||
function Home() {
|
||||
const { todos } = Route.useLoaderData()
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{todos?.map((todo) => (
|
||||
<li key={todo.id}>{todo.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,58 +0,0 @@
|
||||
import { MultipleCodeBlock } from 'ui-patterns/multiple-code-block'
|
||||
|
||||
import type { ContentFileProps } from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
|
||||
const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
const files = [
|
||||
{
|
||||
name: '.env.local',
|
||||
language: 'bash',
|
||||
code: `
|
||||
SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'utils/supabase.ts',
|
||||
language: 'ts',
|
||||
code: `
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_KEY;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'App.vue',
|
||||
language: 'jsx',
|
||||
code: `
|
||||
<script setup>
|
||||
import { supabase } from '../utils/supabase'
|
||||
const todos = ref([])
|
||||
|
||||
async function getTodos() {
|
||||
const { data } = await supabase.from('todos').select()
|
||||
todos.value = data
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTodos()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
<li v-for="todo in todos" :key="todo.id">{{ todo.name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
return <MultipleCodeBlock files={files} />
|
||||
}
|
||||
|
||||
export default ContentFile
|
||||
@@ -1,482 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useConnectState } from './useConnectState'
|
||||
|
||||
describe('useConnectState', () => {
|
||||
// ============================================================================
|
||||
// Initial State Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('initial state', () => {
|
||||
test('should initialize with framework mode by default', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
expect(result.current.state.mode).toBe('framework')
|
||||
})
|
||||
|
||||
test('should initialize with nextjs as default framework', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
expect(result.current.state.framework).toBe('nextjs')
|
||||
})
|
||||
|
||||
test('should initialize with app variant for nextjs', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
expect(result.current.state.frameworkVariant).toBe('app')
|
||||
})
|
||||
|
||||
test('should initialize with supabasejs library', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
expect(result.current.state.library).toBe('supabasejs')
|
||||
})
|
||||
|
||||
test('should accept initial state override', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
|
||||
)
|
||||
expect(result.current.state.mode).toBe('direct')
|
||||
expect(result.current.state.connectionMethod).toBe('transaction')
|
||||
})
|
||||
|
||||
test('should merge initial state with defaults', () => {
|
||||
const { result } = renderHook(() => useConnectState({ framework: 'react' }))
|
||||
expect(result.current.state.mode).toBe('framework')
|
||||
expect(result.current.state.framework).toBe('react')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Mode Switching Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('setMode', () => {
|
||||
test('should switch to direct mode', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
act(() => {
|
||||
result.current.setMode('direct')
|
||||
})
|
||||
|
||||
expect(result.current.state.mode).toBe('direct')
|
||||
})
|
||||
|
||||
test('should initialize direct mode defaults when switching', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
act(() => {
|
||||
result.current.setMode('direct')
|
||||
})
|
||||
|
||||
expect(result.current.state.connectionMethod).toBeDefined()
|
||||
expect(result.current.state.connectionType).toBeDefined()
|
||||
})
|
||||
|
||||
test('should switch to orm mode and initialize defaults', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
act(() => {
|
||||
result.current.setMode('orm')
|
||||
})
|
||||
|
||||
expect(result.current.state.mode).toBe('orm')
|
||||
expect(result.current.state.orm).toBe('prisma')
|
||||
})
|
||||
|
||||
test('should switch to mcp mode and initialize defaults', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
act(() => {
|
||||
result.current.setMode('mcp')
|
||||
})
|
||||
|
||||
expect(result.current.state.mode).toBe('mcp')
|
||||
expect(result.current.state.mcpClient).toBeDefined()
|
||||
})
|
||||
|
||||
test('should preserve framework state when switching back to framework mode', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
// Change framework
|
||||
act(() => {
|
||||
result.current.updateField('framework', 'react')
|
||||
})
|
||||
|
||||
// Switch to direct
|
||||
act(() => {
|
||||
result.current.setMode('direct')
|
||||
})
|
||||
|
||||
// Switch back to framework
|
||||
act(() => {
|
||||
result.current.setMode('framework')
|
||||
})
|
||||
|
||||
expect(result.current.state.framework).toBe('react')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Field Update Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('updateField', () => {
|
||||
test('should update framework selection', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('framework', 'react')
|
||||
})
|
||||
|
||||
expect(result.current.state.framework).toBe('react')
|
||||
})
|
||||
|
||||
test('should cascade variant reset when changing framework', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
// Start with nextjs which has variants
|
||||
expect(result.current.state.frameworkVariant).toBe('app')
|
||||
|
||||
// Switch to a framework with multiple variants
|
||||
act(() => {
|
||||
result.current.updateField('framework', 'react')
|
||||
})
|
||||
|
||||
// Should have the first variant of react
|
||||
expect(result.current.state.frameworkVariant).toBeDefined()
|
||||
})
|
||||
|
||||
test('should remove variant when switching to framework without variants', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
// Start with nextjs which has variants
|
||||
expect(result.current.state.frameworkVariant).toBe('app')
|
||||
|
||||
// Switch to remix which has no variants
|
||||
act(() => {
|
||||
result.current.updateField('framework', 'remix')
|
||||
})
|
||||
|
||||
expect(result.current.state.frameworkVariant).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should update library when variant changes', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('frameworkVariant', 'pages')
|
||||
})
|
||||
|
||||
expect(result.current.state.library).toBe('supabasejs')
|
||||
})
|
||||
|
||||
test('should update connection method', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('connectionMethod', 'transaction')
|
||||
})
|
||||
|
||||
expect(result.current.state.connectionMethod).toBe('transaction')
|
||||
})
|
||||
|
||||
test('should clear useSharedPooler when connectionMethod changes to direct', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({
|
||||
mode: 'direct',
|
||||
connectionMethod: 'transaction',
|
||||
useSharedPooler: true,
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('connectionMethod', 'direct')
|
||||
})
|
||||
|
||||
// useSharedPooler is cleared because it depends on connectionMethod: ['transaction']
|
||||
// When the dependency is not satisfied, the field is removed from state
|
||||
expect(result.current.state.useSharedPooler).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should update MCP client', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('mcpClient', 'codex')
|
||||
})
|
||||
|
||||
expect(result.current.state.mcpClient).toBe('codex')
|
||||
})
|
||||
|
||||
test('should update boolean fields', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('frameworkUi', true)
|
||||
})
|
||||
|
||||
expect(result.current.state.frameworkUi).toBe(true)
|
||||
})
|
||||
|
||||
test('should update ORM selection', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('orm', 'drizzle')
|
||||
})
|
||||
|
||||
expect(result.current.state.orm).toBe('drizzle')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Active Fields Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('activeFields', () => {
|
||||
test('should return framework mode fields', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).toContain('framework')
|
||||
})
|
||||
|
||||
test('should include variant field for nextjs', () => {
|
||||
const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).toContain('frameworkVariant')
|
||||
})
|
||||
|
||||
test('should include frameworkUi field for nextjs', () => {
|
||||
const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).toContain('frameworkUi')
|
||||
})
|
||||
|
||||
test('should not include frameworkUi for non-nextjs/react frameworks', () => {
|
||||
const { result } = renderHook(() => useConnectState({ framework: 'remix' }))
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).not.toContain('frameworkUi')
|
||||
})
|
||||
|
||||
test('should return direct mode fields', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).toContain('connectionMethod')
|
||||
expect(fieldIds).toContain('connectionType')
|
||||
})
|
||||
|
||||
test('should show useSharedPooler only for transaction connection method', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionMethod: 'transaction' })
|
||||
)
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).toContain('useSharedPooler')
|
||||
})
|
||||
|
||||
test('should hide useSharedPooler for direct connection method', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionMethod: 'direct' })
|
||||
)
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).not.toContain('useSharedPooler')
|
||||
})
|
||||
|
||||
test('should return orm mode fields', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).toContain('orm')
|
||||
})
|
||||
|
||||
test('should return mcp mode fields', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
|
||||
|
||||
const fieldIds = result.current.activeFields.map((f) => f.id)
|
||||
expect(fieldIds).toContain('mcpClient')
|
||||
expect(fieldIds).toContain('mcpReadonly')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Resolved Steps Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('resolvedSteps', () => {
|
||||
test('should resolve steps for framework mode', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should have install step for framework mode', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
const stepIds = result.current.resolvedSteps.map((s) => s.id)
|
||||
expect(stepIds).toContain('install')
|
||||
})
|
||||
|
||||
test('should resolve different steps for mcp mode', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
|
||||
|
||||
const stepIds = result.current.resolvedSteps.map((s) => s.id)
|
||||
// MCP mode should have configure step
|
||||
expect(stepIds.some((id) => id.includes('configure') || id.includes('mcp'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should resolve different steps for different mcp clients', () => {
|
||||
const { result: cursorResult } = renderHook(() =>
|
||||
useConnectState({ mode: 'mcp', mcpClient: 'cursor' })
|
||||
)
|
||||
const { result: codexResult } = renderHook(() =>
|
||||
useConnectState({ mode: 'mcp', mcpClient: 'codex' })
|
||||
)
|
||||
|
||||
// Codex has more steps than cursor
|
||||
expect(codexResult.current.resolvedSteps.length).toBeGreaterThanOrEqual(
|
||||
cursorResult.current.resolvedSteps.length
|
||||
)
|
||||
})
|
||||
|
||||
test('should include skills install step', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
const stepIds = result.current.resolvedSteps.map((s) => s.id)
|
||||
expect(stepIds).toContain('install-skills')
|
||||
})
|
||||
|
||||
test('should resolve steps for direct mode', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should resolve steps for orm mode', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
|
||||
|
||||
expect(result.current.resolvedSteps.length).toBeGreaterThan(0)
|
||||
const stepIds = result.current.resolvedSteps.map((s) => s.id)
|
||||
expect(stepIds).toContain('install')
|
||||
expect(stepIds).toContain('configure')
|
||||
})
|
||||
|
||||
test('should resolve shadcn steps when frameworkUi is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ framework: 'nextjs', frameworkUi: true })
|
||||
)
|
||||
|
||||
const stepIds = result.current.resolvedSteps.map((s) => s.id)
|
||||
expect(stepIds).toContain('shadcn-add')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Field Options Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('getFieldOptions', () => {
|
||||
test('should return framework options', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
const options = result.current.getFieldOptions('framework')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
expect(options.some((o) => o.value === 'nextjs')).toBe(true)
|
||||
expect(options.some((o) => o.value === 'react')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return variant options for nextjs', () => {
|
||||
const { result } = renderHook(() => useConnectState({ framework: 'nextjs' }))
|
||||
|
||||
const options = result.current.getFieldOptions('frameworkVariant')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
expect(options.some((o) => o.value === 'app')).toBe(true)
|
||||
expect(options.some((o) => o.value === 'pages')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return empty variant options for frameworks without variants', () => {
|
||||
const { result } = renderHook(() => useConnectState({ framework: 'remix' }))
|
||||
|
||||
const options = result.current.getFieldOptions('frameworkVariant')
|
||||
expect(options).toEqual([])
|
||||
})
|
||||
|
||||
test('should return connection method options', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
const options = result.current.getFieldOptions('connectionMethod')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
expect(options.some((o) => o.value === 'direct')).toBe(true)
|
||||
expect(options.some((o) => o.value === 'transaction')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return connection type options', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
const options = result.current.getFieldOptions('connectionType')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
expect(options.some((o) => o.value === 'uri')).toBe(true)
|
||||
expect(options.some((o) => o.value === 'psql')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return ORM options', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'orm' }))
|
||||
|
||||
const options = result.current.getFieldOptions('orm')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
expect(options.some((o) => o.value === 'prisma')).toBe(true)
|
||||
expect(options.some((o) => o.value === 'drizzle')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return MCP client options', () => {
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'mcp' }))
|
||||
|
||||
const options = result.current.getFieldOptions('mcpClient')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
expect(options.some((o) => o.value === 'cursor')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return empty array for unknown field', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
const options = result.current.getFieldOptions('unknownField')
|
||||
expect(options).toEqual([])
|
||||
})
|
||||
|
||||
test('should return library options for selected framework', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ framework: 'nextjs', frameworkVariant: 'app' })
|
||||
)
|
||||
|
||||
const options = result.current.getFieldOptions('library')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Schema Access Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('schema', () => {
|
||||
test('should expose the connect schema', () => {
|
||||
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', () => {
|
||||
const { result } = renderHook(() => useConnectState())
|
||||
|
||||
const modeIds = result.current.schema.modes.map((m) => m.id)
|
||||
expect(modeIds).toContain('framework')
|
||||
expect(modeIds).toContain('direct')
|
||||
expect(modeIds).toContain('orm')
|
||||
expect(modeIds).toContain('mcp')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,307 +0,0 @@
|
||||
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 {
|
||||
getActiveFields,
|
||||
getDefaultState,
|
||||
resetDependentFields,
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
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[]
|
||||
schema: ConnectSchema
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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
|
||||
const libraryKey = resolveFrameworkLibraryKey({
|
||||
framework: next.framework,
|
||||
frameworkVariant: next.frameworkVariant,
|
||||
library: next.library,
|
||||
})
|
||||
if (libraryKey) {
|
||||
next.library = libraryKey
|
||||
} else {
|
||||
delete next.library
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cascading updates for variant selection
|
||||
if (fieldId === 'frameworkVariant') {
|
||||
const libraryKey = resolveFrameworkLibraryKey({
|
||||
framework: prev.framework,
|
||||
frameworkVariant: String(value),
|
||||
library: next.library,
|
||||
})
|
||||
if (libraryKey) next.library = libraryKey
|
||||
}
|
||||
|
||||
// Reset useSharedPooler when connectionMethod changes to 'direct'
|
||||
if (fieldId === 'connectionMethod' && value === 'direct') {
|
||||
next.useSharedPooler = false
|
||||
}
|
||||
|
||||
return resetDependentFields(next, fieldId, connectSchema)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((mode: ConnectMode) => {
|
||||
setState((prev) => {
|
||||
const next: ConnectState = { ...prev, mode }
|
||||
|
||||
// Initialize mode-specific defaults
|
||||
if (mode === 'framework' && !next.framework) {
|
||||
const firstFramework = FRAMEWORKS[0]
|
||||
next.framework = firstFramework?.key ?? ''
|
||||
if (firstFramework?.children?.length > 1) {
|
||||
next.frameworkVariant = firstFramework.children[0]?.key ?? ''
|
||||
}
|
||||
const libraryKey = resolveFrameworkLibraryKey({
|
||||
framework: next.framework,
|
||||
frameworkVariant: next.frameworkVariant,
|
||||
library: next.library,
|
||||
})
|
||||
if (libraryKey) next.library = libraryKey
|
||||
}
|
||||
|
||||
if (mode === 'direct') {
|
||||
next.connectionMethod = next.connectionMethod ?? 'direct'
|
||||
next.connectionType = next.connectionType ?? 'uri'
|
||||
}
|
||||
|
||||
if (mode === 'orm' && !next.orm) {
|
||||
next.orm = ORMS[0]?.key ?? ''
|
||||
}
|
||||
|
||||
if (mode === 'mcp' && !next.mcpClient) {
|
||||
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activeFields = useMemo(() => getActiveFields(connectSchema, state), [state])
|
||||
|
||||
const resolvedSteps = useMemo(() => resolveSteps(connectSchema, state), [state])
|
||||
|
||||
const getFieldOptions = useCallback(
|
||||
(fieldId: string): FieldOption[] => {
|
||||
const field = activeFields.find((f) => f.id === fieldId)
|
||||
if (!field) return []
|
||||
return resolveFieldOptionsWithSource(field, state)
|
||||
},
|
||||
[activeFields, state]
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
updateField,
|
||||
setMode,
|
||||
activeFields,
|
||||
resolvedSteps,
|
||||
getFieldOptions,
|
||||
schema: connectSchema,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
|
||||
import { Loader } from 'lucide-react'
|
||||
|
||||
import { ConnectButton } from '@/components/interfaces/ConnectButton/ConnectButton'
|
||||
import { ConnectionIcon } from '@/components/interfaces/ConnectSheet/ConnectionIcon'
|
||||
import { useParams } from 'common'
|
||||
import { AlertError } from 'components/ui/AlertError'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
@@ -12,11 +8,15 @@ import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-statu
|
||||
import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui'
|
||||
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { ConnectionIcon } from '@/components/interfaces/Connect/ConnectionIcon'
|
||||
import { ConnectButton } from '@/components/interfaces/ConnectButton/ConnectButton'
|
||||
|
||||
export const APIKeys = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ConnectionType } from '@/components/interfaces/ConnectSheet/Connect.constants'
|
||||
import { ConnectionIcon } from '@/components/interfaces/ConnectSheet/ConnectionIcon'
|
||||
import { Box, Check, ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
@@ -16,6 +14,9 @@ import {
|
||||
cn,
|
||||
} from 'ui'
|
||||
|
||||
import { ConnectionType } from '@/components/interfaces/Connect/Connect.constants'
|
||||
import { ConnectionIcon } from '@/components/interfaces/Connect/ConnectionIcon'
|
||||
|
||||
interface FrameworkSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
|
||||
Reference in new issue
Block a user