diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx index da2d3c10ef..abd1487e0d 100644 --- a/ui/components/providers/providers-accounts-view.test.tsx +++ b/ui/components/providers/providers-accounts-view.test.tsx @@ -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: () =>
, })); @@ -132,9 +146,21 @@ const disconnectedProviders: ProviderProps[] = [ ]; describe("ProvidersAccountsView", () => { + const funnelSignals: ProviderFunnelDetail[] = []; + const recordFunnelSignal: EventListener = (event) => { + funnelSignals.push((event as CustomEvent).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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // Then + expect(onboardingTriggerSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ startAtTarget: undefined }), + ); + }); + it("keeps filters and table visible when providers are disconnected", () => { // Given/When render( diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index 2bbd24c0a1..b94202af38 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -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({ {/* Signals the navbar that this route's data has loaded (enables the replay icon). */} @@ -154,7 +188,9 @@ export function ProvidersAccountsView({ openProviderWizard()} + onOpenWizard={() => + openProviderWizard(WIZARD_OPEN_SOURCE.EMPTY_STATE) + } ctaTourId="add-provider-trigger" /> ) : ( @@ -175,7 +211,11 @@ export function ProvidersAccountsView({ actions={ <> - openProviderWizard()} /> + + 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} />
diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index ea8892ea5b..0f19fce732 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -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).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(); diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 0059c43a77..468f283ecc 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -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; +// 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; + +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; + 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); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index fab1a4440f..46e000d249 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -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); }} /> diff --git a/ui/components/providers/workflow/credentials-role-helper.test.tsx b/ui/components/providers/workflow/credentials-role-helper.test.tsx new file mode 100644 index 0000000000..eadfad51b1 --- /dev/null +++ b/ui/components/providers/workflow/credentials-role-helper.test.tsx @@ -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).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( + , + ); + + // 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( + , + ); + + // When + await user.click( + screen.getByRole("link", { name: /CloudFormation Quick Link/i }), + ); + + // Then + expect(funnelSignals).toEqual([]); + }); + }); +}); diff --git a/ui/components/providers/workflow/credentials-role-helper.tsx b/ui/components/providers/workflow/credentials-role-helper.tsx index 12e0bd3502..549104877a 100644 --- a/ui/components/providers/workflow/credentials-role-helper.tsx +++ b/ui/components/providers/workflow/credentials-role-helper.tsx @@ -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 (
@@ -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"} @@ -85,6 +108,7 @@ export const CredentialsRoleHelper = ({ href={templateLinks.terraform} target="_blank" rel="noopener noreferrer" + onClick={() => signalTemplateOpened(ROLE_TEMPLATE_KIND.TERRAFORM)} > Terraform {integrationType ? "" : "Code"}