fix(ui): stabilize provider wizard modal state

This commit is contained in:
Alan Buscaglia
2026-06-25 12:40:05 +02:00
parent 7785829969
commit 848e9a7fa9
7 changed files with 398 additions and 28 deletions
@@ -9,6 +9,7 @@ import {
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
import { WIZARD_FOOTER_ACTION_TYPE } from "../steps/footer-controls";
import type { ProviderWizardInitialData } from "../types";
import { useProviderWizardController } from "./use-provider-wizard-controller";
@@ -237,7 +238,7 @@ describe("useProviderWizardController", () => {
expect(onOpenChange).not.toHaveBeenCalled();
});
it("closes the wizard after a successful connection test in update mode", async () => {
it("moves to launch step after a successful connection test in update mode", async () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
@@ -265,10 +266,55 @@ describe("useProviderWizardController", () => {
result.current.handleTestSuccess();
});
// Credential rotation skips the launch/schedule step.
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(onOpenChange).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
});
it("does not rerender when setting a semantically unchanged footer config", () => {
// Given
const onOpenChange = vi.fn();
let renderCount = 0;
const { result } = renderHook(() => {
renderCount += 1;
return useProviderWizardController({
open: true,
onOpenChange,
});
});
const firstFooterConfig = {
showBack: true,
backLabel: "Back",
onBack: vi.fn(),
showAction: true,
actionLabel: "Next",
actionDisabled: false,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: vi.fn(),
};
act(() => {
result.current.setFooterConfig(firstFooterConfig);
});
const renderCountAfterFirstUpdate = renderCount;
const footerConfigAfterFirstUpdate = result.current.resolvedFooterConfig;
// When
act(() => {
result.current.setFooterConfig({
...firstFooterConfig,
onBack: vi.fn(),
onAction: vi.fn(),
});
});
// Then
expect(renderCount).toBe(renderCountAfterFirstUpdate);
expect(result.current.resolvedFooterConfig).toBe(
footerConfigAfterFirstUpdate,
);
});
it("does not override launch footer config in the controller", () => {
@@ -1,7 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import {
type Dispatch,
type SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
import { isCloud } from "@/lib/shared/env";
@@ -48,6 +54,32 @@ const EMPTY_FOOTER_CONFIG: WizardFooterConfig = {
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
};
function isSameFooterConfig(
current: WizardFooterConfig,
next: WizardFooterConfig,
) {
return (
current.showBack === next.showBack &&
current.backLabel === next.backLabel &&
Boolean(current.backDisabled) === Boolean(next.backDisabled) &&
Boolean(current.showSecondaryAction) ===
Boolean(next.showSecondaryAction) &&
(current.secondaryActionLabel ?? "") ===
(next.secondaryActionLabel ?? "") &&
Boolean(current.secondaryActionDisabled) ===
Boolean(next.secondaryActionDisabled) &&
current.secondaryActionVariant === next.secondaryActionVariant &&
current.secondaryActionType === next.secondaryActionType &&
(current.secondaryActionFormId ?? "") ===
(next.secondaryActionFormId ?? "") &&
current.showAction === next.showAction &&
current.actionLabel === next.actionLabel &&
Boolean(current.actionDisabled) === Boolean(next.actionDisabled) &&
current.actionType === next.actionType &&
(current.actionFormId ?? "") === (next.actionFormId ?? "")
);
}
interface UseProviderWizardControllerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -82,8 +114,9 @@ export function useProviderWizardController({
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [footerConfig, setFooterConfig] =
const [footerConfig, setFooterConfigState] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const footerConfigRef = useRef<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
);
@@ -123,7 +156,8 @@ export function useProviderWizardController({
);
setOrgCurrentStep(orgInitialData.targetStep);
setOrgSetupPhase(orgInitialData.targetPhase);
setFooterConfig(EMPTY_FOOTER_CONFIG);
footerConfigRef.current = EMPTY_FOOTER_CONFIG;
setFooterConfigState(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
return;
}
@@ -146,7 +180,8 @@ export function useProviderWizardController({
);
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
footerConfigRef.current = EMPTY_FOOTER_CONFIG;
setFooterConfigState(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(initialProviderType);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
return;
@@ -157,7 +192,8 @@ export function useProviderWizardController({
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
footerConfigRef.current = EMPTY_FOOTER_CONFIG;
setFooterConfigState(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
}, [
@@ -194,7 +230,8 @@ export function useProviderWizardController({
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
footerConfigRef.current = EMPTY_FOOTER_CONFIG;
setFooterConfigState(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
@@ -220,13 +257,24 @@ export function useProviderWizardController({
};
const handleTestSuccess = () => {
if (
useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE
) {
handleClose();
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
};
const updateFooterConfig: Dispatch<SetStateAction<WizardFooterConfig>> = (
nextFooterConfig,
) => {
const currentFooterConfig = footerConfigRef.current;
const resolvedNextFooterConfig =
typeof nextFooterConfig === "function"
? nextFooterConfig(currentFooterConfig)
: nextFooterConfig;
if (isSameFooterConfig(currentFooterConfig, resolvedNextFooterConfig)) {
return;
}
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
footerConfigRef.current = resolvedNextFooterConfig;
setFooterConfigState(resolvedNextFooterConfig);
};
const openOrganizationsFlow = () => {
@@ -236,7 +284,8 @@ export function useProviderWizardController({
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setFooterConfig(EMPTY_FOOTER_CONFIG);
footerConfigRef.current = EMPTY_FOOTER_CONFIG;
setFooterConfigState(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
};
@@ -245,7 +294,8 @@ export function useProviderWizardController({
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.PROVIDER);
setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT);
setFooterConfig(EMPTY_FOOTER_CONFIG);
footerConfigRef.current = EMPTY_FOOTER_CONFIG;
setFooterConfigState(EMPTY_FOOTER_CONFIG);
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
};
@@ -274,7 +324,7 @@ export function useProviderWizardController({
providerTypeHint,
resolvedFooterConfig,
setCurrentStep,
setFooterConfig,
setFooterConfig: updateFooterConfig,
setOrgCurrentStep,
setOrgSetupPhase,
setProviderTypeHint,
@@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderWizardModal } from "./provider-wizard-modal";
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@/hooks/use-scroll-hint", () => ({
useScrollHint: () => ({
containerRef: vi.fn(),
sentinelRef: vi.fn(),
showScrollHint: false,
}),
}));
vi.mock("@/components/providers/wizard/steps/connect-step", () => ({
ConnectStep: () => <div>Connect step</div>,
}));
vi.mock("@/components/providers/wizard/steps/credentials-step", () => ({
CredentialsStep: ({ onNext }: { onNext: () => void }) => (
<div>
<div>Credentials step</div>
<button type="button" onClick={onNext}>
Continue to validate connection
</button>
</div>
),
}));
vi.mock("@/components/providers/wizard/steps/test-connection-step", () => ({
TestConnectionStep: ({ onSuccess }: { onSuccess: () => void }) => (
<div>
<div>Test connection step</div>
<button type="button" onClick={onSuccess}>
Check connection
</button>
</div>
),
}));
vi.mock("@/components/providers/wizard/steps/launch-step", () => ({
LaunchStep: () => <div>Launch step</div>,
}));
vi.mock("@/components/providers/organizations/org-setup-form", () => ({
OrgSetupForm: () => <div>Organization setup</div>,
}));
vi.mock("@/components/providers/organizations/org-account-selection", () => ({
OrgAccountSelection: () => <div>Organization account selection</div>,
}));
vi.mock("@/components/providers/organizations/org-launch-scan", () => ({
OrgLaunchScan: () => <div>Organization launch scan</div>,
}));
describe("ProviderWizardModal", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
it("provides an accessible dialog description without requiring visible helper text", () => {
// Given
const onOpenChange = vi.fn();
// When
render(<ProviderWizardModal open onOpenChange={onOpenChange} />);
// Then
const dialog = screen.getByRole("dialog", { name: /adding a provider/i });
const descriptionId = dialog.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId ?? "")).toHaveTextContent(
/connect or update a provider/i,
);
});
it("shows the launch progress step when update mode reaches launch", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<ProviderWizardModal
open
onOpenChange={onOpenChange}
initialData={{
providerId: "provider-1",
providerType: "aws",
providerUid: "111111111111",
providerAlias: "production",
secretId: "secret-1",
mode: PROVIDER_WIZARD_MODE.UPDATE,
}}
/>,
);
expect(await screen.findByText("Credentials step")).toBeVisible();
// When
await user.click(
screen.getByRole("button", { name: /continue to validate connection/i }),
);
await user.click(
await screen.findByRole("button", { name: /check connection/i }),
);
// Then
expect(screen.getByText("Launch step")).toBeVisible();
expect(screen.getByText("Launch Scan")).toBeVisible();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
@@ -33,9 +33,12 @@ import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper";
const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
0,
PROVIDER_WIZARD_STEP.LAUNCH,
PROVIDER_WIZARD_STEP.LAUNCH + 1,
);
const PROVIDER_WIZARD_MODAL_DESCRIPTION =
"Connect or update a provider by adding account details, credentials, testing the connection, and launching a scan.";
interface ProviderWizardModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -100,6 +103,7 @@ export function ProviderWizardModal({
<Modal
open={open}
onOpenChange={handleDialogOpenChange}
description={PROVIDER_WIZARD_MODAL_DESCRIPTION}
size="4xl"
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !w-[calc(100vw-24px)] !max-w-[1192px] flex-col overflow-hidden p-4 sm:!w-[calc(100vw-40px)] sm:p-6 lg:!w-[calc(100vw-64px)] lg:p-8"
>
@@ -0,0 +1,106 @@
import { act, render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ConnectStep } from "./connect-step";
type ConnectStepUiState = {
showBack: boolean;
showAction: boolean;
actionLabel: string;
actionDisabled: boolean;
isLoading: boolean;
};
type CapturedConnectAccountFormProps = {
onUiStateChange?: (state: ConnectStepUiState) => void;
};
const { capturedConnectAccountFormProps } = vi.hoisted(() => ({
capturedConnectAccountFormProps: {
current: null as CapturedConnectAccountFormProps | null,
},
}));
vi.mock("@/components/providers/workflow/forms", () => ({
ConnectAccountForm: (props: CapturedConnectAccountFormProps) => {
capturedConnectAccountFormProps.current = props;
return <div data-testid="connect-account-form" />;
},
}));
describe("ConnectStep", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
capturedConnectAccountFormProps.current = null;
useProviderWizardStore.getState().reset();
});
it("does not publish a new footer config when form UI state is unchanged", async () => {
// Given
const onFooterChange = vi.fn();
render(
<ConnectStep
onNext={vi.fn()}
onSelectOrganizations={vi.fn()}
onFooterChange={onFooterChange}
onProviderTypeChange={vi.fn()}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
// When
act(() => {
capturedConnectAccountFormProps.current?.onUiStateChange?.({
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
});
});
// Then
expect(onFooterChange).toHaveBeenCalledTimes(1);
});
it("publishes a new footer config when form UI state changes", async () => {
// Given
const onFooterChange = vi.fn();
render(
<ConnectStep
onNext={vi.fn()}
onSelectOrganizations={vi.fn()}
onFooterChange={onFooterChange}
onProviderTypeChange={vi.fn()}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(1));
// When
act(() => {
capturedConnectAccountFormProps.current?.onUiStateChange?.({
showBack: true,
showAction: true,
actionLabel: "Next",
actionDisabled: false,
isLoading: false,
});
});
// Then
await waitFor(() => expect(onFooterChange).toHaveBeenCalledTimes(2));
expect(onFooterChange.mock.calls.at(-1)?.[0]).toMatchObject({
showBack: true,
showAction: true,
actionDisabled: false,
});
});
});
@@ -15,6 +15,35 @@ import {
WizardFooterConfig,
} from "./footer-controls";
type ConnectStepUiState = {
showBack: boolean;
showAction: boolean;
actionLabel: string;
actionDisabled: boolean;
isLoading: boolean;
};
const CONNECT_STEP_INITIAL_UI_STATE: ConnectStepUiState = {
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
};
function isSameConnectStepUiState(
current: ConnectStepUiState,
next: ConnectStepUiState,
) {
return (
current.showBack === next.showBack &&
current.showAction === next.showAction &&
current.actionLabel === next.actionLabel &&
current.actionDisabled === next.actionDisabled &&
current.isLoading === next.isLoading
);
}
interface ConnectStepProps {
onNext: () => void;
onSelectOrganizations: () => void;
@@ -31,13 +60,7 @@ export function ConnectStep({
const { setProvider, setVia, setSecretId, setMode } =
useProviderWizardStore();
const backHandlerRef = useRef<(() => void) | null>(null);
const [uiState, setUiState] = useState({
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
});
const [uiState, setUiState] = useState(CONNECT_STEP_INITIAL_UI_STATE);
const formId = "provider-wizard-connect-form";
@@ -54,6 +77,16 @@ export function ConnectStep({
onNext();
};
const handleUiStateChange = (nextUiState: ConnectStepUiState) => {
setUiState((currentUiState) => {
if (isSameConnectStepUiState(currentUiState, nextUiState)) {
return currentUiState;
}
return nextUiState;
});
};
useEffect(() => {
onFooterChange({
showBack: uiState.showBack,
@@ -75,7 +108,7 @@ export function ConnectStep({
onSuccess={handleSuccess}
onSelectOrganizations={onSelectOrganizations}
onProviderTypeChange={onProviderTypeChange}
onUiStateChange={setUiState}
onUiStateChange={handleUiStateChange}
onBackHandlerChange={(handler) => {
backHandlerRef.current = handler;
}}
+5
View File
@@ -65,6 +65,11 @@ export const Modal = ({
)}
</DialogHeader>
)}
{!title && description && (
<DialogDescription className="sr-only">
{description}
</DialogDescription>
)}
{children}
</DialogContent>
</Dialog>