| author | |
| committer | |
| log | 282ecb317877e6cce296976a03278cc388b7ca37 |
| tree | 01c05f7c4432ec01f219392e7ded448141bf0ba4 |
| parent | 32c6012dabdbd0eb178b25ea4225d889ec8f6475 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>5 files changed, 254 insertions(+), 72 deletions(-)
apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | export type WizardNavigation = | |
| 2 | | { readonly kind: "navigate"; readonly step: number } | |
| 3 | | { readonly kind: "blocked"; readonly step: number; readonly error: string }; | |
| 4 | ||
| 5 | const IDENTITY_STEP = 1; | |
| 6 | ||
| 7 | export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; | |
| 8 | ||
| 9 | /** | |
| 10 | * Resolve navigation within the add-provider wizard. | |
| 11 | * | |
| 12 | * Moving forward past Identity requires a valid instance id, whether the user | |
| 13 | * advances one step at a time or skips directly to Config from a step header. | |
| 14 | * A blocked skip lands on Identity so its existing inline validation is | |
| 15 | * visible. Backward navigation is always preserved. | |
| 16 | */ | |
| 17 | export function resolveWizardNavigation( | |
| 18 | currentStep: number, | |
| 19 | requestedStep: number, | |
| 20 | stepCount: number, | |
| 21 | validation: { readonly instanceIdError: string | null }, | |
| 22 | ): WizardNavigation { | |
| 23 | const lastStep = Math.max(0, stepCount - 1); | |
| 24 | const targetStep = Math.max(0, Math.min(lastStep, requestedStep)); | |
| 25 | const movesForwardPastIdentity = currentStep <= IDENTITY_STEP && targetStep > IDENTITY_STEP; | |
| 26 | ||
| 27 | if (movesForwardPastIdentity && validation.instanceIdError !== null) { | |
| 28 | return { | |
| 29 | kind: "blocked", | |
| 30 | step: Math.min(IDENTITY_STEP, lastStep), | |
| 31 | error: validation.instanceIdError, | |
| 32 | }; | |
| 33 | } | |
| 34 | ||
| 35 | return { kind: "navigate", step: targetStep }; | |
| 36 | } |
apps/web/src/components/settings/AddProviderInstanceDialog.test.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic"; | |
| 4 | ||
| 5 | describe("resolveWizardNavigation", () => { | |
| 6 | const invalidId = { instanceIdError: "Instance ID is required." }; | |
| 7 | const validId = { instanceIdError: null }; | |
| 8 | ||
| 9 | it("allows moving from Driver to Identity before the instance id is valid", () => { | |
| 10 | expect(resolveWizardNavigation(0, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 }); | |
| 11 | }); | |
| 12 | ||
| 13 | it("blocks Next from Identity to Config while the instance id is invalid", () => { | |
| 14 | expect(resolveWizardNavigation(1, 2, 3, invalidId)).toEqual({ | |
| 15 | kind: "blocked", | |
| 16 | step: 1, | |
| 17 | error: "Instance ID is required.", | |
| 18 | }); | |
| 19 | }); | |
| 20 | ||
| 21 | it("stops a direct Driver-to-Config skip at Identity and surfaces its error", () => { | |
| 22 | expect(resolveWizardNavigation(0, 2, 3, invalidId)).toEqual({ | |
| 23 | kind: "blocked", | |
| 24 | step: 1, | |
| 25 | error: "Instance ID is required.", | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | it("allows advancing and skipping forward once the instance id is valid", () => { | |
| 30 | expect(resolveWizardNavigation(1, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 }); | |
| 31 | expect(resolveWizardNavigation(0, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 }); | |
| 32 | }); | |
| 33 | ||
| 34 | it("always preserves backward Driver and Identity navigation", () => { | |
| 35 | expect(resolveWizardNavigation(2, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 }); | |
| 36 | expect(resolveWizardNavigation(2, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); | |
| 37 | expect(resolveWizardNavigation(1, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); | |
| 38 | }); | |
| 39 | ||
| 40 | it("clamps requested steps to the wizard bounds", () => { | |
| 41 | expect(resolveWizardNavigation(2, 8, 3, validId)).toEqual({ kind: "navigate", step: 2 }); | |
| 42 | expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); | |
| 43 | }); | |
| 44 | }); |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+43-72| ... | ... | @@ -1,8 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { CheckIcon } from "lucide-react"; | |
| 4 | 3 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 5 | import { useCallback, useMemo, useState } from "react"; | |
| 4 | import { useMemo, useState } from "react"; | |
| 6 | 5 | import { |
| 7 | 6 | ProviderInstanceId, |
| 8 | 7 | ProviderDriverKind, |
| ... | ... | @@ -29,6 +28,12 @@ import { toastManager } from "../ui/toast"; |
| 29 | 28 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; |
| 30 | 29 | import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; |
| 31 | 30 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 31 | import { | |
| 32 | ADD_PROVIDER_WIZARD_STEPS, | |
| 33 | resolveWizardNavigation, | |
| 34 | type WizardNavigation, | |
| 35 | } from "./AddProviderInstanceDialog.logic"; | |
| 36 | import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; | |
| 32 | 37 | |
| 33 | 38 | const PROVIDER_ACCENT_SWATCHES = [ |
| 34 | 39 | "#2563eb", |
| ... | ... | @@ -143,26 +148,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 143 | 148 | const instanceIdError = validateInstanceId(instanceId, existingIds); |
| 144 | 149 | const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; |
| 145 | 150 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; |
| 146 | const wizardSteps = ["Driver", "Identity", "Config"] as const; | |
| 147 | 151 | const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; |
| 148 | 152 | |
| 149 | 153 | const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; |
| 150 | const setConfigDraft = useCallback( | |
| 151 | (config: Record<string, unknown> | undefined) => { | |
| 152 | setConfigByDriver((existing) => { | |
| 153 | const next = { ...existing }; | |
| 154 | if (config === undefined || Object.keys(config).length === 0) { | |
| 155 | delete next[driver]; | |
| 156 | } else { | |
| 157 | next[driver] = config; | |
| 158 | } | |
| 159 | return next; | |
| 160 | }); | |
| 161 | }, | |
| 162 | [driver], | |
| 163 | ); | |
| 154 | const setConfigDraft = (config: Record<string, unknown> | undefined) => { | |
| 155 | setConfigByDriver((existing) => { | |
| 156 | const next = { ...existing }; | |
| 157 | if (config === undefined || Object.keys(config).length === 0) { | |
| 158 | delete next[driver]; | |
| 159 | } else { | |
| 160 | next[driver] = config; | |
| 161 | } | |
| 162 | return next; | |
| 163 | }); | |
| 164 | }; | |
| 165 | ||
| 166 | const applyWizardNavigation = (navigation: WizardNavigation) => { | |
| 167 | if (navigation.kind === "blocked") { | |
| 168 | setHasAttemptedSubmit(true); | |
| 169 | } | |
| 170 | setWizardStep(navigation.step); | |
| 171 | }; | |
| 164 | 172 | |
| 165 | const handleSave = useCallback(() => { | |
| 173 | const navigateToStep = (requestedStep: number) => { | |
| 174 | applyWizardNavigation( | |
| 175 | resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { | |
| 176 | instanceIdError, | |
| 177 | }), | |
| 178 | ); | |
| 179 | }; | |
| 180 | ||
| 181 | const handleSave = () => { | |
| 166 | 182 | setHasAttemptedSubmit(true); |
| 167 | 183 | if (instanceIdError !== null) return; |
| 168 | 184 | |
| ... | ... | @@ -201,18 +217,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 201 | 217 | description: error instanceof Error ? error.message : "Update failed.", |
| 202 | 218 | }); |
| 203 | 219 | } |
| 204 | }, [ | |
| 205 | driver, | |
| 206 | driverOption, | |
| 207 | configByDriver, | |
| 208 | instanceId, | |
| 209 | instanceIdError, | |
| 210 | label, | |
| 211 | accentColor, | |
| 212 | onOpenChange, | |
| 213 | settings.providerInstances, | |
| 214 | updateSettings, | |
| 215 | ]); | |
| 220 | }; | |
| 216 | 221 | |
| 217 | 222 | return ( |
| 218 | 223 | <Dialog open={open} onOpenChange={onOpenChange}> |
| ... | ... | @@ -224,46 +229,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 224 | 229 | Configure an additional provider instance — for example, a second Codex install |
| 225 | 230 | pointed at a different workspace. |
| 226 | 231 | </DialogDescription> |
| 227 | <div className="grid grid-cols-3 gap-2"> | |
| 228 | {wizardSteps.map((step, index) => ( | |
| 229 | <button | |
| 230 | key={step} | |
| 231 | type="button" | |
| 232 | className={cn( | |
| 233 | "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left", | |
| 234 | index === wizardStep | |
| 235 | ? "border-primary bg-primary/10 ring-1 ring-primary/25" | |
| 236 | : index < wizardStep | |
| 237 | ? "border-border bg-background" | |
| 238 | : "border-border bg-muted/40", | |
| 239 | )} | |
| 240 | onClick={() => setWizardStep(index)} | |
| 241 | > | |
| 242 | <span | |
| 243 | className={cn( | |
| 244 | "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border", | |
| 245 | index < wizardStep | |
| 246 | ? "border-primary bg-primary text-primary-foreground" | |
| 247 | : index === wizardStep | |
| 248 | ? "border-primary bg-background" | |
| 249 | : "border-muted-foreground/35 bg-background", | |
| 250 | )} | |
| 251 | aria-hidden | |
| 252 | > | |
| 253 | {index < wizardStep ? <CheckIcon className="size-3" /> : null} | |
| 254 | </span> | |
| 255 | <span className="text-[10px] font-medium uppercase text-muted-foreground"> | |
| 256 | Step {index + 1} | |
| 257 | </span> | |
| 258 | <span className="truncate text-xs font-semibold text-foreground"> | |
| 259 | {step} | |
| 260 | {index < wizardStep && wizardStepSummaries[index] | |
| 261 | ? `: ${wizardStepSummaries[index]}` | |
| 262 | : ""} | |
| 263 | </span> | |
| 264 | </button> | |
| 265 | ))} | |
| 266 | </div> | |
| 232 | <AddProviderInstanceWizardSteps | |
| 233 | currentStep={wizardStep} | |
| 234 | summaries={wizardStepSummaries} | |
| 235 | instanceIdError={instanceIdError} | |
| 236 | onNavigation={applyWizardNavigation} | |
| 237 | /> | |
| 267 | 238 | </DialogHeader> |
| 268 | 239 | |
| 269 | 240 | <div |
| ... | ... | @@ -452,8 +423,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 452 | 423 | > |
| 453 | 424 | {wizardStep === 0 ? "Cancel" : "Back"} |
| 454 | 425 | </Button> |
| 455 | {wizardStep < wizardSteps.length - 1 ? ( | |
| 456 | <Button size="sm" onClick={() => setWizardStep((step) => Math.min(2, step + 1))}> | |
| 426 | {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( | |
| 427 | <Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}> | |
| 457 | 428 | Next |
| 458 | 429 | </Button> |
| 459 | 430 | ) : ( |
apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { Children, isValidElement, type ReactElement } from "react"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { ADD_PROVIDER_WIZARD_STEPS } from "./AddProviderInstanceDialog.logic"; | |
| 5 | import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; | |
| 6 | ||
| 7 | interface StepButtonProps { | |
| 8 | readonly "aria-current"?: string; | |
| 9 | readonly onClick: () => void; | |
| 10 | } | |
| 11 | ||
| 12 | function renderStepButtons( | |
| 13 | currentStep: number, | |
| 14 | instanceIdError: string | null, | |
| 15 | onNavigation: Parameters<typeof AddProviderInstanceWizardSteps>[0]["onNavigation"], | |
| 16 | ): ReactElement<StepButtonProps>[] { | |
| 17 | const header = AddProviderInstanceWizardSteps({ | |
| 18 | currentStep, | |
| 19 | summaries: ["Codex", "Codex Workspace", null], | |
| 20 | instanceIdError, | |
| 21 | onNavigation, | |
| 22 | }); | |
| 23 | ||
| 24 | return Children.toArray(header.props.children).filter( | |
| 25 | (child): child is ReactElement<StepButtonProps> => isValidElement(child), | |
| 26 | ); | |
| 27 | } | |
| 28 | ||
| 29 | describe("AddProviderInstanceWizardSteps", () => { | |
| 30 | it("gates the actual Config header click through Identity validation", () => { | |
| 31 | const onNavigation = vi.fn(); | |
| 32 | const buttons = renderStepButtons(0, "Instance ID is required.", onNavigation); | |
| 33 | ||
| 34 | expect(buttons).toHaveLength(ADD_PROVIDER_WIZARD_STEPS.length); | |
| 35 | buttons[2]!.props.onClick(); | |
| 36 | ||
| 37 | expect(onNavigation).toHaveBeenCalledOnce(); | |
| 38 | expect(onNavigation).toHaveBeenCalledWith({ | |
| 39 | kind: "blocked", | |
| 40 | step: 1, | |
| 41 | error: "Instance ID is required.", | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | it("marks the wizard step separately from the clicked button focus", () => { | |
| 46 | const buttons = renderStepButtons(1, "Instance ID is required.", vi.fn()); | |
| 47 | ||
| 48 | expect(buttons[0]!.props["aria-current"]).toBeUndefined(); | |
| 49 | expect(buttons[1]!.props["aria-current"]).toBe("step"); | |
| 50 | expect(buttons[2]!.props["aria-current"]).toBeUndefined(); | |
| 51 | }); | |
| 52 | ||
| 53 | it("preserves the actual backward header click", () => { | |
| 54 | const onNavigation = vi.fn(); | |
| 55 | const buttons = renderStepButtons(2, "Instance ID is required.", onNavigation); | |
| 56 | ||
| 57 | buttons[0]!.props.onClick(); | |
| 58 | ||
| 59 | expect(onNavigation).toHaveBeenCalledWith({ kind: "navigate", step: 0 }); | |
| 60 | }); | |
| 61 | }); |
apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | import { CheckIcon } from "lucide-react"; | |
| 2 | ||
| 3 | import { cn } from "../../lib/utils"; | |
| 4 | import { | |
| 5 | ADD_PROVIDER_WIZARD_STEPS, | |
| 6 | resolveWizardNavigation, | |
| 7 | type WizardNavigation, | |
| 8 | } from "./AddProviderInstanceDialog.logic"; | |
| 9 | ||
| 10 | interface AddProviderInstanceWizardStepsProps { | |
| 11 | readonly currentStep: number; | |
| 12 | readonly summaries: readonly (string | null)[]; | |
| 13 | readonly instanceIdError: string | null; | |
| 14 | readonly onNavigation: (navigation: WizardNavigation) => void; | |
| 15 | } | |
| 16 | ||
| 17 | export function AddProviderInstanceWizardSteps({ | |
| 18 | currentStep, | |
| 19 | summaries, | |
| 20 | instanceIdError, | |
| 21 | onNavigation, | |
| 22 | }: AddProviderInstanceWizardStepsProps) { | |
| 23 | return ( | |
| 24 | <div className="grid grid-cols-3 gap-2"> | |
| 25 | {ADD_PROVIDER_WIZARD_STEPS.map((step, index) => ( | |
| 26 | <button | |
| 27 | key={step} | |
| 28 | type="button" | |
| 29 | className={cn( | |
| 30 | "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left", | |
| 31 | index === currentStep | |
| 32 | ? "border-primary bg-primary/10 ring-1 ring-primary/25" | |
| 33 | : index < currentStep | |
| 34 | ? "border-border bg-background" | |
| 35 | : "border-border bg-muted/40", | |
| 36 | )} | |
| 37 | aria-current={index === currentStep ? "step" : undefined} | |
| 38 | onClick={() => | |
| 39 | onNavigation( | |
| 40 | resolveWizardNavigation(currentStep, index, ADD_PROVIDER_WIZARD_STEPS.length, { | |
| 41 | instanceIdError, | |
| 42 | }), | |
| 43 | ) | |
| 44 | } | |
| 45 | > | |
| 46 | <span | |
| 47 | className={cn( | |
| 48 | "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border", | |
| 49 | index < currentStep | |
| 50 | ? "border-primary bg-primary text-primary-foreground" | |
| 51 | : index === currentStep | |
| 52 | ? "border-primary bg-background" | |
| 53 | : "border-muted-foreground/35 bg-background", | |
| 54 | )} | |
| 55 | aria-hidden | |
| 56 | > | |
| 57 | {index < currentStep ? <CheckIcon className="size-3" /> : null} | |
| 58 | </span> | |
| 59 | <span className="text-[10px] font-medium uppercase text-muted-foreground"> | |
| 60 | Step {index + 1} | |
| 61 | </span> | |
| 62 | <span className="truncate text-xs font-semibold text-foreground"> | |
| 63 | {step} | |
| 64 | {index < currentStep && summaries[index] ? `: ${summaries[index]}` : ""} | |
| 65 | </span> | |
| 66 | </button> | |
| 67 | ))} | |
| 68 | </div> | |
| 69 | ); | |
| 70 | } |