mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 20:33:41 +00:00
fix(ui): stop the add-provider modal from shifting while it loads (#13179)
This commit is contained in:
1 parent
75c9a6bf51
commit
dcd3d5a0fb
22 files changed
+799
-151
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { LighthouseConnectHelp } from "./lighthouse-connect-help";
|
||||
|
||||
vi.mock(import("@/lib/shared/env"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
isCloud: () => true,
|
||||
}));
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
getLighthouseV2Configurations: () =>
|
||||
Promise.resolve({ data: [{ id: "config-1", connected: true }] }),
|
||||
}));
|
||||
|
||||
const DESCRIPTION =
|
||||
"Lighthouse AI walks you through it with Prowler's docs, right beside this form.";
|
||||
|
||||
// Like the modal frame: a marked size container, so lg: follows its width.
|
||||
function renderInWizardFrame(width: number) {
|
||||
return render(
|
||||
<div data-responsive-container className="@container" style={{ width }}>
|
||||
<LighthouseConnectHelp
|
||||
docsLink="https://goto.prowler.com/provider-aws"
|
||||
docsDestination="Provider"
|
||||
providerType={null}
|
||||
mode="add"
|
||||
stepName="connect"
|
||||
stepLabel="Link a Provider"
|
||||
isOrganizationsFlow={false}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Lighthouse help in the wizard header", () => {
|
||||
it("keeps to a single line while the wizard stacks its steps", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(720);
|
||||
|
||||
// Then
|
||||
const callout = screen.getByRole("region", {
|
||||
name: "Help from Lighthouse AI",
|
||||
});
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByText("Not sure how to connect your cloud?"))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText(DESCRIPTION)).not.toBeVisible();
|
||||
expect(
|
||||
callout.element().getBoundingClientRect().height,
|
||||
).toBeLessThanOrEqual(72);
|
||||
});
|
||||
|
||||
it("explains what Lighthouse AI does beside a wide wizard", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(1200);
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText(DESCRIPTION)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ProviderFunnelDetail,
|
||||
} from "@/lib/provider-funnel/provider-funnel-events";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
@@ -73,6 +74,17 @@ function connectedConfiguration(connected: boolean) {
|
||||
return { data: [{ id: "config-1", connected }] };
|
||||
}
|
||||
|
||||
// An LLM check the test settles by hand.
|
||||
function pendingCheck() {
|
||||
let settle: (
|
||||
result: ReturnType<typeof connectedConfiguration>,
|
||||
) => void = () => {};
|
||||
const promise = new Promise((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return { promise, settle };
|
||||
}
|
||||
|
||||
describe("LighthouseConnectHelp", () => {
|
||||
beforeEach(() => {
|
||||
getConfigurationsMock.mockResolvedValue(connectedConfiguration(true));
|
||||
@@ -82,6 +94,7 @@ describe("LighthouseConnectHelp", () => {
|
||||
});
|
||||
useProviderWizardStore.getState().reset();
|
||||
useLighthouseContextStore.setState({ contributions: {} });
|
||||
useLighthouseLlmStore.setState({ hasConnectedLlm: null, latestCheck: 0 });
|
||||
});
|
||||
|
||||
it("invites the user to ask Lighthouse AI about the provider they picked", async () => {
|
||||
@@ -99,6 +112,142 @@ describe("LighthouseConnectHelp", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("holds the callout's place while it checks for a connected LLM", async () => {
|
||||
// Given
|
||||
let settleCheck: (
|
||||
result: ReturnType<typeof connectedConfiguration>,
|
||||
) => void = () => {};
|
||||
getConfigurationsMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settleCheck = resolve;
|
||||
}),
|
||||
);
|
||||
const funnelEvents = recordFunnelEvents();
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
|
||||
// Then: a placeholder, never the docs line the callout would replace
|
||||
expect(
|
||||
screen.getByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /AWS documentation/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await act(async () => settleCheck(connectedConfiguration(true)));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(funnelEvents.stop()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows the last answer at once when the wizard opens again, then refreshes it", async () => {
|
||||
// Given: an earlier opening found a connected LLM
|
||||
const firstOpening = renderHelp("aws");
|
||||
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
|
||||
firstOpening.unmount();
|
||||
let settleCheck: (
|
||||
result: ReturnType<typeof connectedConfiguration>,
|
||||
) => void = () => {};
|
||||
getConfigurationsMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settleCheck = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
|
||||
// Then: no placeholder while the fresh check runs
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When: the LLM was disconnected meanwhile
|
||||
await act(async () => settleCheck(connectedConfiguration(false)));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /AWS documentation/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("ignores an older check that answers after a newer opening", async () => {
|
||||
// Given: the wizard closes while its check runs, then opens again
|
||||
const olderCheck = pendingCheck();
|
||||
const newerCheck = pendingCheck();
|
||||
getConfigurationsMock
|
||||
.mockReturnValueOnce(olderCheck.promise)
|
||||
.mockReturnValueOnce(newerCheck.promise);
|
||||
const firstOpening = renderHelp("aws");
|
||||
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledOnce());
|
||||
firstOpening.unmount();
|
||||
renderHelp("aws");
|
||||
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
// When: the newer check answers first, then the older one
|
||||
await act(async () => newerCheck.settle(connectedConfiguration(false)));
|
||||
await act(async () => olderCheck.settle(connectedConfiguration(true)));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /AWS documentation/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps the remembered answer when a later check fails", async () => {
|
||||
// Given: an earlier opening found a connected LLM
|
||||
const firstOpening = renderHelp("aws");
|
||||
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
|
||||
firstOpening.unmount();
|
||||
getConfigurationsMock.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
// Then: a failed refresh does not swap the callout for the docs line
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("falls back to the documentation link when the check fails", async () => {
|
||||
// Given
|
||||
getConfigurationsMock.mockRejectedValue(new Error("network"));
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("link", { name: /AWS documentation/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps only the documentation link while Lighthouse AI is not set up", async () => {
|
||||
// Given
|
||||
getConfigurationsMock.mockResolvedValue(connectedConfiguration(false));
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LighthouseCalloutIcon,
|
||||
LighthouseCalloutTitle,
|
||||
} from "@/components/shadcn/lighthouse-callout";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { getVisibleSidePanelTabs } from "@/components/side-panel/side-panel-tabs";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
PROVIDER_FUNNEL_STEP,
|
||||
} from "@/lib/provider-funnel/provider-funnel-events";
|
||||
import { SIDE_PANEL_PUSH_MEDIA_QUERY } from "@/lib/ui-layout";
|
||||
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills";
|
||||
@@ -40,6 +42,13 @@ const CONNECTION_GUIDE_SKILL = getSkillById(
|
||||
LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE,
|
||||
);
|
||||
|
||||
const HELP_STATE = {
|
||||
DOCS: "docs",
|
||||
CHECKING: "checking",
|
||||
LIGHTHOUSE: "lighthouse",
|
||||
} as const;
|
||||
type HelpState = (typeof HELP_STATE)[keyof typeof HELP_STATE];
|
||||
|
||||
interface LighthouseConnectHelpProps {
|
||||
docsLink: string;
|
||||
docsDestination: string;
|
||||
@@ -61,7 +70,7 @@ export function LighthouseConnectHelp({
|
||||
stepLabel,
|
||||
isOrganizationsFlow,
|
||||
}: LighthouseConnectHelpProps) {
|
||||
const isLighthouseReady = useIsLighthouseReady();
|
||||
const helpState = useHelpState();
|
||||
const isGuiding = useSidePanelStore(
|
||||
(state) => state.isOpen && state.selectedTab === SIDE_PANEL_TAB.AI_CHAT,
|
||||
);
|
||||
@@ -73,7 +82,7 @@ export function LighthouseConnectHelp({
|
||||
(state) => state.awsConnectDraft?.method,
|
||||
);
|
||||
|
||||
if (!isLighthouseReady || !CONNECTION_GUIDE_SKILL) {
|
||||
if (helpState === HELP_STATE.DOCS || !CONNECTION_GUIDE_SKILL) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
|
||||
<Info className="size-4 shrink-0" />
|
||||
@@ -120,17 +129,13 @@ export function LighthouseConnectHelp({
|
||||
? getProviderDisplayName(providerType)
|
||||
: null;
|
||||
const copy = getHelpCopy({ isGuiding, providerName });
|
||||
|
||||
return (
|
||||
<LighthouseCallout
|
||||
aria-label="Help from Lighthouse AI"
|
||||
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
|
||||
>
|
||||
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
|
||||
const calloutContent = (
|
||||
<>
|
||||
<LighthouseCalloutIcon />
|
||||
<LighthouseCalloutBody>
|
||||
<LighthouseCalloutTitle>{copy.title}</LighthouseCalloutTitle>
|
||||
<LighthouseCalloutDescription>
|
||||
{/* A stacked wizard keeps the list in view: title and actions only. */}
|
||||
<LighthouseCalloutDescription className="hidden lg:block">
|
||||
{copy.description}
|
||||
</LighthouseCalloutDescription>
|
||||
</LighthouseCalloutBody>
|
||||
@@ -145,6 +150,32 @@ export function LighthouseConnectHelp({
|
||||
{copy.action}
|
||||
</LighthouseCalloutAction>
|
||||
</LighthouseCalloutActions>
|
||||
</>
|
||||
);
|
||||
|
||||
if (helpState === HELP_STATE.CHECKING) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading Lighthouse AI help"
|
||||
className="relative mt-2"
|
||||
>
|
||||
{/* Invisible copy sizes the placeholder exactly like the callout. */}
|
||||
<LighthouseCallout aria-hidden inert className="invisible">
|
||||
{calloutContent}
|
||||
</LighthouseCallout>
|
||||
<Skeleton className="absolute inset-0 rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LighthouseCallout
|
||||
aria-label="Help from Lighthouse AI"
|
||||
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
|
||||
>
|
||||
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
|
||||
{calloutContent}
|
||||
{/* Follow-ups typed in the panel carry the wizard's current state. */}
|
||||
<LighthouseContextContributor
|
||||
contributorId="provider-wizard"
|
||||
@@ -196,34 +227,47 @@ function getHelpCopy({ isGuiding, providerName }: HelpCopyInput) {
|
||||
|
||||
// Lighthouse AI can only help beside the wizard when the panel hosts the chat
|
||||
// (cloud), the panel pushes instead of covering it, and an LLM is connected.
|
||||
function useIsLighthouseReady(): boolean {
|
||||
function useHelpState(): HelpState {
|
||||
const canHostChat = getVisibleSidePanelTabs().some(
|
||||
(tab) => tab.id === SIDE_PANEL_TAB.AI_CHAT,
|
||||
);
|
||||
const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY);
|
||||
// Local state needed: whether an LLM is connected is fetched once per open.
|
||||
const [hasConnectedLlm, setHasConnectedLlm] = useState(false);
|
||||
// Kept across openings; every opening still refreshes it.
|
||||
const hasConnectedLlm = useLighthouseLlmStore(
|
||||
(state) => state.hasConnectedLlm,
|
||||
);
|
||||
const startCheck = useLighthouseLlmStore((state) => state.startCheck);
|
||||
const settleCheck = useLighthouseLlmStore((state) => state.settleCheck);
|
||||
// Local state needed: with nothing remembered, a failed check shows the docs.
|
||||
const [checkFailed, setCheckFailed] = useState(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
if (!canHostChat) return;
|
||||
let isMounted = true;
|
||||
const check = startCheck();
|
||||
// Lazy: the Lighthouse actions only load where the chat can exist (cloud).
|
||||
import("@/app/(prowler)/lighthouse/_actions")
|
||||
.then(({ getLighthouseV2Configurations }) =>
|
||||
getLighthouseV2Configurations(),
|
||||
)
|
||||
.then((result) => {
|
||||
if (!isMounted || "error" in result) return;
|
||||
setHasConnectedLlm(
|
||||
result.data.some((configuration) => configuration.connected),
|
||||
);
|
||||
})
|
||||
// Optional help: a failed check just leaves the docs link in place.
|
||||
.catch(() => {});
|
||||
.then((result) =>
|
||||
"error" in result
|
||||
? null
|
||||
: result.data.some((configuration) => configuration.connected),
|
||||
)
|
||||
// Optional help: a failed check keeps the last answer, else the docs.
|
||||
.catch(() => null)
|
||||
.then((connected) => {
|
||||
if (connected !== null) settleCheck(check, connected);
|
||||
else if (isMounted) setCheckFailed(true);
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
});
|
||||
|
||||
return canHostChat && isPushViewport && hasConnectedLlm;
|
||||
if (!canHostChat || !isPushViewport) return HELP_STATE.DOCS;
|
||||
if (hasConnectedLlm === null)
|
||||
return checkFailed ? HELP_STATE.DOCS : HELP_STATE.CHECKING;
|
||||
return hasConnectedLlm ? HELP_STATE.LIGHTHOUSE : HELP_STATE.DOCS;
|
||||
}
|
||||
@@ -154,8 +154,9 @@ async function enterAccountDetails() {
|
||||
|
||||
describe("provider wizard account creation", () => {
|
||||
beforeEach(() => {
|
||||
// Registry discovery only runs in Cloud.
|
||||
// Registry discovery only runs in Cloud with Registry enabled.
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
useProviderWizardStore.getState().reset();
|
||||
resetToasts();
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
@@ -525,44 +526,45 @@ describe("provider wizard account creation", () => {
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
|
||||
await screen.findByText("Registry providers could not be loaded");
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Amazon Web Services/ }),
|
||||
await screen.findByRole("option", { name: /Amazon Web Services/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// Then: an unanswered discovery cannot vouch for Registry availability.
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Registry" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Retry Registry providers" }),
|
||||
await screen.findByRole("button", { name: "Retry Registry providers" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("option", { name: "Acme Cloud Registry" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("Registry providers could not be loaded"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Registry tab with a retry when eligible discovery fails", async () => {
|
||||
it("shows the retry inside the Registry tab when eligible discovery fails", async () => {
|
||||
// Given: Cloud with Registry enabled, but the catalog read failed.
|
||||
getInstalledRegistryProviderOptions.mockResolvedValueOnce({
|
||||
status: "error",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
|
||||
await screen.findByText("Registry providers could not be loaded");
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
await user.click(await screen.findByRole("tab", { name: "Registry" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Registry providers available.")).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("Registry providers could not be loaded"),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Retry Registry providers" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("No Registry providers available."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { WizardStepper } from "./wizard-stepper";
|
||||
|
||||
// Like the modal frame: a marked size container, so lg: follows its width.
|
||||
function renderInWizardFrame(width: number) {
|
||||
return render(
|
||||
<div data-responsive-container className="@container" style={{ width }}>
|
||||
<WizardStepper currentStep={1} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
function boxOf(element: Element) {
|
||||
const { width, height } = element.getBoundingClientRect();
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
describe("wizard stepper", () => {
|
||||
it("fits on one row naming only the current step while the wizard stacks", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(720);
|
||||
|
||||
// Then
|
||||
const progress = screen
|
||||
.getByRole("navigation", { name: "Wizard progress" })
|
||||
.element();
|
||||
expect(boxOf(progress).height).toBeLessThanOrEqual(40);
|
||||
await expect
|
||||
.element(screen.getByText("Authenticate Credentials"))
|
||||
.toBeVisible();
|
||||
// Other steps keep their names for assistive tech but take no room.
|
||||
await expect.element(
|
||||
screen.getByRole("navigation", { name: "Wizard progress" }),
|
||||
).toMatchAriaInlineSnapshot(`
|
||||
- navigation "Wizard progress":
|
||||
- text: Link a Provider Authenticate Credentials Validate Connection Launch Scan
|
||||
`);
|
||||
const otherStep = screen.getByText("Link a Provider").element();
|
||||
expect(otherStep.checkVisibility()).toBe(true);
|
||||
// The block holding the name is clipped to 1px; the text inside overflows it.
|
||||
expect(boxOf(otherStep.closest("div")!).width).toBeLessThanOrEqual(1);
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText(
|
||||
"Authorize a secure connection between Prowler and your provider.",
|
||||
),
|
||||
)
|
||||
.not.toBeVisible();
|
||||
expect(
|
||||
screen
|
||||
.getByText("Authenticate Credentials")
|
||||
.element()
|
||||
.closest("[aria-current]"),
|
||||
).toHaveAttribute("aria-current", "step");
|
||||
});
|
||||
|
||||
it("lists every step with its description beside a wide wizard", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(1200);
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText(
|
||||
"Enter the provider details you would like to add in Prowler.",
|
||||
),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText("Link a Provider")).toBeVisible();
|
||||
expect(
|
||||
boxOf(
|
||||
screen.getByRole("navigation", { name: "Wizard progress" }).element(),
|
||||
).height,
|
||||
).toBeGreaterThan(300);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react";
|
||||
import { ReactElement } from "react";
|
||||
import { Fragment, ReactElement } from "react";
|
||||
|
||||
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -67,6 +67,13 @@ export function getActiveStepIndex(
|
||||
return Math.max(0, Math.min(currentStep + stepOffset, stepCount - 1));
|
||||
}
|
||||
|
||||
const CONNECTOR_DIRECTION = {
|
||||
VERTICAL: "to bottom",
|
||||
HORIZONTAL: "to right",
|
||||
} as const;
|
||||
type ConnectorDirection =
|
||||
(typeof CONNECTOR_DIRECTION)[keyof typeof CONNECTOR_DIRECTION];
|
||||
|
||||
export function WizardStepper({
|
||||
currentStep,
|
||||
stepOffset = 0,
|
||||
@@ -78,42 +85,71 @@ export function WizardStepper({
|
||||
steps.length,
|
||||
);
|
||||
|
||||
// Below lg the wizard stacks: one row of steps, naming only the current one.
|
||||
return (
|
||||
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
|
||||
<nav
|
||||
aria-label="Wizard progress"
|
||||
className="flex items-center gap-2 lg:flex-col lg:items-stretch lg:gap-0"
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isComplete = index < activeVisualStep;
|
||||
const isActive = index === activeVisualStep;
|
||||
const isInactive = index > activeVisualStep;
|
||||
const hasNext = index < steps.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step.label} className="flex items-start gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<StepCircle
|
||||
isComplete={isComplete}
|
||||
isActive={isActive}
|
||||
icon={step.icon}
|
||||
/>
|
||||
{index < steps.length - 1 && (
|
||||
<StepConnector isComplete={isComplete} />
|
||||
<Fragment key={step.label}>
|
||||
<div
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 lg:items-start lg:gap-3",
|
||||
!isActive && "shrink-0",
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<StepCircle
|
||||
isComplete={isComplete}
|
||||
isActive={isActive}
|
||||
icon={step.icon}
|
||||
/>
|
||||
{hasNext && (
|
||||
<StepConnector
|
||||
isComplete={isComplete}
|
||||
className="hidden h-14 w-px lg:block"
|
||||
direction={CONNECTOR_DIRECTION.VERTICAL}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 pt-[10px]">
|
||||
<span
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg leading-7 font-normal",
|
||||
isActive && "text-text-neutral-primary",
|
||||
isComplete && "text-text-neutral-primary",
|
||||
isInactive && "text-text-neutral-tertiary",
|
||||
"min-w-0 flex-col gap-1 lg:flex lg:pt-[10px]",
|
||||
isActive ? "flex" : "sr-only lg:not-sr-only",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
<p className="text-text-neutral-secondary text-xs leading-5">
|
||||
{step.description}
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm font-normal lg:overflow-visible lg:text-lg lg:leading-7 lg:whitespace-normal",
|
||||
isActive && "text-text-neutral-primary",
|
||||
isComplete && "text-text-neutral-primary",
|
||||
isInactive && "text-text-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
<p className="text-text-neutral-secondary hidden text-xs leading-5 lg:block">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasNext && (
|
||||
<StepConnector
|
||||
isComplete={isComplete}
|
||||
className="h-px min-w-3 flex-1 lg:hidden"
|
||||
direction={CONNECTOR_DIRECTION.HORIZONTAL}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -126,41 +162,58 @@ interface StepCircleProps {
|
||||
icon: IconComponent;
|
||||
}
|
||||
|
||||
const CIRCLE_SIZE = "size-8 lg:size-[44px]";
|
||||
|
||||
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
|
||||
<CircleCheckBig className="text-bg-neutral-primary size-6" />
|
||||
<div
|
||||
className={`bg-button-primary-press flex shrink-0 items-center justify-center rounded-full ${CIRCLE_SIZE}`}
|
||||
>
|
||||
<CircleCheckBig className="text-bg-neutral-primary size-4 lg:size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className="border-border-input-primary-pressed bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
|
||||
<div
|
||||
className={`border-border-input-primary-pressed bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
|
||||
>
|
||||
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
|
||||
<div
|
||||
className={`border-border-neutral-secondary bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
|
||||
>
|
||||
<StepIcon icon={Icon} className="text-text-neutral-tertiary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepConnector({ isComplete }: { isComplete: boolean }) {
|
||||
interface StepConnectorProps {
|
||||
isComplete: boolean;
|
||||
className: string;
|
||||
direction: ConnectorDirection;
|
||||
}
|
||||
|
||||
function StepConnector({
|
||||
isComplete,
|
||||
className,
|
||||
direction,
|
||||
}: StepConnectorProps) {
|
||||
if (isComplete) {
|
||||
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
|
||||
return <div className={cn("bg-border-input-primary-pressed", className)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-14 w-px"
|
||||
className={className}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
|
||||
backgroundImage: `repeating-linear-gradient(${direction}, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -173,10 +226,17 @@ function StepIcon({
|
||||
icon: IconComponent;
|
||||
className: string;
|
||||
}) {
|
||||
// Decorative like lucide's; size classes beat the SVG's width/height attributes.
|
||||
if (isCustomSvgIcon(Icon)) {
|
||||
return <Icon size={24} className={className} />;
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden
|
||||
size={24}
|
||||
className={cn("size-4 lg:size-6", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Icon className={cn("size-6", className)} />;
|
||||
return <Icon className={cn("size-4 lg:size-6", className)} />;
|
||||
}
|
||||
|
||||
function isCustomSvgIcon(
|
||||
|
||||
Reference in new issue
Block a user