From f8fbf842481d6b55b99c1892dfdb5e9a1bc83ca0 Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Mon, 15 Jun 2026 23:51:26 -0700 Subject: [PATCH] fix(settings): validate the add-provider wizard step before advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Next button in the add-provider-instance dialog advanced the wizard unconditionally, so an empty or invalid Instance ID on the Identity step was only caught at the final Add instance step — where it silently no-ops, leaving the user with a dead button and no feedback. Gate advancing on the current step: the Identity step now blocks Next while the Instance ID is invalid and surfaces the existing inline error, matching the check handleSave already applies before submit. Fixes #2813. --- .../AddProviderInstanceDialog.test.ts | 38 +++++++++++++++++++ .../settings/AddProviderInstanceDialog.tsx | 38 ++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.test.ts diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts new file mode 100644 index 00000000000..0ea90a541fe --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { advanceInstanceWizard } from "./AddProviderInstanceDialog"; + +describe("advanceInstanceWizard", () => { + it("advances from the Driver step even when the instance id is invalid", () => { + // The Driver step (index 0) does not own the Instance ID field, so an + // invalid id must not block leaving it. + expect(advanceInstanceWizard(0, 3, { instanceIdError: "Instance ID is required." })).toEqual({ + kind: "advance", + step: 1, + }); + }); + + it("blocks leaving the Identity step while the instance id is invalid", () => { + // Regression for #2813: clicking Next on the Identity step used to advance + // unconditionally, so the user reached the final step only for "Add + // instance" to silently no-op. + expect(advanceInstanceWizard(1, 3, { instanceIdError: "Instance ID is required." })).toEqual({ + kind: "blocked", + error: "Instance ID is required.", + }); + }); + + it("advances from the Identity step once the instance id is valid", () => { + expect(advanceInstanceWizard(1, 3, { instanceIdError: null })).toEqual({ + kind: "advance", + step: 2, + }); + }); + + it("never advances past the last step", () => { + expect(advanceInstanceWizard(2, 3, { instanceIdError: null })).toEqual({ + kind: "advance", + step: 2, + }); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index affa35ff260..632e341f9a5 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -108,6 +108,30 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | return null; } +export type WizardAdvance = + | { readonly kind: "advance"; readonly step: number } + | { readonly kind: "blocked"; readonly error: string }; + +/** + * Decide what clicking "Next" should do in the add-instance wizard. The + * Identity step (index 1) owns the Instance ID, so the wizard must not advance + * past it while the id is invalid — otherwise the user reaches the final step + * only for "Add instance" to silently no-op. Returns either the next step to + * move to, or the error that blocks advancing (mirrors the gate `handleSave` + * applies before submit). + */ +export function advanceInstanceWizard( + currentStep: number, + stepCount: number, + validation: { readonly instanceIdError: string | null }, +): WizardAdvance { + const blockingError = currentStep === 1 ? validation.instanceIdError : null; + if (blockingError !== null) { + return { kind: "blocked", error: blockingError }; + } + return { kind: "advance", step: Math.min(stepCount - 1, currentStep + 1) }; +} + interface AddProviderInstanceDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -475,7 +499,19 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns {wizardStep === 0 ? "Cancel" : "Back"} {wizardStep < wizardSteps.length - 1 ? ( - ) : (