mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
## What kind of change does this PR introduce? UI polish for the Connect sheet: clearer mode selection, wider sheet layout, and step/content chrome across Direct, Server, MCP, and shadcn flows. ## What is the current behavior? - Connect modes use a weak selected state and an awkward grid layout. - The sheet can jump width below the `lg` breakpoint when switching modes. - Direct connection chrome is noisy (reset in a footer, Title Case / mono pooler labels, mismatched copy-button sizes). - Several steps use admonitions or extra tips that repeat footer guidance. - Case-sensitive import of `InlineLink` breaks Linux/Vercel builds. ## What is the new behavior? ### Mode selector and sheet - Stronger selected/hover treatment; comfortable single row that wraps via `@container`. - Empty odd slots use a sunk placeholder cell. - Sheet uses `size="lg"` with `max-w-4xl` and `w-full min-w-0` so width stays stable when switching modes. ### Steps chrome - “Follow these steps” header with a copy-prompt action for coding agents. - Optional steps labelled `(optional)`. - Shared `CodeBlock` for install snippets; MCP feature groups preselect all except Storage. - Server / shadcn tips folded into footers; IPv4 add-on admonition is responsive with an inline Learn more link and a single Enable action. ### Direct connection - Connection string and connection parameters stay one step (same credentials, two formats). - Reset database password lives in the string card title row beside Shared/Dedicated pooler. - Card titles use sans + sentence case (`Shared pooler`, `Connection parameters`); `.env` stays mono. - Icon-only copy buttons match CodeBlock square sizing; row actions sit slightly closer to the right edge (`pr-2`). - Shared pooler toggle copy clarified. | Before | After | | --- | --- | | <img width="390" height="763" alt="API Keys Settings Chisel Toolshed Supabase" src="https://github.com/user-attachments/assets/adca3cc5-94f8-47e5-a4a2-2831790f430a" /> | <img width="390" height="763" alt="API Keys Settings Chisel Toolshed Supabase" src="https://github.com/user-attachments/assets/f03afe58-e654-435e-a821-835f6243ca95" /> | | <img width="1718" height="1323" alt="API Keys Settings Chisel Toolshed Supabase" src="https://github.com/user-attachments/assets/79f08620-7e1e-4246-a70f-801606c0f499" /> | <img width="1718" height="1323" alt="API Keys Settings Chisel Toolshed Supabase" src="https://github.com/user-attachments/assets/fb45e851-955e-46c2-90f1-afecb93d6ac4" /> | | <img width="1718" height="1323" alt="API Keys Settings Chisel Toolshed Supabase" src="https://github.com/user-attachments/assets/eda36d21-bba7-46ab-ad48-134acf93b471" /> | <img width="1718" height="1323" alt="API Keys Settings Chisel Toolshed Supabase" src="https://github.com/user-attachments/assets/b7b728c6-fc92-46a7-8e3f-2f182c56ece7" /> | ### Test plan - [ ] Open **Connect** and confirm mode cells select/hover clearly; narrow the sheet and confirm wrap + stable width. - [ ] Direct: switch Direct / Transaction / Session; confirm pooler title, reset in title row, parameters table, and percent-encode note. - [ ] Toggle IPv4 shared pooler on Transaction; confirm string updates and admonition/Learn more behaviour when on IPv4-only paths. - [ ] Server: `.env` Copy all / row copy sizing; install command copy. - [ ] MCP / shadcn / Framework: steps still resolve and copy prompt still builds a useful agent prompt. - [ ] Spot-check light/dark and a Linux/Vercel build (InlineLink import casing).
256 lines
7.5 KiB
TypeScript
256 lines
7.5 KiB
TypeScript
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,
|
|
optional: step.optional,
|
|
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 }: { 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
|
|
}
|