refactor(ui): remove the onboarding system and product tours (#13176)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-27 11:57:34 +01:00
1 parent d6ea8bf274
commit 1a30b374d6
147 files changed
+446 -8924

No files matched your search

-4
View File
@@ -139,10 +139,6 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run healthcheck
- name: Check product-tour alignment
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run tour:check
- name: Run pnpm audit
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run audit
-6
View File
@@ -51,7 +51,6 @@ Use these skills for detailed patterns on-demand:
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) |
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
@@ -69,12 +68,10 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Adding new providers | `prowler-provider` |
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
| Adding services to existing providers | `prowler-provider` |
| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` |
| After creating/modifying a skill | `skill-sync` |
| App Router / Server Actions | `nextjs-16` |
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
| Building AI chat features | `ai-sdk-5` |
| Changing button labels or section headings on a tour-covered page | `prowler-tour` |
| Committing changes | `prowler-commit` |
| Configuring MCP servers in agentic workflows | `gh-aw` |
| Create PR that requires changelog entry | `prowler-changelog` |
@@ -94,7 +91,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating/updating compliance frameworks | `prowler-compliance` |
| Debug why a GitHub Actions job is failing | `prowler-ci` |
| Debugging gh-aw compilation errors | `gh-aw` |
| Editing a UI file containing data-tour-id attributes | `prowler-tour` |
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
| Fixing bug | `tdd` |
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
@@ -111,8 +107,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
| Refactoring code | `tdd` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing JSON:API compliance | `jsonapi` |
-99
View File
@@ -1,99 +0,0 @@
---
name: prowler-tour
description: >
Keeps product-tour definitions aligned with the UI features they describe.
Trigger: When modifying UI components that have associated tours, editing tour
definition files, or renaming data-tour-id attributes.
license: Apache-2.0
metadata:
author: prowler-cloud
version: "1.0"
scope: [root, ui]
auto_invoke:
- "Editing a UI file containing data-tour-id attributes"
- "Adding, updating, or removing a tour definition (*.tour.ts)"
- "Renaming or removing a data-tour-id attribute value"
- "Changing button labels or section headings on a tour-covered page"
- "Restructuring routes or layouts covered by a tour"
allowed-tools: Read, Glob, Grep
---
# prowler-tour
**Report-only.** This skill never edits tour files or UI files; it inspects
the change, reports drift it finds between tours and the covered UI, and
recommends actions for the developer to apply.
## Early-exit rule
Run this check first. Most UI edits are not tour-related — exit cheaply.
1. Glob `ui/lib/tours/*.tour.ts`.
2. For each tour, check whether any `coversFiles` glob pattern matches any
file in the current change.
3. If no tour matches, respond **exactly**:
> No tour affected — skipping alignment check
and exit. Do not proceed to the checklist.
4. If at least one tour matches, continue to "Drift checklist" for that tour.
## Drift checklist
For each affected tour, evaluate every item. Skip items that obviously do
not apply, but list explicitly which items were checked.
1. **Orphan selectors** — every step's `target` (which composes to
`data-tour-id="<tour-id>-<step.target>"`) must resolve to a real element
in the codebase. Grep `ui/` for the expected attribute value; report
any step whose target is missing.
2. **Renamed selectors** — a `data-tour-id` attribute was edited in this
change. Match it back to any tour step referencing the old value.
3. **Outdated copy** — a popover `title`/`description` references a button
label, heading, or term that no longer exists on the covered page.
4. **Obsolete steps** — a step describes a section, panel, or workflow
that was removed.
5. **Missing steps** — a new feature was added on the covered surface
without a corresponding step (e.g. a new panel, a new primary action,
a new wizard stage).
6. **Reordered flow** — the user's path through the feature changed (e.g.
query builder moved before scan selection) and the step order no
longer reflects it.
## Version-bump decision tree
Apply per tour after listing drift:
- **NO bump** when the change is cosmetic. Examples: fix a typo, soften
copy, rename a `data-tour-id` selector while keeping the same step,
swap one screenshot for another, tighten wording.
- **BUMP `version`** when the user-visible flow changes materially.
Examples: a new step was added or removed; the order changed; an
anchored target was retargeted to a different panel; the tour now
covers a new feature on the surface.
When in doubt, ask: "Would a user who already saw the previous version
miss something useful by not seeing this one?" If yes, bump.
## Output format
When emitting a report, follow the exact structure in
`references/output-format.md`. The structure is mandatory because the
report is consumed downstream and tolerates no field reordering.
## What this skill MUST NOT do
- Do not edit `*.tour.ts` files. This skill is report-only.
- Do not edit UI files to add or rename `data-tour-id` attributes.
- Do not invent new tours. Authoring a new tour is a separate, deliberate
decision — the developer makes it, not the skill.
- Do not flag drift in tours whose `coversFiles` do not match any file
in the current change. Stick to the early-exit rule.
## See also
- `references/output-format.md` — exact report template (read when
emitting a report).
- `references/tours-architecture.md` — code map for the tour abstraction
under `ui/lib/tours/`.
- `assets/tour-template.ts` — boilerplate for authoring a new `*.tour.ts`.
@@ -1,51 +0,0 @@
// @ts-nocheck -- template only; resolves once copied into `ui/lib/tours/`
/**
* Tour template — copy this file to `ui/lib/tours/<your-id>.tour.ts` and
* fill in the placeholders. See `references/tours-architecture.md` for the
* design context.
*
* Conventions:
* - Declare via `defineTour({...})` (NOT `: TourDefinition`) so TS
* preserves the literal union of `target` values. `useDriverTour` uses
* that union to validate `stepHandlers` keys and `waitForStep` args.
* - `id` is kebab-case and unique across all tours.
* - Anchored steps reference DOM via `data-tour-id="<id>-<step.target>"`;
* the hook composes the CSS selector automatically.
* - `coversFiles` lists the globs that describe the tour's surface; the
* `prowler-tour` skill consumes this to decide whether to evaluate
* drift on a given change.
* - Material flow changes bump `version`; cosmetic edits do not.
*/
import {
defineTour,
TOUR_STEP_ALIGNMENTS,
TOUR_STEP_SIDES,
} from "@/lib/tours/tour-types";
export const yourTour = defineTour({
id: "your-tour-id",
version: 1,
coversFiles: [
// List the UI files this tour describes, using globs under `ui/`.
// Example: "ui/app/(prowler)/your-feature/**"
],
steps: [
{
// Modal step — no anchor. Use for intros, outros, and any step
// that does not point at a specific DOM element.
title: "Welcome",
description: "Short, plain-English description.",
},
{
// Anchored step. The hook resolves
// `[data-tour-id="your-tour-id-step-name"]` lazily, so the element
// can be conditionally rendered as long as it exists when the step
// becomes active.
target: "step-name",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Where the action is",
description: "Tell the user what to look at here and why.",
},
],
});
@@ -1,31 +0,0 @@
# Tour Alignment Report — output format
The report is consumed downstream. Field names, order, and headings are
load-bearing — do not rename, reorder, or omit them.
## Template
```text
## Tour Alignment Report
**Tour:** `<tour-id>@v<version>`
**Files touched:** <comma-separated list of files in the change>
### Drift detected
- <one bullet per drift item; include file:line where available>
### Recommended actions
1. <numbered, actionable steps the developer should take>
### Version bump verdict
- <BUMP | NO bump> — <one-line rationale>
```
## Rules
- One report per affected tour. If multiple tours are affected, separate
reports with a `---` line.
- If no drift is detected for an affected tour, still emit the report:
put "No drift detected." under "Drift detected" and "None required."
under "Recommended actions". The verdict line is still mandatory.
- The verdict is exactly one of `BUMP` or `NO bump` — see the
version-bump decision tree in `SKILL.md`.
@@ -1,44 +0,0 @@
# Tours Architecture
The product-tour abstraction lives under [`ui/lib/tours/`](../../../ui/lib/tours/).
This skill operates on tour definitions that follow this architecture.
## Code map
| File | Purpose |
|---|---|
| `ui/lib/tours/tour-types.ts` | Public type surface: `TourDefinition`, `TourStep`, `TourId`, `TourCompletionRecord`, completion-state const map. Also exports `defineTour(...)` — the required authoring helper that preserves literal step `target`s so `useDriverTour` can type-check `stepHandlers` keys and `waitForStep` arguments. |
| `ui/lib/tours/tour-config.ts` | `baseDriverConfig`, `getDriverConfig(theme, overrides?)`, overlay-color map. |
| `ui/lib/tours/store/tour-completion-store.ts` | Persistence interface — the swap point for future API adapters. |
| `ui/lib/tours/store/local-storage-adapter.ts` | The only adapter in the PoC. Key format: `prowler.tour.<id>.v<version>`. |
| `ui/lib/tours/use-driver-tour.ts` | React hook. Initializes driver.js, derives `overlayColor` from `useTheme()`, persists completion. |
| `ui/lib/tours/<id>.tour.ts` | One file per tour. Declared via `defineTour({...})` (not `: TourDefinition`) and imported by the page that opts the user in. |
| `ui/styles/tours.css` | `.driver-popover.prowler-theme` — every color resolved via `var(--...)` from `globals.css`. |
## Selector convention
Tour steps anchor via `data-tour-id="<tour-id>-<step.target>"`. The hook
composes the CSS selector at runtime; tour authors only provide the step
name in `step.target`. Class-based, ID-based, structural selectors are
forbidden — they couple tours to styling decisions that legitimately
change.
## Identity and versioning
A tour is `{ id, version }`. The localStorage key composes both. A
**material content change** bumps `version`; cosmetic edits do not. The
decision tree lives in the parent SKILL.md.
## Persistence scope
Per-user, cross-tenant. A user who completed `attack-paths@v1` in tenant
A does not see the tour again in tenant B, even if they can access the
feature there. The future `UserTourState` model (documented in
`design.md`, not built) is FK to `User`, not `Membership`.
## Drift = #1 risk
Without the maintenance skill + the optional CI gate
(`ui/scripts/check-tour-alignment.mjs`), tours decay silently as the
covered UI evolves. The parent SKILL.md enumerates the six drift
categories the skill checks for.
+30 -36
View File
@@ -14,47 +14,41 @@
> - [`playwright`](../skills/playwright/SKILL.md) - Page Object Model, selectors
> - [`vitest`](../skills/vitest/SKILL.md) - Unit testing with React Testing Library
> - [`tdd`](../skills/tdd/SKILL.md) - TDD workflow (MANDATORY for UI tasks)
> - [`prowler-tour`](../skills/prowler-tour/SKILL.md) - Keep product-tour definitions aligned with the UI
## Auto-invoke Skills
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
| ----------------------------------------------------------------- | ------------------- |
| Add changelog entry for a PR or feature | `prowler-changelog` |
| Adding, updating, or removing a tour definition (\*.tour.ts) | `prowler-tour` |
| App Router / Server Actions | `nextjs-16` |
| Building AI chat features | `ai-sdk-5` |
| Changing button labels or section headings on a tour-covered page | `prowler-tour` |
| Committing changes | `prowler-commit` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Editing a UI file containing data-tour-id attributes | `prowler-tour` |
| Fixing bug | `tdd` |
| Implementing feature | `tdd` |
| Modifying component | `tdd` |
| Refactoring code | `tdd` |
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing Prowler UI components | `prowler-ui` |
| Testing hooks or utilities | `vitest` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Using Zustand stores | `zustand-5` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing unit tests for UI | `vitest` |
| Action | Skill |
| -------------------------------------------------------------- | ------------------- |
| Add changelog entry for a PR or feature | `prowler-changelog` |
| App Router / Server Actions | `nextjs-16` |
| Building AI chat features | `ai-sdk-5` |
| Committing changes | `prowler-commit` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Fixing bug | `tdd` |
| Implementing feature | `tdd` |
| Modifying component | `tdd` |
| Refactoring code | `tdd` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing Prowler UI components | `prowler-ui` |
| Testing hooks or utilities | `vitest` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Using Zustand stores | `zustand-5` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing unit tests for UI | `vitest` |
---
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SentInvitation } from "@/types/onboarding-invite";
import type { SentInvitation } from "@/types/invitations";
const readString = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
+2 -2
View File
@@ -59,11 +59,11 @@ describe("sendInvite", () => {
it("forwards the invitation source as a query param", async () => {
// When
await sendInvite(inviteFormData("onboarding"));
await sendInvite(inviteFormData("provider_connect"));
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/tenants/invitations?source=onboarding",
"https://api.example.com/api/v1/tenants/invitations?source=provider_connect",
expect.objectContaining({ method: "POST" }),
);
const body = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
+1 -1
View File
@@ -53,7 +53,7 @@ export const sendInvite = async (formData: FormData) => {
const role = formData.get("role");
const source = formData.get("source");
const url = new URL(`${apiBaseUrl}/tenants/invitations`);
// Origin of the invitation (e.g. `onboarding`); the API may record it.
// Origin of the invitation (e.g. `provider_connect`); the API may record it.
if (typeof source === "string" && source) {
url.searchParams.set("source", source);
}
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { getRoles } from "@/actions/roles";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
import type { InvitationRoleOption } from "@/types/invitations";
const ROLES_PAGE_SIZE = 50;
@@ -58,7 +58,6 @@ vi.mock("@/components/shadcn/table", () => ({
data,
metadata,
controlledPage,
getRowAttributes,
}: {
columns: Array<{
id?: string;
@@ -75,10 +74,6 @@ vi.mock("@/components/shadcn/table", () => ({
};
};
controlledPage: number;
getRowAttributes?: (row: {
index: number;
original: AttackPathScan;
}) => Record<string, string | undefined>;
}) => (
<div>
<span>{metadata.pagination.count} Total Entries</span>
@@ -100,8 +95,8 @@ vi.mock("@/components/shadcn/table", () => ({
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr key={row.id} {...getRowAttributes?.({ index, original: row })}>
{data.map((row) => (
<tr key={row.id}>
{columns.map((column, index) => (
<td key={column.id ?? index}>
{column.cell
@@ -181,20 +176,6 @@ describe("ScanListTable", () => {
);
});
it("anchors the attack paths scan tour to the first visible scan row", () => {
render(
<ScanListTable scans={[createScan(1), createScan(2), createScan(3)]} />,
);
const firstRow = screen
.getAllByRole("radio", {
name: "Select scan",
})[0]
.closest("tr");
expect(firstRow).toHaveAttribute("data-tour-id", "attack-paths-scan-list");
});
it("enables the radio button for a failed scan when graph data is ready", async () => {
const user = userEvent.setup();
const failedScan: AttackPathScan = {
@@ -295,9 +295,6 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
handleSelectScan(row.original.id);
}
}}
getRowAttributes={(row) =>
row.index === 0 ? { "data-tour-id": "attack-paths-scan-list" } : {}
}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
@@ -6,16 +6,6 @@ import { getAttackPathScans } from "@/actions/attack-paths";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { AttackPathScan } from "@/types/attack-paths";
export interface UseAttackPathScansOptions {
/**
* Invoked once the initial load resolves with no scan whose graph data is
* ready (including empty results or a fetch failure). The page passes a
* redirect only during onboarding replay; an established user gets `undefined`
* and stays on the page.
*/
onNoReadyScan?: () => void;
}
export interface UseAttackPathScansResult {
scans: AttackPathScan[];
scansLoading: boolean;
@@ -30,11 +20,7 @@ export interface UseAttackPathScansResult {
* project forbids `useEffect` in components, but a reusable data hook is the
* sanctioned place for a mount-time fetch when no fetching library is wired up.
*/
export function useAttackPathScans(
options: UseAttackPathScansOptions = {},
): UseAttackPathScansResult {
const { onNoReadyScan } = options;
export function useAttackPathScans(): UseAttackPathScansResult {
const [scans, setScans] = useState<AttackPathScan[]>([]);
const [scansLoading, setScansLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
@@ -64,20 +50,15 @@ export function useAttackPathScans(
if (!mountedRef.current) return;
if (scansData?.data) {
setScans(scansData.data);
if (!scansData.data.some((scan) => scan.attributes.graph_data_ready)) {
onNoReadyScan?.();
}
} else {
setScans([]);
setLoadError(true);
onNoReadyScan?.();
}
} catch (error) {
if (!mountedRef.current) return;
console.error("Failed to load scans:", error);
setScans([]);
setLoadError(true);
onNoReadyScan?.();
} finally {
if (mountedRef.current) setScansLoading(false);
}
@@ -203,13 +203,10 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
}
get queryBuilderCard(): HTMLElement | null {
return (
this.container
.querySelector<HTMLElement>(
'[data-tour-id="attack-paths-query-selector"]',
)
?.closest<HTMLElement>('[data-slot="card"]') ?? null
);
const executeButton = Array.from(
this.container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => /Execut(e|ing) Query/.test(button.textContent ?? ""));
return executeButton?.closest<HTMLElement>('[data-slot="card"]') ?? null;
}
/** Whether the query-parameters panel is shown. */
@@ -1,7 +1,7 @@
"use client";
import { ArrowLeft, Maximize2 } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
@@ -15,7 +15,6 @@ import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result
import type { GraphHandle } from "@/components/attack-paths/graph/attack-path-graph";
import { FindingDetailDrawer } from "@/components/findings/table";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { PageReady } from "@/components/onboarding";
import { AutoRefresh } from "@/components/scans";
import { Button, Card, useToast } from "@/components/shadcn";
import {
@@ -28,7 +27,6 @@ import {
} from "@/components/shadcn/dialog";
import { StatusAlert } from "@/components/shared/status-alert";
import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { exportGraphAsPNG, isProwlerFindingNode } from "@/lib/attack-paths";
import {
buildAttackPathView,
@@ -38,14 +36,6 @@ import {
} from "@/lib/attack-paths/group-graph";
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
import { isCloud } from "@/lib/shared/env";
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
import {
attackPathsTour,
type AttackPathsTourTarget,
pickDemoQuery,
pickDemoScan,
} from "@/lib/tours/attack-paths.tour";
import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour";
import type {
AttackPathQuery,
AttackPathQueryError,
@@ -87,22 +77,13 @@ const SCROLL_CONTAINER_CLASS =
export default function AttackPathsPage() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const scanId = searchParams.get("scanId");
// Onboarding tours are Cloud-only.
const onboardingEnabled = isCloud();
const isAttackPathsReplay =
onboardingEnabled && searchParams.get("onboarding") === "attack-paths";
const graphState = useGraphState();
const finding = useFindingDetails();
const { toast } = useToast();
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
useAttackPathScans({
onNoReadyScan: isAttackPathsReplay
? () => router.push("/scans?onboarding=view-first-scan")
: undefined,
});
useAttackPathScans();
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
@@ -121,62 +102,6 @@ export default function AttackPathsPage() {
const queryBuilder = useQueryBuilder(queries);
const hasReadyScan = scans.some((scan) => scan.attributes.graph_data_ready);
const hasNoScans = scans.length === 0;
useDriverTour(attackPathsEmptyTour, {
// Gate on !loadError: the empty-scans CTA anchor only renders in the
// NO_SCANS view-state, not in the ERROR state (which also has scans === []).
enabled: onboardingEnabled && !scansLoading && !loadError && hasNoScans,
});
const { start: startAttackPathsTour } = useDriverTour<AttackPathsTourTarget>(
attackPathsTour,
{
enabled: onboardingEnabled && !scansLoading && hasReadyScan,
autoOpen: !isAttackPathsReplay,
// Page owns tour auto-open; OnboardingSequenceBanner is the sole Continue/Skip control.
// pickDemoScan/pickDemoQuery policy lives in attack-paths.tour.ts.
stepHandlers: {
"scan-list": {
onNext: async ({ waitForStep }) => {
const selected = pickDemoScan(scans);
if (!selected) return;
const params = new URLSearchParams(searchParams.toString());
params.set("scanId", selected.id);
router.push(`${pathname}?${params.toString()}`);
await waitForStep("query-selector");
},
},
"query-selector": {
onNext: async ({ waitForStep }) => {
const selected = pickDemoQuery(queries);
if (!selected) return;
queryBuilder.handleQueryChange(selected.id);
await waitForStep("execute-button");
},
},
},
},
);
// Onboarding replay entry: start the tour once and strip the `onboarding`
// param. Invoked from <AttackPathsReplayTrigger>, which mounts only when the
// replay conditions hold — so `useMountEffect` fires it exactly once and the
// old `replayStartedRef` run-once guard is gone.
const startAttackPathsReplay = () => {
startAttackPathsTour();
const params = new URLSearchParams(searchParams.toString());
params.delete("onboarding");
const query = params.toString();
window.history.replaceState(
null,
"",
query ? `${pathname}?${query}` : pathname,
);
};
useEffect(() => {
if (!hasResetRef.current) {
hasResetRef.current = true;
@@ -268,9 +193,6 @@ export default function AttackPathsPage() {
return;
}
// The tour's execute step is autoAdvance: the real Execute click moves it forward.
advanceActiveTour();
graphState.startLoading();
graphState.setError(null);
@@ -510,13 +432,6 @@ export default function AttackPathsPage() {
onRefresh={refreshScans}
/>
{isAttackPathsReplay && !scansLoading && hasReadyScan && (
<AttackPathsReplayTrigger onReplay={startAttackPathsReplay} />
)}
{/* Enables the navbar replay icon once the initial scan load resolves. */}
{!scansLoading && <PageReady />}
{lighthouseContext && (
<LighthouseContextContributor
key={JSON.stringify(lighthouseContext)}
@@ -525,7 +440,7 @@ export default function AttackPathsPage() {
/>
)}
<div data-tour-id="attack-paths-intro">
<div>
<p className="text-text-neutral-secondary text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
@@ -541,12 +456,7 @@ export default function AttackPathsPage() {
<p className="text-sm">Loading scans...</p>
</Card>
) : viewState === ATTACK_PATHS_VIEW_STATES.NO_SCANS ? (
// Keep the empty-scans tour anchor: attackPathsEmptyTour targets
// data-tour-id="attack-paths-empty-scans-cta". The panel's NO_SCANS
// render is the same "No scans available" + Go to Scan Jobs CTA.
<div data-tour-id="attack-paths-empty-scans-cta">
<AttackPathsStatusPanel state={viewState} />
</div>
<AttackPathsStatusPanel state={viewState} />
) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? (
<AttackPathsStatusPanel
state={viewState}
@@ -584,13 +494,11 @@ export default function AttackPathsPage() {
) : (
<>
<FormProvider {...queryBuilder.form}>
<div data-tour-id="attack-paths-query-selector">
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={queryBuilder.handleQueryChange}
/>
</div>
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={queryBuilder.handleQueryChange}
/>
{queryBuilder.selectedQueryData && (
<QueryDescription
@@ -605,10 +513,7 @@ export default function AttackPathsPage() {
)}
</FormProvider>
<div
data-tour-id="attack-paths-execute-button"
className="flex justify-end gap-3"
>
<div className="flex justify-end gap-3">
<ExecuteButton
isLoading={graphState.loading}
isDisabled={
@@ -808,26 +713,3 @@ export default function AttackPathsPage() {
</div>
);
}
interface AttackPathsReplayTriggerProps {
onReplay: () => void;
}
// Conditional-mount trigger: the parent renders this only when the replay
// should start. The microtask keeps driver.js/flushSync outside React's
// mount lifecycle while still running before the next browser task.
function AttackPathsReplayTrigger({ onReplay }: AttackPathsReplayTriggerProps) {
useMountEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) onReplay();
});
return () => {
cancelled = true;
};
});
return null;
}
+1 -5
View File
@@ -8,11 +8,7 @@ export default function AttackPathsLayout({
children: React.ReactNode;
}) {
return (
<ContentLayout
title="Attack Paths"
icon={<GitBranch />}
onboardingAction={{ flowId: "attack-paths" }}
>
<ContentLayout title="Attack Paths" icon={<GitBranch />}>
{children}
</ContentLayout>
);
@@ -66,7 +66,7 @@ export const CompliancePageTabs = ({
{/* Wraps below the tabs on narrow viewports instead of squeezing the
triggers, which are the primary navigation of the page. */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div data-tour-id="view-compliance-tabs" className="overflow-x-auto">
<div className="overflow-x-auto">
<TabsList>
<TabsTrigger
value={COMPLIANCE_TAB.CROSS_PROVIDER}
+4 -45
View File
@@ -7,7 +7,7 @@ import {
getCompliancesOverview,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScan, getScans, getScansByState } from "@/actions/scans";
import { getScan, getScans } from "@/actions/scans";
import {
ComplianceSkeletonGrid,
NoScansAvailable,
@@ -93,29 +93,8 @@ export default async function Compliance({
// Only the active tab's payload is built: switching tabs is a real
// navigation, so pre-building the inactive tab buys nothing.
if (activeTab === COMPLIANCE_TAB.CROSS_PROVIDER) {
// The tour's anchors (search, framework cards) only exist on Single Scan,
// so replaying it from here navigates there — and with no scan to render
// those anchors never mount. Fall back to the scan flow instead, matching
// the Per Scan branch below. Fail-open: a failed fetch assumes scans exist.
const scansByState = await getScansByState();
const hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.length > 0
: true;
return (
<ContentLayout
title="Compliance"
icon={<ShieldCheck />}
onboardingAction={
hasCompletedScan
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
}
}
>
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
@@ -170,15 +149,7 @@ export default async function Compliance({
if (!scansData?.data) {
return (
<ContentLayout
title="Compliance"
icon={<ShieldCheck />}
onboardingAction={{
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
}}
>
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
@@ -226,14 +197,6 @@ export default async function Compliance({
(await resolveUrlScanId(scanIdFromUrl, expandedScansData)) ||
expandedScansData[0]?.id ||
null;
const onboardingAction = selectedScanId
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
};
const selectedScan = expandedScansData.find(
(scan) => scan.id === selectedScanId,
);
@@ -321,11 +284,7 @@ export default async function Compliance({
);
return (
<ContentLayout
title="Compliance"
icon={<ShieldCheck />}
onboardingAction={onboardingAction}
>
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
<CompliancePageTabs
activeTab={activeTab}
crossProviderEnabled={crossProviderEnabled}
+1 -5
View File
@@ -7,11 +7,7 @@ import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton
export default function FindingsLoading() {
return (
<ContentLayout
title="Findings"
icon={<Tag />}
onboardingAction={{ flowId: "explore-findings" }}
>
<ContentLayout title="Findings" icon={<Tag />}>
<div className="mb-6">
<FindingsFiltersSkeleton />
</div>
+2 -15
View File
@@ -40,7 +40,7 @@ export default async function Findings({
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
// The page shell awaits only what both the filters and the table depend on:
// the completed scans (onboarding + scan filter) and the scan date range.
// the completed scans (scan filter) and the scan date range.
const [scansData, filtersWithScanDates] = await Promise.all([
getScans({
pageSize: 50,
@@ -68,21 +68,8 @@ export default async function Findings({
scan.attributes.unique_resource_count > 1,
) || [];
const onboardingAction =
completedScans.length > 0
? { flowId: "explore-findings" }
: {
flowId: "explore-findings",
fallbackFlowId: "view-first-scan",
useFallback: true,
};
return (
<ContentLayout
title="Findings"
icon={<Tag />}
onboardingAction={onboardingAction}
>
<ContentLayout title="Findings" icon={<Tag />}>
<FilterTransitionWrapper>
<div className="mb-6">
<Suspense fallback={<FindingsFiltersSkeleton />}>
+8 -40
View File
@@ -5,14 +5,9 @@ import { Metadata, Viewport } from "next";
import { ReactNode, Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScansByState } from "@/actions/scans/scans";
import { auth } from "@/auth.config";
import MainLayout from "@/components/layout/main-layout/main-layout";
import {
OnboardingCheckpointWatcher,
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import { FirstRunRedirect } from "@/components/providers/first-run-redirect";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import { NavigationProgress } from "@/components/shadcn/navigation-progress";
import { Toaster } from "@/components/shadcn/toast";
@@ -26,7 +21,6 @@ import { evaluateRegistryAccess } from "@/lib/registry/access.server";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import { StoreInitializer } from "@/store/ui/store-initializer";
import { SCAN_STATES } from "@/types/attack-paths";
import { Providers } from "../providers";
@@ -56,37 +50,19 @@ export default async function RootLayout({
}: {
children: ReactNode;
}) {
// Skip Cloud-only onboarding fetches and orchestrators in OSS.
const cloudEnabled = isCloud();
// Every deployment needs the provider count: it drives the first-run redirect
// and the sidebar's Add Provider action.
const providersPromise = getProviders({ page: 1, pageSize: 1 });
const session = await auth();
// One-time server-side Registry gate per request: only an ELIGIBLE answer
// shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it. Started
// here so it resolves in parallel with the Cloud onboarding fetches.
const registryAccessPromise = auth().then((session) =>
evaluateRegistryAccess(session?.accessToken),
);
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
// progression on a fetch error.
let hasCompletedScan = true;
// Scopes the onboarding steps' local markers, so resolving them for one
// tenant does not silence them for another.
let tenantId: string | null = null;
if (cloudEnabled) {
const scansByState = await getScansByState();
hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.some(
(scan: { attributes?: { state?: string } }) =>
scan.attributes?.state === SCAN_STATES.COMPLETED,
)
: true;
tenantId = (await auth())?.tenantId ?? null;
}
// shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it.
const registryAccessPromise = evaluateRegistryAccess(session?.accessToken);
// Scopes the first-run marker, so resolving it for one tenant does not
// silence it for another.
const tenantId = session?.tenantId ?? null;
const providersData = await providersPromise;
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
@@ -119,15 +95,7 @@ export default async function RootLayout({
{/* Tri-state for both: an unknown count leaves the store unresolved and the gate closed. */}
<StoreInitializer values={{ hasProviders, registryEligible }} />
{/* Every deployment: an empty tenant lands on the add-provider wizard once. */}
<OnboardingGate hasProviders={hasProviders} tenantId={tenantId} />
{cloudEnabled && (
<>
{/* Single mount point so the watcher survives post-connect navigation. */}
<OnboardingCheckpointWatcher tenantId={tenantId} />
{/* Persistent banner shown only while a guided sequence is active. */}
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
</>
)}
<FirstRunRedirect hasProviders={hasProviders} tenantId={tenantId} />
<MainLayout>{children}</MainLayout>
{cloudEnabled && <FeedbackSurvey />}
{/* Always mounted: it hosts the detail (finding/resource) views in
+3 -13
View File
@@ -23,22 +23,12 @@ export default async function Providers({
const activeTab = getProviderTab(resolvedSearchParams.tab);
const isCloudEnvironment = isCloud();
// Exclude `tab` and `onboarding` from the key: tab switches must not re-suspend,
// and `onboarding` is ephemeral (stripped via history.replaceState) — keeping it
// would remount ProvidersAccountsView and reset the wizard mid-flow.
const {
tab: _tab,
onboarding: _onboarding,
...stableParams
} = resolvedSearchParams || {};
// Exclude `tab` from the key: tab switches must not re-suspend.
const { tab: _tab, ...stableParams } = resolvedSearchParams || {};
const searchParamsKey = JSON.stringify(stableParams);
return (
<ContentLayout
title="Providers"
icon={<CloudCog />}
onboardingAction={{ flowId: "add-provider" }}
>
<ContentLayout title="Providers" icon={<CloudCog />}>
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
<FilterTransitionWrapper>
<ProviderPageTabs
+3 -54
View File
@@ -16,7 +16,6 @@ const {
getAllProviderGroupsMock,
getScansMock,
redirectMock,
contentLayoutSpy,
scansPageShellSpy,
} = vi.hoisted(() => ({
authMock: vi.fn(),
@@ -24,7 +23,6 @@ const {
getAllProviderGroupsMock: vi.fn(),
getScansMock: vi.fn(),
redirectMock: vi.fn(),
contentLayoutSpy: vi.fn(),
scansPageShellSpy: vi.fn(),
}));
@@ -53,13 +51,9 @@ vi.mock("@/actions/schedules", () => ({
// The shell is exercised in its own test; here we only assert the page always
// mounts it (with a table subtree) and never gates on provider state.
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: (props: {
children: ReactNode;
onboardingAction: unknown;
}) => {
contentLayoutSpy(props);
return <div data-testid="content-layout">{props.children}</div>;
},
ContentLayout: (props: { children: ReactNode }) => (
<div data-testid="content-layout">{props.children}</div>
),
}));
vi.mock("@/components/scans/scans-page-shell", () => ({
ScansPageShell: (props: {
@@ -127,51 +121,6 @@ describe("scans page rendering", () => {
expect(await screen.findByRole("table")).toBeInTheDocument();
},
);
it("uses the plain view-first-scan onboarding action when a provider is connected", async () => {
getAllProvidersMock.mockResolvedValue({ data: [connectedProvider] });
await renderPage();
const props = contentLayoutSpy.mock.calls.at(-1)?.[0];
expect(props?.onboardingAction).toEqual({ flowId: "view-first-scan" });
});
it("falls back to add-provider onboarding when a permitted user has no connected provider", async () => {
getAllProvidersMock.mockResolvedValue({ data: [disconnectedProvider] });
await renderPage();
const props = contentLayoutSpy.mock.calls.at(-1)?.[0];
expect(props?.onboardingAction).toEqual({
flowId: "view-first-scan",
fallbackFlowId: "add-provider",
useFallback: true,
});
});
// ScansPageShell only mounts the view-first-scan trigger when Launch Scan is
// usable, so offering the navbar action without manage_scans yields an enabled
// button that can never start a tour.
it.each([
["a provider is connected", [connectedProvider]],
["no provider is connected", [disconnectedProvider]],
["there are no providers", [] as ProviderProps[]],
])(
"omits the onboarding action when manage_scans is missing and %s",
async (_state, providers) => {
authMock.mockResolvedValue({
user: { permissions: { manage_scans: false } },
});
getAllProvidersMock.mockResolvedValue({ data: providers });
await renderPage();
const props = contentLayoutSpy.mock.calls.at(-1)?.[0];
expect(props).toHaveProperty("onboardingAction");
expect(props?.onboardingAction).toBeUndefined();
},
);
});
describe("scans page scheduled tab source", () => {
+1 -24
View File
@@ -187,11 +187,6 @@ export default async function Scans({
const providers = providersData?.data ?? [];
const providerGroups = providerGroupsData?.data ?? [];
const hasConnectedProvider = providers.some(
(provider: ProviderProps) =>
provider.attributes.connection.connected === true,
);
const hasManageScansPermission = Boolean(
session?.user?.permissions?.manage_scans,
);
@@ -202,26 +197,8 @@ export default async function Scans({
getActiveScanCount(resolvedSearchParams),
isReportDownloadLocked(),
]);
// Mirrors ScansPageShell's launch gate: it only mounts the view-first-scan trigger
// when Launch Scan is usable (manage_scans + a connected provider). Without the
// permission nothing can consume the navbar action, so offer none rather than an
// enabled button that starts no tour.
const onboardingAction = !hasManageScansPermission
? undefined
: hasConnectedProvider
? { flowId: "view-first-scan" }
: {
flowId: "view-first-scan",
fallbackFlowId: "add-provider",
useFallback: true,
};
return (
<ContentLayout
title="Scans"
icon={<Timer />}
onboardingAction={onboardingAction}
>
<ContentLayout title="Scans" icon={<Timer />}>
<ScansPageShell
providers={providers}
providerGroups={providerGroups}
@@ -0,0 +1 @@
Product tours and the guided onboarding sequence are no longer shown; new tenants still land on the add-provider wizard
@@ -2,9 +2,6 @@ import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TourStepHandlers } from "@/lib/tours/tour-types";
import type { ViewComplianceTourTarget } from "@/lib/tours/view-compliance.tour";
import { VIEW_COMPLIANCE_TOUR_TARGETS } from "@/lib/tours/view-compliance.tour";
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
import type { ComplianceOverviewData } from "@/types/compliance";
@@ -22,28 +19,6 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({
LighthouseContextContributor: () => null,
}));
type ViewComplianceStepHandlers = {
[K in ViewComplianceTourTarget]?: TourStepHandlers<ViewComplianceTourTarget>;
};
// Captured so the tour's own step handlers can be exercised: the trigger is a
// driver.js host, and the handlers are the only part of it this grid owns.
const capturedStepHandlers = vi.hoisted(() => ({
current: {} as ViewComplianceStepHandlers,
}));
vi.mock("@/components/onboarding", () => ({
OnboardingTrigger: ({
stepHandlers,
}: {
stepHandlers: ViewComplianceStepHandlers;
}) => {
capturedStepHandlers.current = stepHandlers;
return null;
},
PageReady: () => null,
}));
vi.mock("@/actions/compliance-watchlist", () => ({
addComplianceToWatchlist: vi.fn(),
bulkUpdateComplianceWatchlist: vi.fn(),
@@ -252,63 +227,3 @@ describe("ComplianceOverviewGrid with a universal framework", () => {
);
});
});
describe("ComplianceOverviewGrid tour anchor", () => {
const searchHandlers = () =>
capturedStepHandlers.current[VIEW_COMPLIANCE_TOUR_TARGETS.SEARCH];
it("keeps the anchor on the first rendered card regardless of pin state", () => {
renderGrid({
catalogEntries: [
catalogEntry("cis_1.4_aws", false),
catalogEntry("gdpr_aws", true),
catalogEntry("iso27001_aws", false),
],
providerType: "aws",
});
expect(
document.querySelector('[data-tour-id="view-compliance-frameworks"]'),
).toHaveTextContent("CIS");
});
it("waits for the framework card when one will render", async () => {
const waitForStep = vi
.fn()
.mockResolvedValue(document.createElement("div"));
renderGrid();
const onNext = searchHandlers()?.onNext;
if (!onNext) throw new Error("Expected a search step handler");
await onNext({ waitForStep });
expect(waitForStep).toHaveBeenCalledWith("frameworks");
});
it("skips the wait when the persisted filter leaves no card to anchor to", async () => {
// The filter survives reloads, so the tour can start on a grid that renders
// the empty state instead of a card — and waiting for an anchor that never
// mounts would hang it there.
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
const waitForStep = vi
.fn()
.mockResolvedValue(document.createElement("div"));
renderGrid({
catalogEntries: [
catalogEntry("cis_1.4_aws", false),
catalogEntry("gdpr_aws", false),
catalogEntry("iso27001_aws", false),
],
providerType: "aws",
});
const onNext = searchHandlers()?.onNext;
if (!onNext) throw new Error("Expected a search step handler");
await onNext({ waitForStep });
expect(waitForStep).not.toHaveBeenCalled();
expect(
document.querySelector('[data-tour-id="view-compliance-frameworks"]'),
).toBeNull();
});
});
@@ -1,15 +1,13 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { ComplianceCard } from "@/components/compliance/compliance-card";
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTableSearch } from "@/components/shadcn/table/data-table-search";
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import {
buildWatchlistIndex,
isFrameworkPinned,
@@ -20,8 +18,6 @@ import {
LIGHTHOUSE_CONTEXT_CONTRIBUTOR_LIMIT,
} from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { getFlowById } from "@/lib/onboarding";
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
@@ -30,15 +26,6 @@ import type { ScanEntity } from "@/types/scans";
import { WatchlistEmptyState } from "./watchlist/watchlist-empty-state";
import { WatchlistToggle } from "./watchlist/watchlist-toggle";
const viewComplianceFlow = getFlowById("view-compliance")!;
// Module-level so the identity is stable: `configOverrides` is an effect dependency in
// `useDriverTour`, and a fresh object per keystroke would tear the tour down mid-typing.
const VIEW_COMPLIANCE_TOUR_CONFIG = {
// Last step opens the first card (see createViewComplianceTourStepHandlers).
doneBtnText: "Open Compliance",
};
interface ComplianceOverviewGridProps {
frameworks: ComplianceOverviewData[];
scanId: string;
@@ -60,7 +47,6 @@ export const ComplianceOverviewGrid = ({
canManageWatchlist = false,
subscriptionOnly = false,
}: ComplianceOverviewGridProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState("");
const showOnlyWatchlist = useShowOnlyWatchlist();
@@ -92,34 +78,6 @@ export const ComplianceOverviewGrid = ({
const visibleFrameworks = filterToWatchlist
? filteredFrameworks.filter((compliance) => isPinned(compliance.id))
: filteredFrameworks;
const tourAnchorId = visibleFrameworks[0]?.id;
const resetSearch = () => {
setSearchTerm("");
// Clearing the search does not bring the anchor back while the persisted
// watchlist filter is on and nothing is pinned: the grid renders the empty
// state instead, so the tour has to skip the step rather than wait for a
// selector that never mounts.
return filterToWatchlist ? pinnedTotal > 0 : frameworks.length > 0;
};
const openFirstFramework = () => {
// The fallback covers a search that filtered every card away — never the
// watchlist filter, where opening a hidden framework would contradict the
// list the user is looking at.
const first =
visibleFrameworks[0] ?? (filterToWatchlist ? undefined : frameworks[0]);
if (!first) return;
router.push(
buildComplianceDetailPath({
title: first.attributes.framework,
complianceId: first.id,
version: first.attributes.version,
scanId,
regionFilter: searchParams.get("filter[region__in]"),
}),
);
};
const renderGrid = (items: ComplianceOverviewData[]) => (
<ComplianceFrameworkGrid>
@@ -129,13 +87,7 @@ export const ComplianceOverviewGrid = ({
attributes;
return (
<div
key={id}
{...(id === tourAnchorId
? { "data-tour-id": "view-compliance-frameworks" }
: {})}
className="h-full [&>*]:h-full"
>
<div key={id} className="h-full [&>*]:h-full">
<ComplianceCard
title={framework}
version={version}
@@ -195,27 +147,12 @@ export const ComplianceOverviewGrid = ({
})}
/>
))}
{/* Suspense required: OnboardingTrigger reads useSearchParams */}
<Suspense fallback={null}>
<OnboardingTrigger
flow={viewComplianceFlow}
stepHandlers={createViewComplianceTourStepHandlers({
resetSearch,
openFirstFramework,
})}
configOverrides={VIEW_COMPLIANCE_TOUR_CONFIG}
/>
</Suspense>
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
<PageReady />
<div className="flex items-center justify-between gap-4">
<div data-tour-id="view-compliance-search">
<DataTableSearch
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search frameworks..."
/>
</div>
<DataTableSearch
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search frameworks..."
/>
<span className="text-text-neutral-secondary shrink-0 text-sm">
{visibleFrameworks.length.toLocaleString()} Total Entries
</span>
+1 -1
View File
@@ -383,7 +383,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
});
return (
<div data-tour-id="explore-findings-filters">
<div>
<FindingsFilterBatchControls
{...props}
appliedFilters={appliedFilters}
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Fragment, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -44,7 +44,6 @@ vi.mock("@/components/shadcn/table", () => ({
DataTable: ({
data,
toolbarRightContent,
getRowAttributes,
getRowCanSelect,
onRowSelectionChange,
renderAfterRow,
@@ -52,10 +51,6 @@ vi.mock("@/components/shadcn/table", () => ({
}: {
data?: Array<{ checkId?: string }>;
toolbarRightContent?: ReactNode;
getRowAttributes?: (row: {
index: number;
original: { checkId?: string };
}) => Record<string, string | undefined>;
getRowCanSelect?: (row: {
index: number;
original: { checkId?: string };
@@ -88,7 +83,6 @@ vi.mock("@/components/shadcn/table", () => ({
data-can-select={String(
getRowCanSelect?.({ index, original }) ?? true,
)}
{...getRowAttributes?.({ index, original })}
>
<td>{original.checkId}</td>
<td>
@@ -136,39 +130,6 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({
),
}));
vi.mock("@/components/onboarding", () => ({
// Exposes the "Open a finding group" step so tests can drive it and read
// whether the handler waited for the resources step.
OnboardingTrigger: ({
stepHandlers,
}: {
stepHandlers?: Record<
string,
{
onNext?: (context: {
waitForStep: (target: string) => Promise<void>;
}) => Promise<void>;
}
>;
}) => (
<button
type="button"
data-testid="onboarding-trigger"
onClick={() => {
const waitForStep = vi.fn(async () => {});
void stepHandlers?.group?.onNext?.({ waitForStep }).then(() => {
document.body.dataset.tourWaited = String(
waitForStep.mock.calls.length > 0,
);
});
}}
>
Open first group
</button>
),
PageReady: () => <div data-testid="page-ready" />,
}));
vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({
CustomCheckboxMutedFindings: () => (
<label>
@@ -315,51 +276,6 @@ describe("FindingsGroupTable", () => {
).toBeInTheDocument();
});
it("mounts the tour only when finding groups exist", () => {
// Given / When
const { rerender } = render(
<FindingsGroupTable
data={[]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
expect(screen.getByTestId("page-ready")).toBeInTheDocument();
// When
rerender(
<FindingsGroupTable
data={[makeGroup("check-a")]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
});
it("anchors the finding-group tour to the first row only", () => {
// Given / When
render(
<FindingsGroupTable
data={[makeGroup("check-a"), makeGroup("check-b")]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(screen.getByTestId("row-0")).toHaveAttribute(
"data-tour-id",
"explore-findings-group",
);
expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
});
it("opens a drillable group from the expanded deep link", () => {
// Given / When
render(
@@ -532,38 +448,6 @@ describe("FindingsGroupTable", () => {
).toBeInTheDocument();
});
it("skips the tour resources step when the only drillable group is locked", async () => {
// Given: the previewed rows cannot drill down, a locked one can
const user = userEvent.setup();
delete document.body.dataset.tourWaited;
render(
<FindingsGroupTable
data={[
makeGroup("check-a", 0, { resourcesTotal: 0 }),
makeGroup("check-b", 0, { resourcesTotal: 0 }),
makeGroup("check-c"),
]}
resolvedFilters={{}}
hasHistoricalData={false}
previewRowLimit={2}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: "Open first group" }),
);
// Then: no drill-down, no upgrade prompt, and the tour did not wait
await waitFor(() =>
expect(document.body.dataset.tourWaited).toBe("false"),
);
expect(
screen.queryByRole("button", { name: "Select finding-1" }),
).not.toBeInTheDocument();
expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
});
it("ignores an expanded deep link that points past the preview", () => {
// Given / When
render(
@@ -2,12 +2,11 @@
import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useRef, useState } from "react";
import { useRef, useState } from "react";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTable } from "@/components/shadcn/table";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
@@ -19,8 +18,6 @@ import {
buildFindingGroupContext,
buildFindingSummaryContext,
} from "@/lib/lighthouse/context/contributions";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
import { useCloudUpgradeStore } from "@/store";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
@@ -38,7 +35,6 @@ import {
InlineResourceContainerHandle,
} from "./inline-resource-container";
const exploreFindingsFlow = getFlowById("explore-findings")!;
const EMPTY_FINDING_GROUPS: FindingGroupRow[] = [];
const MAX_FINDING_CONTEXT_SELECTIONS = LIGHTHOUSE_CONTEXT_LIMIT.ITEMS - 2;
@@ -350,20 +346,6 @@ const FindingsGroupTableContent = ({
setResourceSelection([]);
};
// Drives the onboarding "Open a finding group" step: opens the first row when
// drillable, otherwise the first drillable previewed group (a locked row
// would only open the upgrade prompt). Returns false when none can open so
// the tour skips the resources step instead of hanging.
const openFirstFindingGroup = (): boolean => {
const target =
previewedGroups[0] && canDrillDownFindingGroup(previewedGroups[0])
? previewedGroups[0]
: previewedGroups.find((group) => canDrillDownFindingGroup(group));
if (!target) return false;
handleDrillDown(target.checkId, target);
return true;
};
const columns = getColumnFindingGroups({
rowSelection,
selectableRowCount,
@@ -402,7 +384,6 @@ const FindingsGroupTableContent = ({
resolveMuteIds,
}}
>
{/* Gate the tour on having at least one finding group */}
<div>
{metadata?.pagination.count !== undefined && (
<LighthouseContextContributor
@@ -423,18 +404,6 @@ const FindingsGroupTableContent = ({
item={buildFindingGroupContext(finding)}
/>
))}
<Suspense fallback={null}>
{safeData.length > 0 && (
<OnboardingTrigger
flow={exploreFindingsFlow}
stepHandlers={createExploreFindingsTourStepHandlers(
openFirstFindingGroup,
)}
/>
)}
</Suspense>
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
<PageReady />
<DataTable
columns={columns}
data={safeData}
@@ -457,11 +426,6 @@ const FindingsGroupTableContent = ({
}
toolbarRightContent={<CustomCheckboxMutedFindings />}
renderAfterRow={renderAfterRow}
// Anchor the "Open a finding group" tour step to the first group row
// (there may be only one); driver.js resolves to the first match.
getRowAttributes={(row) =>
row.index === 0 ? { "data-tour-id": "explore-findings-group" } : {}
}
previewLock={
previewRowLimit !== undefined
? {
@@ -291,8 +291,6 @@ export function InlineResourceContainer({
<td colSpan={columnCount} className="max-w-0 p-0">
<AnimatePresence initial>
<motion.div
// Onboarding anchor: the "Review the affected resources" tour step.
data-tour-id="explore-findings-resources"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
@@ -101,7 +101,7 @@ describe("SendInvitationForm", () => {
<SendInvitationForm
roles={ROLES}
isSelectorDisabled={false}
source="onboarding"
source="provider_connect"
onSuccess={onSuccess}
/>,
);
@@ -113,6 +113,6 @@ describe("SendInvitationForm", () => {
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith("inv-1"));
expect(pushMock).not.toHaveBeenCalled();
const formData = sendInviteMock.mock.calls[0]?.[0] as FormData;
expect(formData.get("source")).toBe("onboarding");
expect(formData.get("source")).toBe("provider_connect");
});
});
@@ -14,7 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
import type { InvitationRoleOption } from "@/types/invitations";
import { useSendInvitation } from "./use-send-invitation";
@@ -23,7 +23,7 @@ interface SendInvitationFormProps {
defaultRole?: string;
isSelectorDisabled: boolean;
// Where the invitation was sent from, forwarded to the API as `?source=`
// so the origin can be told apart (e.g. the onboarding invite step).
// so the origin can be told apart (e.g. the add-provider wizard).
source?: string;
// Replaces the default navigation to the invitation details page.
onSuccess?: (invitationId: string) => void;
@@ -8,7 +8,7 @@ import { sendInvite } from "@/actions/invitations/invitation";
import { toSentInvitation } from "@/actions/invitations/invitation.adapter";
import { useToast } from "@/components/shadcn";
import { ApiError } from "@/types";
import type { SentInvitation } from "@/types/onboarding-invite";
import type { SentInvitation } from "@/types/invitations";
export const sendInvitationFormSchema = z.object({
email: z.email({ error: "Please enter a valid email" }),
@@ -22,7 +22,7 @@ const ROLES_ERROR_POINTER = "/data/relationships/roles";
interface UseSendInvitationOptions {
// Where the invitation is sent from, forwarded to the API as `?source=`
// so the origin can be told apart (e.g. the onboarding invite step).
// so the origin can be told apart (e.g. the add-provider wizard).
source?: string;
defaultRoleId?: string;
// `onChange` lets a caller gate its submit button on `isValid`.
@@ -1,57 +1,25 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getFlowById } from "@/lib/onboarding";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { usePageReadyStore } from "@/store/page-ready";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
import { FeedsLoadingFallback, NavbarClient } from "./navbar-client";
const navigationMocks = vi.hoisted(() => ({
pathname: "/findings",
push: vi.fn(),
searchParams: new URLSearchParams(),
}));
const requestReplayMock = vi.hoisted(() => vi.fn());
/** `replayFlow` reads the live query from `window` (not `useSearchParams`), so
* the CSR bailout stays inside the Suspense boundary NavbarClient renders. */
const setLocationSearch = (search: string) =>
window.history.replaceState(null, "", `/${search}`);
vi.mock("next/navigation", () => ({
usePathname: () => navigationMocks.pathname,
useRouter: () => ({ push: navigationMocks.push }),
useSearchParams: () => navigationMocks.searchParams,
usePathname: () => "/findings",
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/store/onboarding-replay", () => {
const state = { requestReplay: requestReplayMock };
const hook = (selector: (s: typeof state) => unknown) => selector(state);
hook.getState = () => state;
return { useOnboardingReplayStore: hook };
});
vi.mock("@/components/ThemeSwitch", () => ({
ThemeSwitch: () => <button type="button">Theme switch</button>,
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
BreadcrumbNavigation: ({
title,
titleAction,
}: {
title: string;
titleAction?: ReactNode;
}) => (
BreadcrumbNavigation: ({ title }: { title: string }) => (
<nav aria-label="Breadcrumb">
<h1>{title}</h1>
{titleAction}
</nav>
),
}));
@@ -66,237 +34,14 @@ vi.mock("../user-nav/user-nav", () => ({
describe("NavbarClient", () => {
beforeEach(() => {
navigationMocks.pathname = "/findings";
navigationMocks.push.mockClear();
requestReplayMock.mockClear();
navigationMocks.searchParams = new URLSearchParams();
setLocationSearch("");
window.localStorage.clear();
// Replay icon is Cloud-only.
// The Lighthouse AI trigger is Cloud-only.
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// Default: the current route's content has loaded, so the icon is enabled.
usePageReadyStore.setState({ readyPath: "/findings" });
useSidePanelStore.setState({
isOpen: false,
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
});
});
it("renders an accessible contextual onboarding button in the breadcrumb", async () => {
// Given
const user = userEvent.setup();
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "explore-findings" }}
/>,
);
// When
await user.hover(
screen.getByRole("button", {
name: /start product tour: explore your findings/i,
}),
);
// Then
expect(
screen.getByRole("heading", { name: "Findings" }),
).toBeInTheDocument();
expect(await screen.findAllByText("See how it works")).not.toHaveLength(0);
expect(
screen.getByRole("button", {
name: /start product tour: explore your findings/i,
}),
).toHaveClass("size-7");
});
it("pulses the onboarding icon while the tour has not been seen", () => {
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "explore-findings" }}
/>,
);
const icon = screen
.getByRole("button", {
name: /start product tour: explore your findings/i,
})
.querySelector("svg");
expect(icon).toHaveClass("animate-pulse");
});
it("keeps the onboarding button but stops pulsing once the tour is closed", () => {
const flow = getFlowById("explore-findings");
localStorageAdapter.set(flow!.tour, {
tourId: flow!.tour.id,
version: flow!.tour.version,
state: "dismissed",
completedAt: "2026-01-01T00:00:00.000Z",
});
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "explore-findings" }}
/>,
);
const button = screen.getByRole("button", {
name: /start product tour: explore your findings/i,
});
expect(button).toBeInTheDocument();
expect(button.querySelector("svg")).not.toHaveClass("animate-pulse");
});
it("starts the replay in-memory (no navigation) when already on the flow's route", async () => {
// Given the user is already on the flow's page
navigationMocks.pathname = "/findings";
usePageReadyStore.setState({ readyPath: "/findings" });
const user = userEvent.setup();
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "explore-findings" }}
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: /start product tour: explore your findings/i,
}),
);
// Then the replay is requested via the in-memory store — no router.push, so no
// `?onboarding=` URL param and no Next.js RSC refetch of the page.
expect(requestReplayMock).toHaveBeenCalledWith("explore-findings");
expect(navigationMocks.push).not.toHaveBeenCalled();
});
it("navigates when the flow pins a tab the current route does not carry", async () => {
// Given the user is on the bare /compliance route (Multiple Scans), while
// the tour anchors live on the Single Scan tab.
navigationMocks.pathname = "/compliance";
setLocationSearch("");
usePageReadyStore.setState({ readyPath: "/compliance" });
const user = userEvent.setup();
render(
<NavbarClient
title="Compliance"
onboardingAction={{ flowId: "view-compliance" }}
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: /start product tour: check compliance/i,
}),
);
// Then a real navigation switches tabs before the tour starts.
expect(requestReplayMock).not.toHaveBeenCalled();
expect(navigationMocks.push).toHaveBeenCalledWith(
"/compliance?tab=per-scan&onboarding=view-compliance",
);
});
it("replays in-memory when the pinned tab is already active, keeping the selected scan", async () => {
// Given the user is on Single Scan with a scan and a filter picked — a
// navigation here would reset both, so the pinned tab must not force one.
navigationMocks.pathname = "/compliance";
setLocationSearch(
"?tab=per-scan&scanId=scan-1&filter%5Bregion__in%5D=eu-west-1",
);
usePageReadyStore.setState({ readyPath: "/compliance" });
const user = userEvent.setup();
render(
<NavbarClient
title="Compliance"
onboardingAction={{ flowId: "view-compliance" }}
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: /start product tour: check compliance/i,
}),
);
// Then
expect(requestReplayMock).toHaveBeenCalledWith("view-compliance");
expect(navigationMocks.push).not.toHaveBeenCalled();
});
it("navigates to the fallback flow without leaking current-route params", async () => {
// Given
navigationMocks.pathname = "/findings";
navigationMocks.searchParams = new URLSearchParams(
"filter%5Bseverity__in%5D=critical",
);
const user = userEvent.setup();
render(
<NavbarClient
title="Findings"
onboardingAction={{
flowId: "explore-findings",
fallbackFlowId: "view-first-scan",
useFallback: true,
}}
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: /start product tour: run your first scan/i,
}),
);
// Then: the flow's own route (incl. its `?tab=active`) is preserved and the
// onboarding param is appended with `&` — the current /findings filter is dropped.
expect(navigationMocks.push).toHaveBeenCalledWith(
"/scans?tab=active&onboarding=view-first-scan",
);
});
it("hides the replay icon until the route's content has loaded", () => {
// Given the page has not signalled ready for the current route
usePageReadyStore.setState({ readyPath: null });
// When
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "explore-findings" }}
/>,
);
// Then the icon is not rendered at all
expect(
screen.queryByRole("button", {
name: /start product tour: explore your findings/i,
}),
).not.toBeInTheDocument();
});
it("hides the replay icon entirely in self-hosted (OSS) deployments", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "explore-findings" }}
/>,
);
expect(
screen.queryByRole("button", { name: /start product tour/i }),
).not.toBeInTheDocument();
});
it("shows the Lighthouse AI side-panel trigger in cloud", () => {
// Given / When
render(<NavbarClient title="Findings" />);
@@ -352,21 +97,6 @@ describe("NavbarClient", () => {
).not.toBeInTheDocument();
});
it("does not render a contextual onboarding button for unknown flows", () => {
// Given / When
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "unknown-flow" }}
/>,
);
// Then
expect(
screen.queryByRole("button", { name: /start product tour/i }),
).not.toBeInTheDocument();
});
it("draws a bottom separator that reaches the sidebar's edge", () => {
// Given / When
render(<NavbarClient title="Findings" />);
+3 -96
View File
@@ -1,89 +1,22 @@
"use client";
import { BellRing, Info } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { BellRing } from "lucide-react";
import { ReactNode, Suspense, type ReactElement } from "react";
import { MobileAppSidebar } from "@/components/layout/app-sidebar";
import {
BreadcrumbNavigation,
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { BreadcrumbNavigation, Button } from "@/components/shadcn";
import { SidePanelTrigger } from "@/components/side-panel";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { getFlowById, isOnFlowRoute } from "@/lib/onboarding";
import { isCloud } from "@/lib/shared/env";
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
import { cn } from "@/lib/utils";
import { useOnboardingReplayStore } from "@/store/onboarding-replay";
import { usePageReadyStore } from "@/store/page-ready";
import { UserNav } from "../user-nav/user-nav";
export interface OnboardingActionConfig {
flowId: string;
fallbackFlowId?: string;
useFallback?: boolean;
}
interface NavbarClientProps {
title: string;
icon?: ReactElement;
onboardingAction?: OnboardingActionConfig;
feedsSlot?: ReactNode;
}
export function NavbarClient({
title,
icon,
onboardingAction,
feedsSlot,
}: NavbarClientProps) {
const pathname = usePathname();
const router = useRouter();
const requestReplay = useOnboardingReplayStore(
(state) => state.requestReplay,
);
const targetFlowId =
onboardingAction?.useFallback && onboardingAction.fallbackFlowId
? onboardingAction.fallbackFlowId
: onboardingAction?.flowId;
// Cloud-only: no flow → no replay icon in OSS.
const flow =
isCloud() && targetFlowId ? getFlowById(targetFlowId) : undefined;
// Pulse only until the tour is seen; any close (completed/skipped/dismissed)
// calms it. The replay button itself stays.
const seen = useTourCompletion(flow?.tour ?? null) !== null;
// Keep the replay icon disabled until this route's content has finished loading,
// so a tour never starts before its anchors are in the DOM.
const readyPath = usePageReadyStore((state) => state.readyPath);
const pageReady = readyPath === pathname;
const replayFlow = () => {
if (!flow) return;
// Already on the flow's page: start the replay via an in-memory signal so the
// URL never changes and Next.js never refetches the (often heavy) page — and
// the params the user built up (selected scan, filters) survive.
// The query is read from `window` rather than `useSearchParams()` on purpose:
// the hook would hoist the CSR bailout to this component, outside the Suspense
// boundary below that exists to contain it. This runs on click, client-only.
const currentQuery = new URLSearchParams(window.location.search);
if (isOnFlowRoute(flow.route, pathname, currentQuery)) {
requestReplay(flow.id);
return;
}
// Different page: a real navigation is needed; the `onboarding=` param survives
// it and starts the tour on the destination route. Routes may already carry a
// query string (e.g. `/scans?tab=active`), so pick the right separator.
const separator = flow.route.includes("?") ? "&" : "?";
router.push(`${flow.route}${separator}onboarding=${flow.id}`);
};
export function NavbarClient({ title, icon, feedsSlot }: NavbarClientProps) {
return (
// -ml-4/pl-4: bleed the bar across <main>'s 16px left gutter so its
// border-b meets the sidebar's border-r. The gutter is main's padding —
@@ -100,32 +33,6 @@ export function NavbarClient({
mode="auto"
title={title}
icon={icon}
titleAction={
// Hidden until the route's content has loaded, so the tour never starts
// before its anchors exist (and we avoid a disabled-then-enabled flash).
flow && pageReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Start product tour: ${flow.title}`}
onClick={replayFlow}
>
<Info
className={cn(
"text-bg-data-info size-4",
!seen && "animate-pulse",
)}
aria-hidden="true"
/>
</Button>
</TooltipTrigger>
<TooltipContent>See how it works</TooltipContent>
</Tooltip>
) : null
}
paramToPreserve="scanId"
/>
</Suspense>
+2 -10
View File
@@ -2,26 +2,18 @@ import { Suspense, type ReactElement } from "react";
import { FeedsServer } from "@/components/feeds";
import {
FeedsLoadingFallback,
NavbarClient,
type OnboardingActionConfig,
} from "./navbar-client";
export type { OnboardingActionConfig };
import { FeedsLoadingFallback, NavbarClient } from "./navbar-client";
interface NavbarProps {
title: string;
icon?: ReactElement;
onboardingAction?: OnboardingActionConfig;
}
export function Navbar({ title, icon, onboardingAction }: NavbarProps) {
export function Navbar({ title, icon }: NavbarProps) {
return (
<NavbarClient
title={title}
icon={icon}
onboardingAction={onboardingAction}
feedsSlot={
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
<FeedsServer limit={15} />
@@ -1,109 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OnboardingCheckpointDialog } from "../onboarding-checkpoint-dialog";
describe("OnboardingCheckpointDialog", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("when open", () => {
it("shows the checkpoint title and both choices", () => {
render(
<OnboardingCheckpointDialog
open
onContinue={vi.fn()}
onFinish={vi.fn()}
/>,
);
expect(
screen.getByText("Provider added — keep exploring?"),
).toBeInTheDocument();
expect(
screen.getByText(/Your first provider is added\./),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /continue the tour/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /finish here/i }),
).toBeInTheDocument();
});
it("calls onContinue when the primary button is clicked", async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
const onFinish = vi.fn();
render(
<OnboardingCheckpointDialog
open
onContinue={onContinue}
onFinish={onFinish}
/>,
);
await user.click(
screen.getByRole("button", { name: /continue the tour/i }),
);
expect(onContinue).toHaveBeenCalledTimes(1);
expect(onFinish).not.toHaveBeenCalled();
});
it("calls onFinish when the outline button is clicked", async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
const onFinish = vi.fn();
render(
<OnboardingCheckpointDialog
open
onContinue={onContinue}
onFinish={onFinish}
/>,
);
await user.click(screen.getByRole("button", { name: /finish here/i }));
expect(onFinish).toHaveBeenCalledTimes(1);
expect(onContinue).not.toHaveBeenCalled();
});
it("treats Escape (overlay/X dismiss) as finish", async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
const onFinish = vi.fn();
render(
<OnboardingCheckpointDialog
open
onContinue={onContinue}
onFinish={onFinish}
/>,
);
await user.keyboard("{Escape}");
// Escape/overlay dismiss must route to onFinish, not onContinue.
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onContinue).not.toHaveBeenCalled();
});
});
describe("when closed", () => {
it("renders nothing", () => {
render(
<OnboardingCheckpointDialog
open={false}
onContinue={vi.fn()}
onFinish={vi.fn()}
/>,
);
expect(
screen.queryByText("Provider added — keep exploring?"),
).not.toBeInTheDocument();
});
});
});
@@ -1,132 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { onboardingInviteMarkerKey } from "@/lib/onboarding/invite-marker";
import { OnboardingCheckpointWatcher } from "../onboarding-checkpoint-watcher";
// Tests set this before render to control the store `open` flag.
let checkpointOpenState = false;
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/store/onboarding-sequence", () => ({
useOnboardingSequenceStore: {
getState: () => ({ startSequence: vi.fn() }),
},
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
CHECKPOINT_MARKER: "prowler.onboarding.checkpoint",
useOnboardingCheckpointStore: Object.assign(
(selector: (state: { open: boolean }) => unknown) =>
selector({ open: checkpointOpenState }),
{
getState: () => ({ close: vi.fn() }),
},
),
}));
vi.mock("../onboarding-invite-step", () => ({
OnboardingInviteStep: ({ onDone }: { onDone: () => void }) => (
<button type="button" onClick={onDone}>
Resolve invite step
</button>
),
}));
const TENANT_ID = "3f6c2f1e-7b0a-4d5c-9a21-0c9f4f2a7b10";
const OTHER_TENANT_ID = "8a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
const MARKER_KEY = onboardingInviteMarkerKey(TENANT_ID) as string;
const CHECKPOINT_TITLE = "Provider added — keep exploring?";
describe("OnboardingCheckpointWatcher invite step", () => {
beforeEach(() => {
window.localStorage.clear();
checkpointOpenState = true;
});
it("offers the invite step before the checkpoint dialog and keeps the store open", async () => {
// Given
const user = userEvent.setup();
render(<OnboardingCheckpointWatcher tenantId={TENANT_ID} />);
// The step is loaded on demand, so it arrives a tick after render.
expect(
await screen.findByRole("button", { name: "Resolve invite step" }),
).toBeInTheDocument();
expect(screen.queryByText(CHECKPOINT_TITLE)).not.toBeInTheDocument();
// When
await user.click(
screen.getByRole("button", { name: "Resolve invite step" }),
);
// Then
expect(await screen.findByText(CHECKPOINT_TITLE)).toBeInTheDocument();
expect(window.localStorage.getItem(MARKER_KEY)).toBe("true");
});
it("is off unless a deployment opts in", () => {
// When
render(<OnboardingCheckpointWatcher />);
// Then
expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Resolve invite step" }),
).not.toBeInTheDocument();
});
it("does not offer the step again once this browser saw it", () => {
// Given
window.localStorage.setItem(MARKER_KEY, "true");
// When
render(<OnboardingCheckpointWatcher tenantId={TENANT_ID} />);
// Then
expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument();
});
it("renders nothing for the step while the checkpoint is not requested", () => {
// Given
checkpointOpenState = false;
// When
render(<OnboardingCheckpointWatcher tenantId={TENANT_ID} />);
// Then
expect(
screen.queryByRole("button", { name: "Resolve invite step" }),
).not.toBeInTheDocument();
expect(screen.queryByText(CHECKPOINT_TITLE)).not.toBeInTheDocument();
});
it("offers the step again to another tenant of the same browser", async () => {
// Given — this browser already saw it for one tenant.
window.localStorage.setItem(MARKER_KEY, "true");
// When
render(<OnboardingCheckpointWatcher tenantId={OTHER_TENANT_ID} />);
// Then
expect(
await screen.findByRole("button", { name: "Resolve invite step" }),
).toBeInTheDocument();
});
it("does not offer the step without a usable tenant", () => {
// When
render(<OnboardingCheckpointWatcher tenantId={null} />);
// Then
expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Resolve invite step" }),
).not.toBeInTheDocument();
});
});
@@ -1,117 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFlowById } from "@/lib/onboarding";
import { OnboardingCheckpointWatcher } from "../onboarding-checkpoint-watcher";
const pushMock = vi.fn();
const startSequenceMock = vi.fn();
const closeMock = vi.fn();
const CHECKPOINT_MARKER = "prowler.onboarding.checkpoint";
// Tests set this before render to control the store `open` flag the watcher subscribes to.
let checkpointOpenState = false;
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
vi.mock("@/store/onboarding-sequence", () => ({
useOnboardingSequenceStore: {
getState: () => ({ startSequence: startSequenceMock }),
},
}));
// The invite step is exercised in its own test; keep this one on the dialog.
vi.mock("../onboarding-invite-step", () => ({
OnboardingInviteStep: () => null,
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
CHECKPOINT_MARKER: "prowler.onboarding.checkpoint",
useOnboardingCheckpointStore: Object.assign(
(selector: (state: { open: boolean }) => unknown) =>
selector({ open: checkpointOpenState }),
{
getState: () => ({ close: closeMock }),
},
),
}));
describe("OnboardingCheckpointWatcher", () => {
beforeEach(() => {
pushMock.mockClear();
startSequenceMock.mockClear();
closeMock.mockClear();
checkpointOpenState = false;
window.localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("rendering", () => {
it("renders the dialog when the store open flag is true", () => {
checkpointOpenState = true;
render(<OnboardingCheckpointWatcher />);
expect(
screen.getByText("Provider added — keep exploring?"),
).toBeInTheDocument();
});
it("does not render the dialog when the store open flag is false", () => {
checkpointOpenState = false;
render(<OnboardingCheckpointWatcher />);
expect(
screen.queryByText("Provider added — keep exploring?"),
).not.toBeInTheDocument();
});
});
describe("when the user continues the tour", () => {
it("marks handled, starts the sequence at the next flow, navigates, and closes the store", async () => {
const user = userEvent.setup();
checkpointOpenState = true;
render(<OnboardingCheckpointWatcher />);
await screen.findByText("Provider added — keep exploring?");
await user.click(
screen.getByRole("button", { name: /continue the tour/i }),
);
const nextFlow = getFlowById("view-first-scan");
expect(window.localStorage.getItem(CHECKPOINT_MARKER)).not.toBeNull();
expect(closeMock).toHaveBeenCalledTimes(1);
if (nextFlow) {
expect(startSequenceMock).toHaveBeenCalledWith(nextFlow.id);
expect(pushMock).toHaveBeenCalledWith(nextFlow.route);
} else {
// Guard: registry is still add-provider-only.
expect(startSequenceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
}
});
});
describe("when the user finishes here", () => {
it("marks handled, starts no sequence, does not navigate, and closes the store", async () => {
const user = userEvent.setup();
checkpointOpenState = true;
render(<OnboardingCheckpointWatcher />);
await screen.findByText("Provider added — keep exploring?");
await user.click(screen.getByRole("button", { name: /finish here/i }));
expect(window.localStorage.getItem(CHECKPOINT_MARKER)).not.toBeNull();
expect(startSequenceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
await waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
});
});
});
@@ -1,320 +0,0 @@
import { act, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FIRST_RUN_MAX_ATTEMPTS,
isFirstRunHandled,
} from "@/lib/onboarding/first-run-marker";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_STEP,
WIZARD_OPEN_SOURCE,
} from "@/lib/provider-funnel/provider-funnel-events";
import { addProviderTour } from "@/lib/tours/add-provider.tour";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { OnboardingGate } from "../onboarding-gate";
const replaceMock = vi.fn();
const armMock = vi.fn();
const pathnameMock = vi.fn();
const permissionsMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: permissionsMock() }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
usePathname: () => pathnameMock(),
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
useOnboardingCheckpointStore: {
getState: () => ({ arm: armMock }),
},
}));
const addProviderTourId = {
id: addProviderTour.id,
version: addProviderTour.version,
};
const TENANT_A = "11111111-1111-4111-8111-111111111111";
const TENANT_B = "22222222-2222-4222-8222-222222222222";
const CLOUD_FIRST_RUN_HREF =
"/providers?addProvider=true&addProviderSource=first_run&onboarding=add-provider";
const OSS_FIRST_RUN_HREF =
"/providers?addProvider=true&addProviderSource=first_run";
describe("OnboardingGate", () => {
beforeEach(() => {
window.localStorage.clear();
replaceMock.mockClear();
armMock.mockClear();
pathnameMock.mockReturnValue("/");
permissionsMock.mockReturnValue({ manage_providers: true });
vi.stubEnv("UI_CLOUD_ENABLED", "true");
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it.each(["/billing", "/billing/", "/billing/checkout"])(
"defers the first run on %s without resolving it",
(pathname) => {
// Given
pathnameMock.mockReturnValue(pathname);
// When
render(<OnboardingGate hasProviders={false} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(armMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
},
);
it("sends the user to add a provider after leaving billing, without remounting the gate", async () => {
// Given
pathnameMock.mockReturnValue("/billing");
const { rerender } = render(<OnboardingGate hasProviders={false} />);
// When
pathnameMock.mockReturnValue("/");
rerender(<OnboardingGate hasProviders={false} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(CLOUD_FIRST_RUN_HREF),
);
});
it("does not defer on a route that only shares the billing prefix", async () => {
// Given
pathnameMock.mockReturnValue("/billing-history");
// When
render(<OnboardingGate hasProviders={false} />);
// Then
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
});
describe("when a Cloud tenant has no providers and never went through the first run", () => {
it("opens the add-provider wizard with its tour and arms the checkpoint", async () => {
// Given / When
render(<OnboardingGate hasProviders={false} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(
CLOUD_FIRST_RUN_HREF,
),
);
expect(armMock).toHaveBeenCalledOnce();
});
it("tries again on the next load when the wizard never opened (the navigation was cut short)", async () => {
// Given
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(
CLOUD_FIRST_RUN_HREF,
),
);
expect(isFirstRunHandled(TENANT_A)).toBe(false);
});
it("is resolved once the add-provider wizard opens, so later loads leave the user alone", async () => {
// Given
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
act(() => {
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED,
source: WIZARD_OPEN_SOURCE.FIRST_RUN,
});
});
unmount();
replaceMock.mockClear();
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
it("gives up after a few attempts that never reached the wizard, so no browser is trapped", async () => {
// Given: three loads whose navigation never completed.
for (let attempt = 0; attempt < FIRST_RUN_MAX_ATTEMPTS; attempt++) {
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
}
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
it.each(["true", "1legacy", "-1"])(
"honours a browser-wide marker holding %s, written before markers counted attempts",
(value) => {
// Given: e2e storage state and pre-existing browsers set the bare key.
window.localStorage.setItem("prowler.onboarding.first-run", value);
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled(TENANT_A)).toBe(true);
},
);
it("still runs for a different empty tenant on the same browser", async () => {
// Given: tenant A went through its first run on this browser.
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
act(() => {
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED,
source: WIZARD_OPEN_SOURCE.FIRST_RUN,
});
});
unmount();
replaceMock.mockClear();
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_B} />);
// Then
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(isFirstRunHandled(TENANT_B)).toBe(false);
});
});
describe("when a self-hosted deployment has no providers", () => {
it("opens the add-provider wizard without the Cloud-only tour or checkpoint", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(<OnboardingGate hasProviders={false} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(OSS_FIRST_RUN_HREF),
);
expect(armMock).not.toHaveBeenCalled();
});
it("tries again on the next load when the wizard never opened, with no tenant id available", async () => {
// Given: self-hosted layouts mount the gate without a tenant id.
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const { unmount } = render(<OnboardingGate hasProviders={false} />);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
// When
render(<OnboardingGate hasProviders={false} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(OSS_FIRST_RUN_HREF),
);
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the user cannot add providers", () => {
it("leaves the user where they are, since an empty list may just be limited visibility", () => {
// Given
permissionsMock.mockReturnValue({ manage_providers: false });
// When
render(<OnboardingGate hasProviders={false} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the tenant already has providers", () => {
it("leaves the user where they are", () => {
// Given / When
render(<OnboardingGate hasProviders />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the add-provider tour was already resolved in this browser", () => {
it("leaves the user where they are", () => {
// Given
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
// When
render(<OnboardingGate hasProviders={false} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(armMock).not.toHaveBeenCalled();
});
});
describe("when the provider count is unknown (fail-open)", () => {
it("leaves the user where they are when the fetch failed", () => {
// Given / When
render(<OnboardingGate hasProviders={undefined} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
});
it("can be mounted with the prop omitted entirely", () => {
// Given / When
render(<OnboardingGate />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
});
});
});
@@ -1,229 +0,0 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ONBOARDING_INVITE_STEP_EVENT,
type OnboardingInviteStepDetail,
} from "@/lib/onboarding/onboarding-events";
import { OnboardingInviteStep } from "../onboarding-invite-step";
const { getRolesMock, sendInviteMock, toastMock } = vi.hoisted(() => ({
getRolesMock: vi.fn(),
sendInviteMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/actions/invitations/roles", () => ({
getInvitationRoles: getRolesMock,
}));
vi.mock("@/actions/invitations/invitation", () => ({
sendInvite: sendInviteMock,
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/shadcn")>()),
useToast: () => ({ toast: toastMock }),
}));
// Radix Select does not open in jsdom; a native select keeps the test on the
// form's behaviour rather than the dropdown's.
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({
value,
onValueChange,
disabled,
children,
}: {
value?: string;
onValueChange: (value: string) => void;
disabled?: boolean;
children: React.ReactNode;
}) => (
<select
aria-label="Select a role"
value={value ?? ""}
disabled={disabled}
onChange={(event) => onValueChange(event.target.value)}
>
<option value="">Select a role</option>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => <option value={value}>{children}</option>,
}));
const ROLES = [
{ id: "11111111-1111-4111-8111-111111111111", name: "member" },
{ id: "22222222-2222-4222-8222-222222222222", name: "admin" },
];
describe("OnboardingInviteStep", () => {
const outcomes: OnboardingInviteStepDetail[] = [];
const recordOutcome = (event: Event) => {
outcomes.push((event as CustomEvent<OnboardingInviteStepDetail>).detail);
};
beforeEach(() => {
outcomes.length = 0;
window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome);
getRolesMock.mockReset().mockResolvedValue(ROLES);
// The API answers with the created record, token included.
sendInviteMock.mockReset().mockResolvedValue({
data: {
id: "inv-1",
attributes: { email: "teammate@company.com", token: "abc123DEF45678" },
},
});
toastMock.mockReset();
});
afterEach(() => {
window.removeEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome);
});
it("announces the impression and renders the invitation form once roles load", async () => {
// When
render(<OnboardingInviteStep onDone={vi.fn()} />);
// Then
expect(outcomes).toEqual([{ outcome: "shown" }]);
expect(await screen.findByText("Invite your team")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /send invitation/i }),
).toBeInTheDocument();
// The admin role is listed first so it is the natural pick.
const options = screen.getAllByRole("option").map((o) => o.textContent);
expect(options).toEqual(["Select a role", "admin", "member"]);
});
it("sends the invitation tagged for onboarding and resolves the step", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// When
await user.type(screen.getByLabelText(/email/i), "teammate@company.com");
await user.selectOptions(screen.getByRole("combobox"), ROLES[1].id);
await user.click(screen.getByRole("button", { name: /send invitation/i }));
// Then
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
const formData = sendInviteMock.mock.calls[0]?.[0] as FormData;
expect(formData.get("email")).toBe("teammate@company.com");
expect(formData.get("role")).toBe(ROLES[1].id);
expect(formData.get("source")).toBe("onboarding");
expect(outcomes).toEqual([{ outcome: "shown" }, { outcome: "submitted" }]);
});
it("keeps the step open when the API rejects the invitation", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
sendInviteMock.mockResolvedValue({
errors: [
{
detail: "This email has already been invited.",
source: { pointer: "/data/attributes/email" },
},
],
});
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// When
await user.type(screen.getByLabelText(/email/i), "teammate@company.com");
await user.selectOptions(screen.getByRole("combobox"), ROLES[1].id);
await user.click(screen.getByRole("button", { name: /send invitation/i }));
// Then
expect(
await screen.findByText("This email has already been invited."),
).toBeInTheDocument();
expect(onDone).not.toHaveBeenCalled();
expect(outcomes).toEqual([{ outcome: "shown" }]);
});
it("records a skip and resolves the step", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// When
await user.click(screen.getByRole("button", { name: "Skip for now" }));
// Then
expect(outcomes.at(-1)).toEqual({ outcome: "skipped" });
expect(onDone).toHaveBeenCalledTimes(1);
expect(sendInviteMock).not.toHaveBeenCalled();
});
it("still lets the user skip when roles cannot be loaded", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
getRolesMock.mockRejectedValue(new Error("roles unavailable"));
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// Then
expect(screen.getByText(/Roles could not be loaded/)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /send invitation/i }),
).not.toBeInTheDocument();
// When
await user.click(screen.getByRole("button", { name: "Skip for now" }));
// Then
expect(onDone).toHaveBeenCalledTimes(1);
});
it("falls back to the skip when the roles never arrive", async () => {
// Given — a request that never settles.
vi.useFakeTimers();
try {
getRolesMock.mockReturnValue(new Promise(() => {}));
render(<OnboardingInviteStep onDone={vi.fn()} />);
expect(screen.queryByText("Invite your team")).not.toBeInTheDocument();
// When — the step's own deadline passes.
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
// Then
expect(screen.getByText("Invite your team")).toBeInTheDocument();
expect(screen.getByText(/Roles could not be loaded/)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /send invitation/i }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Skip for now" }),
).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});
@@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import type { OnboardingFlow } from "@/lib/onboarding";
import { getSequenceProgress } from "../onboarding-sequence-banner.logic";
// Minimal fixtures — the progress helper only reads id/order/title/route and the optional dataRequirementHint.
const buildFlow = (overrides: Partial<OnboardingFlow>): OnboardingFlow =>
({
id: overrides.id ?? "flow",
order: overrides.order ?? 1,
title: overrides.title ?? "Title",
description: overrides.description ?? "Description",
route: overrides.route ?? "/route",
tour: overrides.tour ?? { id: "t", version: 1, coversFiles: [], steps: [] },
dataRequirementHint: overrides.dataRequirementHint,
}) as OnboardingFlow;
const flows: OnboardingFlow[] = [
buildFlow({ id: "a", order: 1, title: "First", route: "/a" }),
buildFlow({
id: "b",
order: 2,
title: "Second",
route: "/b",
dataRequirementHint: "needs a scan",
}),
buildFlow({ id: "c", order: 3, title: "Third", route: "/c" }),
];
describe("getSequenceProgress", () => {
it("computes the index, total, current flow, and next flow for a middle step", () => {
const progress = getSequenceProgress("b", flows);
// 0-based index 1 of 3, current is b, next is c
expect(progress).not.toBeNull();
expect(progress?.index).toBe(1);
expect(progress?.total).toBe(3);
expect(progress?.flow.id).toBe("b");
expect(progress?.nextFlow?.id).toBe("c");
});
it("returns a null nextFlow on the last step", () => {
const progress = getSequenceProgress("c", flows);
expect(progress?.index).toBe(2);
expect(progress?.total).toBe(3);
expect(progress?.nextFlow).toBeNull();
});
it("returns null when the currentFlowId is unknown or null", () => {
expect(getSequenceProgress("missing", flows)).toBeNull();
expect(getSequenceProgress(null, flows)).toBeNull();
});
it("exposes the data requirement hint of the current flow when present", () => {
const withHint = getSequenceProgress("b", flows);
const withoutHint = getSequenceProgress("a", flows);
expect(withHint?.flow.dataRequirementHint).toBe("needs a scan");
expect(withoutHint?.flow.dataRequirementHint).toBeUndefined();
});
});
@@ -1,193 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFlowById } from "@/lib/onboarding";
import { OnboardingSequenceBanner } from "../onboarding-sequence-banner";
const pushMock = vi.fn();
const advanceMock = vi.fn();
const stopMock = vi.fn();
// Mutable snapshot the mocked store hook returns; tests mutate via setSlice().
let sliceState = {
active: false,
currentFlowId: null as string | null,
advance: advanceMock,
stop: stopMock,
};
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
vi.mock("@/store/onboarding-sequence", () => {
const hook = (selector: (state: typeof sliceState) => unknown) =>
selector(sliceState);
hook.getState = () => sliceState;
return { useOnboardingSequenceStore: hook };
});
function setSlice(next: Partial<typeof sliceState>) {
sliceState = { ...sliceState, ...next };
}
describe("OnboardingSequenceBanner", () => {
beforeEach(() => {
pushMock.mockClear();
advanceMock.mockClear();
stopMock.mockClear();
sliceState = {
active: false,
currentFlowId: null,
advance: advanceMock,
stop: stopMock,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders nothing when the sequence is inactive", () => {
const { container } = render(<OnboardingSequenceBanner />);
expect(container).toBeEmptyDOMElement();
});
it("shows the step progress for the active flow", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const flow = getFlowById("view-first-scan")!;
render(<OnboardingSequenceBanner />);
expect(screen.getByText(`Step 2 of 5: ${flow.title}`)).toBeInTheDocument();
});
it("announces step progress to screen readers via a polite live region", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const flow = getFlowById("view-first-scan")!;
render(<OnboardingSequenceBanner />);
// Polite live region so screen readers announce step transitions on update.
const status = screen.getByRole("status");
expect(status).toHaveTextContent(`Step 2 of 5: ${flow.title}`);
expect(status).toHaveAttribute("aria-live", "polite");
});
it("does not show the data requirement hint on a scan-dependent step once a scan has finished", () => {
// The Continue gate already guarantees we only reach this step with data,
// so the "wait for findings" hint would be stale/misleading here.
setSlice({ active: true, currentFlowId: "explore-findings" });
render(<OnboardingSequenceBanner hasCompletedScan={true} />);
expect(
screen.queryByText(/wait for the scan to finish/i),
).not.toBeInTheDocument();
});
it("does not show a hint for a flow without one", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
render(<OnboardingSequenceBanner />);
expect(
screen.queryByText(/wait for the scan to finish/i),
).not.toBeInTheDocument();
});
it("advances and navigates to the next flow when Continue is clicked", async () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const nextFlow = getFlowById("explore-findings")!;
const user = userEvent.setup();
render(<OnboardingSequenceBanner />);
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(advanceMock).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith(nextFlow.route);
expect(stopMock).not.toHaveBeenCalled();
});
it("stops the sequence without navigating when Continue is clicked on the last step", async () => {
setSlice({ active: true, currentFlowId: "attack-paths" });
const user = userEvent.setup();
render(<OnboardingSequenceBanner />);
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(stopMock).toHaveBeenCalledTimes(1);
expect(pushMock).not.toHaveBeenCalled();
});
it("stops the sequence when Skip is clicked", async () => {
setSlice({ active: true, currentFlowId: "explore-findings" });
const user = userEvent.setup();
render(<OnboardingSequenceBanner />);
await user.click(screen.getByRole("button", { name: /skip/i }));
expect(stopMock).toHaveBeenCalledTimes(1);
expect(advanceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
describe("scan-gated Continue", () => {
it("disables Continue when the next step needs scan data and none has finished", () => {
// On the scan step, advancing would land on explore-findings (scan-dependent).
setSlice({ active: true, currentFlowId: "view-first-scan" });
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
// The next step's hint explains why progression is blocked.
expect(
screen.getByText(/wait for the scan to finish/i),
).toBeInTheDocument();
});
it("does not advance even if a disabled Continue is force-clicked", async () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const user = userEvent.setup();
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(advanceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
it("enables Continue once a scan has finished", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
render(<OnboardingSequenceBanner hasCompletedScan={true} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
});
it("surfaces the hint on a scan-dependent step itself when no scan has finished", () => {
// Edge case: if we somehow sit on findings without a completed scan, the
// next step (compliance) is still gated, so the hint explains the block.
setSlice({ active: true, currentFlowId: "explore-findings" });
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
expect(
screen.getByText(/wait for the scan to finish/i),
).toBeInTheDocument();
});
it("never gates Continue when the next step does not need scan data", () => {
// add-provider → view-first-scan: neither requires scan data.
setSlice({ active: true, currentFlowId: "add-provider" });
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
});
});
});
@@ -1,164 +0,0 @@
import { describe, expect, it } from "vitest";
import {
mapCloseToSequenceAction,
resolveTriggerRequest,
} from "../onboarding-trigger.logic";
describe("resolveTriggerRequest", () => {
describe("replay (param) path", () => {
it("requests a replay start when the param matches this flow", () => {
const result = resolveTriggerRequest({
param: "add-provider",
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "replay" });
});
it("takes precedence over the sequence when the param matches", () => {
const result = resolveTriggerRequest({
param: "add-provider",
replayRequestFlowId: null,
sliceActive: true,
currentFlowId: "add-provider",
flowId: "add-provider",
});
// Param takes precedence over sequence when both match.
expect(result).toEqual({ start: true, mode: "replay" });
});
it("takes precedence over an in-memory replay request", () => {
const result = resolveTriggerRequest({
param: "add-provider",
replayRequestFlowId: "add-provider",
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
// Both resolve to replay; the param branch wins but the mode is identical.
expect(result).toEqual({ start: true, mode: "replay" });
});
});
describe("replay (in-memory request) path", () => {
it("requests a replay start when the store names this flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: "add-provider",
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "replay" });
});
it("takes precedence over the sequence when the store names this flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: "add-provider",
sliceActive: true,
currentFlowId: "add-provider",
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "replay" });
});
it("does not start when the store names a different flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: "view-first-scan",
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toBeNull();
});
});
describe("sequence (slice) path", () => {
it("requests a sequence start when the active slice names this flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: true,
currentFlowId: "add-provider",
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "sequence" });
});
it("does not start when the active slice names a different flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: true,
currentFlowId: "view-first-scan",
flowId: "add-provider",
});
expect(result).toBeNull();
});
it("does not start when the slice is inactive even if the id matches", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: "add-provider",
flowId: "add-provider",
});
expect(result).toBeNull();
});
});
describe("no-match path", () => {
it("returns null when the param targets a different flow and the slice is inactive", () => {
const result = resolveTriggerRequest({
param: "view-first-scan",
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toBeNull();
});
it("returns null when neither the param, the store, nor the slice match", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toBeNull();
});
});
});
describe("mapCloseToSequenceAction", () => {
it("advances the sequence when the tour completes", () => {
expect(mapCloseToSequenceAction("completed")).toBe("advance");
});
it("stops the sequence when the tour is skipped", () => {
expect(mapCloseToSequenceAction("skipped")).toBe("stop");
});
it("stops the sequence when the tour is dismissed", () => {
expect(mapCloseToSequenceAction("dismissed")).toBe("stop");
});
});
@@ -1,318 +0,0 @@
import { render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFlowById } from "@/lib/onboarding";
import type { OnboardingSequenceMode } from "@/store/onboarding-sequence";
import { OnboardingTrigger } from "../onboarding-trigger";
const startMock = vi.fn();
const pushMock = vi.fn();
const advanceMock = vi.fn();
const stopMock = vi.fn();
const consumeMock = vi.fn();
// Counts hook re-invocations; the regression test uses this to confirm the runner stays mounted.
const useDriverTourMock = vi.fn();
// Captures onClosed so close→action wiring can be exercised without driver.js.
let capturedOnClosed: ((state: string) => void) | undefined;
let searchParamsValue = new URLSearchParams();
let sliceState = {
active: false,
currentFlowId: null as string | null,
mode: null as OnboardingSequenceMode | null,
advance: advanceMock,
stop: stopMock,
};
let replayState = {
flowId: null as string | null,
token: 0,
consume: consumeMock,
};
vi.mock("next/navigation", () => ({
useSearchParams: () => searchParamsValue,
usePathname: () => "/providers",
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
}));
vi.mock("@/lib/tours/use-driver-tour", () => ({
useDriverTour: (
_tour: unknown,
options: { onClosed?: (state: string) => void },
) => {
useDriverTourMock();
capturedOnClosed = options?.onClosed;
return { start: startMock, stop: vi.fn(), hasCompleted: false };
},
}));
vi.mock("@/store/onboarding-sequence", () => {
const hook = (selector: (state: typeof sliceState) => unknown) =>
selector(sliceState);
hook.getState = () => sliceState;
return { useOnboardingSequenceStore: hook };
});
vi.mock("@/store/onboarding-replay", () => {
const hook = (selector: (state: typeof replayState) => unknown) =>
selector(replayState);
hook.getState = () => replayState;
return { useOnboardingReplayStore: hook };
});
const addProviderFlow = getFlowById("add-provider")!;
function setSlice(next: Partial<typeof sliceState>) {
sliceState = { ...sliceState, ...next };
}
function setReplay(next: Partial<typeof replayState>) {
replayState = { ...replayState, ...next };
}
describe("OnboardingTrigger", () => {
beforeEach(() => {
startMock.mockClear();
pushMock.mockClear();
advanceMock.mockClear();
stopMock.mockClear();
consumeMock.mockClear();
useDriverTourMock.mockClear();
capturedOnClosed = undefined;
// Trigger only resolves in cloud.
vi.stubEnv("UI_CLOUD_ENABLED", "true");
searchParamsValue = new URLSearchParams();
sliceState = {
active: false,
currentFlowId: null,
mode: null,
advance: advanceMock,
stop: stopMock,
};
replayState = { flowId: null, token: 0, consume: consumeMock };
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("when the onboarding param matches this flow (replay)", () => {
it("force-starts the tour and strips the param", async () => {
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
// Param is stripped via history.replaceState (no router round-trip).
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
window.location.pathname,
),
);
});
it("starts at the step the page asks for, skipping the ones before it", async () => {
// Given
searchParamsValue = new URLSearchParams("onboarding=add-provider");
// When
render(
<OnboardingTrigger
flow={addProviderFlow}
startAtTarget="provider-type"
/>,
);
// Then
await waitFor(() =>
expect(startMock).toHaveBeenCalledExactlyOnceWith("provider-type"),
);
});
it("strips only the onboarding param and preserves other query params", async () => {
searchParamsValue = new URLSearchParams(
"scanId=scan-1&onboarding=add-provider&tab=completed",
);
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
`${window.location.pathname}?scanId=scan-1&tab=completed`,
),
);
});
});
describe("when the in-memory replay store names this flow", () => {
it("force-starts the tour, consumes the request, and never touches the URL", async () => {
// Same-route navbar replay: started via the store, no `?onboarding=` param.
setReplay({ flowId: "add-provider", token: 1 });
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(consumeMock).toHaveBeenCalledTimes(1));
// No URL param to strip, and no router round-trip → no RSC refetch.
expect(replaceStateSpy).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when the sequence names this flow", () => {
it("force-starts the tour without stripping any param", async () => {
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
expect(replaceStateSpy).not.toHaveBeenCalled();
});
});
describe("when neither the param nor the sequence names this flow", () => {
it("renders null and does not start the tour", async () => {
searchParamsValue = new URLSearchParams();
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const { container } = render(
<OnboardingTrigger flow={addProviderFlow} />,
);
expect(container).toBeEmptyDOMElement();
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
expect(replaceStateSpy).not.toHaveBeenCalled();
});
});
describe("when the onboarding param targets a different flow", () => {
it("does not start the tour", async () => {
searchParamsValue = new URLSearchParams("onboarding=explore-findings");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
});
});
describe("in self-hosted (OSS) deployments", () => {
it("renders null and never starts the tour, even with a matching param", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const { container } = render(
<OnboardingTrigger flow={addProviderFlow} />,
);
expect(container).toBeEmptyDOMElement();
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
});
it("ignores an active sequence slice in OSS", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
const { container } = render(
<OnboardingTrigger flow={addProviderFlow} />,
);
expect(container).toBeEmptyDOMElement();
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
});
});
describe("when a sequence tour completes", () => {
it("leaves the sequence slice untouched (the banner owns advance now)", async () => {
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(capturedOnClosed).toBeDefined());
capturedOnClosed?.("completed");
// Banner is the sole advance/exit control; closing the tour must not auto-advance.
expect(advanceMock).not.toHaveBeenCalled();
expect(stopMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when a sequence tour is dismissed", () => {
it("leaves the sequence slice untouched (no auto-stop on close)", async () => {
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(capturedOnClosed).toBeDefined());
capturedOnClosed?.("skipped");
// Only the banner Exit button ends the sequence; closing the tour must not auto-stop.
expect(stopMock).not.toHaveBeenCalled();
expect(advanceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when a replay tour closes", () => {
it("does not touch the sequence slice", async () => {
searchParamsValue = new URLSearchParams("onboarding=add-provider");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(capturedOnClosed).toBeDefined());
capturedOnClosed?.("completed");
// Single-flow replay never advances or stops the sequence.
expect(advanceMock).not.toHaveBeenCalled();
expect(stopMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when the param is cleared after the tour starts (regression)", () => {
it("keeps the runner mounted and does not restart the tour", async () => {
// StrictMode-safe latch: stripping the param must not unmount the runner or re-start the tour.
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const { rerender } = render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
const callsBeforeClear = useDriverTourMock.mock.calls.length;
searchParamsValue = new URLSearchParams();
rerender(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() =>
expect(useDriverTourMock.mock.calls.length).toBeGreaterThan(
callsBeforeClear,
),
);
expect(startMock).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,30 +0,0 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePageReadyStore } from "@/store/page-ready";
import { PageReady } from "../page-ready";
vi.mock("next/navigation", () => ({
usePathname: () => "/compliance",
}));
describe("PageReady", () => {
beforeEach(() => usePageReadyStore.setState({ readyPath: null }));
it("marks the current route ready on mount", () => {
render(<PageReady />);
expect(usePageReadyStore.getState().readyPath).toBe("/compliance");
});
it("clears readiness on unmount", () => {
const { unmount } = render(<PageReady />);
unmount();
expect(usePageReadyStore.getState().readyPath).toBeNull();
});
it("renders nothing", () => {
const { container } = render(<PageReady />);
expect(container).toBeEmptyDOMElement();
});
});
-5
View File
@@ -1,5 +0,0 @@
export { OnboardingCheckpointWatcher } from "./onboarding-checkpoint-watcher";
export { OnboardingGate } from "./onboarding-gate";
export { OnboardingSequenceBanner } from "./onboarding-sequence-banner";
export { OnboardingTrigger } from "./onboarding-trigger";
export { PageReady } from "./page-ready";
@@ -1,38 +0,0 @@
"use client";
import { Button } from "@/components/shadcn";
import { DialogFooter } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal/modal";
interface OnboardingCheckpointDialogProps {
open: boolean;
onContinue: () => void;
onFinish: () => void;
}
export function OnboardingCheckpointDialog({
open,
onContinue,
onFinish,
}: OnboardingCheckpointDialogProps) {
return (
<Modal
open={open}
title="Provider added — keep exploring?"
description="Your first provider is added. Want a quick guided tour of scans, findings, compliance, and attack paths? You can stop anytime."
size="lg"
// Overlay/Escape/X maps to "Finish here" — persists the handled marker once.
onOpenChange={(next) => {
if (!next) onFinish();
}}
>
<DialogFooter>
{/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
<Button variant="outline" onClick={onFinish}>
Finish here
</Button>
<Button onClick={onContinue}>Continue the tour</Button>
</DialogFooter>
</Modal>
);
}
@@ -1,99 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getOrderedFlows } from "@/lib/onboarding";
import {
isOnboardingInviteHandled,
markOnboardingInviteHandled,
} from "@/lib/onboarding/invite-marker";
import {
CHECKPOINT_MARKER,
useOnboardingCheckpointStore,
} from "@/store/onboarding-checkpoint";
import { useOnboardingSequenceStore } from "@/store/onboarding-sequence";
import { OnboardingCheckpointDialog } from "./onboarding-checkpoint-dialog";
// Loaded on demand: the step pulls the invitation form and its server
// actions, which this module (re-exported by the shared barrel) must not
// carry statically.
const OnboardingInviteStep = dynamic(
() =>
import("./onboarding-invite-step").then(
(module) => module.OnboardingInviteStep,
),
{ ssr: false },
);
interface OnboardingCheckpointWatcherProps {
// Scopes the invite step's local marker: the offer is per tenant, not per
// browser. Without it the step is not offered.
tenantId?: string | null;
}
// Sequence begins at the flow after `add-provider` (the gate).
const FIRST_FLOW_ID = "add-provider";
function markCheckpointHandled(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CHECKPOINT_MARKER, "true");
} catch {
// Non-fatal: a re-shown checkpoint beats a thrown render.
}
}
// Layout-level watcher: renders the checkpoint dialog when the store `open` flag is set.
export function OnboardingCheckpointWatcher({
tenantId = null,
}: OnboardingCheckpointWatcherProps = {}) {
const router = useRouter();
const open = useOnboardingCheckpointStore((state) => state.open);
// Session flag: the marker is written on resolve, but a state change is
// what re-renders this component into the checkpoint dialog.
const [inviteResolved, setInviteResolved] = useState(false);
const handleContinue = () => {
markCheckpointHandled(); // before navigation to prevent re-open on re-render
useOnboardingCheckpointStore.getState().close();
// Start at the flow immediately after the gate, not just any non-gate flow:
// a future registry insertion before it must not be skipped past.
const ordered = getOrderedFlows();
const gateIndex = ordered.findIndex((flow) => flow.id === FIRST_FLOW_ID);
const nextFlow = gateIndex >= 0 ? ordered[gateIndex + 1] : undefined;
if (!nextFlow) return;
useOnboardingSequenceStore.getState().startSequence(nextFlow.id);
router.push(nextFlow.route);
};
const handleFinish = () => {
markCheckpointHandled();
useOnboardingCheckpointStore.getState().close();
};
// "Invite your team" goes first, once per tenant, and leaves the store
// open, so the checkpoint dialog follows unchanged once it resolves.
if (open && !inviteResolved && !isOnboardingInviteHandled(tenantId)) {
return (
<OnboardingInviteStep
onDone={() => {
markOnboardingInviteHandled(tenantId);
setInviteResolved(true);
}}
/>
);
}
return (
<OnboardingCheckpointDialog
open={open}
onContinue={handleContinue}
onFinish={handleFinish}
/>
);
}
@@ -1,68 +0,0 @@
"use client";
import { SendInvitationForm } from "@/components/invitations/workflow/forms/send-invitation-form";
import { Button } from "@/components/shadcn";
import { DialogFooter } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal/modal";
import { orderRolesAdminFirst } from "@/lib/invitations/order-roles";
import {
INVITATION_SOURCE,
type InvitationRoleOption,
} from "@/types/onboarding-invite";
interface OnboardingInviteDialogProps {
open: boolean;
roles: InvitationRoleOption[];
onSent: (invitationId: string) => void;
onSkip: () => void;
}
// "Invite your team", offered once right after the first provider is
// connected: permissions on the cloud were just granted and the value of
// sharing the first scan is fresh. Reuses the members-page form, tagged as an
// onboarding invitation.
export function OnboardingInviteDialog({
open,
roles,
onSent,
onSkip,
}: OnboardingInviteDialogProps) {
const hasRoles = roles.length > 0;
return (
<Modal
open={open}
title="Invite your team"
description="Security is a team effort. Invite a teammate now so they see the first scan with you. You can skip this."
size="lg"
// Overlay/Escape/X counts as a skip — the caller persists the marker once.
onOpenChange={(next) => {
if (!next) onSkip();
}}
>
<div className="flex flex-col gap-4">
{hasRoles ? (
<SendInvitationForm
// Admin first: the natural pick for a first teammate; the form
// itself keeps the selection required.
roles={orderRolesAdminFirst(roles)}
isSelectorDisabled={false}
source={INVITATION_SOURCE.ONBOARDING}
onSuccess={onSent}
/>
) : (
<p className="text-text-neutral-secondary text-sm">
Roles could not be loaded right now. You can invite your team later
from the Invitations page.
</p>
)}
<DialogFooter>
{/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
<Button type="button" variant="outline" onClick={onSkip}>
Skip for now
</Button>
</DialogFooter>
</div>
</Modal>
);
}
@@ -1,49 +0,0 @@
"use client";
import { useInvitationRoles } from "@/hooks/use-invitation-roles";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
dispatchOnboardingInviteStep,
ONBOARDING_STEP_OUTCOME,
} from "@/lib/onboarding/onboarding-events";
import { OnboardingInviteDialog } from "./onboarding-invite-dialog";
interface OnboardingInviteStepProps {
onDone: () => void;
}
// Mounted only while the step is showing: loads the roles once, announces
// the impression once, and resolves through a sent invitation or a skip.
export function OnboardingInviteStep({ onDone }: OnboardingInviteStepProps) {
// `null` until the roles settle: the invitation form takes its default
// role from the list at mount, so the dialog renders once the list is known.
// Without roles the dialog offers only the skip, so the checkpoint is never
// blocked: not by a failed read, not by one that never answers.
const roles = useInvitationRoles();
useMountEffect(() => {
dispatchOnboardingInviteStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN });
});
if (roles === null) return null;
return (
<OnboardingInviteDialog
open
roles={roles}
onSent={() => {
dispatchOnboardingInviteStep({
outcome: ONBOARDING_STEP_OUTCOME.SUBMITTED,
});
onDone();
}}
onSkip={() => {
dispatchOnboardingInviteStep({
outcome: ONBOARDING_STEP_OUTCOME.SKIPPED,
});
onDone();
}}
/>
);
}
@@ -1,32 +0,0 @@
import {
getOrderedFlows,
type OnboardingFlow,
onboardingFlows,
} from "@/lib/onboarding";
// Framework-free projection of the active sequence position — unit-testable without React.
export interface SequenceProgress {
index: number; // 0-based
total: number;
flow: OnboardingFlow;
nextFlow: OnboardingFlow | null; // null when on the last step
}
// Returns null when `currentFlowId` is absent or not in the registry.
export function getSequenceProgress(
currentFlowId: string | null,
flows: readonly OnboardingFlow[] = onboardingFlows,
): SequenceProgress | null {
if (!currentFlowId) return null;
const ordered = getOrderedFlows(flows);
const index = ordered.findIndex((flow) => flow.id === currentFlowId);
if (index < 0) return null;
return {
index,
total: ordered.length,
flow: ordered[index],
nextFlow: ordered[index + 1] ?? null,
};
}
@@ -1,100 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/shadcn";
import { cn } from "@/lib/utils";
import { useOnboardingSequenceStore } from "@/store/onboarding-sequence";
import { getSequenceProgress } from "./onboarding-sequence-banner.logic";
interface OnboardingSequenceBannerProps {
// Defaults to true (fail-open) when scan state is unknown, so the banner never
// wrongly blocks progression.
hasCompletedScan?: boolean;
}
// Non-blocking bottom banner for an active sequence. Owns advance/exit; the per-route
// tour only shows on arrival and no longer auto-advances on close.
export function OnboardingSequenceBanner({
hasCompletedScan = true,
}: OnboardingSequenceBannerProps = {}) {
const router = useRouter();
const active = useOnboardingSequenceStore((state) => state.active);
const currentFlowId = useOnboardingSequenceStore(
(state) => state.currentFlowId,
);
const progress = getSequenceProgress(currentFlowId);
if (!active || !progress) return null;
const { index, total, flow, nextFlow } = progress;
// Block advancing into a scan-dependent step (e.g. findings, compliance) until a
// scan has finished — those steps have no data to show otherwise.
const continueDisabled =
hasCompletedScan === false && Boolean(nextFlow?.dataRequirementHint);
// Only surface a hint to explain why Continue is disabled. The gate already
// guarantees scan-dependent steps are reached with data, so showing a step's
// own "wait for findings" hint once we're there would be stale/misleading.
const hint = continueDisabled ? nextFlow?.dataRequirementHint : undefined;
const handleContinue = () => {
if (continueDisabled) return;
const sequence = useOnboardingSequenceStore.getState();
if (!nextFlow) {
sequence.stop(); // last step — end in place
return;
}
sequence.advance();
router.push(nextFlow.route);
};
const handleExit = () => {
useOnboardingSequenceStore.getState().stop();
};
return (
<div
role="region"
aria-label="Onboarding tour progress"
className={cn(
"fixed inset-x-0 bottom-0 z-40",
"border-border-neutral-secondary bg-bg-neutral-secondary border-t",
"px-4 py-3 shadow-lg",
)}
>
{/* Everything aligned to the right edge: step text first, then the two buttons.
No max-width/centering — that left a gap between the content and the screen. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end sm:gap-6">
<div className="flex flex-col gap-1 sm:text-right">
{/* Polite live region: screen readers announce step transitions. */}
<p
role="status"
aria-live="polite"
className="text-text-neutral-primary text-sm font-medium"
>
Step {index + 1} of {total}: {flow.title}
</p>
{hint ? (
<p className="text-text-warning-primary text-xs">{hint}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Secondary action: skips the whole tour. Outline matches the app's modal Cancel variant. */}
<Button variant="outline" size="sm" onClick={handleExit}>
Skip
</Button>
<Button
variant="default"
size="sm"
disabled={continueDisabled}
onClick={handleContinue}
>
Continue
</Button>
</div>
</div>
</div>
);
}
@@ -1,48 +0,0 @@
import type { TourCompletionRecord } from "@/lib/tours/tour-types";
import type { OnboardingSequenceMode } from "@/store/onboarding-sequence";
// Flat, framework-free inputs for `resolveTriggerRequest` — unit-testable without React.
export interface TriggerRequestInput {
param: string | null; // `?onboarding=<id>` value, or null
replayRequestFlowId: string | null; // in-memory replay request (same-route navbar), or null
sliceActive: boolean;
currentFlowId: string | null; // flow the active sequence points at
flowId: string; // flow this route owns
}
// Resolved start request, or null when this route's flow should not start.
export interface TriggerRequest {
start: true;
mode: OnboardingSequenceMode;
}
// Replay (param or in-memory request) takes precedence over the sequence so a
// manual replay is never hijacked. The in-memory request is how the navbar starts
// a same-route replay without a `?onboarding=` URL param (which would force an RSC refetch).
export function resolveTriggerRequest({
param,
replayRequestFlowId,
sliceActive,
currentFlowId,
flowId,
}: TriggerRequestInput): TriggerRequest | null {
if (param === flowId) {
return { start: true, mode: "replay" };
}
if (replayRequestFlowId === flowId) {
return { start: true, mode: "replay" };
}
if (sliceActive && currentFlowId === flowId) {
return { start: true, mode: "sequence" };
}
return null;
}
// completed → advance the sequence; anything else → stop.
export type SequenceCloseAction = "advance" | "stop";
export function mapCloseToSequenceAction(
state: TourCompletionRecord["state"],
): SequenceCloseAction {
return state === "completed" ? "advance" : "stop";
}
@@ -1,171 +0,0 @@
"use client";
import type { Config } from "driver.js";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { type OnboardingFlow } from "@/lib/onboarding";
import { isCloud } from "@/lib/shared/env";
import type { TourStepHandlers } from "@/lib/tours/tour-types";
import { useDriverTour } from "@/lib/tours/use-driver-tour";
import { useOnboardingReplayStore } from "@/store/onboarding-replay";
import {
type OnboardingSequenceMode,
useOnboardingSequenceStore,
} from "@/store/onboarding-sequence";
import { resolveTriggerRequest } from "./onboarding-trigger.logic";
const ONBOARDING_PARAM = "onboarding";
function stripOnboardingParamFromLocation(queryString: string) {
const params = new URLSearchParams(queryString);
params.delete(ONBOARDING_PARAM);
const query = params.toString();
window.history.replaceState(
null,
"",
`${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`,
);
}
interface OnboardingTriggerProps<TTarget extends string = string> {
flow: OnboardingFlow; // force-started when the sequence names it or `?onboarding=<id>` matches
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
configOverrides?: Partial<Config>;
// Step to begin from when the page already did what the earlier steps ask for.
startAtTarget?: TTarget;
}
// Latched per-trigger: `key` mounts a fresh runner on each re-trigger; `mode` drives param-strip logic.
interface OnboardingRequest {
flow: OnboardingFlow;
key: number;
mode: OnboardingSequenceMode;
queryString: string;
}
// Per-route trigger: derives via `resolveTriggerRequest` whether this flow should start,
// then mounts a keyed runner. Renders nothing.
export function OnboardingTrigger<TTarget extends string = string>({
flow,
stepHandlers,
configOverrides,
startAtTarget,
}: OnboardingTriggerProps<TTarget>) {
const searchParams = useSearchParams();
const param = searchParams?.get(ONBOARDING_PARAM) ?? null; // null outside Suspense context
const queryString = searchParams?.toString() ?? "";
const sliceActive = useOnboardingSequenceStore((state) => state.active);
const currentFlowId = useOnboardingSequenceStore(
(state) => state.currentFlowId,
);
// In-memory replay request (same-route navbar click): starts the tour without a
// `?onboarding=` URL param, so Next.js never refetches the page. `token` bumps on
// every request so re-clicking the same flow yields a fresh signal.
const replayRequestFlowId = useOnboardingReplayStore((state) => state.flowId);
const replayToken = useOnboardingReplayStore((state) => state.token);
// Cloud-only: in OSS the tour never resolves, so a manual `?onboarding=` URL can't start it.
const resolved = isCloud()
? resolveTriggerRequest({
param,
replayRequestFlowId,
sliceActive,
currentFlowId,
flowId: flow.id,
})
: null;
// Token is part of the signal so a repeat same-route replay re-mints the key.
// For param/sequence starts the token is stable, so behaviour is unchanged.
const signal = resolved ? `${flow.id}:${resolved.mode}:${replayToken}` : null;
// Latched via "adjust state while rendering" (no useEffect): mints a fresh key on each
// new truthy signal; when signal drops to null (param stripped) the runner stays mounted.
const [request, setRequest] = useState<OnboardingRequest | null>(null);
const [lastSignal, setLastSignal] = useState<string | null>(null);
if (signal !== lastSignal) {
setLastSignal(signal);
if (resolved) {
setRequest((prev) => ({
flow,
key: (prev?.key ?? 0) + 1,
mode: resolved.mode,
queryString,
}));
}
}
if (!request) return null;
// Keyed runner: fresh mount per re-trigger; `useDriverTour` is unconditional so hooks rules hold.
return (
<OnboardingTourRunner<TTarget>
key={request.key}
flow={request.flow}
mode={request.mode}
queryString={request.queryString}
stepHandlers={stepHandlers}
configOverrides={configOverrides}
startAtTarget={startAtTarget}
/>
);
}
interface OnboardingTourRunnerProps<TTarget extends string> {
flow: OnboardingFlow;
mode: OnboardingSequenceMode;
queryString: string;
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
configOverrides?: Partial<Config>;
startAtTarget?: TTarget;
}
function OnboardingTourRunner<TTarget extends string>({
flow,
mode,
queryString,
stepHandlers,
configOverrides,
startAtTarget,
}: OnboardingTourRunnerProps<TTarget>) {
// onClosed is intentionally inert — the banner owns advance/exit for both modes.
const { start } = useDriverTour(flow.tour, {
autoOpen: false,
stepHandlers,
configOverrides,
onClosed: () => {},
});
// replaceState (not router.replace) avoids a useSearchParams re-trigger that would
// drop the latched runner. The microtask keeps driver.js/flushSync outside
// React's mount lifecycle.
useMountEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
start(startAtTarget);
if (mode === "replay") {
// Only strip when the param actually started this replay; a same-route
// in-memory request leaves the URL untouched (no replaceState needed).
if (new URLSearchParams(queryString).has(ONBOARDING_PARAM)) {
stripOnboardingParamFromLocation(queryString);
}
// Clear any in-memory request so returning to this page won't auto-replay.
useOnboardingReplayStore.getState().consume();
}
});
return () => {
cancelled = true;
};
});
return null;
}
-25
View File
@@ -1,25 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { usePageReadyStore } from "@/store/page-ready";
/**
* Invisible marker rendered inside a page's post-Suspense (data-loaded) content.
* While it is unmounted the route counts as "still loading", which the navbar uses
* to keep the product-tour replay icon disabled until the page's requests resolve.
* Mounting marks the route ready; unmounting (navigation / in-page re-suspense) clears it.
*/
export function PageReady() {
const pathname = usePathname();
const markReady = usePageReadyStore((state) => state.markReady);
const clearReady = usePageReadyStore((state) => state.clearReady);
useMountEffect(() => {
markReady(pathname);
return () => clearReady(pathname);
});
return null;
}
@@ -23,9 +23,7 @@ export const AddProviderButton = ({ onOpenWizard }: AddProviderButtonProps) => {
return (
<>
<Button data-tour-id="add-provider-trigger" onClick={handleOpen}>
Add Provider
</Button>
<Button onClick={handleOpen}>Add Provider</Button>
{!onOpenWizard && (
<ProviderWizardModal open={open} onOpenChange={setOpen} />
)}
@@ -0,0 +1,266 @@
import { act, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FIRST_RUN_MAX_ATTEMPTS,
isFirstRunHandled,
} from "@/lib/provider-funnel/first-run-marker";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_STEP,
WIZARD_OPEN_SOURCE,
} from "@/lib/provider-funnel/provider-funnel-events";
import { FirstRunRedirect } from "./first-run-redirect";
const replaceMock = vi.fn();
const pathnameMock = vi.fn();
const permissionsMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: permissionsMock() }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
usePathname: () => pathnameMock(),
}));
const TENANT_A = "11111111-1111-4111-8111-111111111111";
const TENANT_B = "22222222-2222-4222-8222-222222222222";
const FIRST_RUN_HREF =
"/providers?addProvider=true&addProviderSource=first_run";
const openWizard = () =>
act(() => {
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED,
source: WIZARD_OPEN_SOURCE.FIRST_RUN,
});
});
describe("FirstRunRedirect", () => {
beforeEach(() => {
window.localStorage.clear();
replaceMock.mockClear();
pathnameMock.mockReturnValue("/");
permissionsMock.mockReturnValue({ manage_providers: true });
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("when the tenant has no providers and never went through the first run", () => {
it.each(["true", "false"])(
"opens the add-provider wizard (UI_CLOUD_ENABLED=%s)",
async (cloudEnabled) => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", cloudEnabled);
// When
render(<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(FIRST_RUN_HREF),
);
},
);
it("tries again on the next load when the wizard never opened (the navigation was cut short)", async () => {
// Given
const { unmount } = render(
<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
// When
render(<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(FIRST_RUN_HREF),
);
expect(isFirstRunHandled(TENANT_A)).toBe(false);
});
it("is resolved once the add-provider wizard opens, so later loads leave the user alone", async () => {
// Given
const { unmount } = render(
<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
openWizard();
unmount();
replaceMock.mockClear();
// When
render(<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
it("gives up after a few attempts that never reached the wizard, so no browser is trapped", async () => {
// Given: three loads whose navigation never completed.
for (let attempt = 0; attempt < FIRST_RUN_MAX_ATTEMPTS; attempt++) {
const { unmount } = render(
<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
}
// When
render(<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
it.each(["true", "1legacy", "-1"])(
"honours a browser-wide marker holding %s",
(value) => {
// Given: e2e storage state sets the bare key.
window.localStorage.setItem("prowler.first-run", value);
// When
render(<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled(TENANT_A)).toBe(true);
},
);
it("still runs for a different empty tenant on the same browser", async () => {
// Given: tenant A went through its first run on this browser.
const { unmount } = render(
<FirstRunRedirect hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
openWizard();
unmount();
replaceMock.mockClear();
// When
render(<FirstRunRedirect hasProviders={false} tenantId={TENANT_B} />);
// Then
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(isFirstRunHandled(TENANT_B)).toBe(false);
});
it("tries again on the next load with no tenant id available", async () => {
// Given
const { unmount } = render(<FirstRunRedirect hasProviders={false} />);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
// When
render(<FirstRunRedirect hasProviders={false} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(FIRST_RUN_HREF),
);
expect(isFirstRunHandled()).toBe(false);
});
});
describe("on billing routes", () => {
it.each(["/billing", "/billing/", "/billing/checkout"])(
"defers the first run on %s without resolving it",
(pathname) => {
// Given
pathnameMock.mockReturnValue(pathname);
// When
render(<FirstRunRedirect hasProviders={false} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
},
);
it("sends the user to add a provider after leaving billing, without remounting", async () => {
// Given
pathnameMock.mockReturnValue("/billing");
const { rerender } = render(<FirstRunRedirect hasProviders={false} />);
// When
pathnameMock.mockReturnValue("/");
rerender(<FirstRunRedirect hasProviders={false} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(FIRST_RUN_HREF),
);
});
it("does not defer on a route that only shares the billing prefix", async () => {
// Given
pathnameMock.mockReturnValue("/billing-history");
// When
render(<FirstRunRedirect hasProviders={false} />);
// Then
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
});
});
describe("when the user cannot add providers", () => {
it("leaves the user where they are, since an empty list may just be limited visibility", () => {
// Given
permissionsMock.mockReturnValue({ manage_providers: false });
// When
render(<FirstRunRedirect hasProviders={false} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the tenant already has providers", () => {
it("leaves the user where they are", () => {
// Given / When
render(<FirstRunRedirect hasProviders />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the provider count is unknown (fail-open)", () => {
it("leaves the user where they are when the fetch failed", () => {
// Given / When
render(<FirstRunRedirect hasProviders={undefined} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
});
it("can be mounted with the prop omitted entirely", () => {
// Given / When
render(<FirstRunRedirect />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
});
});
});
@@ -4,16 +4,11 @@ import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
getOrderedFlows,
type OnboardingFlow,
shouldStartOnboarding,
} from "@/lib/onboarding";
import {
isFirstRunHandled,
markFirstRunHandled,
recordFirstRunAttempt,
} from "@/lib/onboarding/first-run-marker";
} from "@/lib/provider-funnel/first-run-marker";
import {
PROVIDER_FUNNEL_EVENT,
PROVIDER_FUNNEL_STEP,
@@ -21,12 +16,8 @@ import {
WIZARD_OPEN_SOURCE,
} from "@/lib/provider-funnel/provider-funnel-events";
import { buildAddProviderHref } from "@/lib/providers-navigation";
import { isCloud } from "@/lib/shared/env";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint";
interface OnboardingGateProps {
interface FirstRunRedirectProps {
// `undefined` = fetch failed/ambiguous; fail-open (never force the first run).
hasProviders?: boolean;
// Scopes the first-run marker so one tenant's first run never silences another's.
@@ -36,53 +27,37 @@ interface OnboardingGateProps {
// New-tenant gate. Mounted once in the layout: an empty tenant is sent straight to
// the add-provider wizard, retried per load until the wizard opens once for that
// tenant on this browser (bounded attempts). Renders nothing.
export function OnboardingGate({
export function FirstRunRedirect({
hasProviders,
tenantId = null,
}: OnboardingGateProps) {
}: FirstRunRedirectProps) {
const pathname = usePathname();
const { permissions } = useAuth();
// Billing must stay usable before onboarding; leaving it keeps the gate eligible.
// Billing must stay usable before a provider exists; leaving it keeps the gate eligible.
const isBillingRoute =
pathname === "/billing" || pathname?.startsWith("/billing/");
// Gate forces only the first flow (`add-provider`); remaining flows come via checkpoint/replay.
const flow = getOrderedFlows()[0] ?? null;
// Returns null on server/first render; the redirect re-reads storage before acting.
const completionRecord = useTourCompletion(flow?.tour ?? null);
// Strict `=== false`: an unknown count fails open. Limited-visibility users list
// zero providers in a tenant that is not empty, so the permission is required.
const shouldRedirect =
flow !== null &&
!isBillingRoute &&
shouldStartOnboarding({
hasProviders,
canManageProviders: permissions.manage_providers === true,
completionRecord,
});
hasProviders === false &&
permissions.manage_providers === true &&
!isBillingRoute;
if (!shouldRedirect) return null;
return <FirstRunRedirect flow={flow} tenantId={tenantId} />;
return <AddProviderRedirect tenantId={tenantId} />;
}
interface FirstRunRedirectProps {
flow: OnboardingFlow;
interface AddProviderRedirectProps {
tenantId: string | null;
}
function FirstRunRedirect({ flow, tenantId }: FirstRunRedirectProps) {
function AddProviderRedirect({ tenantId }: AddProviderRedirectProps) {
const router = useRouter();
useMountEffect(() => {
// Hydration renders with an empty completion snapshot, so decide from storage here.
const tourId = { id: flow.tour.id, version: flow.tour.version };
if (
isFirstRunHandled(tenantId) ||
localStorageAdapter.get(tourId) !== null
) {
return;
}
if (isFirstRunHandled(tenantId)) return;
// The wizard opening resolves the first run, whether this redirect got there
// or the user opened it on their own. Until then each load retries, bounded
@@ -96,14 +71,7 @@ function FirstRunRedirect({ flow, tenantId }: FirstRunRedirectProps) {
window.addEventListener(PROVIDER_FUNNEL_EVENT, resolveOnWizardOpened);
recordFirstRunAttempt(tenantId);
const addProviderHref = buildAddProviderHref(WIZARD_OPEN_SOURCE.FIRST_RUN);
if (!isCloud()) {
router.replace(addProviderHref);
} else {
// Tours and the post-connect checkpoint are Cloud-only.
useOnboardingCheckpointStore.getState().arm();
router.replace(`${addProviderHref}&onboarding=${flow.id}`);
}
router.replace(buildAddProviderHref(WIZARD_OPEN_SOURCE.FIRST_RUN));
return () =>
window.removeEventListener(PROVIDER_FUNNEL_EVENT, resolveOnWizardOpened);
@@ -13,8 +13,6 @@ const NO_PROVIDERS_ADDED_ACTION = {
interface NoProvidersAddedBaseProps {
containerClassName?: string;
// Tour anchor for the CTA; needed because this empty state replaces the table's AddProviderButton.
ctaTourId?: string;
}
interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
@@ -51,7 +49,6 @@ const renderCta = (props: NoProvidersAddedProps) => {
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
data-tour-id={props.ctaTourId}
size="lg"
onClick={props.onOpenWizard}
>
@@ -12,13 +12,11 @@ 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(),
@@ -34,14 +32,6 @@ 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" />,
}));
@@ -160,7 +150,6 @@ describe("ProvidersAccountsView", () => {
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
vi.restoreAllMocks();
providersAccountsTableSpy.mockClear();
onboardingTriggerSpy.mockClear();
searchParamsValue.current = "";
window.history.replaceState({}, "", "/");
});
@@ -397,45 +386,6 @@ describe("ProvidersAccountsView", () => {
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(
@@ -2,9 +2,8 @@
import { Info } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { useState } from "react";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { AddProviderButton } from "@/components/providers/add-provider-button";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
@@ -18,7 +17,6 @@ import type {
} 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,
@@ -31,14 +29,6 @@ import {
ADD_PROVIDER_SOURCE_PARAM,
resolveAddProviderSource,
} from "@/lib/providers-navigation";
import {
ADD_PROVIDER_TOUR_TARGETS,
addProviderTour,
} from "@/lib/tours/add-provider.tour";
import {
advanceActiveTourWhenReady,
getTourTargetSelector,
} from "@/lib/tours/use-driver-tour";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProviderGroup } from "@/types/components";
import {
@@ -52,21 +42,6 @@ import type {
} from "@/types/scan-configurations";
import type { ScanScheduleCapability } from "@/types/schedules";
const addProviderFlow = getFlowById("add-provider")!;
// Softer overlay for this onboarding tour — a heavy dim over the wizard feels harsh
// while the user fills the form. Module-level constant keeps the driver config stable
// across renders (useDriverTour recreates the driver when configOverrides identity
// changes).
const ADD_PROVIDER_TOUR_CONFIG = { overlayOpacity: 0.45 } as const;
// The tour's "trigger" step auto-advances when the wizard opens; this is the anchor
// it waits for (the provider-type selector inside the wizard).
const PROVIDER_TYPE_TOUR_SELECTOR = getTourTargetSelector(
addProviderTour.id,
ADD_PROVIDER_TOUR_TARGETS.PROVIDER_TYPE,
);
interface ProvidersAccountsViewProps {
isCloud: boolean;
filters: FilterOption[];
@@ -137,9 +112,6 @@ export function ProvidersAccountsView({
setOrgWizardInitialData(undefined);
setProviderWizardInitialData(initialData);
setIsProviderWizardOpen(true);
// If the add-provider tour is on its "trigger" step, opening the wizard is the
// advance signal: move to the provider-type step once it mounts. No-op otherwise.
advanceActiveTourWhenReady(PROVIDER_TYPE_TOUR_SELECTOR);
};
const openOrganizationWizard = (initialData: OrgWizardInitialData) => {
@@ -173,21 +145,6 @@ export function ProvidersAccountsView({
return (
<>
{/* Suspense required: OnboardingTrigger reads useSearchParams */}
<Suspense fallback={null}>
<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). */}
<PageReady />
{hasNoProviders ? (
<NoProvidersAdded
action="button"
@@ -195,7 +152,6 @@ export function ProvidersAccountsView({
onOpenWizard={() =>
openProviderWizard(WIZARD_OPEN_SOURCE.EMPTY_STATE)
}
ctaTourId="add-provider-trigger"
/>
) : (
<div className="flex flex-col gap-6">
@@ -17,9 +17,8 @@ import type { ProviderWizardInitialData } from "../types";
import { useProviderWizardController } from "./use-provider-wizard-controller";
const { refreshMock, requestOpenOnWizardCloseMock } = vi.hoisted(() => ({
const { refreshMock } = vi.hoisted(() => ({
refreshMock: vi.fn(),
requestOpenOnWizardCloseMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
@@ -28,14 +27,6 @@ vi.mock("next/navigation", () => ({
}),
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
useOnboardingCheckpointStore: {
getState: () => ({
requestOpenOnWizardClose: requestOpenOnWizardCloseMock,
}),
},
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: null,
@@ -58,11 +49,8 @@ describe("useProviderWizardController", () => {
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
vi.useRealTimers();
vi.clearAllMocks();
requestOpenOnWizardCloseMock.mockClear();
sessionStorage.clear();
localStorage.clear();
// Checkpoint is Cloud-only.
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
@@ -87,78 +75,6 @@ describe("useProviderWizardController", () => {
expect(refreshMock).toHaveBeenCalledTimes(1);
});
it("requests the onboarding checkpoint with providerConnected:true when a provider was created", () => {
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
act(() => {
useProviderWizardStore.getState().setProvider({
id: "provider-1",
type: "aws",
uid: "111111111111",
alias: "production",
});
});
act(() => {
result.current.handleClose();
});
// providerId is read before the store reset clears it.
expect(requestOpenOnWizardCloseMock).toHaveBeenCalledWith({
providerConnected: true,
});
});
it("requests the onboarding checkpoint with providerConnected:false when no provider was created", () => {
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
act(() => {
result.current.handleClose();
});
expect(requestOpenOnWizardCloseMock).toHaveBeenCalledWith({
providerConnected: false,
});
});
it("does not request the onboarding checkpoint in self-hosted (OSS) deployments", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
useProviderWizardController({
open: true,
onOpenChange,
}),
);
act(() => {
useProviderWizardStore.getState().setProvider({
id: "provider-1",
type: "aws",
uid: "111111111111",
alias: "production",
});
});
act(() => {
result.current.handleClose();
});
// Checkpoint stays untouched, but the close still refreshes.
expect(requestOpenOnWizardCloseMock).not.toHaveBeenCalled();
expect(refreshMock).toHaveBeenCalledTimes(1);
});
it("signals the step where the wizard was left and that no provider was created", () => {
// Given
const { result } = renderHook(() =>
@@ -9,9 +9,6 @@ import {
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";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import {
@@ -222,10 +219,6 @@ export function useProviderWizardController({
);
const handleClose = () => {
// Closing the wizard at any point ends the add-provider tour; the checkpoint
// logic below still drives the handoff to scans. No-op off-onboarding.
endActiveTour();
// Read providerId before reset clears it — non-null means a provider was connected.
const connectedProviderId = useProviderWizardStore.getState().providerId;
@@ -248,13 +241,6 @@ export function useProviderWizardController({
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
// Cloud-only; only fires if the store is armed (user started onboarding).
if (isCloud()) {
useOnboardingCheckpointStore.getState().requestOpenOnWizardClose({
providerConnected: connectedProviderId !== null,
});
}
if (refreshOnClose) {
router.refresh();
}
@@ -281,9 +267,6 @@ export function useProviderWizardController({
const openOrganizationsFlow = (
orgType: OrgFlowType = ORGANIZATION_TYPE.AWS,
) => {
// 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,
@@ -8,7 +8,6 @@ import {
PROVIDER_FUNNEL_EVENT,
type ProviderFunnelDetail,
} from "@/lib/provider-funnel/provider-funnel-events";
import { endActiveTour } from "@/lib/tours/use-driver-tour";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { useUIStore } from "@/store/ui/store";
import { CONNECTION_CHECK_STATUS } from "@/types/providers";
@@ -74,10 +73,6 @@ vi.mock(
vi.mock("@/hooks/use-scroll-hint", () => ({
useScrollHint: () => ({ showScrollHint: false }),
}));
vi.mock("@/lib/tours/use-driver-tour", () => ({
advanceActiveTour: vi.fn(),
endActiveTour: vi.fn(),
}));
vi.mock("./steps/credentials-step", () => ({
CredentialsStep: ({ onBack }: { onBack: () => void }) => (
<>
@@ -450,27 +445,6 @@ describe("provider wizard account creation", () => {
expect(screen.queryByText("Credential details")).not.toBeInTheDocument();
});
it("steps the tour aside once the account can be connected", async () => {
// Given
vi.mocked(endActiveTour).mockClear();
const user = await pickAws();
// When
await user.type(
screen.getByRole("textbox", { name: /Role ARN/ }),
ROLE_ARN,
);
// Then: the footer sits outside the tour's spotlight, so the tour ends
// right when the user is ready to press Connect account.
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Connect account" }),
).toBeEnabled(),
);
expect(endActiveTour).toHaveBeenCalled();
});
it("closes the wizard once a teammate has been invited to connect the account instead", async () => {
// Given: a user who can invite but cannot reach the account.
session.data = {
@@ -16,7 +16,6 @@ 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,
ORG_WIZARD_STEP,
@@ -157,12 +156,7 @@ export function ProviderWizardModal({
className="hidden w-[100px] min-w-0 shrink lg:block"
/>
{/* Anchors the add-provider tour's final step to the form column only, so
its popover has room on the left, under the stepper. */}
<div
data-tour-id="add-provider-wizard-body"
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="relative min-h-0 flex-1 overflow-hidden">
<div
ref={containerRef}
@@ -174,24 +168,17 @@ export function ProviderWizardModal({
initialProviderType={providerTypeHint}
onNext={() => {
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS);
// Reaching credentials is the tour's handoff point: end it so the
// user continues on their own. No-op off-onboarding.
endActiveTour();
}}
onCredentialsSaved={() => {
// AWS stored its credentials and tested the connection in this
// step, so it takes the same exit the test step took: an update
// closes the wizard, an add moves on to the launch step.
handleTestSuccess();
endActiveTour();
}}
onClose={handleClose}
onSelectOrganizations={openOrganizationsFlow}
onFooterChange={setFooterConfig}
onProviderTypeChange={(providerType) => {
// 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({
@@ -383,8 +370,7 @@ export function ProviderWizardModal({
{(resolvedFooterConfig.showBack ||
resolvedFooterConfig.showSecondaryAction ||
resolvedFooterConfig.showAction) && (
// Outside the tour's spotlight, yet the way forward: keep it clickable.
<div className="mt-8 pt-6" data-tour-interactive>
<div className="mt-8 pt-6">
<div className="flex items-center justify-between">
<div>
{resolvedFooterConfig.showBack && (
@@ -23,7 +23,6 @@ const {
updateCredentialsProvider,
testProviderConnection,
openCloudUpgradeMock,
endActiveTour,
getInvitationRoles,
sendInvite,
session,
@@ -34,7 +33,6 @@ const {
updateCredentialsProvider: vi.fn(),
testProviderConnection: vi.fn(),
openCloudUpgradeMock: vi.fn(),
endActiveTour: vi.fn(),
getInvitationRoles: vi.fn(),
sendInvite: vi.fn(),
// Mutable: only the permissions differ between suites.
@@ -49,7 +47,6 @@ const {
vi.mock("next-auth/react", () => ({
useSession: () => ({ data: session.data, status: "authenticated" }),
}));
vi.mock("@/lib/tours/use-driver-tour", () => ({ endActiveTour }));
vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles }));
vi.mock("@/actions/invitations/invitation", () => ({ sendInvite }));
vi.mock("@/actions/providers/providers", () => ({
@@ -804,7 +801,7 @@ describe("AwsConnectStep", () => {
await user.click(inviteRadio()!);
await user.click(inviteRadio()!);
// Then: the teammate form takes the step and the footer, the tour steps aside.
// Then: the teammate form takes the step and the footer.
expect(inviteRadio()).toBeChecked();
expect(
screen.queryByRole("textbox", { name: /Role ARN/ }),
@@ -815,7 +812,6 @@ describe("AwsConnectStep", () => {
expect(
screen.getByRole("button", { name: "Send invitation" }),
).toBeInTheDocument();
expect(endActiveTour).toHaveBeenCalled();
expect(
funnelSignals.filter((signal) => signal.step === "method_selected"),
).toEqual([
@@ -49,7 +49,6 @@ import {
PROVIDER_FUNNEL_STEP,
} from "@/lib/provider-funnel/provider-funnel-events";
import { testProviderConnection } from "@/lib/provider-helpers";
import { endActiveTour } from "@/lib/tours/use-driver-tour";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import type { AWSCredentials, AWSCredentialsRole } from "@/types";
import {
@@ -155,8 +154,6 @@ export function AwsConnectStep({
useProviderWizardStore
.getState()
.setAwsConnectDraft({ panel: AWS_CONNECT_PANEL.INVITE });
// Delegating diverges from the path the tour guides toward. No-op off-onboarding.
endActiveTour();
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED,
providerType: "aws",
@@ -6,7 +6,6 @@ import {
ConnectAccountForm,
ConnectAccountSuccessData,
} from "@/components/providers/workflow/forms";
import { endActiveTour } from "@/lib/tours/use-driver-tour";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { useUIStore } from "@/store/ui/store";
import { ORGANIZATION_TYPE, OrgFlowType } from "@/types/organizations";
@@ -77,11 +76,6 @@ export function ConnectStep({
};
useEffect(() => {
// The footer sits outside the tour's spotlight, so once the user can continue
// the tour has done its job and gets out of the way. No-op off-onboarding.
if (uiState.showAction && !uiState.actionDisabled && !uiState.isLoading) {
endActiveTour();
}
// Nothing left to submit once a teammate has been invited: the action closes.
const closes = uiState.actionKind === AWS_CONNECT_ACTION_KIND.CLOSE;
onFooterChange({
@@ -18,7 +18,7 @@ import {
INVITATION_SOURCE,
type InvitationRoleOption,
type SentInvitation,
} from "@/types/onboarding-invite";
} from "@/types/invitations";
import { getProviderDisplayName, type ProviderType } from "@/types/providers";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types";
@@ -6,7 +6,7 @@ import { useState } from "react";
import { useInvitationRoles } from "@/hooks/use-invitation-roles";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { orderRolesAdminFirst } from "@/lib/invitations/order-roles";
import type { SentInvitation } from "@/types/onboarding-invite";
import type { SentInvitation } from "@/types/invitations";
import type { ProviderType } from "@/types/providers";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types";
@@ -6,7 +6,7 @@ import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link";
import { isCloud } from "@/lib/shared/env";
import type { SentInvitation } from "@/types/onboarding-invite";
import type { SentInvitation } from "@/types/invitations";
import { getProviderDisplayName, type ProviderType } from "@/types/providers";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types";
@@ -439,7 +439,7 @@ export const ConnectAccountForm = ({
>
{/* Step 1: Provider selection */}
{prevStep === 1 && (
<div data-tour-id="add-provider-provider-type">
<div>
{registryDiscovery?.status ===
REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED && (
<RegistryUpgradePrompt />
@@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OnboardingFlow } from "@/lib/onboarding";
import { useScansStore } from "@/store";
import { ProviderProps } from "@/types";
@@ -18,10 +17,6 @@ const { scansFilterBarSpy } = vi.hoisted(() => ({
scansFilterBarSpy: vi.fn(),
}));
const { onboardingTriggerSpy } = vi.hoisted(() => ({
onboardingTriggerSpy: vi.fn(),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {};
@@ -108,26 +103,6 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
MutedFindingsConfigButton: () => <a href="/mutelist">Configure Mutelist</a>,
}));
vi.mock("@/components/onboarding", () => ({
OnboardingTrigger: (props: unknown) => {
onboardingTriggerSpy(props);
return <div data-testid="onboarding-trigger" />;
},
PageReady: () => <div data-testid="page-ready" />,
}));
interface OnboardingTriggerProps {
flow: OnboardingFlow;
}
const getTriggeredTourTargets = () => {
const triggerProps = onboardingTriggerSpy.mock.calls.at(-1)?.[0] as
| OnboardingTriggerProps
| undefined;
return triggerProps?.flow.tour.steps.map((step) => step.target);
};
const providers: ProviderProps[] = [
{
id: "provider-1",
@@ -603,116 +578,4 @@ describe("ScansPageShell", () => {
screen.queryByText("No Providers Configured"),
).not.toBeInTheDocument();
});
it("starts the view-first-scan tour when a provider is connected", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
});
it("uses only mounted tour targets when an active scan exists on the completed tab", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=completed";
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={1}
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(getTriggeredTourTargets()).toEqual([undefined, "launch", "tabs"]);
});
it("targets the running scan when its row is mounted on the in progress tab", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
searchParamsValue.current = "tab=active";
// When
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={1}
>
<div>Scans table</div>
</ScansPageShell>,
);
// Then
expect(getTriggeredTourTargets()).toEqual([
undefined,
"in-progress",
"launch",
]);
});
it("suppresses the view-first-scan tour when no provider is connected, since Launch Scan is disabled", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell
providers={disconnectedProviders}
hasManageScansPermission
>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
// The table (and therefore imported scans) must still render even with the tour suppressed.
expect(screen.getByText("Scans table")).toBeInTheDocument();
});
it("suppresses the view-first-scan tour when there are no providers", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={[]} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
});
it("suppresses the view-first-scan tour when a provider is connected but manage scans is missing", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission={false}>
<div>Scans table</div>
</ScansPageShell>,
);
// Launch Scan is disabled without manage_scans, so the tour must not anchor to it.
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
// The table (and therefore imported scans) must still render.
expect(screen.getByText("Scans table")).toBeInTheDocument();
});
it("still signals page-ready without a connected provider so the navbar replay fallback works", () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
render(
<ScansPageShell providers={[]} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByTestId("page-ready")).toBeInTheDocument();
});
});
+3 -34
View File
@@ -1,9 +1,8 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { type ReactNode, Suspense, useState } from "react";
import { type ReactNode, useState } from "react";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import {
Button,
@@ -12,21 +11,17 @@ import {
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import { getFlowById } from "@/lib/onboarding";
import {
LAUNCH_SCAN_SEARCH_PARAM,
LAUNCH_SCAN_SEARCH_VALUE,
} from "@/lib/scans-navigation";
import { isCloud } from "@/lib/shared/env";
import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import type { ScanScheduleCapability } from "@/types/schedules";
const viewFirstScanFlow = getFlowById("view-first-scan")!;
import { CliImportBanner } from "./cli-import-banner";
import { ImportFindingsModal } from "./import-findings-modal";
import { LaunchScanModal } from "./launch-scan-modal";
@@ -73,17 +68,13 @@ export function ScansPageShell({
(provider) => provider.attributes.connection.connected === true,
);
const thereAreNoProviders = providers.length === 0;
// Non-blocking onboarding hint: shown when scans can't be launched (no provider, or
// Non-blocking hint: shown when scans can't be launched (no provider, or
// none connected). The table still renders below, so imported scans stay visible.
const showProvidersHint = thereAreNoProviders || !hasConnectedProviders;
const isCloudEnvironment = isCloud();
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
const launchOpen =
!launchDisabled && (isLaunchScanModalOpen || urlLaunchOpen);
// ScanJobsTable only mounts the in-progress row anchor on the active tab.
// Other tabs use the fallback tour so every target exists in the current DOM.
const hasVisibleInProgressScan =
activeScanCount > 0 && filters.activeTab === SCAN_JOBS_TAB.ACTIVE;
const getTabLabel = (tab: ScanJobsTab) => {
const label = SCAN_TAB_LABELS[tab];
@@ -112,24 +103,6 @@ export function ScansPageShell({
return (
<div className="flex flex-col gap-[18px]">
{/* Gate on the full launch condition: the tour anchors on Launch Scan, which is
disabled without a connected provider AND manage_scans — starting it would
point at an unusable button. Every start path (?onboarding= URL, active
sequence, navbar replay) funnels through this trigger, so gating here covers
them all. */}
{/* Suspense required: OnboardingTrigger reads useSearchParams */}
{!launchDisabled && (
<Suspense fallback={null}>
<OnboardingTrigger
flow={{
...viewFirstScanFlow,
tour: buildViewFirstScanTour(hasVisibleInProgressScan),
}}
/>
</Suspense>
)}
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
<PageReady />
{showProvidersHint && (
<ScansProvidersEmptyState thereIsNoProviders={thereAreNoProviders} />
)}
@@ -162,10 +135,7 @@ export function ScansPageShell({
aria-label="Scan tabs"
className="flex flex-wrap items-center justify-between gap-3"
>
<TabsList
className="w-full overflow-x-auto sm:w-auto"
data-tour-id="view-first-scan-tabs"
>
<TabsList className="w-full overflow-x-auto sm:w-auto">
{Object.values(SCAN_JOBS_TAB).map((tab) => (
<TabsTrigger key={tab} value={tab}>
{getTabLabel(tab as ScanJobsTab)}
@@ -178,7 +148,6 @@ export function ScansPageShell({
onClick={() => handleLaunchOpenChange(true)}
disabled={launchDisabled}
className="w-full sm:w-auto"
data-tour-id="view-first-scan-launch"
>
Launch Scan
</Button>
@@ -1,77 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SCAN_JOBS_TAB, type ScanProps } from "@/types";
import { ScanJobsTable } from "../scan-jobs-table";
// Mock DataTable to a minimal table that applies getRowAttributes per row, so we can
// assert the view-first-scan "in-progress" anchor lands on the right row/tab.
vi.mock("@/components/shadcn/table", () => ({
DataTable: ({
data,
getRowAttributes,
}: {
data?: ScanProps[];
getRowAttributes?: (row: {
index: number;
original: ScanProps;
}) => Record<string, string | undefined>;
}) => (
<table>
<tbody>
{(data ?? []).map((original, index) => (
<tr
key={index}
data-testid={`row-${index}`}
{...getRowAttributes?.({ index, original })}
>
<td>scan</td>
</tr>
))}
</tbody>
</table>
),
}));
// AutoRefresh polls via router on a timer; irrelevant to anchor placement.
vi.mock("../../auto-refresh", () => ({ AutoRefresh: () => null }));
vi.mock("../scan-jobs-columns", () => ({ getScanJobsColumns: () => [] }));
// Not rendered (data is non-empty), but mocked so its server/auth import chain
// doesn't load under vitest.
vi.mock("../../no-scans-empty-state", () => ({
NoScansEmptyState: () => null,
}));
const scan = (state: string): ScanProps =>
({ attributes: { state } }) as unknown as ScanProps;
describe("ScanJobsTable in-progress tour anchor", () => {
it("anchors the tour to the first row on the active (In Progress) tab", () => {
render(
<ScanJobsTable
data={[scan("executing"), scan("executing")]}
tab={SCAN_JOBS_TAB.ACTIVE}
/>,
);
// Only the first active-tab row carries the anchor — driver.js resolves the
// running scan from it.
expect(screen.getByTestId("row-0")).toHaveAttribute(
"data-tour-id",
"view-first-scan-in-progress",
);
expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
});
it("never anchors on a non-active tab (no running scan to point at)", () => {
render(
<ScanJobsTable
data={[scan("completed")]}
tab={SCAN_JOBS_TAB.COMPLETED}
/>,
);
expect(screen.getByTestId("row-0")).not.toHaveAttribute("data-tour-id");
});
});
@@ -9,7 +9,6 @@ import {
buildScanSummaryContext,
} from "@/lib/lighthouse/context/contributions";
import type { MetaDataProps, ScanJobsTab, ScanProps } from "@/types";
import { SCAN_JOBS_TAB } from "@/types";
import type { ScanScheduleCapability } from "@/types/schedules";
import { AutoRefresh } from "../auto-refresh";
@@ -82,14 +81,6 @@ export function ScanJobsTable({
columns={columns}
data={data}
metadata={meta}
// Anchor the view-first-scan tour's "in-progress" step to the first row of
// the active (In Progress) tab; absent on other tabs so the tour only points
// at a running scan.
getRowAttributes={(row) =>
row.index === 0 && tab === SCAN_JOBS_TAB.ACTIVE
? { "data-tour-id": "view-first-scan-in-progress" }
: {}
}
/>
)}
</>
@@ -17,25 +17,6 @@ describe("BreadcrumbNavigation", () => {
navigationMock.pathname = "/findings";
});
it("renders the title action next to the current breadcrumb title", () => {
// Given / When
render(
<BreadcrumbNavigation
mode="auto"
title="Findings"
titleAction={<button type="button">Start product tour</button>}
/>,
);
// Then
expect(
screen.getByRole("heading", { name: "Findings" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Start product tour" }),
).toBeInTheDocument();
});
it("renders the page icon next to a top-level title", () => {
// Given / When
render(
@@ -36,7 +36,6 @@ interface BreadcrumbNavigationProps {
mode?: "auto" | "custom" | "hybrid";
title?: string;
icon?: ReactElement;
titleAction?: ReactNode;
customItems?: CustomBreadcrumbItem[];
className?: string;
paramToPreserve?: string;
@@ -47,7 +46,6 @@ export function BreadcrumbNavigation({
mode = "auto",
title,
icon,
titleAction,
customItems = [],
className = "",
paramToPreserve = "scanId",
@@ -140,7 +138,6 @@ export function BreadcrumbNavigation({
>
{titleText}
</h1>
{titleAction}
</div>
);
+3 -3
View File
@@ -47,12 +47,12 @@ describe("Button", () => {
it("supports extra-small icon buttons", () => {
render(
<Button size="icon-xs" aria-label="Open tour">
Open tour
<Button size="icon-xs" aria-label="Open menu">
Open menu
</Button>,
);
expect(screen.getByRole("button", { name: "Open tour" })).toHaveClass(
expect(screen.getByRole("button", { name: "Open menu" })).toHaveClass(
"size-7",
);
});
@@ -1,26 +1,17 @@
import { type ReactElement } from "react";
import {
Navbar,
type OnboardingActionConfig,
} from "@/components/layout/nav-bar/navbar";
import { Navbar } from "@/components/layout/nav-bar/navbar";
interface ContentLayoutProps {
title: string;
icon?: ReactElement;
onboardingAction?: OnboardingActionConfig;
children: React.ReactNode;
}
export function ContentLayout({
title,
icon,
onboardingAction,
children,
}: ContentLayoutProps) {
export function ContentLayout({ title, icon, children }: ContentLayoutProps) {
return (
<>
<Navbar title={title} icon={icon} onboardingAction={onboardingAction} />
<Navbar title={title} icon={icon} />
<div className="py-4 pr-6">{children}</div>
</>
);
@@ -1,83 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Button } from "@/components/shadcn/button/button";
import {
DiscoveryCallout,
DiscoveryCalloutAnchor,
DiscoveryCalloutContent,
} from "./discovery-callout";
function renderCallout(open: boolean, onDismiss: () => void) {
return render(
<DiscoveryCallout open={open} onDismiss={onDismiss}>
<DiscoveryCalloutAnchor asChild>
<Button type="button">Anchor</Button>
</DiscoveryCalloutAnchor>
<DiscoveryCalloutContent
title="Meet the feature"
description="It lives right here."
data-testid="callout"
/>
</DiscoveryCallout>,
);
}
describe("DiscoveryCallout", () => {
it("renders the title and description while open", () => {
// Given / When
renderCallout(true, vi.fn());
// Then
expect(screen.getByTestId("callout")).toBeInTheDocument();
expect(screen.getByText("Meet the feature")).toBeInTheDocument();
expect(screen.getByText("It lives right here.")).toBeInTheDocument();
});
it("renders nothing while closed", () => {
// Given / When
renderCallout(false, vi.fn());
// Then
expect(screen.queryByTestId("callout")).not.toBeInTheDocument();
});
it("dismisses through the action button", () => {
// Given
const onDismiss = vi.fn();
renderCallout(true, onDismiss);
// When
fireEvent.click(screen.getByRole("button", { name: "Got it" }));
// Then
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("stays open when focus moves elsewhere, e.g. to a product tour", () => {
// Given
const onDismiss = vi.fn();
renderCallout(true, onDismiss);
const elsewhere = document.createElement("button");
document.body.appendChild(elsewhere);
// When: driver.js focuses its own popover as a tour starts.
fireEvent.focusIn(elsewhere);
// Then: a passive hint is not dismissed by focus it never held.
expect(onDismiss).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Got it" })).toBeInTheDocument();
elsewhere.remove();
});
it("keeps focus free when it opens", () => {
// Given / When: the callout opens on its own (not user-invoked)
renderCallout(true, vi.fn());
// Then: focus stays wherever the user had it
expect(screen.getByTestId("callout")).not.toContainElement(
document.activeElement as HTMLElement,
);
});
});
@@ -1,88 +0,0 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/shadcn/button/button";
import {
Popover,
PopoverAnchor,
PopoverClose,
PopoverContent,
} from "@/components/shadcn/popover";
interface DiscoveryCalloutProps {
// Controlled: the caller decides when the hint shows and persists "seen".
open: boolean;
// Fired on every dismissal path: the action button, outside click, Escape.
onDismiss: () => void;
children: ReactNode;
}
// One-time feature-discovery callout anchored to the control it introduces.
// Compound usage: wrap the anchor element in DiscoveryCalloutAnchor and
// render one DiscoveryCalloutContent beside it, all inside DiscoveryCallout.
export function DiscoveryCallout({
open,
onDismiss,
children,
}: DiscoveryCalloutProps) {
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onDismiss();
}}
>
{children}
</Popover>
);
}
export const DiscoveryCalloutAnchor = PopoverAnchor;
interface DiscoveryCalloutContentProps {
title: string;
description: ReactNode;
dismissLabel?: string;
side?: ComponentProps<typeof PopoverContent>["side"];
align?: ComponentProps<typeof PopoverContent>["align"];
"data-testid"?: string;
}
export function DiscoveryCalloutContent({
title,
description,
dismissLabel = "Got it",
side = "bottom",
align = "end",
"data-testid": testId,
}: DiscoveryCalloutContentProps) {
return (
<PopoverContent
side={side}
align={align}
sideOffset={8}
// A hint is worth showing even mid-tour: above driver.js's overlay (z 10000)
// and still dismissible while the tour locks the rest of the page.
className="z-[10001]"
data-tour-interactive
// A discovery hint must never steal focus from what the user is doing,
// nor vanish because something else took it (a tour popover, a form).
onOpenAutoFocus={(event) => event.preventDefault()}
onFocusOutside={(event) => event.preventDefault()}
data-testid={testId}
>
<div className="flex flex-col gap-2">
<p className="text-text-neutral-primary text-sm font-medium">{title}</p>
<p className="text-text-neutral-secondary text-sm">{description}</p>
<div className="flex justify-end">
<PopoverClose asChild>
<Button type="button" variant="outline" size="sm">
{dismissLabel}
</Button>
</PopoverClose>
</div>
</div>
</PopoverContent>
);
}
@@ -38,10 +38,6 @@ import { useScrollportPin } from "@/hooks/use-scrollport-pin";
import { cn } from "@/lib/utils";
import { FilterOption, MetaDataProps } from "@/types";
type DataTableRowAttributes = {
[key: `data-${string}`]: string | undefined;
};
/**
* Default column size used by TanStack Table when no explicit size is set.
* We skip applying inline width styles for columns with this default value
@@ -148,8 +144,6 @@ interface DataTableProviderProps<TData, TValue> {
searchBadge?: { label: string; onDismiss: () => void };
/** Optional click handler for top-level rows. */
onRowClick?: (row: Row<TData>) => void;
/** Optional data attributes applied to each top-level row. */
getRowAttributes?: (row: Row<TData>) => DataTableRowAttributes;
/** Optional header rendered inside the table container, above the toolbar. */
header?: ReactNode;
/** Optional content rendered in the toolbar before the total entries count. */
@@ -185,7 +179,6 @@ export function DataTable<TData, TValue>({
renderAfterRow,
searchBadge,
onRowClick,
getRowAttributes,
header,
toolbarRightContent,
previewLock,
@@ -433,7 +426,6 @@ export function DataTable<TData, TValue>({
ref={
position === rows.length - 1 ? lastRowRef : undefined
}
{...getRowAttributes?.(row)}
data-state={row.getIsSelected() && "selected"}
data-locked-row={
isLockedPosition(position) ? "" : undefined
@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -13,141 +13,39 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/findings",
}));
const HINT_DELAY_MS = 1500;
describe("SidePanelTrigger discovery callout", () => {
describe("SidePanelTrigger", () => {
beforeEach(() => {
vi.useFakeTimers();
isCloudMock.mockReturnValue(true);
localStorage.clear();
useSidePanelStore.setState({
isOpen: false,
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
selectedTab: SIDE_PANEL_TAB.CONTEXT,
hasBeenOpened: false,
hasSeenAiTriggerHint: false,
});
});
afterEach(() => {
document.body.classList.remove("driver-active");
vi.useRealTimers();
});
it("surfaces the callout once, after the settle delay", () => {
// Given / When
render(<SidePanelTrigger />);
// Then: nothing competes with the page while it loads
expect(screen.queryByTestId("side-panel-ai-hint")).not.toBeInTheDocument();
// When: the delay elapses
act(() => vi.advanceTimersByTime(HINT_DELAY_MS));
// Then
expect(screen.getByTestId("side-panel-ai-hint")).toBeInTheDocument();
expect(
screen.getByText("Ask Lighthouse AI from any page"),
).toBeInTheDocument();
});
it("stays usable above a running product tour", () => {
// Given: driver.js dims and disables everything outside its spotlight.
document.body.classList.add("driver-active");
render(<SidePanelTrigger />);
// When
act(() => vi.advanceTimersByTime(HINT_DELAY_MS));
// Then: the callout opts out of that, so it is visible and dismissible.
expect(screen.getByTestId("side-panel-ai-hint")).toHaveAttribute(
"data-tour-interactive",
);
});
it("never surfaces the callout again once seen", () => {
// Given: a returning user
useSidePanelStore.setState({ hasSeenAiTriggerHint: true });
// When
render(<SidePanelTrigger />);
act(() => vi.advanceTimersByTime(HINT_DELAY_MS * 2));
// Then
expect(screen.queryByTestId("side-panel-ai-hint")).not.toBeInTheDocument();
});
it("dismisses and persists via Got it", () => {
it("opens the side panel on the AI chat", () => {
// Given
render(<SidePanelTrigger />);
act(() => vi.advanceTimersByTime(HINT_DELAY_MS));
// When
fireEvent.click(screen.getByRole("button", { name: "Got it" }));
fireEvent.click(screen.getByRole("button", { name: "Ask Lighthouse AI" }));
// Then
expect(screen.queryByTestId("side-panel-ai-hint")).not.toBeInTheDocument();
expect(useSidePanelStore.getState().hasSeenAiTriggerHint).toBe(true);
const persisted = JSON.parse(
localStorage.getItem("side-panel-store") ?? "{}",
);
expect(persisted.state.hasSeenAiTriggerHint).toBe(true);
});
it("retires the callout when the user opens the chat from the trigger", () => {
// Given
render(<SidePanelTrigger />);
act(() => vi.advanceTimersByTime(HINT_DELAY_MS));
// When
fireEvent.click(screen.getByTestId("side-panel-ai-trigger"));
// Then: the panel opened on the AI tab and the hint is retired for good
expect(useSidePanelStore.getState().isOpen).toBe(true);
expect(useSidePanelStore.getState().hasSeenAiTriggerHint).toBe(true);
expect(screen.queryByTestId("side-panel-ai-hint")).not.toBeInTheDocument();
expect(useSidePanelStore.getState().selectedTab).toBe(
SIDE_PANEL_TAB.AI_CHAT,
);
});
it("retires the callout when the chat opens from anywhere else", () => {
// Given: the callout is showing
render(<SidePanelTrigger />);
act(() => vi.advanceTimersByTime(HINT_DELAY_MS));
expect(screen.getByTestId("side-panel-ai-hint")).toBeInTheDocument();
// When: another entry point (⌘., Overview banner) opens the AI chat
act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT));
// Then
expect(screen.queryByTestId("side-panel-ai-hint")).not.toBeInTheDocument();
});
it("glows while undiscovered and calms once dismissed", () => {
// Given: first visit; the icon starts calm during page load
const { container } = render(<SidePanelTrigger />);
expect(container.querySelector("animate")).not.toBeInTheDocument();
// When: the callout appears
act(() => vi.advanceTimersByTime(HINT_DELAY_MS));
// Then: the animated aura glows alongside it
expect(container.querySelector("animate")).toBeInTheDocument();
// When: the user dismisses the hint
fireEvent.click(screen.getByRole("button", { name: "Got it" }));
// Then: the icon settles back to its calm rendering
expect(container.querySelector("animate")).not.toBeInTheDocument();
});
it("stays quiet in OSS where the trigger does not render", () => {
it("renders nothing in OSS", () => {
// Given
isCloudMock.mockReturnValue(false);
// When
const { container } = render(<SidePanelTrigger />);
act(() => vi.advanceTimersByTime(HINT_DELAY_MS));
// Then
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId("side-panel-ai-hint")).not.toBeInTheDocument();
});
});
+17 -60
View File
@@ -1,29 +1,18 @@
"use client";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { LighthouseIcon } from "@/components/icons";
import { Button } from "@/components/shadcn/button/button";
import {
DiscoveryCallout,
DiscoveryCalloutAnchor,
DiscoveryCalloutContent,
} from "@/components/shadcn/discovery-callout/discovery-callout";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isLighthouseChatRoute } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
// Late enough that the page has settled and the callout reads as a pointer,
// not as part of the page loading in.
const AI_HINT_DELAY_MS = 1500;
export function SidePanelTrigger() {
const pathname = usePathname();
const openPanel = useSidePanelStore((state) => state.openPanel);
@@ -32,20 +21,6 @@ export function SidePanelTrigger() {
const isAiChatPanelOpen = useSidePanelStore(
(state) => state.isOpen && state.selectedTab === SIDE_PANEL_TAB.AI_CHAT,
);
// One-time discovery callout: opens once after a short delay and never
// again — dismissing it or reaching the AI chat any other way (⌘., the
// Overview banner) marks it seen in the persisted store.
const hintSeen = useSidePanelStore((state) => state.hasSeenAiTriggerHint);
const markHintSeen = useSidePanelStore(
(state) => state.markAiTriggerHintSeen,
);
const [hintReady, setHintReady] = useState(false);
useMountEffect(() => {
if (useSidePanelStore.getState().hasSeenAiTriggerHint) return;
const timer = setTimeout(() => setHintReady(true), AI_HINT_DELAY_MS);
return () => clearTimeout(timer);
});
// Lighthouse AI (and the panel itself) is cloud-only. On the full-page chat
// route the panel is not available: the chat lives in one place at a time.
@@ -53,41 +28,23 @@ export function SidePanelTrigger() {
if (isLighthouseChatRoute(pathname)) return null;
if (isAiChatPanelOpen) return null;
// Gated on hintReady (client-only) so SSR and hydration render the calm
// icon; the glow starts with the callout and stops once discovered.
const undiscovered = hintReady && !hintSeen;
return (
<DiscoveryCallout open={undiscovered} onDismiss={markHintSeen}>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<DiscoveryCalloutAnchor asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Ask Lighthouse AI"
data-testid="side-panel-ai-trigger"
onClick={() => openPanel(SIDE_PANEL_TAB.AI_CHAT)}
>
<LighthouseIcon animatedAura={undiscovered} className="size-5" />
</Button>
</DiscoveryCalloutAnchor>
</TooltipTrigger>
<TooltipContent>
Ask Lighthouse AI <kbd className="ml-1 text-xs">⌘.</kbd>
</TooltipContent>
</Tooltip>
<DiscoveryCalloutContent
title="Ask Lighthouse AI from any page"
description={
<>
Open the assistant right here whenever you need it, or press{" "}
<kbd className="text-xs">⌘.</kbd> to toggle it.
</>
}
data-testid="side-panel-ai-hint"
/>
</DiscoveryCallout>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Ask Lighthouse AI"
data-testid="side-panel-ai-trigger"
onClick={() => openPanel(SIDE_PANEL_TAB.AI_CHAT)}
>
<LighthouseIcon className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
Ask Lighthouse AI <kbd className="ml-1 text-xs">⌘.</kbd>
</TooltipContent>
</Tooltip>
);
}
-8
View File
@@ -415,14 +415,6 @@
"strategy": "installed",
"generatedAt": "2026-06-17T18:31:57.448Z"
},
{
"section": "dependencies",
"name": "driver.js",
"from": "1.4.0",
"to": "1.4.0",
"strategy": "installed",
"generatedAt": "2026-05-26T13:05:09.776Z"
},
{
"section": "dependencies",
"name": "framer-motion",
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { getInvitationRoles } from "@/actions/invitations/roles";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
import type { InvitationRoleOption } from "@/types/invitations";
// Roles that have not arrived by then count as unavailable, so a request
// that never answers cannot hold a form behind an empty list.
+1 -1
View File
@@ -1,4 +1,4 @@
import type { InvitationRoleOption } from "@/types/onboarding-invite";
import type { InvitationRoleOption } from "@/types/invitations";
const ADMIN_ROLE_NAME = "admin";
-116
View File
@@ -1,116 +0,0 @@
# Onboarding system (developer guide)
The onboarding system runs short, anchored driver.js tours and orchestrates a
cross-route **guided sequence** after a user connects their first provider.
The tours and the guided sequence run on client state (the sequence slice
is ephemeral and resets on a hard reload); tour completion and the one-time
markers persist in localStorage. Server input is the tri-state `hasProviders`
the layout derives from `getProviders()`, plus the invitation the invite step
posts to the API.
## Building blocks
| Concern | File |
| -------------------------------------- | --------------------------------------------------------------------- |
| Flow registry (single source of truth) | `ui/lib/onboarding/registry.ts` |
| Flow type (`OnboardingFlow`) | `ui/lib/onboarding/onboarding-types.ts` |
| Tour definitions (`*.tour.ts`) | `ui/lib/tours/` |
| Driver primitive (`useDriverTour`) | `ui/lib/tours/use-driver-tour.ts` |
| Per-route trigger | `ui/components/onboarding/onboarding-trigger.tsx` |
| Ephemeral sequence slice | `ui/store/onboarding-sequence.ts` |
| Checkpoint watcher + dialog | `ui/components/onboarding/onboarding-checkpoint-{watcher,dialog}.tsx` |
| New-tenant gate (first-run redirect) | `ui/components/onboarding/onboarding-gate.tsx` |
| First-run marker (once per tenant) | `ui/lib/onboarding/first-run-marker.ts` |
| Step outcome events (window) | `ui/lib/onboarding/onboarding-events.ts` |
| Invite step before the checkpoint | `ui/components/onboarding/onboarding-invite-{step,dialog}.tsx` |
| Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` |
## First run
The gate is mounted in every deployment. When the tenant provably has no
providers (`hasProviders === false`), the user holds `manage_providers` and
neither the first-run marker (`prowler.onboarding.first-run.<tenantId>`, so a
first run in one tenant never silences it for another on the same browser; the
bare `prowler.onboarding.first-run` key is a browser-wide opt-out, which is what
the e2e storage state sets) nor an add-provider completion record exists, it
replaces the route once with
`/providers?addProvider=true&addProviderSource=first_run`, so the add-provider
wizard is already open. Billing routes defer it; an unknown provider count or a
user without the permission (an empty list may only mean limited visibility)
never triggers it.
In Cloud the URL also carries `&onboarding=add-provider` and the checkpoint is
armed. Because the wizard is already open, the providers page passes
`startAtTarget="provider-type"` to its `<OnboardingTrigger />`, which skips the
tour's welcome and "open the wizard" steps. A navbar replay with the wizard
closed still starts from the first step. Self-hosted deployments get the
redirect only: tours and the checkpoint stay Cloud-only.
## How the guided sequence works
1. The `(prowler)/layout.tsx` derives a tri-state `hasProviders` on every
navigation and mounts `<OnboardingCheckpointWatcher />` (sibling to the gate).
2. When the watcher observes a concrete `false → true` `hasProviders` flip (the
user actually connected a provider), it opens the checkpoint dialog **once**.
An `undefined → true` (user already had providers) never fires. A localStorage
marker (`prowler.onboarding.checkpoint`) prevents re-appearance.
3. "Continue the tour" calls `startSequence(nextFlowId)` on the ephemeral
`useOnboardingSequenceStore` and navigates to that flow's route.
4. Each route mounts an `<OnboardingTrigger flow={...} />`. The trigger force
starts the flow when `slice.currentFlowId === flow.id` (sequence) **or** when
the `?onboarding=<id>` param matches (replay). The StrictMode-safe latch /
keyed runner / empty-deps force-start is preserved verbatim.
5. On tour close, `useDriverTour`'s `onClosed(state)` reports the outcome:
`completed` → `advance()` (navigate to the next flow), `skipped`/`dismissed`
→ `stop()` (the sequence ends; closing any tour ends the sequence).
6. The slice is **ephemeral** (plain Zustand `create`, no `persist`). It carries
`currentFlowId` across client navigations but resets on a hard reload, so a
mid-sequence refresh never re-fires.
`attack-paths` is special: its page already owns a driver, so its registry entry
sets `ownsAutoOpen: true`, the trigger does **not** mount a runner for it, and
the page wires `onClosed` to the slice itself (single-fire).
## Add a new flow (the extensibility contract)
A new flow is **one registry entry + one tour file + its anchors + a trigger
mount** — no gate, modal, or nav edits.
1. **Tour file** — `ui/lib/tours/<flow-id>.tour.ts` via `defineTour<Target>` and
the `assets/tour-template.ts`. Keep it shallow: a centered welcome step plus
1–2 anchored steps. `coversFiles` scopes the drift check.
2. **Anchors** — add `data-tour-id="<flow-id>-<target>"` on the page-specific
**client** component for each anchored step (never the shared Navbar).
The tour file and its anchors MUST ship in the SAME PR (`tour:check`
hard-fails a tour target with no matching anchor).
3. **Registry entry** — add `{ id, order, title, description, route, tour }` to
`onboardingFlows` in `registry.ts`. Ordering is data (`order`).
4. **Trigger mount** — render `<OnboardingTrigger flow={getFlowById("<id>")!} />`
inside the route's client host. Pass `stepHandlers`/`configOverrides` only if
the flow needs them (e.g. add-provider opens the wizard).
The avatar "Product tour" submenu and `advance()` both derive from
`getOrderedFlows()`, so a new flow appears in the replay list and participates in
the sequence automatically.
## CI gates
- `pnpm run tour:check` (`ui/scripts/check-tour-alignment.mjs`) — every tour
`target` must resolve to a real `data-tour-id` anchor within its `coversFiles`.
- `pnpm exec vitest run --project unit` — pure logic (slice, helpers, registry,
tour shapes). The driver primitive short-circuits in `NODE_ENV==="test"`.
## Invite step
The first time the checkpoint opens (right after the first provider is
connected), `OnboardingCheckpointWatcher` renders `OnboardingInviteStep` before
the checkpoint dialog: the members-page `SendInvitationForm`, tagged
`source=onboarding` for the API, plus a "Skip for now" action. If the roles
cannot be loaded, or have not arrived after five seconds, only the skip is
offered, so the checkpoint is never blocked. The store stays
`open` while the step shows, so the checkpoint dialog follows unchanged once it
resolves. A per-tenant localStorage marker (`prowler.onboarding.invite.<tenantId>`)
keeps it to one offer; without a usable `tenantId` the step is not offered.
Outcomes (`shown`, `submitted`, `skipped`) are announced as the
`prowler:onboarding-invite-step` window event (`dispatchOnboardingInviteStep`).
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { isOnFlowRoute } from "../flow-route";
const query = (search: string) => new URLSearchParams(search);
describe("isOnFlowRoute", () => {
it("matches a plain route on the same pathname", () => {
expect(isOnFlowRoute("/findings", "/findings", query(""))).toBe(true);
});
it("ignores extra params the user built up", () => {
expect(
isOnFlowRoute(
"/compliance?tab=per-scan",
"/compliance",
query("tab=per-scan&scanId=scan-1&filter%5Bregion__in%5D=eu-west-1"),
),
).toBe(true);
});
it("rejects a different pathname", () => {
expect(
isOnFlowRoute("/compliance?tab=per-scan", "/findings", query("")),
).toBe(false);
});
it("rejects the same pathname when a pinned param is missing", () => {
expect(
isOnFlowRoute("/compliance?tab=per-scan", "/compliance", query("")),
).toBe(false);
});
it("rejects the same pathname when a pinned param has another value", () => {
expect(
isOnFlowRoute("/scans?tab=active", "/scans", query("tab=scheduled")),
).toBe(false);
});
it("requires every pinned param, not just the first", () => {
expect(
isOnFlowRoute(
"/scans?tab=active&view=list",
"/scans",
query("tab=active"),
),
).toBe(false);
});
});
@@ -1,91 +0,0 @@
import { describe, expect, it } from "vitest";
import type { TourCompletionRecord } from "@/lib/tours/tour-types";
import { TOUR_COMPLETION_STATES } from "@/lib/tours/tour-types";
import { shouldStartOnboarding } from "../gate-decision";
const recordWithState = (
state: TourCompletionRecord["state"],
): TourCompletionRecord => ({
tourId: "add-provider",
version: 1,
state,
completedAt: "2026-01-15T12:00:00.000Z",
});
describe("shouldStartOnboarding", () => {
it("returns true for a zero-provider user with no completion record", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(true);
});
it("returns false when the user cannot add providers, even in an empty tenant", () => {
// Limited-visibility users see zero providers without the tenant being empty.
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: false,
completionRecord: null,
});
expect(result).toBe(false);
});
it("returns false when the user already has providers", () => {
const result = shouldStartOnboarding({
hasProviders: true,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(false);
});
it("returns false when a dismissed record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.DISMISSED),
});
expect(result).toBe(false);
});
it("returns false when a completed record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.COMPLETED),
});
expect(result).toBe(false);
});
it("returns false when a skipped record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.SKIPPED),
});
expect(result).toBe(false);
});
it("fails open when hasProviders is undefined", () => {
// strict === false check rejects non-false values; don't force onboarding on unknown state
const result = shouldStartOnboarding({
hasProviders: undefined,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(false);
});
it("fails open when hasProviders is null", () => {
const result = shouldStartOnboarding({
hasProviders: null as unknown as boolean,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(false);
});
});
@@ -1,185 +0,0 @@
import { describe, expect, it } from "vitest";
import type { TourDefinition } from "@/lib/tours/tour-types";
import * as onboardingPublicApi from "../index";
import type { OnboardingContext, OnboardingFlow } from "../onboarding-types";
import { getFlowById, getOrderedFlows, onboardingFlows } from "../registry";
// Registry only reads { id, version } from tours, so a flat stub is sufficient.
const buildTour = (id: string, version = 1): TourDefinition => ({
id,
version,
coversFiles: [],
steps: [],
});
const buildFlow = (
overrides: Partial<OnboardingFlow> = {},
): OnboardingFlow => ({
id: overrides.id ?? "flow",
order: overrides.order ?? 1,
title: overrides.title ?? "Title",
description: overrides.description ?? "Description",
route: overrides.route ?? "/route",
tour: overrides.tour ?? buildTour(overrides.id ?? "flow"),
isComplete: overrides.isComplete,
ownsAutoOpen: overrides.ownsAutoOpen,
});
describe("OnboardingFlow / OnboardingContext types", () => {
it("compiles a fully-populated OnboardingFlow against the declared contract", () => {
const ctx: OnboardingContext = { hasProviders: false };
const flow: OnboardingFlow = {
id: "add-provider",
order: 1,
title: "Add your first provider",
description: "Connect a cloud account to start scanning.",
route: "/providers",
tour: buildTour("add-provider"),
isComplete: (c: OnboardingContext) => c.hasProviders,
};
expect(flow.id).toBe("add-provider");
expect(flow.order).toBe(1);
expect(flow.route).toBe("/providers");
expect(flow.isComplete?.(ctx)).toBe(false);
expect(flow.isComplete?.({ hasProviders: true })).toBe(true);
});
});
describe("onboardingFlows (production registry)", () => {
it("registers the add-provider flow as the first onboarding flow", () => {
const addProvider = getFlowById("add-provider", onboardingFlows);
expect(addProvider).toBeDefined();
expect(addProvider?.order).toBe(1);
expect(addProvider?.route).toBe("/providers");
expect(addProvider?.tour.id).toBe("add-provider");
});
it("treats add-provider as complete when the context reports providers", () => {
const addProvider = getFlowById("add-provider", onboardingFlows);
expect(addProvider?.isComplete?.({ hasProviders: true })).toBe(true);
expect(addProvider?.isComplete?.({ hasProviders: false })).toBe(false);
});
it("registers the view-first-scan flow at order 2 on the active scans tab", () => {
const flow = getFlowById("view-first-scan", onboardingFlows);
expect(flow).toBeDefined();
expect(flow?.order).toBe(2);
expect(flow?.route).toBe("/scans?tab=active");
expect(flow?.tour.id).toBe("view-first-scan");
});
it("registers the explore-findings flow at order 3 on the findings route", () => {
const flow = getFlowById("explore-findings", onboardingFlows);
expect(flow).toBeDefined();
expect(flow?.order).toBe(3);
expect(flow?.route).toBe("/findings");
expect(flow?.tour.id).toBe("explore-findings");
});
it("registers the view-compliance flow at order 4 on the single scan tab", () => {
const flow = getFlowById("view-compliance", onboardingFlows);
expect(flow).toBeDefined();
expect(flow?.order).toBe(4);
expect(flow?.route).toBe("/compliance?tab=per-scan");
expect(flow?.tour.id).toBe("view-compliance");
});
it("registers the attack-paths flow at order 5 on the attack-paths route", () => {
const flow = getFlowById("attack-paths", onboardingFlows);
expect(flow).toBeDefined();
expect(flow?.order).toBe(5);
expect(flow?.route).toBe("/attack-paths");
expect(flow?.tour.id).toBe("attack-paths");
});
it("flags attack-paths with ownsAutoOpen so the shared trigger never drives it", () => {
// attack-paths page owns its own driver; all other flows must be falsy
const attackPaths = getFlowById("attack-paths", onboardingFlows);
expect(attackPaths?.ownsAutoOpen).toBe(true);
for (const flow of onboardingFlows) {
if (flow.id === "attack-paths") continue;
expect(flow.ownsAutoOpen).toBeFalsy();
}
});
it("sets a data requirement hint on the scan-dependent flows only", () => {
const scanDependent = [
"explore-findings",
"view-compliance",
"attack-paths",
];
const standalone = ["add-provider", "view-first-scan"];
const expectedHint = "Wait for the scan to finish and have findings.";
for (const id of scanDependent) {
const flow = getFlowById(id, onboardingFlows);
expect(flow?.dataRequirementHint).toBe(expectedHint);
}
for (const id of standalone) {
const flow = getFlowById(id, onboardingFlows);
expect(flow?.dataRequirementHint).toBeUndefined();
}
});
it("orders the five sequence flows 1..5 by registry order", () => {
const ordered = getOrderedFlows(onboardingFlows);
expect(ordered.map((flow) => flow.id)).toEqual([
"add-provider",
"view-first-scan",
"explore-findings",
"view-compliance",
"attack-paths",
]);
});
});
describe("public api barrel", () => {
it("re-exports the registry and gate-decision surface", () => {
expect(onboardingPublicApi.getOrderedFlows).toBe(getOrderedFlows);
expect(onboardingPublicApi.getFlowById).toBe(getFlowById);
expect(onboardingPublicApi.onboardingFlows).toBe(onboardingFlows);
expect(typeof onboardingPublicApi.shouldStartOnboarding).toBe("function");
});
});
describe("getOrderedFlows", () => {
it("returns an empty array when the registry has no flows", () => {
expect(getOrderedFlows([])).toEqual([]);
});
it("sorts ascending by order, stably preserving array position on ties", () => {
const second = buildFlow({ id: "second", order: 2 });
const first = buildFlow({ id: "first", order: 1 });
const secondTie = buildFlow({ id: "second-tie", order: 2 });
const flows = [second, first, secondTie];
// order 1 first, then the two order-2 entries in original sequence
expect(getOrderedFlows(flows).map((f) => f.id)).toEqual([
"first",
"second",
"second-tie",
]);
});
});
describe("getFlowById", () => {
it("returns the matching flow when the id exists", () => {
const addProvider = buildFlow({ id: "add-provider", order: 1 });
const flows = [addProvider, buildFlow({ id: "other", order: 2 })];
expect(getFlowById("add-provider", flows)).toBe(addProvider);
});
it("returns undefined when the id is unknown", () => {
const flows = [buildFlow({ id: "add-provider", order: 1 })];
expect(getFlowById("unknown-xyz", flows)).toBeUndefined();
});
});
describe("getFlowById fallback", () => {
it("returns undefined for an unknown id against the production registry", () => {
expect(getFlowById("does-not-exist", onboardingFlows)).toBeUndefined();
});
});
-33
View File
@@ -1,33 +0,0 @@
/**
* Tells whether the browser is already showing a flow's route.
*
* A flow route may pin query params (`/compliance?tab=per-scan`,
* `/scans?tab=active`) that select the view its anchors live in, so a plain
* string compare against `pathname` never matches and the replay degrades into
* a full navigation — losing whatever params the user had built up.
*
* The pinned params are a subset check, not an equality one: extra params the
* user picked up (a selected scan, filters) still count as being on the route.
*/
export function isOnFlowRoute(
flowRoute: string,
pathname: string,
searchParams: URLSearchParams,
): boolean {
const [routePath, routeQuery = ""] = flowRoute.split("?");
if (routePath !== pathname) {
return false;
}
// forEach, not for..of: URLSearchParams is only iterable under
// downlevelIteration, which this tsconfig does not enable.
let carriesPinnedParams = true;
new URLSearchParams(routeQuery).forEach((value, key) => {
if (searchParams.get(key) !== value) {
carriesPinnedParams = false;
}
});
return carriesPinnedParams;
}
-21
View File
@@ -1,21 +0,0 @@
import type { TourCompletionRecord } from "@/lib/tours/tour-types";
export interface GateDecisionInput {
// `undefined` allowed; strict `=== false` check below fails open on ambiguous signals.
hasProviders: boolean | undefined;
// Limited-visibility users list zero providers in a tenant that is not empty.
canManageProviders: boolean;
completionRecord: TourCompletionRecord | null;
}
// Only forces onboarding when providers are provably absent, the user can add one
// and no record exists.
export function shouldStartOnboarding({
hasProviders,
canManageProviders,
completionRecord,
}: GateDecisionInput): boolean {
const hasNoRecord =
completionRecord === null || completionRecord === undefined;
return hasProviders === false && canManageProviders && hasNoRecord;
}
-16
View File
@@ -1,16 +0,0 @@
// Public barrel — import from `@/lib/onboarding`, not individual modules.
export type { GateDecisionInput } from "./gate-decision";
export { shouldStartOnboarding } from "./gate-decision";
export { isOnFlowRoute } from "./flow-route";
export type { OnboardingContext, OnboardingFlow } from "./onboarding-types";
export { getFlowById, getOrderedFlows, onboardingFlows } from "./registry";
export type {
OnboardingInviteStepDetail,
OnboardingStepOutcome,
} from "./onboarding-events";
export {
dispatchOnboardingInviteStep,
ONBOARDING_INVITE_STEP_EVENT,
ONBOARDING_STEP_OUTCOME,
} from "./onboarding-events";
-48
View File
@@ -1,48 +0,0 @@
// Durable "this browser already saw the invite step" memory, mirroring the
// checkpoint marker: the step is offered once per tenant onboarding.
//
// The key carries the tenant, like the profile marker, because the offer is
// tenant-scoped: a user who saw it for one tenant must still see it when they
// onboard another.
const ONBOARDING_INVITE_MARKER_PREFIX = "prowler.onboarding.invite";
// Tenant ids are UUIDs; anything else is refused rather than concatenated
// into a storage key.
const TENANT_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function onboardingInviteMarkerKey(
tenantId: string | null | undefined,
): string | null {
if (!tenantId || !TENANT_ID_PATTERN.test(tenantId)) return null;
return `${ONBOARDING_INVITE_MARKER_PREFIX}.${tenantId.toLowerCase()}`;
}
export function isOnboardingInviteHandled(
tenantId: string | null | undefined,
): boolean {
if (typeof window === "undefined") return true;
const key = onboardingInviteMarkerKey(tenantId);
// Without a usable tenant the step cannot be attributed to an onboarding,
// so it is not offered rather than offered to everyone.
if (!key) return true;
try {
return window.localStorage.getItem(key) !== null;
} catch {
// Unreadable storage must not re-open the step forever: treat as handled.
return true;
}
}
export function markOnboardingInviteHandled(
tenantId: string | null | undefined,
): void {
if (typeof window === "undefined") return;
const key = onboardingInviteMarkerKey(tenantId);
if (!key) return;
try {
window.localStorage.setItem(key, "true");
} catch {
// Non-fatal: a re-shown step beats a thrown render.
}
}
-33
View File
@@ -1,33 +0,0 @@
// Window events the onboarding steps dispatch when they resolve. They carry
// no listener of their own: a deployment that wants to observe the steps
// (product analytics, for instance) subscribes from outside, so the steps
// stay free of any tracking dependency.
export const ONBOARDING_INVITE_STEP_EVENT = "prowler:onboarding-invite-step";
export const ONBOARDING_STEP_OUTCOME = {
SHOWN: "shown",
SUBMITTED: "submitted",
SKIPPED: "skipped",
} as const;
export type OnboardingStepOutcome =
(typeof ONBOARDING_STEP_OUTCOME)[keyof typeof ONBOARDING_STEP_OUTCOME];
export interface OnboardingInviteStepDetail {
outcome: OnboardingStepOutcome;
}
function dispatch<Detail>(name: string, detail: Detail): void {
if (typeof window === "undefined") return;
try {
window.dispatchEvent(new CustomEvent<Detail>(name, { detail }));
} catch {
// A listener that throws must never break the step that resolved.
}
}
export function dispatchOnboardingInviteStep(
detail: OnboardingInviteStepDetail,
): void {
dispatch(ONBOARDING_INVITE_STEP_EVENT, detail);
}
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more