Files
Danny White 6cff728742 feat(studio): polish Connect sheet mode selector and steps (#48266)
## 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).
2026-07-25 00:13:54 +10:00

278 lines
9.4 KiB
TypeScript

import {
cn,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types'
import { ConnectionIcon } from './ConnectionIcon'
import {
ConnectModeButton,
getConnectModeButtonCornerVariants,
getConnectModeEmptySlotClasses,
} from './ConnectModeButton'
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) {
if (activeFields.length === 0) return null
return (
<div className="flex flex-col gap-y-4">
{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':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<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 supportsDarkMode icon={option.icon} />}
<span className="text-sm">{option.label}</span>
</div>
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'radio-list':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<RadioGroupStacked
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
className="min-w-0 w-full"
>
{options.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={`connect-${field.id}-${option.value}`}
value={option.value}
className="min-w-0 w-full text-left"
label={
<span className="flex min-w-0 items-center gap-2">
{option.icon && <ConnectionIcon icon={option.icon} />}
<span className="truncate">{option.label}</span>
</span>
}
description={option.description}
/>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
>
<Select
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
>
<SelectTrigger
size="small"
className="[&>span:first-child]:flex [&>span:first-child]:items-center [&>span:first-child]:gap-x-2"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="[&>span:last-child]:flex [&>span:last-child]:items-center [&>span:last-child]:gap-x-2"
>
{/*
[Joshen] Omitting MCP icons for now as the images are not optimized (large)
and is causing noticeably latency issues on the browser (even with the existing Connect UI)
*/}
{field.id === 'framework' && option.icon && (
<ConnectionIcon icon={option.icon} />
)}
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)
case 'switch':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
className="[&>div>label>span]:break-keep! [&>div>label>span]:text-balance"
>
<Switch
id={field.id}
checked={Boolean(value)}
onCheckedChange={(v) => onFieldChange(field.id, v)}
/>
</FormItemLayout>
)
case 'multi-select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
>
<MultiSelector
values={Array.isArray(value) ? value : []}
onValuesChange={(v) => onFieldChange(field.id, v)}
>
<MultiSelectorTrigger
className="w-full"
label="Select features"
badgeLimit="wrap"
showIcon={true}
/>
<MultiSelectorContent>
<MultiSelectorList>
{options.map((option) => (
<MultiSelectorItem
key={option.value}
value={option.value}
className="items-start"
>
<div className="flex flex-col ml-2 gap-y-0.5">
<span className="font-medium">{option.label}</span>
{option.description && (
<span className="text-xs text-foreground-light">
{option.description}
</span>
)}
</div>
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)
default:
return null
}
})}
</div>
)
}
interface ModeSelectorProps {
modes: Array<{ id: ConnectMode; label: string; description: string }>
selected: ConnectMode
onChange: (mode: ConnectMode) => void
}
export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) {
const count = modes.length
// 2-col layout leaves an empty cell when count is odd; hide it once we switch to a single row
const emptySlots = count % 2 === 1 ? 1 : 0
return (
// Container query: 2-col when the sheet is narrow; one equal row when there's room
<div className="@container">
<div
className={cn(
'grid',
'grid-cols-2',
count === 3 && '@[28rem]:grid-cols-3',
count === 4 && '@[30rem]:grid-cols-4',
count === 5 && '@[32rem]:grid-cols-5',
count >= 6 && '@[36rem]:grid-cols-6'
)}
>
{modes.map((mode, index) => (
<ConnectModeButton
key={mode.id}
modeId={mode.id}
label={mode.label}
description={mode.description}
selected={selected === mode.id}
onClick={() => onChange(mode.id)}
{...getConnectModeButtonCornerVariants({ index, count, emptySlots })}
/>
))}
{Array.from({ length: emptySlots }, (_, index) => (
<div
key={`empty-${index}`}
aria-hidden
className={getConnectModeEmptySlotClasses(count)}
/>
))}
</div>
</div>
)
}