mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
feat(ui): emit funnel signals from the add-provider journey
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user