Compare commits

...
Author SHA1 Message Date
Alan Buscaglia 76199ccff3 Merge branch 'master' into fix/provider-wizard-modal-loop 2026-07-20 13:16:51 +02:00
Alan Buscaglia 1b7f20fc5b refactor(ui): simplify provider wizard footer state 2026-07-20 12:18:52 +02:00
Alan Buscaglia 79e595f36e fix(ui): keep provider wizard footer callbacks fresh
- Preserve latest footer action closures without redundant renders
- Cover callback freshness and semantic accessibility assertions
2026-07-20 11:34:34 +02:00
Alan Buscaglia 1c7c5ca5e9 fix(ui): stabilize provider wizard modal state 2026-07-17 15:08:29 +02:00
Alan Buscaglia 848e9a7fa9 fix(ui): stabilize provider wizard modal state 2026-06-25 12:40:05 +02:00
8 changed files with 367 additions and 23 deletions
@@ -0,0 +1 @@
Provider wizard modal state no longer loops or closes before the launch step
@@ -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,83 @@ 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("keeps the footer setter stable and uses replacement action callbacks", () => {
// Given
const onOpenChange = vi.fn();
const firstOnBack = vi.fn();
const firstOnSecondaryAction = vi.fn();
const firstOnAction = vi.fn();
const latestOnBack = vi.fn();
const latestOnSecondaryAction = vi.fn();
const latestOnAction = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
const initialSetFooterConfig = result.current.setFooterConfig;
const firstFooterConfig = {
showBack: true,
backLabel: "Back",
onBack: firstOnBack,
showSecondaryAction: true,
secondaryActionLabel: "Cancel",
secondaryActionVariant: "outline" as const,
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onSecondaryAction: firstOnSecondaryAction,
showAction: true,
actionLabel: "Next",
actionDisabled: false,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
onAction: firstOnAction,
};
act(() => {
result.current.setFooterConfig(firstFooterConfig);
});
// When
act(() => {
result.current.setFooterConfig({
...firstFooterConfig,
onBack: latestOnBack,
onSecondaryAction: latestOnSecondaryAction,
onAction: latestOnAction,
});
});
act(() => {
result.current.resolvedFooterConfig.onBack?.();
result.current.resolvedFooterConfig.onSecondaryAction?.();
result.current.resolvedFooterConfig.onAction?.();
});
// Then
expect(result.current.setFooterConfig).toBe(initialSetFooterConfig);
expect(result.current.resolvedFooterConfig).toMatchObject({
showBack: true,
backLabel: "Back",
showSecondaryAction: true,
secondaryActionLabel: "Cancel",
secondaryActionVariant: "outline",
secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
showAction: true,
actionLabel: "Next",
actionDisabled: false,
actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON,
});
expect(firstOnBack).not.toHaveBeenCalled();
expect(firstOnSecondaryAction).not.toHaveBeenCalled();
expect(firstOnAction).not.toHaveBeenCalled();
expect(latestOnBack).toHaveBeenCalledTimes(1);
expect(latestOnSecondaryAction).toHaveBeenCalledTimes(1);
expect(latestOnAction).toHaveBeenCalledTimes(1);
});
it("does not override launch footer config in the controller", () => {
@@ -82,7 +82,7 @@ export function useProviderWizardController({
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [footerConfig, setFooterConfig] =
const [resolvedFooterConfig, setFooterConfig] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
@@ -220,12 +220,6 @@ export function useProviderWizardController({
};
const handleTestSuccess = () => {
if (
useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE
) {
handleClose();
return;
}
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
};
@@ -254,13 +248,11 @@ export function useProviderWizardController({
const docsLink = isProviderFlow
? getProviderHelpText(providerTypeHint ?? providerType ?? "").link
: DOCS_URLS.AWS_ORGANIZATIONS;
const resolvedFooterConfig: WizardFooterConfig = footerConfig;
const modalTitle = getProviderWizardModalTitle(mode);
return {
currentStep,
docsLink,
footerConfig,
handleClose,
handleDialogOpenChange,
handleTestSuccess,
@@ -0,0 +1,122 @@
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 });
expect(dialog).toHaveAccessibleDescription(/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,113 @@
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 republish footer config for repeated unchanged form UI state", 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(() => {
const unchangedUiState = {
showBack: false,
showAction: false,
actionLabel: "Next",
actionDisabled: true,
isLoading: false,
};
capturedConnectAccountFormProps.current?.onUiStateChange?.(
unchangedUiState,
);
capturedConnectAccountFormProps.current?.onUiStateChange?.({
...unchangedUiState,
});
});
// 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
@@ -79,6 +79,11 @@ export const Modal = ({
)}
</DialogHeader>
)}
{!title && description && (
<DialogDescription className="sr-only">
{description}
</DialogDescription>
)}
{children}
</DialogContent>
</Dialog>