feat(ui): emit funnel signals from the add-provider journey

This commit is contained in:
alejandrobailo
2026-09-21 16:45:49 +02:00
parent 793cc32a1d
commit 8da79bb47e
7 changed files with 445 additions and 6 deletions
@@ -1,18 +1,24 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
PROVIDER_FUNNEL_EVENT,
type ProviderFunnelDetail,
} from "@/lib/provider-funnel/provider-funnel-events";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
const {
onboardingTriggerSpy,
providersAccountsTableSpy,
refreshMock,
replaceMock,
searchParamsValue,
} = vi.hoisted(() => ({
onboardingTriggerSpy: vi.fn(),
providersAccountsTableSpy: vi.fn(),
refreshMock: vi.fn(),
replaceMock: vi.fn(),
@@ -28,6 +34,14 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/onboarding", () => ({
OnboardingTrigger: (props: { startAtTarget?: string }) => {
onboardingTriggerSpy(props);
return null;
},
PageReady: () => null,
}));
vi.mock("@/components/providers/table", () => ({
SkeletonTableProviders: () => <div data-testid="providers-skeleton" />,
}));
@@ -132,9 +146,21 @@ const disconnectedProviders: ProviderProps[] = [
];
describe("ProvidersAccountsView", () => {
const funnelSignals: ProviderFunnelDetail[] = [];
const recordFunnelSignal: EventListener = (event) => {
funnelSignals.push((event as CustomEvent<ProviderFunnelDetail>).detail);
};
beforeEach(() => {
funnelSignals.length = 0;
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
});
afterEach(() => {
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
vi.restoreAllMocks();
providersAccountsTableSpy.mockClear();
onboardingTriggerSpy.mockClear();
searchParamsValue.current = "";
window.history.replaceState({}, "", "/");
});
@@ -273,6 +299,143 @@ describe("ProvidersAccountsView", () => {
expect(replaceMock).not.toHaveBeenCalled();
});
it("signals which control opened the wizard", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Add Provider" }));
// Then
expect(funnelSignals).toEqual([
{ step: "wizard_opened", source: "page_button" },
]);
});
it("signals the empty-state CTA as the wizard entry point", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(funnelSignals).toEqual([
{ step: "wizard_opened", source: "empty_state" },
]);
});
it("signals the entry point carried in the URL and cleans it on close", async () => {
// Given
searchParamsValue.current =
"tab=connected&addProvider=true&addProviderSource=sidebar_cta";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// Then
expect(funnelSignals).toEqual([
{ step: "wizard_opened", source: "sidebar_cta" },
]);
// When
await user.click(screen.getByRole("button", { name: /close/i }));
// Then
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
"/providers?tab=connected",
);
});
it("treats an unknown URL entry point as a plain URL open", () => {
// Given
searchParamsValue.current = "addProvider=true&addProviderSource=made_up";
// When
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// Then
expect(funnelSignals).toEqual([{ step: "wizard_opened", source: "url" }]);
});
it("starts the tour at the provider-type step when the wizard is already open", () => {
// Given
searchParamsValue.current = "addProvider=true&onboarding=add-provider";
// When
render(
<ProvidersAccountsView
isCloud
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// Then: the welcome and "open the wizard" steps have nothing left to ask for.
expect(onboardingTriggerSpy).toHaveBeenLastCalledWith(
expect.objectContaining({ startAtTarget: "provider-type" }),
);
});
it("lets the tour start from its first step while the wizard is closed", () => {
// Given / When
render(
<ProvidersAccountsView
isCloud
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// Then
expect(onboardingTriggerSpy).toHaveBeenLastCalledWith(
expect.objectContaining({ startAtTarget: undefined }),
);
});
it("keeps filters and table visible when providers are disconnected", () => {
// Given/When
render(
@@ -16,10 +16,19 @@ import type {
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { getFlowById } from "@/lib/onboarding";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_STEP,
WIZARD_OPEN_SOURCE,
type WizardOpenSource,
} from "@/lib/provider-funnel/provider-funnel-events";
import {
ADD_PROVIDER_SEARCH_PARAM,
ADD_PROVIDER_SEARCH_VALUE,
ADD_PROVIDER_SOURCE_PARAM,
resolveAddProviderSource,
} from "@/lib/providers-navigation";
import {
ADD_PROVIDER_TOUR_TARGETS,
@@ -102,7 +111,25 @@ export function ProvidersAccountsView({
OrgWizardInitialData | undefined
>(undefined);
const openProviderWizard = (initialData?: ProviderWizardInitialData) => {
const signalWizardOpened = (source: WizardOpenSource) =>
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED,
source,
});
// A URL-opened wizard never goes through openProviderWizard, so signal it on mount.
useMountEffect(() => {
if (!shouldOpenProviderWizardFromUrl) return;
signalWizardOpened(
resolveAddProviderSource(searchParams.get(ADD_PROVIDER_SOURCE_PARAM)),
);
});
const openProviderWizard = (
source: WizardOpenSource,
initialData?: ProviderWizardInitialData,
) => {
signalWizardOpened(source);
setOrgWizardInitialData(undefined);
setProviderWizardInitialData(initialData);
setIsProviderWizardOpen(true);
@@ -130,6 +157,7 @@ export function ProvidersAccountsView({
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(ADD_PROVIDER_SEARCH_PARAM);
params.delete(ADD_PROVIDER_SOURCE_PARAM);
const query = params.toString();
window.history.replaceState(
null,
@@ -146,6 +174,12 @@ export function ProvidersAccountsView({
<OnboardingTrigger
flow={addProviderFlow}
configOverrides={ADD_PROVIDER_TOUR_CONFIG}
// An already-open wizard (first run) leaves nothing for the earlier steps to ask.
startAtTarget={
isProviderWizardOpen
? ADD_PROVIDER_TOUR_TARGETS.PROVIDER_TYPE
: undefined
}
/>
</Suspense>
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
@@ -154,7 +188,9 @@ export function ProvidersAccountsView({
<NoProvidersAdded
action="button"
containerClassName="min-h-[calc(100dvh-28rem)]"
onOpenWizard={() => openProviderWizard()}
onOpenWizard={() =>
openProviderWizard(WIZARD_OPEN_SOURCE.EMPTY_STATE)
}
ctaTourId="add-provider-trigger"
/>
) : (
@@ -175,7 +211,11 @@ export function ProvidersAccountsView({
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
<AddProviderButton
onOpenWizard={() =>
openProviderWizard(WIZARD_OPEN_SOURCE.PAGE_BUTTON)
}
/>
</>
}
/>
@@ -186,7 +226,9 @@ export function ProvidersAccountsView({
scanScheduleCapability={scanScheduleCapability}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
onOpenProviderWizard={openProviderWizard}
onOpenProviderWizard={(initialData) =>
openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
@@ -1,6 +1,10 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
PROVIDER_FUNNEL_EVENT,
type ProviderFunnelDetail,
} from "@/lib/provider-funnel/provider-funnel-events";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ORG_WIZARD_STEP, ORGANIZATION_TYPE } from "@/types/organizations";
@@ -40,7 +44,18 @@ vi.mock("next-auth/react", () => ({
}));
describe("useProviderWizardController", () => {
const funnelSignals: ProviderFunnelDetail[] = [];
const recordFunnelSignal: EventListener = (event) => {
funnelSignals.push((event as CustomEvent<ProviderFunnelDetail>).detail);
};
afterEach(() => {
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
});
beforeEach(() => {
funnelSignals.length = 0;
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
vi.useRealTimers();
vi.clearAllMocks();
requestOpenOnWizardCloseMock.mockClear();
@@ -144,6 +159,70 @@ describe("useProviderWizardController", () => {
expect(refreshMock).toHaveBeenCalledTimes(1);
});
it("signals the step where the wizard was left and that no provider was created", () => {
// Given
const { result } = renderHook(() =>
useProviderWizardController({ open: true, onOpenChange: vi.fn() }),
);
// When
act(() => {
result.current.handleClose();
});
// Then
expect(funnelSignals).toEqual([
{ step: "wizard_closed", lastStep: "connect", providerCreated: false },
]);
});
it("signals a close after the provider was created, from the step reached", () => {
// Given
const { result } = renderHook(() =>
useProviderWizardController({ open: true, onOpenChange: vi.fn() }),
);
act(() => {
useProviderWizardStore.getState().setProvider({
id: "provider-1",
type: "aws",
uid: "123456789012",
alias: null,
});
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
});
// When
act(() => {
result.current.handleClose();
});
// Then
expect(funnelSignals).toEqual([
{ step: "wizard_closed", lastStep: "test", providerCreated: true },
]);
});
it("signals the organization method when the organizations flow opens", () => {
// Given
const { result } = renderHook(() =>
useProviderWizardController({ open: true, onOpenChange: vi.fn() }),
);
// When
act(() => {
result.current.openOrganizationsFlow(ORGANIZATION_TYPE.AZURE);
});
// Then
expect(funnelSignals).toEqual([
{
step: "method_selected",
providerType: "azure",
method: "organization",
},
]);
});
it("hydrates update mode when initial data is provided", async () => {
// Given
const onOpenChange = vi.fn();
@@ -4,6 +4,11 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_METHOD,
PROVIDER_FUNNEL_STEP,
} from "@/lib/provider-funnel/provider-funnel-events";
import { isCloud } from "@/lib/shared/env";
import { endActiveTour } from "@/lib/tours/use-driver-tour";
import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint";
@@ -44,6 +49,20 @@ const ORG_DOCS_URL = {
[ORGANIZATION_TYPE.GCP]: DOCS_URLS.GCP_ORGANIZATIONS,
} as const satisfies Record<OrgFlowType, string>;
// Stable names for the abandonment signal; the numeric step ids are not a contract.
const PROVIDER_STEP_NAME = {
[PROVIDER_WIZARD_STEP.CONNECT]: "connect",
[PROVIDER_WIZARD_STEP.CREDENTIALS]: "credentials",
[PROVIDER_WIZARD_STEP.TEST]: "test",
[PROVIDER_WIZARD_STEP.LAUNCH]: "launch",
} as const satisfies Record<ProviderWizardStep, string>;
const ORG_STEP_NAME = {
[ORG_WIZARD_STEP.SETUP]: "organizations_setup",
[ORG_WIZARD_STEP.VALIDATE]: "organizations_validate",
[ORG_WIZARD_STEP.LAUNCH]: "organizations_launch",
} as const satisfies Record<OrgWizardStep, string>;
const EMPTY_FOOTER_CONFIG: WizardFooterConfig = {
showBack: false,
backLabel: "Back",
@@ -205,6 +224,15 @@ export function useProviderWizardController({
// Read providerId before reset clears it — non-null means a provider was connected.
const connectedProviderId = useProviderWizardStore.getState().providerId;
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.WIZARD_CLOSED,
lastStep:
wizardVariant === WIZARD_VARIANT.PROVIDER
? PROVIDER_STEP_NAME[currentStep]
: ORG_STEP_NAME[orgCurrentStep],
providerCreated: connectedProviderId !== null,
});
resetProviderWizard();
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.PROVIDER);
@@ -251,6 +279,11 @@ export function useProviderWizardController({
// Organizations diverges from the credentials path the tour guides toward; end
// it so it doesn't dangle on a step that no longer fits. No-op off-onboarding.
endActiveTour();
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED,
providerType: orgType,
method: PROVIDER_FUNNEL_METHOD.ORGANIZATION,
});
resetOrgWizard();
setOrganizationType(orgType);
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
@@ -12,6 +12,10 @@ import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal";
import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_STEP,
} from "@/lib/provider-funnel/provider-funnel-events";
import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour";
import {
ORG_SETUP_PHASE,
@@ -78,6 +82,7 @@ export function ProviderWizardModal({
organizationType,
orgCurrentStep,
orgSetupPhase,
providerTypeHint,
resolvedFooterConfig,
setCurrentStep,
setFooterConfig,
@@ -179,6 +184,13 @@ export function ProviderWizardModal({
// Picking a type reveals the account-detail inputs. Advance the tour
// to its wizard-body step, pinned beside the form. No-op off-onboarding.
if (providerType) advanceActiveTour();
// The form re-reports the same type on re-render; signal a pick once.
if (providerType && providerType !== providerTypeHint) {
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.PROVIDER_TYPE_SELECTED,
providerType,
});
}
setProviderTypeHint(providerType);
}}
/>
@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
PROVIDER_FUNNEL_EVENT,
type ProviderFunnelDetail,
} from "@/lib/provider-funnel/provider-funnel-events";
import { CredentialsRoleHelper } from "./credentials-role-helper";
const templateLinks = {
cloudformation: "https://example.com/template.yml",
cloudformationQuickLink: "https://example.com/quick-create",
terraform: "https://example.com/terraform",
};
describe("CredentialsRoleHelper", () => {
const funnelSignals: ProviderFunnelDetail[] = [];
const recordFunnelSignal: EventListener = (event) => {
funnelSignals.push((event as CustomEvent<ProviderFunnelDetail>).detail);
};
beforeEach(() => {
funnelSignals.length = 0;
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
});
afterEach(() => {
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
});
describe("when connecting a provider", () => {
it("signals which role template the user opened", async () => {
// Given
const user = userEvent.setup();
render(
<CredentialsRoleHelper
externalId="tenant-1"
templateLinks={templateLinks}
/>,
);
// When
await user.click(
screen.getByRole("link", { name: /CloudFormation Quick Link/i }),
);
await user.click(
screen.getByRole("link", { name: "CloudFormation Template" }),
);
await user.click(screen.getByRole("link", { name: "Terraform Code" }));
// Then
expect(funnelSignals).toEqual([
{
step: "role_template_opened",
template: "cloudformation_quick_create",
},
{ step: "role_template_opened", template: "cloudformation_template" },
{ step: "role_template_opened", template: "terraform" },
]);
});
});
describe("when configuring an integration", () => {
it("stays out of the provider funnel", async () => {
// Given
const user = userEvent.setup();
render(
<CredentialsRoleHelper
externalId="tenant-1"
templateLinks={templateLinks}
integrationType="amazon_s3"
/>,
);
// When
await user.click(
screen.getByRole("link", { name: /CloudFormation Quick Link/i }),
);
// Then
expect(funnelSignals).toEqual([]);
});
});
});
@@ -3,6 +3,12 @@
import { IdIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_STEP,
ROLE_TEMPLATE_KIND,
type RoleTemplateKind,
} from "@/lib/provider-funnel/provider-funnel-events";
import { IntegrationType } from "@/types/integrations";
interface CredentialsRoleHelperProps {
@@ -22,6 +28,15 @@ export const CredentialsRoleHelper = ({
}: CredentialsRoleHelperProps) => {
const isAmazonS3 = integrationType === "amazon_s3";
// Integrations reuse this helper; only the add-provider journey is signalled.
const signalTemplateOpened = (template: RoleTemplateKind) => {
if (integrationType) return;
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.ROLE_TEMPLATE_OPENED,
template,
});
};
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
@@ -40,6 +55,11 @@ export const CredentialsRoleHelper = ({
href={templateLinks.cloudformationQuickLink}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
signalTemplateOpened(
ROLE_TEMPLATE_KIND.CLOUDFORMATION_QUICK_CREATE,
)
}
>
Use the following AWS CloudFormation Quick Link to create the IAM
Role
@@ -71,6 +91,9 @@ export const CredentialsRoleHelper = ({
href={templateLinks.cloudformation}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
signalTemplateOpened(ROLE_TEMPLATE_KIND.CLOUDFORMATION_TEMPLATE)
}
>
CloudFormation {integrationType ? "" : "Template"}
</a>
@@ -85,6 +108,7 @@ export const CredentialsRoleHelper = ({
href={templateLinks.terraform}
target="_blank"
rel="noopener noreferrer"
onClick={() => signalTemplateOpened(ROLE_TEMPLATE_KIND.TERRAFORM)}
>
Terraform {integrationType ? "" : "Code"}
</a>