Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/web/src/components/settings/AddProviderInstanceDialog.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
38 changes: 37 additions & 1 deletion apps/web/src/components/settings/AddProviderInstanceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ function validateInstanceId(id: string, existing: ReadonlySet<string>): 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;
Expand Down Expand Up @@ -475,7 +499,19 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
{wizardStep === 0 ? "Cancel" : "Back"}
</Button>
{wizardStep < wizardSteps.length - 1 ? (
<Button size="sm" onClick={() => setWizardStep((step) => Math.min(2, step + 1))}>
<Button
size="sm"
onClick={() => {
const advance = advanceInstanceWizard(wizardStep, wizardSteps.length, {
instanceIdError,
});
if (advance.kind === "blocked") {
setHasAttemptedSubmit(true);
return;
}
setWizardStep(advance.step);
}}
>
Next
</Button>
) : (
Expand Down
Loading