feat(ui): UI onboarding system (#11430)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
This commit is contained in:
Alan Buscaglia
2026-06-15 13:53:48 +02:00
committed by GitHub
co-authored by Pablo F.G Claude Opus 4.7 alejandrobailo Alejandro Bailo
parent 6db8ce672c
commit 49309b43d3
121 changed files with 8233 additions and 589 deletions
+4
View File
@@ -131,6 +131,10 @@ 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,6 +51,7 @@ 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) |
@@ -67,10 +68,12 @@ 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` |
@@ -89,6 +92,7 @@ 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` |
@@ -105,6 +109,8 @@ 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
@@ -0,0 +1,99 @@
---
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`.
@@ -0,0 +1,51 @@
// @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.",
},
],
});
@@ -0,0 +1,31 @@
# 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`.
@@ -0,0 +1,44 @@
# 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.
+35 -29
View File
@@ -14,40 +14,46 @@
> - [`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` |
| 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` |
| 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` |
| 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` |
| 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
View File
@@ -25,6 +25,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Guided product onboarding for new users — step-by-step tours covering providers, scans, findings, compliance, and attack paths, replayable anytime from the info icon in the page header [(#11430)](https://github.com/prowler-cloud/prowler/pull/11430)
### 🔄 Changed
+5
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/provider-filters";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { SCAN_STATES } from "@/types/attack-paths";
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
export const getScans = async ({
@@ -64,6 +65,10 @@ export const getScansByState = async () => {
"filter[provider_type__in]",
sanitizeProviderTypesCsv(),
);
// Only need to know whether at least one completed scan exists; filter server-side
// and cap to a single row so the answer is correct regardless of total scan count.
url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED);
url.searchParams.append("page[size]", "1");
try {
const response = await fetch(url.toString(), {
@@ -58,6 +58,7 @@ vi.mock("@/components/ui/table", () => ({
data,
metadata,
controlledPage,
getRowAttributes,
}: {
columns: Array<{
id?: string;
@@ -74,6 +75,10 @@ vi.mock("@/components/ui/table", () => ({
};
};
controlledPage: number;
getRowAttributes?: (row: {
index: number;
original: AttackPathScan;
}) => Record<string, string | undefined>;
}) => (
<div>
<span>{metadata.pagination.count} Total Entries</span>
@@ -95,8 +100,8 @@ vi.mock("@/components/ui/table", () => ({
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{data.map((row, index) => (
<tr key={row.id} {...getRowAttributes?.({ index, original: row })}>
{columns.map((column, index) => (
<td key={column.id ?? index}>
{column.cell
@@ -176,6 +181,20 @@ 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,6 +295,9 @@ 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)}
/>
@@ -1,3 +1,4 @@
export { useAttackPathScans } from "./use-attack-path-scans";
export { useGraphState } from "./use-graph-state";
export { useQueryBuilder } from "./use-query-builder";
export { useWizardState } from "./use-wizard-state";
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
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;
refreshScans: () => Promise<void>;
}
/**
* `useData`-style hook owning the Attack Paths scan list. The direct
* `useEffect` (via `useMountEffect`) lives here, not in the component: the
* 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;
const [scans, setScans] = useState<AttackPathScan[]>([]);
const [scansLoading, setScansLoading] = useState(true);
const refreshScans = async () => {
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
}
} catch (error) {
console.error("Failed to refresh scans:", error);
}
};
useMountEffect(() => {
let active = true;
const loadScans = async () => {
setScansLoading(true);
try {
const scansData = await getAttackPathScans();
const nextScans = scansData?.data ?? [];
if (!active) return;
setScans(nextScans);
if (!nextScans.some((scan) => scan.attributes.graph_data_ready)) {
onNoReadyScan?.();
}
} catch (error) {
if (!active) return;
console.error("Failed to load scans:", error);
setScans([]);
onNoReadyScan?.();
} finally {
if (active) setScansLoading(false);
}
};
void loadScans();
return () => {
active = false;
};
});
return { scans, scansLoading, refreshScans };
}
@@ -1,19 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("AttackPathsPage", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "attack-paths-page.tsx");
const source = readFileSync(filePath, "utf8");
it("keeps the page description without rendering a duplicate Attack Paths heading", () => {
// Then
expect(source).not.toContain(">\n Attack Paths\n </h2>");
expect(source).toContain(
"Select a scan, build a query, and visualize Attack Paths in your",
);
});
});
@@ -2,7 +2,7 @@
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
@@ -10,11 +10,11 @@ import {
buildAttackPathQueries,
executeCustomQuery,
executeQuery,
getAttackPathScans,
getAvailableQueries,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { FindingDetailDrawer } from "@/components/findings/table";
import { PageReady } from "@/components/onboarding";
import { useFindingDetails } from "@/components/resources/table/use-finding-details";
import { AutoRefresh } from "@/components/scans";
import {
@@ -32,10 +32,19 @@ import {
DialogTrigger,
} from "@/components/shadcn/dialog";
import { useToast } from "@/components/ui";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isCloud } from "@/lib/shared/env";
import {
attackPathsTour,
type AttackPathsTourTarget,
pickDemoQuery,
pickDemoScan,
} from "@/lib/tours/attack-paths.tour";
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour";
import type {
AttackPathQuery,
AttackPathQueryError,
AttackPathScan,
GraphNode,
} from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths";
@@ -53,23 +62,30 @@ import {
ScanListTable,
} from "./_components";
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsPNG } from "./_lib";
/**
* Attack Paths
* Allows users to select a scan, build a query, and visualize the attack path graph
*/
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 [scansLoading, setScansLoading] = useState(true);
const [scans, setScans] = useState<AttackPathScan[]>([]);
const { scans, scansLoading, refreshScans } = useAttackPathScans({
onNoReadyScan: isAttackPathsReplay
? () => router.push("/scans?onboarding=view-first-scan")
: undefined,
});
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
@@ -81,10 +97,62 @@ export default function AttackPathsPage() {
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
// Use custom hook for query builder form state and validation
const queryBuilder = useQueryBuilder(queries);
// Reset graph state when component mounts
const hasReadyScan = scans.some((scan) => scan.attributes.graph_data_ready);
const hasNoScans = scans.length === 0;
useDriverTour(attackPathsEmptyTour, {
enabled: onboardingEnabled && !scansLoading && 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;
@@ -92,60 +160,22 @@ export default function AttackPathsPage() {
}
}, [graphState]);
// Reset graph state when scan changes
useEffect(() => {
graphState.resetGraph();
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
// Load available scans on mount
useEffect(() => {
const loadScans = async () => {
setScansLoading(true);
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
} else {
setScans([]);
}
} catch (error) {
console.error("Failed to load scans:", error);
setScans([]);
} finally {
setScansLoading(false);
}
};
loadScans();
}, []);
// Check if there's an executing scan for auto-refresh
const hasExecutingScan = scans.some(
(scan) =>
scan.attributes.state === SCAN_STATES.EXECUTING ||
scan.attributes.state === SCAN_STATES.SCHEDULED,
);
// Detect if the selected scan is showing data from a previous cycle
const selectedScan = scans.find((scan) => scan.id === scanId);
const isViewingPreviousCycleData =
selectedScan &&
selectedScan.attributes.graph_data_ready &&
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
// Callback to refresh scans (used by AutoRefresh component)
const refreshScans = async () => {
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
}
} catch (error) {
console.error("Failed to refresh scans:", error);
}
};
// Load available queries on mount
useEffect(() => {
const loadQueries = async () => {
if (!scanId) {
@@ -205,7 +235,6 @@ export default function AttackPathsPage() {
return;
}
// Validate form before executing query
const isValid = await queryBuilder.form.trigger();
if (!isValid) {
showErrorToast(
@@ -215,6 +244,9 @@ 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);
@@ -257,7 +289,6 @@ export default function AttackPathsPage() {
variant: "default",
});
// Scroll to graph after successful query execution
setTimeout(() => {
graphContainerRef.current?.scrollIntoView({
behavior: "smooth",
@@ -297,13 +328,9 @@ export default function AttackPathsPage() {
}
findingNavigationInFlightRef.current = true;
// Findings skip the intermediate node-details modal. The finding drawer
// is the useful destination, so open it directly from the graph click.
// Open finding drawer directly, bypassing the node-details modal.
graphState.enterFilteredView(node.id);
// enterFilteredView stores the filtered node as selected so the graph can
// highlight it. Clear the selection right after for findings so the node
// details modal does not open before the finding drawer.
graphState.selectNode(null);
graphState.selectNode(null); // clear so node-details modal doesn't open first
void handleViewFinding(String(node.properties?.id || node.id));
return;
}
@@ -368,14 +395,19 @@ export default function AttackPathsPage() {
return (
<div className="flex flex-col gap-6">
{/* Auto-refresh scans when there's an executing scan */}
<AutoRefresh
hasExecutingScan={hasExecutingScan}
onRefresh={refreshScans}
/>
{/* Page introduction */}
<div>
{isAttackPathsReplay && !scansLoading && hasReadyScan && (
<AttackPathsReplayTrigger onReplay={startAttackPathsReplay} />
)}
{/* Enables the navbar replay icon once the initial scan load resolves. */}
{!scansLoading && <PageReady />}
<div data-tour-id="attack-paths-intro">
<p className="text-text-neutral-secondary text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
@@ -390,27 +422,27 @@ export default function AttackPathsPage() {
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
<p className="text-sm">Loading scans...</p>
</div>
) : scans.length === 0 ? (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>No scans available</AlertTitle>
<AlertDescription>
<span>
You need to run a scan before you can analyze attack paths.{" "}
<Link href="/scans" className="font-medium underline">
Go to Scan Jobs
</Link>
</span>
</AlertDescription>
</Alert>
) : hasNoScans ? (
<div data-tour-id="attack-paths-empty-scans-cta">
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>No scans available</AlertTitle>
<AlertDescription>
<span>
You need to run a scan before you can analyze attack paths.{" "}
<Link href="/scans" className="font-medium underline">
Go to Scan Jobs
</Link>
</span>
</AlertDescription>
</Alert>
</div>
) : (
<>
{/* Scans Table */}
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
{/* Banner: viewing data from a previous scan cycle */}
{isViewingPreviousCycleData && (
<Alert variant="info">
<Info className="size-4" />
@@ -425,7 +457,6 @@ export default function AttackPathsPage() {
</Alert>
)}
{/* Query Builder Section - shown only after selecting a scan */}
{scanId && (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
{queriesLoading ? (
@@ -438,11 +469,13 @@ export default function AttackPathsPage() {
) : (
<>
<FormProvider {...queryBuilder.form}>
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={queryBuilder.handleQueryChange}
/>
<div data-tour-id="attack-paths-query-selector">
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={queryBuilder.handleQueryChange}
/>
</div>
{queryBuilder.selectedQueryData && (
<QueryDescription
@@ -457,7 +490,10 @@ export default function AttackPathsPage() {
)}
</FormProvider>
<div className="flex justify-end gap-3">
<div
data-tour-id="attack-paths-execute-button"
className="flex justify-end gap-3"
>
<ExecuteButton
isLoading={graphState.loading}
isDisabled={
@@ -476,7 +512,6 @@ export default function AttackPathsPage() {
</div>
)}
{/* Graph Visualization (Full Width) */}
{(graphState.loading ||
(graphState.data &&
graphState.data.nodes &&
@@ -488,7 +523,6 @@ export default function AttackPathsPage() {
graphState.data.nodes &&
graphState.data.nodes.length > 0 ? (
<>
{/* Info message and controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{graphState.isFilteredView ? (
<div className="flex items-center gap-3">
@@ -537,7 +571,6 @@ export default function AttackPathsPage() {
</div>
)}
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">
<GraphControls
onZoomIn={() => graphRef.current?.zoomIn()}
@@ -546,7 +579,6 @@ export default function AttackPathsPage() {
onExport={() => handleGraphExport("main")}
/>
{/* Fullscreen button */}
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
<Dialog
open={isFullscreenOpen}
@@ -604,7 +636,6 @@ export default function AttackPathsPage() {
</div>
</div>
{/* Graph in the middle */}
<div
ref={graphContainerRef}
className="h-[calc(100vh-22rem)]"
@@ -619,7 +650,6 @@ export default function AttackPathsPage() {
/>
</div>
{/* Legend below */}
<div className="flex justify-center overflow-x-auto">
<GraphLegend
data={graphState.data}
@@ -647,3 +677,26 @@ 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;
}
+5 -1
View File
@@ -6,7 +6,11 @@ export default function AttackPathsLayout({
children: React.ReactNode;
}) {
return (
<ContentLayout title="Attack Paths" icon="lucide:git-branch">
<ContentLayout
title="Attack Paths"
icon="lucide:git-branch"
onboardingAction={{ flowId: "attack-paths" }}
>
{children}
</ContentLayout>
);
+26 -18
View File
@@ -46,16 +46,26 @@ export default async function Compliance({
});
if (!scansData?.data) {
return <NoScansAvailable />;
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={{
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
}}
>
<NoScansAvailable />
</ContentLayout>
);
}
// Process scans with provider information from included data
const expandedScansData: ExpandedScanData[] = scansData.data
.filter((scan: ScanProps) => scan.relationships?.provider?.data?.id)
.map((scan: ScanProps) => {
const providerId = scan.relationships!.provider!.data!.id;
// Find the provider data in the included array
const providerData = scansData.included?.find(
(item: { type: string; id: string }) =>
item.type === "providers" && item.id === providerId,
@@ -76,15 +86,20 @@ export default async function Compliance({
})
.filter(Boolean) as ExpandedScanData[];
// Use scanId from URL, or select the first scan if not provided
const scanIdParam = resolvedSearchParams.scanId;
const scanIdFromUrl = Array.isArray(scanIdParam)
? scanIdParam[0]
: scanIdParam;
const selectedScanId: string | null =
scanIdFromUrl || expandedScansData[0]?.id || null;
const onboardingAction = selectedScanId
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
};
// Find the selected scan
const selectedScan = expandedScansData.find(
(scan) => scan.id === selectedScanId,
);
@@ -100,7 +115,6 @@ export default async function Compliance({
}
: undefined;
// Fetch metadata if we have a selected scan
const metadataInfoData = selectedScanId
? await getComplianceOverviewMetadataInfo({
filters: {
@@ -111,7 +125,6 @@ export default async function Compliance({
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
// Fetch ThreatScore data from API if we have a selected scan
let threatScoreData = null;
if (selectedScanId && typeof selectedScanId === "string") {
const threatScoreResponse = await getThreatScore({
@@ -128,10 +141,13 @@ export default async function Compliance({
}
return (
<ContentLayout title="Compliance" icon="lucide:shield-check">
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={onboardingAction}
>
{selectedScanId ? (
<>
{/* Row 1: Filters */}
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
@@ -140,7 +156,6 @@ export default async function Compliance({
/>
</div>
{/* Row 2: ThreatScore card — full width, horizontal */}
{threatScoreData &&
typeof selectedScanId === "string" &&
selectedScan && (
@@ -155,7 +170,6 @@ export default async function Compliance({
</div>
)}
{/* Row 3: Compliance grid with client-side search */}
<Suspense
key={searchParamsKey}
fallback={
@@ -189,7 +203,6 @@ const SSRComplianceGrid = async ({
}) => {
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
// Only fetch compliance data if we have a valid scanId
const compliancesData =
scanId && scanId.trim() !== ""
? await getCompliancesOverview({
@@ -207,7 +220,6 @@ const SSRComplianceGrid = async ({
a.attributes.framework.localeCompare(b.attributes.framework),
);
// Check if the response contains no data
if (
!compliancesData ||
!compliancesData.data ||
@@ -225,7 +237,6 @@ const SSRComplianceGrid = async ({
);
}
// Handle errors returned by the API
if (compliancesData?.errors?.length > 0) {
return (
<Alert variant="info">
@@ -235,10 +246,7 @@ const SSRComplianceGrid = async ({
);
}
// Compute the set of latest CIS variants per provider once, so each card
// can gate its PDF button without re-parsing on every render. The backend
// only generates a CIS PDF for the latest version per provider, so any
// other CIS card must not expose the PDF download button.
// Backend only generates CIS PDFs for the latest version per provider.
const latestCisIds = pickLatestCisPerProvider(
compliancesData.data.map(
(compliance: ComplianceOverviewData) => compliance.id,
+14 -5
View File
@@ -59,7 +59,6 @@ export default async function Findings({
filters: resolvedFilters,
});
// Extract unique regions, services, categories, groups from the new endpoint
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes =
@@ -67,7 +66,6 @@ export default async function Findings({
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
@@ -76,6 +74,14 @@ export default async function Findings({
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const onboardingAction =
completedScanIds.length > 0
? { flowId: "explore-findings" }
: {
flowId: "explore-findings",
fallbackFlowId: "view-first-scan",
useFallback: true,
};
const scanDetails = createScanDetailsMapping(
completedScans || [],
@@ -84,7 +90,11 @@ export default async function Findings({
const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return (
<ContentLayout title="Findings" icon="lucide:tag">
<ContentLayout
title="Findings"
icon="lucide:tag"
onboardingAction={onboardingAction}
>
<FilterTransitionWrapper>
<div className="mb-6">
<FindingsFilters
@@ -146,9 +156,8 @@ const SSRDataTable = async ({
pageSize,
});
// Transform API response to FindingGroupRow[]
const groups = adaptFindingGroupsResponse(findingGroupsData);
// Key resets all client state (selection, drill-down) when data changes
// Key resets client state (selection, drill-down) when data changes.
const groupKey = groups.map((g) => g.id).join(",");
return (
+49 -5
View File
@@ -2,16 +2,24 @@ import "@/styles/globals.css";
import * as Sentry from "@sentry/nextjs";
import { Metadata, Viewport } from "next";
import { ReactNode } from "react";
import { ReactNode, Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScansByState } from "@/actions/scans/scans";
import {
OnboardingCheckpointWatcher,
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import MainLayout from "@/components/ui/main-layout/main-layout";
import { NavigationProgress } from "@/components/ui/navigation-progress";
import { Toaster } from "@/components/ui/toast";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
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";
@@ -41,8 +49,30 @@ export default async function RootLayout({
}: {
children: ReactNode;
}) {
const providersData = await getProviders({ page: 1, pageSize: 1 });
const hasProviders = !!(providersData?.data && providersData.data.length > 0);
// Onboarding is Cloud-only; skip its fetches and orchestrators in OSS.
const onboardingEnabled = isCloud();
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
// progression on a fetch error.
let hasCompletedScan = true;
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
let hasProviders: boolean | undefined = false;
if (onboardingEnabled) {
const [providersData, scansByState] = await Promise.all([
getProviders({ page: 1, pageSize: 1 }),
getScansByState(),
]);
hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.some(
(scan: { attributes?: { state?: string } }) =>
scan.attributes?.state === SCAN_STATES.COMPLETED,
)
: true;
hasProviders = Array.isArray(providersData?.data)
? providersData.data.length > 0
: undefined;
}
return (
<html suppressHydrationWarning lang="en">
@@ -55,8 +85,22 @@ export default async function RootLayout({
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<NavigationProgress />
<StoreInitializer values={{ hasProviders }} />
{/* Suspense contains the useSearchParams() CSR bailout so statically
prerendered pages don't fail the build (matches the auth layout). */}
<Suspense>
<NavigationProgress />
</Suspense>
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
<StoreInitializer values={{ hasProviders: hasProviders ?? false }} />
{onboardingEnabled && (
<>
<OnboardingGate hasProviders={hasProviders} />
{/* Single mount point so the watcher survives post-connect navigation. */}
<OnboardingCheckpointWatcher />
{/* Persistent banner shown only while a guided sequence is active. */}
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
</>
)}
<MainLayout>{children}</MainLayout>
<Toaster />
</Providers>
+14 -9
View File
@@ -22,12 +22,22 @@ export default async function Providers({
const activeTab = getProviderTab(resolvedSearchParams.tab);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
// Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend
const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {};
const searchParamsKey = JSON.stringify(paramsWithoutTab);
// 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 || {};
const searchParamsKey = JSON.stringify(stableParams);
return (
<ContentLayout title="Providers" icon="lucide:cloud-cog">
<ContentLayout
title="Providers"
icon="lucide:cloud-cog"
onboardingAction={{ flowId: "add-provider" }}
>
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
<FilterTransitionWrapper>
<ProviderPageTabs
@@ -58,15 +68,10 @@ const ProvidersTableFallback = () => {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-4">
{/* ProviderTypeSelector */}
<Skeleton className="h-[52px] min-w-[200px] flex-1 rounded-lg md:max-w-[280px]" />
{/* Organizations filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Provider Groups filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Status filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Action buttons */}
<div className="ml-auto flex flex-wrap gap-4">
<Skeleton className="h-9 w-[160px] rounded-md" />
<Skeleton className="h-9 w-[120px] rounded-md" />
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("scans page onboarding", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
it("redirects the scan tour replay to add-provider when providers are missing or disconnected", () => {
expect(source).toContain('redirect("/providers?onboarding=add-provider")');
expect(source).toContain(
'resolvedSearchParams.onboarding === "view-first-scan"',
);
});
it("passes the scan onboarding action to the page header when the tour can run", () => {
expect(source).toContain('flowId: "view-first-scan"');
expect(source).toContain("onboardingAction={onboardingAction}");
});
});
+35 -7
View File
@@ -1,8 +1,10 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
import {
getScanJobsTab,
getScanJobsTabFilters,
@@ -69,19 +71,45 @@ export default async function Scans({
const thereIsNoProviders = providers.length === 0;
const thereIsNoProvidersConnected =
!thereIsNoProviders && connectedProviders.length === 0;
const missingScanPrerequisite =
thereIsNoProviders || thereIsNoProvidersConnected;
if (
missingScanPrerequisite &&
resolvedSearchParams.onboarding === "view-first-scan"
) {
redirect("/providers?onboarding=add-provider");
}
const hasManageScansPermission = Boolean(
session?.user?.permissions?.manage_scans,
);
const activeScanCount =
thereIsNoProviders || thereIsNoProvidersConnected
? 0
: await getActiveScanCount(resolvedSearchParams);
const activeScanCount = missingScanPrerequisite
? 0
: await getActiveScanCount(resolvedSearchParams);
const onboardingAction = missingScanPrerequisite
? {
flowId: "view-first-scan",
fallbackFlowId: "add-provider",
useFallback: true,
}
: { flowId: "view-first-scan" };
return (
<ContentLayout title="Scan Jobs" icon="lucide:timer">
{thereIsNoProviders || thereIsNoProvidersConnected ? (
<ScansProvidersEmptyState thereIsNoProviders={thereIsNoProviders} />
<ContentLayout
title="Scan Jobs"
icon="lucide:timer"
onboardingAction={onboardingAction}
>
{missingScanPrerequisite ? (
<>
{/* The populated branch mounts <PageReady/> inside ScansPageShell to
enable the navbar tour icon. The empty branch must mark the route
ready too, otherwise the icon (which falls back to the add-provider
flow here) stays hidden for users with no connected provider. */}
<PageReady />
<ScansProvidersEmptyState thereIsNoProviders={thereIsNoProviders} />
</>
) : (
<ScansPageShell
providers={providers}
+10 -14
View File
@@ -10,6 +10,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
import {
getScoreIndicatorClass,
@@ -69,20 +70,15 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
};
const navigateToDetail = () => {
const formattedTitleForUrl = encodeURIComponent(title);
const path = `/compliance/${formattedTitleForUrl}`;
const params = new URLSearchParams();
params.set("complianceId", id);
params.set("version", version);
params.set("scanId", scanId);
const regionFilter = searchParams.get("filter[region__in]");
if (regionFilter) {
params.set("filter[region__in]", regionFilter);
}
router.push(`${path}?${params.toString()}`);
router.push(
buildComplianceDetailPath({
title,
complianceId: id,
version,
scanId,
regionFilter: searchParams.get("filter[region__in]"),
}),
);
};
return (
@@ -1,12 +1,26 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ComplianceCard } from "@/components/compliance/compliance-card";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTableSearch } from "@/components/ui/table/data-table-search";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import { getFlowById } from "@/lib/onboarding";
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { ScanEntity } from "@/types/scans";
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;
@@ -25,6 +39,8 @@ export const ComplianceOverviewGrid = ({
selectedScan,
latestCisIds,
}: ComplianceOverviewGridProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState("");
const filteredFrameworks = frameworks.filter((compliance) =>
@@ -33,20 +49,54 @@ export const ComplianceOverviewGrid = ({
.includes(searchTerm.toLowerCase()),
);
const resetSearch = () => {
setSearchTerm("");
return frameworks.length > 0;
};
const openFirstFramework = () => {
const first = 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]"),
}),
);
};
return (
<>
<div className="flex items-center justify-between gap-4">
<DataTableSearch
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search frameworks..."
{/* 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>
<span className="text-text-neutral-secondary shrink-0 text-sm">
{filteredFrameworks.length.toLocaleString()} Total Entries
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{filteredFrameworks.map((compliance) => {
{filteredFrameworks.map((compliance, index) => {
const { attributes, id } = compliance;
const {
framework,
@@ -55,9 +105,8 @@ export const ComplianceOverviewGrid = ({
total_requirements,
} = attributes;
return (
const card = (
<ComplianceCard
key={id}
title={framework}
version={version}
passingRequirements={requirements_passed}
@@ -71,6 +120,22 @@ export const ComplianceOverviewGrid = ({
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
/>
);
// Anchor the tour to a single card, not the whole grid: highlighting the
// grid lit up the entire viewport and scrolled the page to the bottom.
return index === 0 ? (
<div
key={id}
data-tour-id="view-compliance-frameworks"
className="h-full [&>*]:h-full"
>
{card}
</div>
) : (
<div key={id} className="h-full [&>*]:h-full">
{card}
</div>
);
})}
</div>
</>
+16 -18
View File
@@ -95,7 +95,6 @@ export const FindingsFilterBatchControls = ({
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
// Custom filters for the expandable section.
const customFilters = [
...filterFindings
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
@@ -182,8 +181,6 @@ export const FindingsFilterBatchControls = ({
const showAppliedRow = appliedFilterChips.length > 0;
const showPendingRow = hasChanges;
// Handler for removing a single chip: update the pending filter to remove that value.
// setPending handles both "filter[key]" and "key" formats internally.
const handleChipRemove = (filterKey: string, value?: string) => {
if (value === undefined) {
setPending(filterKey, []);
@@ -195,7 +192,6 @@ export const FindingsFilterBatchControls = ({
setPending(filterKey, nextValues);
};
// For the date picker, read from pendingFilters
const pendingDateValues = pendingFilters["filter[inserted_at]"];
const pendingDateValue =
pendingDateValues && pendingDateValues.length > 0
@@ -333,19 +329,21 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
});
return (
<FindingsFilterBatchControls
{...props}
appliedFilters={appliedFilters}
pendingFilters={pendingFilters}
changedFilters={changedFilters}
setPending={setPending}
applyAll={applyAll}
discardAll={discardAll}
clearAndApply={clearAndApply}
removeAppliedAndApply={removeAppliedAndApply}
hasChanges={hasChanges}
changeCount={changeCount}
getFilterValue={getFilterValue}
/>
<div data-tour-id="explore-findings-filters">
<FindingsFilterBatchControls
{...props}
appliedFilters={appliedFilters}
pendingFilters={pendingFilters}
changedFilters={changedFilters}
setPending={setPending}
applyAll={applyAll}
discardAll={discardAll}
clearAndApply={clearAndApply}
removeAppliedAndApply={removeAppliedAndApply}
hasChanges={hasChanges}
changeCount={changeCount}
getFilterValue={getFilterValue}
/>
</div>
);
};
@@ -9,17 +9,47 @@ vi.mock("next/navigation", () => ({
refresh: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/findings",
}));
vi.mock("@/components/ui/table", () => ({
DataTable: ({ toolbarRightContent }: { toolbarRightContent?: ReactNode }) => (
DataTable: ({
data,
toolbarRightContent,
getRowAttributes,
}: {
data?: Array<{ checkId?: string }>;
toolbarRightContent?: ReactNode;
getRowAttributes?: (row: {
index: number;
original: { checkId?: string };
}) => Record<string, string | undefined>;
}) => (
<div>
<div data-testid="table-toolbar-right">{toolbarRightContent}</div>
<span>10 Total Entries</span>
<table>
<tbody>
{(data ?? []).map((original, index) => (
<tr
key={original.checkId ?? index}
data-testid={`row-${index}`}
{...getRowAttributes?.({ index, original })}
>
<td>{original.checkId}</td>
</tr>
))}
</tbody>
</table>
</div>
),
}));
vi.mock("@/components/onboarding", () => ({
OnboardingTrigger: () => <div data-testid="onboarding-trigger" />,
PageReady: () => <div data-testid="page-ready" />,
}));
vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({
CustomCheckboxMutedFindings: () => (
<label>
@@ -75,4 +105,68 @@ describe("FindingsGroupTable", () => {
expect(toolbar).toHaveTextContent("Include muted findings");
});
});
describe("explore-findings tour gating", () => {
it("does not mount the tour trigger when there are no finding groups", () => {
// Given an empty table (e.g. a scan is still running)
render(
<FindingsGroupTable
data={[]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then the tour never starts — there is no first-row anchor for the
// "Open a finding group" step to resolve, which would otherwise throw.
expect(
screen.queryByTestId("onboarding-trigger"),
).not.toBeInTheDocument();
// PageReady still signals the navbar that the route's data has loaded.
expect(screen.getByTestId("page-ready")).toBeInTheDocument();
});
it("mounts the tour trigger once at least one finding group exists", () => {
// Given a populated table
const data = [{ checkId: "check-a" }] as unknown as Parameters<
typeof FindingsGroupTable
>[0]["data"];
render(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then the explore-findings tour is allowed to start.
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
});
});
describe("onboarding anchor", () => {
it("anchors the finding-group tour step to the first row only", () => {
// Given two finding groups (the tour must point at the first, even if there is one)
const data = [
{ checkId: "check-a" },
{ checkId: "check-b" },
] as unknown as Parameters<typeof FindingsGroupTable>[0]["data"];
render(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then driver.js resolves `[data-tour-id="explore-findings-group"]` to the first row.
expect(screen.getByTestId("row-0")).toHaveAttribute(
"data-tour-id",
"explore-findings-group",
);
expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
});
});
});
@@ -2,12 +2,15 @@
import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import { Suspense, useRef, useState } from "react";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTable } from "@/components/ui/table";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
@@ -19,6 +22,8 @@ import {
InlineResourceContainerHandle,
} from "./inline-resource-container";
const exploreFindingsFlow = getFlowById("explore-findings")!;
function buildMuteLabel(groupCount: number, resourceCount: number): string {
const parts: string[] = [];
if (groupCount > 0) {
@@ -52,23 +57,17 @@ export function FindingsGroupTable({
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
null,
);
// Separate display state (updates on keystroke) from committed search (updates on Enter only).
// This prevents InlineResourceContainer from remounting on every keystroke.
// Separate input (keystroke) from committed search (Enter) to avoid remounting InlineResourceContainer.
const [resourceSearchInput, setResourceSearchInput] = useState("");
const [resourceSearch, setResourceSearch] = useState("");
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
const inlineRef = useRef<InlineResourceContainerHandle>(null);
// State resets (selection, drill-down) are handled by the parent via
// key={groupKey} — when data changes, the component remounts with fresh state.
const safeData = data ?? [];
const hasResourceSelection = resourceSelection.length > 0;
const filters = resolvedFilters;
// Get selected group check IDs. When the expanded group has individual resource
// selections, exclude it from group-level mute targets — the resource-level
// FloatingMuteButton handles those.
// Exclude expanded group from group-level mutes when it has resource selections.
const selectedCheckIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => safeData[parseInt(idx)]?.checkId)
@@ -82,7 +81,6 @@ export function FindingsGroupTable({
.map((idx) => safeData[parseInt(idx)])
.filter(Boolean);
// Count of selectable rows (groups where not ALL findings are muted)
const selectableRowCount = safeData.filter((g) =>
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
@@ -128,7 +126,6 @@ export function FindingsGroupTable({
return Array.from(new Set(results.flat()));
};
/** Shared resolver for group row action dropdowns (via context). */
const resolveMuteIds = async (checkIds: string[]) =>
resolveGroupMuteIds(checkIds);
@@ -141,10 +138,9 @@ export function FindingsGroupTable({
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
// No resources in the group → nothing to show, skip drill-down
if (!canDrillDownFindingGroup(group)) return;
// Toggle: same group = collapse, different = switch
// Toggle: same group collapses, different group switches
if (expandedCheckId === checkId) {
handleCollapse();
return;
@@ -164,6 +160,19 @@ export function FindingsGroupTable({
setResourceSelection([]);
};
// Drives the onboarding "Open a finding group" step: opens the first row when
// drillable, otherwise the first drillable group. Returns false when none can
// open so the tour skips the resources step instead of hanging.
const openFirstFindingGroup = (): boolean => {
const target =
safeData[0] && canDrillDownFindingGroup(safeData[0])
? safeData[0]
: safeData.find((group) => canDrillDownFindingGroup(group));
if (!target) return false;
handleDrillDown(target.checkId, target);
return true;
};
const columns = getColumnFindingGroups({
rowSelection,
selectableRowCount,
@@ -201,29 +210,49 @@ export function FindingsGroupTable({
resolveMuteIds,
}}
>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
controlledSearch={expandedCheckId ? resourceSearchInput : undefined}
onSearchChange={expandedCheckId ? setResourceSearchInput : undefined}
onSearchCommit={expandedCheckId ? setResourceSearch : undefined}
searchBadge={
expandedGroup
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
: undefined
}
toolbarRightContent={<CustomCheckboxMutedFindings />}
renderAfterRow={renderAfterRow}
/>
{/* Gate the tour on having at least one finding group */}
<div>
<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}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
controlledSearch={expandedCheckId ? resourceSearchInput : undefined}
onSearchChange={expandedCheckId ? setResourceSearchInput : undefined}
onSearchCommit={expandedCheckId ? setResourceSearch : undefined}
searchBadge={
expandedGroup
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
: undefined
}
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" } : {}
}
/>
</div>
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
<FloatingMuteButton
@@ -238,7 +267,6 @@ export function FindingsGroupTable({
selectedCheckIds.length > 0
? resolveGroupMuteIds(selectedCheckIds)
: Promise.resolve([]),
// resourceSelection already contains real finding UUIDs
Promise.resolve(hasResourceSelection ? resourceSelection : []),
]);
return [...groupIds, ...resourceIds];
@@ -217,6 +217,8 @@ export function InlineResourceContainer({
<td colSpan={columnCount} className="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 }}
-66
View File
@@ -1322,69 +1322,3 @@ export const BellIcon: React.FC<IconSvgProps> = ({
</svg>
);
};
export const SidebarExpandIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.25 10L5.5 12L7.25 14M9.5 21V3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export const SidebarCollapseIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.75 10L18.5 12L16.75 14M14.5 21V3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
@@ -0,0 +1,109 @@
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();
});
});
});
@@ -0,0 +1,112 @@
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 }),
},
}));
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));
});
});
});
@@ -0,0 +1,185 @@
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 { addProviderTour } from "@/lib/tours/add-provider.tour";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { OnboardingGate } from "../onboarding-gate";
const pushMock = vi.fn();
const armMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
useOnboardingCheckpointStore: {
getState: () => ({ arm: armMock }),
},
}));
const addProviderTourId = {
id: addProviderTour.id,
version: addProviderTour.version,
};
describe("OnboardingGate", () => {
beforeEach(() => {
window.localStorage.clear();
pushMock.mockClear();
armMock.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("when the user has no providers and no completion record", () => {
it("shows the Welcome modal", async () => {
render(<OnboardingGate hasProviders={false} />);
expect(
await screen.findByRole("button", { name: /get started/i }),
).toBeInTheDocument();
});
});
describe("when the user already has providers", () => {
it("does not show the Welcome modal", async () => {
render(<OnboardingGate hasProviders={true} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when a completion record already exists in this browser", () => {
it("does not show the Welcome modal", async () => {
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
render(<OnboardingGate hasProviders={false} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when the gate flow is dismissed but later sequence flows are incomplete", () => {
it("does not show the Welcome modal for a later flow", async () => {
// Later flows are only reachable via the checkpoint/sequence, never the gate.
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
render(<OnboardingGate hasProviders={false} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when hasProviders is undefined (fail-open)", () => {
it("does not show the Welcome modal", async () => {
// `undefined` mirrors the tri-state layout forwards on a failed provider fetch.
render(<OnboardingGate hasProviders={undefined} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
it("can be mounted with the prop omitted entirely (fail-open)", async () => {
render(<OnboardingGate />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when the user accepts the Welcome modal", () => {
it("navigates to the flow route with the onboarding query param and writes no record", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const getStarted = await screen.findByRole("button", {
name: /get started/i,
});
await user.click(getStarted);
expect(pushMock).toHaveBeenCalledWith(
"/providers?onboarding=add-provider",
);
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
});
it("arms the onboarding checkpoint", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const getStarted = await screen.findByRole("button", {
name: /get started/i,
});
await user.click(getStarted);
expect(armMock).toHaveBeenCalledTimes(1);
});
});
describe("when the user dismisses the Welcome modal", () => {
it("writes a dismissed record and stops showing the modal", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const skip = await screen.findByRole("button", {
name: /skip for now/i,
});
await user.click(skip);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /skip for now/i }),
).not.toBeInTheDocument();
});
const record = localStorageAdapter.get(addProviderTourId);
expect(record).not.toBeNull();
expect(record?.state).toBe("dismissed");
});
it("does NOT arm the onboarding checkpoint", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const skip = await screen.findByRole("button", {
name: /skip for now/i,
});
await user.click(skip);
// Skipping must never arm the checkpoint (user opted out).
expect(armMock).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,63 @@
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();
});
});
@@ -0,0 +1,193 @@
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();
});
});
});
@@ -0,0 +1,164 @@
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");
});
});
@@ -0,0 +1,300 @@
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("NEXT_PUBLIC_IS_CLOUD_ENV", "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("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("NEXT_PUBLIC_IS_CLOUD_ENV", "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("NEXT_PUBLIC_IS_CLOUD_ENV", "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);
});
});
});
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { OnboardingWelcomeModal } from "../onboarding-welcome-modal";
describe("OnboardingWelcomeModal", () => {
describe("when open is true", () => {
it("renders the flow title and description", () => {
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
flowDescription="Connect a cloud account so Prowler has something to scan."
onAccept={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(screen.getByText("Add your first provider")).toBeInTheDocument();
expect(
screen.getByText(
"Connect a cloud account so Prowler has something to scan.",
),
).toBeInTheDocument();
});
it("calls onAccept when the primary action is clicked", async () => {
const user = userEvent.setup();
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
onAccept={onAccept}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /get started/i }));
expect(onAccept).toHaveBeenCalledTimes(1);
expect(onDismiss).not.toHaveBeenCalled();
});
it("calls onDismiss when the skip action is clicked", async () => {
const user = userEvent.setup();
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
onAccept={onAccept}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /skip for now/i }));
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onAccept).not.toHaveBeenCalled();
});
});
describe("when open is false", () => {
it("does not render the modal content", () => {
render(
<OnboardingWelcomeModal
open={false}
flowTitle="Add your first provider"
onAccept={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,5 @@
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";
@@ -0,0 +1,38 @@
"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>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useRouter } from "next/navigation";
import { getOrderedFlows } from "@/lib/onboarding";
import {
CHECKPOINT_MARKER,
useOnboardingCheckpointStore,
} from "@/store/onboarding-checkpoint";
import { useOnboardingSequenceStore } from "@/store/onboarding-sequence";
import { OnboardingCheckpointDialog } from "./onboarding-checkpoint-dialog";
// 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() {
const router = useRouter();
const open = useOnboardingCheckpointStore((state) => state.open);
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();
};
return (
<OnboardingCheckpointDialog
open={open}
onContinue={handleContinue}
onFinish={handleFinish}
/>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getOrderedFlows, shouldStartOnboarding } from "@/lib/onboarding";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { TOUR_COMPLETION_STATES } from "@/lib/tours/tour-types";
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint";
import { OnboardingWelcomeModal } from "./onboarding-welcome-modal";
interface OnboardingGateProps {
// `undefined` = fetch failed/ambiguous; fail-open (never force the modal).
hasProviders?: boolean;
}
// Mandatory new-user gate. Mounted once in the layout; decision derived during render
// via useSyncExternalStore — server renders nothing, no hydration mismatch.
export function OnboardingGate({ hasProviders }: OnboardingGateProps) {
const router = useRouter();
// 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 — gate stays closed until resolved client-side.
const completionRecord = useTourCompletion(flow?.tour ?? null);
// Session flag prevents the gate re-opening after accept/dismiss within this mount.
const [resolvedThisSession, setResolvedThisSession] = useState(false);
const activeFlow =
flow &&
!resolvedThisSession &&
shouldStartOnboarding({ hasProviders, completionRecord })
? flow
: null;
if (!activeFlow) return null;
const handleAccept = () => {
// Arm checkpoint only on explicit accept — skip must never arm it.
useOnboardingCheckpointStore.getState().arm();
setResolvedThisSession(true);
// Routes may already carry a query string, so pick the right separator.
const separator = activeFlow.route.includes("?") ? "&" : "?";
router.push(`${activeFlow.route}${separator}onboarding=${activeFlow.id}`);
};
const handleDismiss = () => {
// Persist dismissal so the gate silently skips on future visits.
localStorageAdapter.set(
{ id: activeFlow.tour.id, version: activeFlow.tour.version },
{
tourId: activeFlow.tour.id,
version: activeFlow.tour.version,
state: TOUR_COMPLETION_STATES.DISMISSED,
completedAt: new Date().toISOString(),
},
);
setResolvedThisSession(true);
};
return (
<OnboardingWelcomeModal
open
flowTitle={activeFlow.title}
flowDescription={activeFlow.description}
onAccept={handleAccept}
onDismiss={handleDismiss}
/>
);
}
@@ -0,0 +1,32 @@
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,
};
}
@@ -0,0 +1,100 @@
"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>
);
}
@@ -0,0 +1,48 @@
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";
}
@@ -0,0 +1,165 @@
"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>;
}
// 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,
}: 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}
/>
);
}
interface OnboardingTourRunnerProps<TTarget extends string> {
flow: OnboardingFlow;
mode: OnboardingSequenceMode;
queryString: string;
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
configOverrides?: Partial<Config>;
}
function OnboardingTourRunner<TTarget extends string>({
flow,
mode,
queryString,
stepHandlers,
configOverrides,
}: 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();
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;
}
@@ -0,0 +1,42 @@
"use client";
import { Button } from "@/components/shadcn";
import { DialogFooter } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal/modal";
interface OnboardingWelcomeModalProps {
open: boolean;
flowTitle?: string;
flowDescription?: string;
onAccept: () => void;
onDismiss: () => void;
}
export function OnboardingWelcomeModal({
open,
flowTitle,
flowDescription,
onAccept,
onDismiss,
}: OnboardingWelcomeModalProps) {
return (
<Modal
open={open}
title={flowTitle}
description={flowDescription}
size="lg"
// Overlay/Escape/X counts as dismiss — gate persists the record once.
onOpenChange={(next) => {
if (!next) onDismiss();
}}
>
<DialogFooter>
{/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
<Button variant="outline" onClick={onDismiss}>
Skip for now
</Button>
<Button onClick={onAccept}>Get started</Button>
</DialogFooter>
</Modal>
);
}
+25
View File
@@ -0,0 +1,25 @@
"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,7 +23,9 @@ export const AddProviderButton = ({ onOpenWizard }: AddProviderButtonProps) => {
return (
<>
<Button onClick={handleOpen}>Add Provider</Button>
<Button data-tour-id="add-provider-trigger" onClick={handleOpen}>
Add Provider
</Button>
{!onOpenWizard && (
<ProviderWizardModal open={open} onOpenChange={setOpen} />
)}
@@ -13,6 +13,8 @@ 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 {
@@ -40,7 +42,7 @@ const renderCta = (props: NoProvidersAddedProps) => {
className="w-full max-w-xs justify-center"
size="lg"
>
<Link href={props.href}>Get Started</Link>
<Link href={props.href}>Add a Provider</Link>
</Button>
);
}
@@ -49,10 +51,11 @@ 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}
>
Get Started
Add a Provider
</Button>
);
};
@@ -1,8 +1,9 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Suspense, 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";
@@ -13,13 +14,37 @@ import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import { getFlowById } from "@/lib/onboarding";
import {
ADD_PROVIDER_SEARCH_PARAM,
ADD_PROVIDER_SEARCH_VALUE,
} 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 { ProvidersTableRow } from "@/types/providers-table";
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[];
@@ -54,6 +79,9 @@ 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) => {
@@ -70,12 +98,8 @@ export function ProvidersAccountsView({
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
// Only clean the one-shot ?addProvider intent from the URL bar, via the
// History API so it does NOT trigger an RSC refetch. We must not refresh
// here: the provider-creation actions (addProvider / addCredentialsProvider
// / checkConnectionProvider) already revalidatePath("/providers"), so the
// table updates behind the modal. A router.refresh()/replace() on close
// re-ran the whole /providers Server Component, which read as a full reload.
// Remove ?addProvider via History API (not router.replace) to avoid an RSC refetch;
// revalidatePath in the creation actions already refreshes the table.
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(ADD_PROVIDER_SEARCH_PARAM);
@@ -90,11 +114,21 @@ export function ProvidersAccountsView({
return (
<>
{/* Suspense required: OnboardingTrigger reads useSearchParams */}
<Suspense fallback={null}>
<OnboardingTrigger
flow={addProviderFlow}
configOverrides={ADD_PROVIDER_TOUR_CONFIG}
/>
</Suspense>
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
<PageReady />
{hasNoProviders ? (
<NoProvidersAdded
action="button"
containerClassName="min-h-[calc(100dvh-28rem)]"
onOpenWizard={() => openProviderWizard()}
ctaTourId="add-provider-trigger"
/>
) : (
<div className="flex flex-col gap-6">
@@ -12,8 +12,9 @@ import {
import type { ProviderWizardInitialData } from "../types";
import { useProviderWizardController } from "./use-provider-wizard-controller";
const { refreshMock } = vi.hoisted(() => ({
const { refreshMock, requestOpenOnWizardCloseMock } = vi.hoisted(() => ({
refreshMock: vi.fn(),
requestOpenOnWizardCloseMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
@@ -22,6 +23,14 @@ vi.mock("next/navigation", () => ({
}),
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
useOnboardingCheckpointStore: {
getState: () => ({
requestOpenOnWizardClose: requestOpenOnWizardCloseMock,
}),
},
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: null,
@@ -33,8 +42,11 @@ describe("useProviderWizardController", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
requestOpenOnWizardCloseMock.mockClear();
sessionStorage.clear();
localStorage.clear();
// Checkpoint is Cloud-only.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
useProviderWizardStore.getState().reset();
useOrgSetupStore.getState().reset();
});
@@ -59,6 +71,78 @@ 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("NEXT_PUBLIC_IS_CLOUD_ENV", "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("hydrates update mode when initial data is provided", async () => {
// Given
const onOpenChange = vi.fn();
@@ -181,7 +265,7 @@ describe("useProviderWizardController", () => {
result.current.handleTestSuccess();
});
// Then: credential rotation never surfaces the launch/schedule step
// Credential rotation skips the launch/schedule step.
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH);
@@ -280,7 +364,7 @@ describe("useProviderWizardController", () => {
result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
});
// When: provider data refreshes while modal is still open
// Provider data refreshes while modal is still open — user progress must be kept.
rerender({
open: true,
initialData: {
@@ -293,7 +377,6 @@ describe("useProviderWizardController", () => {
},
});
// Then: keep user progress in the current flow
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.TEST);
expect(useProviderWizardStore.getState().via).toBe("service-account");
});
@@ -4,6 +4,9 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
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 {
@@ -50,9 +53,7 @@ interface UseProviderWizardControllerProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
// When false, the caller skips the post-close router.refresh() and relies on
// the provider-creation actions' revalidatePath("/providers") to refresh the
// data. Defaults to true so standalone callers keep refreshing.
// When false, skips post-close router.refresh() — caller relies on revalidatePath instead.
refreshOnClose?: boolean;
}
@@ -181,6 +182,13 @@ export function useProviderWizardController({
const isOrgDirectEntry = Boolean(orgInitialData);
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;
resetProviderWizard();
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.PROVIDER);
@@ -190,6 +198,14 @@ export function useProviderWizardController({
setProviderTypeHint(null);
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();
}
@@ -214,6 +230,9 @@ export function useProviderWizardController({
};
const openOrganizationsFlow = () => {
// 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();
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
@@ -9,6 +9,7 @@ import { Button } from "@/components/shadcn/button/button";
import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
@@ -129,188 +130,212 @@ export function ProviderWizardModal({
</div>
<div aria-hidden className="hidden w-[100px] min-w-0 shrink lg:block" />
<div className="relative flex-1 overflow-hidden">
<div
ref={containerRef}
className="minimal-scrollbar h-full w-full overflow-y-scroll [scrollbar-gutter:stable] lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]"
>
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.CONNECT && (
<ConnectStep
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)}
onSelectOrganizations={openOrganizationsFlow}
onFooterChange={setFooterConfig}
onProviderTypeChange={setProviderTypeHint}
/>
)}
{/* Anchors the add-provider tour's final step to the whole right section —
the form inputs AND the footer — so the spotlight covers the Next button
instead of leaving it under the overlay. The popover still pins to the left
of this section, beside the inputs. */}
<div
data-tour-id="add-provider-wizard-body"
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}
className="minimal-scrollbar h-full w-full overflow-y-scroll [scrollbar-gutter:stable] lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]"
>
{isProviderFlow &&
currentStep === PROVIDER_WIZARD_STEP.CONNECT && (
<ConnectStep
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();
}}
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();
setProviderTypeHint(providerType);
}}
/>
)}
{isProviderFlow &&
currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && (
<CredentialsStep
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
{isProviderFlow &&
currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && (
<CredentialsStep
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
<TestConnectionStep
onSuccess={handleTestSuccess}
onResetCredentials={() =>
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)
}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
<TestConnectionStep
onSuccess={handleTestSuccess}
onResetCredentials={() =>
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)
}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow &&
currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
<LaunchStep
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onClose={handleClose}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
<LaunchStep
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onClose={handleClose}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
<OrgSetupForm
onBack={isOrgDirectEntry ? handleClose : backToProviderFlow}
onClose={handleClose}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
onPhaseChange={setOrgSetupPhase}
initialPhase={orgSetupPhase}
initialValues={
orgInitialData
? {
organizationName: orgInitialData.organizationName,
awsOrgId: orgInitialData.externalId,
}
: undefined
}
intent={orgInitialData?.intent}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
<OrgSetupForm
onBack={isOrgDirectEntry ? handleClose : backToProviderFlow}
onClose={handleClose}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
onPhaseChange={setOrgSetupPhase}
initialPhase={orgSetupPhase}
initialValues={
orgInitialData
? {
organizationName: orgInitialData.organizationName,
awsOrgId: orgInitialData.externalId,
}
: undefined
}
intent={orgInitialData?.intent}
/>
)}
{!isProviderFlow &&
orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
<OrgAccountSelection
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS);
}}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
}}
onSkip={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
}}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
<OrgAccountSelection
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.SETUP);
setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS);
}}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
}}
onSkip={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH);
}}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
<OrgLaunchScan
onClose={handleClose}
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && (
<OrgLaunchScan
onClose={handleClose}
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
/>
)}
{/* Sentinel element for IntersectionObserver scroll detection */}
<div ref={sentinelRef} aria-hidden className="h-px shrink-0" />
</div>
{/* Sentinel element for IntersectionObserver scroll detection */}
<div ref={sentinelRef} aria-hidden className="h-px shrink-0" />
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-0 z-10">
<div className="from-bg-neutral-secondary h-12 bg-gradient-to-t to-transparent" />
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<span className="bg-bg-neutral-secondary/85 text-text-neutral-tertiary rounded-full px-3 py-1 text-xs backdrop-blur-sm">
Scroll to see more
</span>
</div>
</div>
)}
</div>
{/* Inside the highlighted right section so the tour spotlight covers the Next
button (no longer dimmed by the overlay). Aligned to the form width so Next
stays at the right edge. */}
{(resolvedFooterConfig.showBack ||
resolvedFooterConfig.showSecondaryAction ||
resolvedFooterConfig.showAction) && (
<div className="mt-8 w-full pt-6 lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]">
<div className="flex items-center justify-between">
<div>
{resolvedFooterConfig.showBack && (
<Button
type="button"
variant="outline"
size="xl"
disabled={resolvedFooterConfig.backDisabled}
onClick={resolvedFooterConfig.onBack}
>
{resolvedFooterConfig.backLabel}
</Button>
)}
</div>
<div className="flex items-center gap-6">
{resolvedFooterConfig.showSecondaryAction && (
<Button
size={
resolvedFooterConfig.secondaryActionVariant === "link"
? "link-sm"
: "xl"
}
className={
resolvedFooterConfig.secondaryActionVariant === "link"
? "h-auto p-0"
: undefined
}
variant={resolvedFooterConfig.secondaryActionVariant}
type={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.secondaryActionFormId}
disabled={resolvedFooterConfig.secondaryActionDisabled}
onClick={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onSecondaryAction
: undefined
}
>
{resolvedFooterConfig.secondaryActionLabel}
</Button>
)}
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-0 z-10">
<div className="from-bg-neutral-secondary h-12 bg-gradient-to-t to-transparent" />
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<span className="bg-bg-neutral-secondary/85 text-text-neutral-tertiary rounded-full px-3 py-1 text-xs backdrop-blur-sm">
Scroll to see more
</span>
{resolvedFooterConfig.showAction && (
<Button
size="xl"
type={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.actionFormId}
disabled={resolvedFooterConfig.actionDisabled}
onClick={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onAction
: undefined
}
>
{resolvedFooterConfig.actionLabel}
</Button>
)}
</div>
</div>
</div>
)}
</div>
</div>
{(resolvedFooterConfig.showBack ||
resolvedFooterConfig.showSecondaryAction ||
resolvedFooterConfig.showAction) && (
<div className="mt-8 pt-6">
<div className="flex items-center justify-between">
<div>
{resolvedFooterConfig.showBack && (
<Button
type="button"
variant="outline"
size="xl"
disabled={resolvedFooterConfig.backDisabled}
onClick={resolvedFooterConfig.onBack}
>
{resolvedFooterConfig.backLabel}
</Button>
)}
</div>
<div className="flex items-center gap-6">
{resolvedFooterConfig.showSecondaryAction && (
<Button
size={
resolvedFooterConfig.secondaryActionVariant === "link"
? "link-sm"
: "xl"
}
className={
resolvedFooterConfig.secondaryActionVariant === "link"
? "h-auto p-0"
: undefined
}
variant={resolvedFooterConfig.secondaryActionVariant}
type={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.secondaryActionFormId}
disabled={resolvedFooterConfig.secondaryActionDisabled}
onClick={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onSecondaryAction
: undefined
}
>
{resolvedFooterConfig.secondaryActionLabel}
</Button>
)}
{resolvedFooterConfig.showAction && (
<Button
size="xl"
type={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.actionFormId}
disabled={resolvedFooterConfig.actionDisabled}
onClick={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onAction
: undefined
}
>
{resolvedFooterConfig.actionLabel}
</Button>
)}
</div>
</div>
</div>
)}
</Modal>
);
}
@@ -147,7 +147,6 @@ function applyBackStep({
setPrevStep: Dispatch<SetStateAction<number>>;
setAwsMethod: Dispatch<SetStateAction<"single" | null>>;
}) {
// If in UID form after choosing single, go back to method selector
if (prevStep === 2 && awsMethod === "single") {
setAwsMethod(null);
form.setValue("providerUid", "", { shouldValidate: false });
@@ -156,17 +155,14 @@ function applyBackStep({
}
setPrevStep((prev) => prev - 1);
// Deselect the providerType if the user is going back to the first step
if (prevStep === 2) {
form.setValue("providerType", undefined as unknown as ProviderType, {
shouldValidate: false,
});
setAwsMethod(null);
}
// Reset the providerUid and providerAlias fields when going back
form.setValue("providerUid", "", { shouldValidate: false });
form.setValue("providerAlias", "", { shouldValidate: false });
// Clear all validation errors so the radio buttons don't show red borders
form.clearErrors();
}
@@ -215,7 +211,6 @@ export const ConnectAccountForm = ({
const data = await addProvider(formData);
if (data?.errors && data.errors.length > 0) {
// Handle server-side validation errors
data.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
const pointer = error.source?.pointer;
@@ -250,7 +245,6 @@ export const ConnectAccountForm = ({
});
return;
} else {
// Go to the next step after successful submission
const {
id,
attributes: { provider: createdProviderType, uid, alias },
@@ -347,11 +341,13 @@ export const ConnectAccountForm = ({
>
{/* Step 1: Provider selection */}
{prevStep === 1 && (
<RadioGroupProvider
control={form.control}
isInvalid={!!form.formState.errors.providerType}
errorMessage={form.formState.errors.providerType?.message}
/>
<div data-tour-id="add-provider-provider-type">
<RadioGroupProvider
control={form.control}
isInvalid={!!form.formState.errors.providerType}
errorMessage={form.formState.errors.providerType?.message}
/>
</div>
)}
{/* Step 2: AWS method selector (only for AWS, before choosing method) */}
{prevStep === 2 && providerType === "aws" && awsMethod === null && (
+10 -3
View File
@@ -302,9 +302,10 @@ describe("ScansPageShell", () => {
expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i);
});
it("strips the launchScan URL param when closing the URL-opened modal", async () => {
it("strips the launchScan URL param via the History API when closing the URL-opened modal", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=completed&launchScan=true";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
@@ -316,11 +317,17 @@ describe("ScansPageShell", () => {
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith(
// History API (not router.replace) so Next.js does not refetch and reload the page;
// scanOnDemand's revalidatePath already refreshes the list when a scan launches.
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
"/scans?tab=completed",
expect.objectContaining({ scroll: false }),
);
expect(replaceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
replaceStateSpy.mockRestore();
});
it("opens and closes the launch scan modal from client state without navigation", async () => {
+34 -7
View File
@@ -1,8 +1,9 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { type ReactNode, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { type ReactNode, Suspense, useState } from "react";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import {
Button,
@@ -11,14 +12,18 @@ 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 { 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 { ProviderProps } from "@/types/providers";
const viewFirstScanFlow = getFlowById("view-first-scan")!;
import { CliImportBanner } from "./cli-import-banner";
import { LaunchScanModal } from "./launch-scan-modal";
import { ScansFilterBar } from "./scans-filter-bar";
@@ -37,7 +42,6 @@ export function ScansPageShell({
activeScanCount = 0,
children,
}: ScansPageShellProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [urlLaunchOpen, setUrlLaunchOpen] = useState(
@@ -57,6 +61,9 @@ export function ScansPageShell({
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
const launchOpen = isLaunchScanModalOpen || urlLaunchOpen;
// When a scan is already running, the tour highlights its row (anchored in
// ScanJobsTable); otherwise it falls back to the Launch Scan button + tabs.
const hasInProgressScan = activeScanCount > 0;
const getTabLabel = (tab: ScanJobsTab) => {
const label = SCAN_TAB_LABELS[tab];
@@ -69,17 +76,33 @@ export function ScansPageShell({
setLaunchScanModalOpen(open);
if (open) return;
setUrlLaunchOpen(false);
// Remove ?launchScan via History API (not router.replace) to avoid an RSC
// refetch that reloads the page; revalidatePath in scanOnDemand already
// refreshes the scans list when a scan is launched.
if (!searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) return;
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
window.history.replaceState(
null,
"",
query ? `${pathname}?${query}` : pathname,
);
};
return (
<div className="flex flex-col gap-[18px]">
{/* Suspense required: OnboardingTrigger reads useSearchParams */}
<Suspense fallback={null}>
<OnboardingTrigger
flow={{
...viewFirstScanFlow,
tour: buildViewFirstScanTour(hasInProgressScan),
}}
/>
</Suspense>
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
<PageReady />
<div
role="group"
aria-label="Scan filters and actions"
@@ -101,6 +124,7 @@ export function ScansPageShell({
onClick={() => handleLaunchOpenChange(true)}
disabled={launchDisabled}
className="w-full md:w-auto"
data-tour-id="view-first-scan-launch"
>
Launch Scan
</Button>
@@ -118,7 +142,10 @@ export function ScansPageShell({
aria-label="Scan tabs"
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<TabsList className="overflow-x-auto">
<TabsList
className="overflow-x-auto"
data-tour-id="view-first-scan-tabs"
>
{Object.values(SCAN_JOBS_TAB).map((tab) => (
<TabsTrigger key={tab} value={tab}>
{getTabLabel(tab as ScanJobsTab)}
@@ -0,0 +1,77 @@
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/ui/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");
});
});
@@ -2,6 +2,7 @@
import { DataTable } from "@/components/ui/table";
import type { MetaDataProps, ScanJobsTab, ScanProps } from "@/types";
import { SCAN_JOBS_TAB } from "@/types";
import { AutoRefresh } from "../auto-refresh";
import { NoScansEmptyState } from "../no-scans-empty-state";
@@ -41,6 +42,14 @@ 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" }
: {}
}
/>
)}
</>
@@ -32,4 +32,29 @@ describe("Button", () => {
"text-xs",
);
});
it("supports extra-small icon buttons", () => {
render(
<Button size="icon-xs" aria-label="Open tour">
Open tour
</Button>,
);
expect(screen.getByRole("button", { name: "Open tour" })).toHaveClass(
"size-7",
);
});
it("renders the bare variant chrome-free (no background or border)", () => {
render(
<Button variant="bare" size="icon-sm" aria-label="Toggle sidebar">
<svg />
</Button>,
);
const button = screen.getByRole("button", { name: "Toggle sidebar" });
expect(button).toHaveClass("bg-transparent");
expect(button).toHaveClass("border-0");
expect(button).toHaveClass("p-0");
});
});
+4
View File
@@ -22,6 +22,9 @@ const buttonVariants = cva(
ghost:
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
// Chrome-free: no border/background, only the icon shows. Hover/active shift
// the icon color instead of painting a box. Pair with an `icon*` size.
bare: "border-0 bg-transparent p-0 text-text-neutral-secondary hover:text-text-neutral-primary active:text-text-neutral-primary focus-visible:ring-border-neutral-secondary/50 disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
"menu-active":
@@ -35,6 +38,7 @@ const buttonVariants = cva(
lg: "h-10 px-6 has-[>svg]:px-4",
xl: "h-12 px-8 text-base has-[>svg]:px-6",
icon: "size-9",
"icon-xs": "size-7",
"icon-sm": "size-8",
"icon-lg": "size-10",
"link-xs": "text-xs",
+3 -23
View File
@@ -10,9 +10,6 @@ import {
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
/**
* Trigger component style parts using semantic class names
*/
const TRIGGER_STYLES = {
base: "relative inline-flex min-w-0 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
@@ -26,15 +23,9 @@ const TRIGGER_STYLES = {
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
} as const;
/**
* Content component styles
*/
const CONTENT_STYLES =
"mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
/**
* Build trigger className by combining style parts
*/
function buildTriggerClassName(): string {
return [
TRIGGER_STYLES.base,
@@ -47,13 +38,8 @@ function buildTriggerClassName(): string {
].join(" ");
}
/**
* Build list className
*/
function buildListClassName(): string {
// `flex` + `min-w-0` lets the triggers shrink proportionally when the
// container is narrow, so each trigger truncates with ellipsis instead
// of forcing a horizontal scrollbar.
// min-w-0 lets triggers shrink to ellipsis instead of forcing a scrollbar
return "flex w-full min-w-0 items-center border-[#E9E9F0] dark:border-[#171D30]";
}
@@ -85,11 +71,7 @@ function TabsList({
interface TabsTriggerProps
extends ComponentProps<typeof TabsPrimitive.Trigger> {
/**
* When set, the trigger is wrapped in a shadcn Tooltip rendered below
* the bar. Useful for showing the full name when the label is truncated
* to ellipsis on narrow containers.
*/
/** Tooltip shown below the trigger — useful when the label is truncated. */
tooltip?: ReactNode;
}
@@ -105,9 +87,7 @@ function TabsTrigger({
className={cn(buildTriggerClassName(), className)}
{...props}
>
{/* Wrapper provides the block-level box needed for `truncate` to
* actually render an ellipsis. Padding and gap on the trigger stay
* constant; only this span shrinks below its content width. */}
{/* block + min-w-0 needed for truncate to render ellipsis */}
<span className="block min-w-0 truncate">{children}</span>
</TabsPrimitive.Trigger>
);
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { BreadcrumbNavigation } from "./breadcrumb-navigation";
vi.mock("next/navigation", () => ({
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@iconify/react", () => ({
Icon: ({ icon }: { icon: string }) => <span aria-label={icon} />,
}));
vi.mock("@heroui/breadcrumbs", () => ({
Breadcrumbs: ({ children }: { children: React.ReactNode }) => (
<nav aria-label="Breadcrumb">{children}</nav>
),
BreadcrumbItem: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
}));
describe("BreadcrumbNavigation", () => {
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();
});
});
@@ -22,6 +22,7 @@ interface BreadcrumbNavigationProps {
mode?: "auto" | "custom" | "hybrid";
title?: string;
icon?: string | ReactNode;
titleAction?: ReactNode;
customItems?: CustomBreadcrumbItem[];
className?: string;
paramToPreserve?: string;
@@ -32,6 +33,7 @@ export function BreadcrumbNavigation({
mode = "auto",
title,
icon,
titleAction,
customItems = [],
className = "",
paramToPreserve = "scanId",
@@ -74,7 +76,6 @@ export function BreadcrumbNavigation({
const isLast = index === pathSegments.length - 1;
let displayName = segment.charAt(0).toUpperCase() + segment.slice(1);
// Special cases:
if (segment.includes("-")) {
displayName = segment
.split("-")
@@ -126,10 +127,10 @@ export function BreadcrumbNavigation({
>
{titleText}
</h1>
{titleAction}
</div>
);
// Determine which breadcrumbs to use
let breadcrumbItems: CustomBreadcrumbItem[] = [];
switch (mode) {
@@ -1,18 +1,24 @@
import { ReactNode } from "react";
import { Navbar } from "../nav-bar/navbar";
import { Navbar, type OnboardingActionConfig } from "../nav-bar/navbar";
interface ContentLayoutProps {
title: string;
icon?: string | ReactNode;
onboardingAction?: OnboardingActionConfig;
children: React.ReactNode;
}
export function ContentLayout({ title, icon, children }: ContentLayoutProps) {
export function ContentLayout({
title,
icon,
onboardingAction,
children,
}: ContentLayoutProps) {
return (
<>
<Navbar title={title} icon={icon} />
<div className="px-6 py-4 sm:px-8">{children}</div>
<Navbar title={title} icon={icon} onboardingAction={onboardingAction} />
<div className="py-4 pr-6">{children}</div>
</>
);
}
@@ -0,0 +1,255 @@
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 { NavbarClient } from "./navbar-client";
const navigationMocks = vi.hoisted(() => ({
pathname: "/findings",
push: vi.fn(),
searchParams: new URLSearchParams(),
}));
const requestReplayMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
usePathname: () => navigationMocks.pathname,
useRouter: () => ({ push: navigationMocks.push }),
useSearchParams: () => navigationMocks.searchParams,
}));
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("@/hooks/use-sidebar", () => ({
useSidebar: () => ({ isOpen: true, toggleOpen: vi.fn() }),
}));
vi.mock("@/components/ThemeSwitch", () => ({
ThemeSwitch: () => <button type="button">Theme switch</button>,
}));
vi.mock("@/components/ui", () => ({
BreadcrumbNavigation: ({
title,
titleAction,
}: {
title: string;
titleAction?: ReactNode;
}) => (
<nav aria-label="Breadcrumb">
<h1>{title}</h1>
{titleAction}
</nav>
),
}));
vi.mock("../sidebar/sheet-menu", () => ({
SheetMenu: () => <button type="button">Open menu</button>,
}));
vi.mock("../sidebar/sidebar-toggle", () => ({
SidebarToggle: () => <button type="button">Toggle sidebar</button>,
}));
vi.mock("../user-nav/user-nav", () => ({
UserNav: () => <button type="button">User menu</button>,
}));
describe("NavbarClient", () => {
beforeEach(() => {
navigationMocks.pathname = "/findings";
navigationMocks.push.mockClear();
requestReplayMock.mockClear();
navigationMocks.searchParams = new URLSearchParams();
window.localStorage.clear();
// Replay icon is Cloud-only.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// Default: the current route's content has loaded, so the icon is enabled.
usePageReadyStore.setState({ readyPath: "/findings" });
});
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(screen.getAllByText("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 = "/compliance";
navigationMocks.searchParams = new URLSearchParams("scanId=scan-1&foo=bar");
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 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("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("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(
<NavbarClient
title="Findings"
onboardingAction={{ flowId: "explore-findings" }}
/>,
);
expect(
screen.queryByRole("button", { name: /start product tour/i }),
).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();
});
});
+101 -11
View File
@@ -1,40 +1,130 @@
"use client";
import { BellRing } from "lucide-react";
import { ReactNode } from "react";
import { BellRing, Info } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, Suspense } from "react";
import { Button } from "@/components/shadcn";
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { BreadcrumbNavigation } from "@/components/ui";
import { useSidebar } from "@/hooks/use-sidebar";
import { getFlowById } 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 { SheetMenu } from "../sidebar/sheet-menu";
import { SidebarToggle } from "../sidebar/sidebar-toggle";
import { UserNav } from "../user-nav/user-nav";
export interface OnboardingActionConfig {
flowId: string;
fallbackFlowId?: string;
useFallback?: boolean;
}
interface NavbarClientProps {
title: string;
icon?: string | ReactNode;
onboardingAction?: OnboardingActionConfig;
feedsSlot?: ReactNode;
}
export function NavbarClient({ title, icon, feedsSlot }: NavbarClientProps) {
export function NavbarClient({
title,
icon,
onboardingAction,
feedsSlot,
}: NavbarClientProps) {
const { isOpen, toggleOpen } = useSidebar();
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.
if (flow.route === pathname) {
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}`);
};
return (
<header className="sticky top-0 z-10 w-full pt-4 backdrop-blur-sm">
<div className="mx-4 flex h-14 items-center sm:mx-8">
<div className="flex h-14 items-center pr-6">
<div className="flex items-center gap-2">
<SheetMenu />
<div className="hidden lg:block">
<SidebarToggle isOpen={isOpen} setIsOpen={toggleOpen} />
</div>
<BreadcrumbNavigation
mode="auto"
title={title}
icon={icon}
paramToPreserve="scanId"
/>
{/* Suspense contains the useSearchParams() CSR bailout in BreadcrumbNavigation
so statically prerendered pages don't fail the build. */}
<Suspense fallback={null}>
<BreadcrumbNavigation
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>
</div>
<div className="flex flex-1 items-center justify-end gap-3">
<ThemeSwitch />
+10 -2
View File
@@ -2,18 +2,26 @@ import { ReactNode, Suspense } from "react";
import { FeedsServer } from "@/components/feeds";
import { FeedsLoadingFallback, NavbarClient } from "./navbar-client";
import {
FeedsLoadingFallback,
NavbarClient,
type OnboardingActionConfig,
} from "./navbar-client";
export type { OnboardingActionConfig };
interface NavbarProps {
title: string;
icon?: string | ReactNode;
onboardingAction?: OnboardingActionConfig;
}
export function Navbar({ title, icon }: NavbarProps) {
export function Navbar({ title, icon, onboardingAction }: NavbarProps) {
return (
<NavbarClient
title={title}
icon={icon}
onboardingAction={onboardingAction}
feedsSlot={
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
<FeedsServer limit={15} />
+14 -16
View File
@@ -1,39 +1,37 @@
import {
SidebarCollapseIcon,
SidebarExpandIcon,
} from "@/components/icons/Icons";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/shadcn/button/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { Button } from "../button/button";
interface SidebarToggleProps {
isOpen: boolean | undefined;
setIsOpen?: () => void;
}
export function SidebarToggle({ isOpen, setIsOpen }: SidebarToggleProps) {
// Closed → chevron right (will open); open/undefined → chevron left (will collapse).
const isClosed = isOpen === false;
const Chevron = isClosed ? ChevronRight : ChevronLeft;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="bare"
size="icon-sm"
onClick={() => setIsOpen?.()}
className="h-8 w-8 rounded-md"
variant="outline"
size="icon"
aria-label={isClosed ? "Expand sidebar" : "Collapse sidebar"}
>
{isOpen === false ? (
<SidebarCollapseIcon className="h-5 w-5" />
) : (
<SidebarExpandIcon className="h-5 w-5" />
)}
<Chevron className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isOpen ? "Collapse Sidebar" : "Expand Sidebar"}
<TooltipContent side="bottom">
{isClosed ? "Expand Sidebar" : "Collapse Sidebar"}
</TooltipContent>
</Tooltip>
);
+8
View File
@@ -34,6 +34,10 @@ import { useFilterTransitionOptional } from "@/contexts";
import { cn } from "@/lib";
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
@@ -110,6 +114,8 @@ 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. */
@@ -144,6 +150,7 @@ export function DataTable<TData, TValue>({
renderAfterRow,
searchBadge,
onRowClick,
getRowAttributes,
header,
toolbarRightContent,
}: DataTableProviderProps<TData, TValue>) {
@@ -314,6 +321,7 @@ export function DataTable<TData, TValue>({
) : (
<Fragment key={row.id}>
<TableRow
{...getRowAttributes?.(row)}
data-state={row.getIsSelected() && "selected"}
className={cn(onRowClick && "cursor-pointer")}
onClick={(event) =>
+8
View File
@@ -407,6 +407,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"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",
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { buildComplianceDetailPath } from "./compliance-detail-url";
describe("buildComplianceDetailPath", () => {
it("builds the detail path with the encoded title and required params", () => {
const path = buildComplianceDetailPath({
title: "CIS 2.0 (AWS)",
complianceId: "cis_2.0_aws",
version: "2.0",
scanId: "scan-123",
});
expect(path).toBe(
"/compliance/CIS%202.0%20(AWS)?complianceId=cis_2.0_aws&version=2.0&scanId=scan-123",
);
});
it("propagates the region filter only when present", () => {
const withRegion = buildComplianceDetailPath({
title: "ens",
complianceId: "ens_rd2022_aws",
version: "RD2022",
scanId: "scan-1",
regionFilter: "eu-west-1",
});
const withoutRegion = buildComplianceDetailPath({
title: "ens",
complianceId: "ens_rd2022_aws",
version: "RD2022",
scanId: "scan-1",
regionFilter: null,
});
expect(withRegion).toContain("filter%5Bregion__in%5D=eu-west-1");
expect(withoutRegion).not.toContain("region__in");
});
});
@@ -0,0 +1,26 @@
interface ComplianceDetailPathParams {
/** Framework title as shown on the card (URL-encoded into the path). */
title: string;
complianceId: string;
version: string;
scanId: string;
regionFilter?: string | null;
}
/** Builds the `/compliance/[compliancetitle]` detail URL used by the overview cards. */
export function buildComplianceDetailPath({
title,
complianceId,
version,
scanId,
regionFilter,
}: ComplianceDetailPathParams): string {
const params = new URLSearchParams();
params.set("complianceId", complianceId);
params.set("version", version);
params.set("scanId", scanId);
if (regionFilter) {
params.set("filter[region__in]", regionFilter);
}
return `/compliance/${encodeURIComponent(title)}?${params.toString()}`;
}
+76
View File
@@ -0,0 +1,76 @@
# 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.
Everything lives in client state and localStorage — there is **zero backend
coupling**.
## 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` |
| Mandatory new-user gate | `ui/components/onboarding/onboarding-gate.tsx` |
| Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` |
## 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"`.
- `pnpm run test:e2e tests/onboarding/` — full-system behavior (sequence,
checkpoint, replay, single-fire, refresh). Requires the Prowler stack.
@@ -0,0 +1,74 @@
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,
completionRecord: null,
});
expect(result).toBe(true);
});
it("returns false when the user already has providers", () => {
const result = shouldStartOnboarding({
hasProviders: true,
completionRecord: null,
});
expect(result).toBe(false);
});
it("returns false when a dismissed record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.DISMISSED),
});
expect(result).toBe(false);
});
it("returns false when a completed record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.COMPLETED),
});
expect(result).toBe(false);
});
it("returns false when a skipped record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
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,
completionRecord: null,
});
expect(result).toBe(false);
});
it("fails open when hasProviders is null", () => {
const result = shouldStartOnboarding({
hasProviders: null as unknown as boolean,
completionRecord: null,
});
expect(result).toBe(false);
});
});
@@ -0,0 +1,185 @@
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 compliance route", () => {
const flow = getFlowById("view-compliance", onboardingFlows);
expect(flow).toBeDefined();
expect(flow?.order).toBe(4);
expect(flow?.route).toBe("/compliance");
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();
});
});
+17
View File
@@ -0,0 +1,17 @@
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;
completionRecord: TourCompletionRecord | null;
}
// Only forces onboarding when providers are provably absent and no record exists.
export function shouldStartOnboarding({
hasProviders,
completionRecord,
}: GateDecisionInput): boolean {
const hasNoRecord =
completionRecord === null || completionRecord === undefined;
return hasProviders === false && hasNoRecord;
}
+6
View File
@@ -0,0 +1,6 @@
// Public barrel — import from `@/lib/onboarding`, not individual modules.
export type { GateDecisionInput } from "./gate-decision";
export { shouldStartOnboarding } from "./gate-decision";
export type { OnboardingContext, OnboardingFlow } from "./onboarding-types";
export { getFlowById, getOrderedFlows, onboardingFlows } from "./registry";
+22
View File
@@ -0,0 +1,22 @@
import type { TourDefinition } from "@/lib/tours/tour-types";
// Single argument passed to every `isComplete(ctx)` predicate.
export interface OnboardingContext {
hasProviders: boolean;
}
// Completion is persisted via the tour's own id/version — no second source of truth.
// `order` gaps are allowed; reordering is a data edit.
export interface OnboardingFlow {
id: string;
order: number;
title: string;
description: string;
route: string;
tour: TourDefinition;
isComplete?: (ctx: OnboardingContext) => boolean;
// When true, the page owns auto-open; OnboardingTrigger must not mount a second runner.
ownsAutoOpen?: boolean;
// Shown in the sequence banner when the step needs a completed scan to display data.
dataRequirementHint?: string;
}
+80
View File
@@ -0,0 +1,80 @@
import { addProviderTour } from "@/lib/tours/add-provider.tour";
import { attackPathsTour } from "@/lib/tours/attack-paths.tour";
import { exploreFindingsTour } from "@/lib/tours/explore-findings.tour";
import { viewComplianceTour } from "@/lib/tours/view-compliance.tour";
import { viewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
import type { OnboardingFlow } from "./onboarding-types";
// Shown in the sequence banner when the step needs a completed scan.
const SCAN_DATA_HINT = "Wait for the scan to finish and have findings.";
// Add a flow: one entry here + a `*.tour.ts` file. No gate/modal/nav edits needed.
export const onboardingFlows: readonly OnboardingFlow[] = [
{
id: "add-provider",
order: 1,
title: "Add your first provider",
description:
"Connect a provider so Prowler has something to scan and assess.",
route: "/providers",
tour: addProviderTour,
// Server-derived: existing providers bypass this flow regardless of local record.
isComplete: (ctx) => ctx.hasProviders,
},
{
id: "view-first-scan",
order: 2,
title: "Run your first scan",
description:
"Launch a scan and watch Prowler assess your connected provider.",
// Land on the In Progress tab so a running scan is the first thing the tour shows.
route: "/scans?tab=active",
tour: viewFirstScanTour,
},
{
id: "explore-findings",
order: 3,
title: "Explore your findings",
description: "See what Prowler detected and how to fix it.",
route: "/findings",
tour: exploreFindingsTour,
dataRequirementHint: SCAN_DATA_HINT,
},
{
id: "view-compliance",
order: 4,
title: "Check compliance",
description: "Map your findings to frameworks like CIS.",
route: "/compliance",
tour: viewComplianceTour,
dataRequirementHint: SCAN_DATA_HINT,
},
{
id: "attack-paths",
order: 5,
title: "Visualize attack paths",
description: "See how a compromise could spread across your cloud.",
route: "/attack-paths",
tour: attackPathsTour,
dataRequirementHint: SCAN_DATA_HINT,
ownsAutoOpen: true, // page drives this tour; OnboardingTrigger must not mount a second runner
},
];
// Stable sort by `order`; `flows` defaults to the production registry so tests can inject.
export function getOrderedFlows(
flows: readonly OnboardingFlow[] = onboardingFlows,
): OnboardingFlow[] {
return flows
.map((flow, index) => ({ flow, index }))
.sort((a, b) => a.flow.order - b.flow.order || a.index - b.index)
.map(({ flow }) => flow);
}
export function getFlowById(
id: string,
flows: readonly OnboardingFlow[] = onboardingFlows,
): OnboardingFlow | undefined {
return flows.find((flow) => flow.id === id);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Shared environment helpers.
*/
/**
* Whether the UI is running inside a Prowler Cloud deployment.
*
* `NEXT_PUBLIC_*` vars are statically inlined by Next.js wherever the literal
* `process.env.NEXT_PUBLIC_IS_CLOUD_ENV` appears in source, so keeping this read
* inside a helper is safe.
*/
export function isCloud(): boolean {
return process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
addProviderTour,
type AddProviderTourTarget,
} from "../add-provider.tour";
// These carry a `data-tour-id` in the UI; the welcome step has no target.
const ALLOWED_TARGETS = ["trigger", "provider-type", "wizard-body"] as const;
const definedTargets = (): AddProviderTourTarget[] =>
addProviderTour.steps
.map((step) => step.target)
.filter((target): target is AddProviderTourTarget => target !== undefined);
describe("addProviderTour shape", () => {
it("declares the add-provider id", () => {
expect(addProviderTour.id).toBe("add-provider");
});
it("declares a positive integer version", () => {
expect(Number.isInteger(addProviderTour.version)).toBe(true);
expect(addProviderTour.version).toBeGreaterThan(0);
});
it("includes the trigger, provider-type, and wizard-body steps in order", () => {
const targets = definedTargets();
expect(targets).toEqual(["trigger", "provider-type", "wizard-body"]);
});
it("marks every anchored step as autoAdvance (no Next button)", () => {
// All anchored steps advance imperatively from the UI (clicking the real
// "Add a Provider" button, picking a type, etc.), so none renders a Next button.
const byTarget = (target: AddProviderTourTarget) =>
addProviderTour.steps.find((step) => step.target === target);
expect(byTarget("trigger")?.autoAdvance).toBe(true);
expect(byTarget("provider-type")?.autoAdvance).toBe(true);
expect(byTarget("wizard-body")?.autoAdvance).toBe(true);
});
it("never targets an element outside the allowed anchor set", () => {
const targets = definedTargets();
for (const target of targets) {
expect(ALLOWED_TARGETS).toContain(target);
}
});
it("includes a non-anchored welcome step (no target)", () => {
const welcomeSteps = addProviderTour.steps.filter(
(step) => step.target === undefined,
);
expect(welcomeSteps.length).toBeGreaterThanOrEqual(1);
});
it("covers the providers component tree for the tour:check gate", () => {
expect(addProviderTour.coversFiles).toContain("ui/components/providers/**");
});
});
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import type { AttackPathQuery, AttackPathScan } from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
import {
attackPathsTour,
type AttackPathsTourTarget,
pickDemoQuery,
pickDemoScan,
} from "../attack-paths.tour";
// Only these carry a `data-tour-id` in the page; keep in sync with tour:check.
const ALLOWED_TARGETS = [
"intro",
"scan-list",
"query-selector",
"execute-button",
] as const;
const definedTargets = (): AttackPathsTourTarget[] =>
attackPathsTour.steps
.map((step) => step.target)
.filter((target): target is AttackPathsTourTarget => target !== undefined);
const makeScan = (overrides: {
id: string;
ready: boolean;
provider: AttackPathScan["attributes"]["provider_type"];
}): AttackPathScan =>
({
type: "attack-paths-scans",
id: overrides.id,
attributes: {
graph_data_ready: overrides.ready,
provider_type: overrides.provider,
},
}) as AttackPathScan;
const makeQuery = (overrides: {
id: string;
paramCount: number;
}): AttackPathQuery =>
({
type: "attack-paths-scans",
id: overrides.id,
attributes: {
parameters: Array.from({ length: overrides.paramCount }, (_, index) => ({
name: `p${index}`,
})),
},
}) as AttackPathQuery;
describe("attackPathsTour shape", () => {
it("declares the attack-paths id", () => {
expect(attackPathsTour.id).toBe("attack-paths");
});
it("never targets an element outside the allowed anchor set", () => {
for (const target of definedTargets()) {
expect(ALLOWED_TARGETS).toContain(target);
}
});
});
describe("pickDemoScan", () => {
it("prefers a ready AWS scan over other ready scans", () => {
const scans = [
makeScan({ id: "gcp", ready: true, provider: "gcp" }),
makeScan({ id: "aws", ready: true, provider: "aws" }),
];
expect(pickDemoScan(scans)?.id).toBe("aws");
});
it("falls back to any ready scan when no ready AWS scan exists", () => {
const scans = [
makeScan({ id: "aws-not-ready", ready: false, provider: "aws" }),
makeScan({ id: "gcp-ready", ready: true, provider: "gcp" }),
];
expect(pickDemoScan(scans)?.id).toBe("gcp-ready");
});
it("returns undefined when no scan is ready", () => {
const scans = [makeScan({ id: "x", ready: false, provider: "aws" })];
expect(pickDemoScan(scans)).toBeUndefined();
});
});
describe("pickDemoQuery", () => {
it("prefers the IAM wildcard query when it is runnable", () => {
const queries = [
makeQuery({ id: "other", paramCount: 0 }),
makeQuery({ id: "aws-iam-statements-allow-all-actions", paramCount: 0 }),
];
expect(pickDemoQuery(queries)?.id).toBe(
"aws-iam-statements-allow-all-actions",
);
});
it("falls back to any parameter-free non-custom query", () => {
const queries = [
makeQuery({ id: ATTACK_PATH_QUERY_IDS.CUSTOM, paramCount: 0 }),
makeQuery({ id: "needs-params", paramCount: 2 }),
makeQuery({ id: "runnable", paramCount: 0 }),
];
expect(pickDemoQuery(queries)?.id).toBe("runnable");
});
it("never picks the custom query (it needs Cypher input)", () => {
const queries = [
makeQuery({ id: ATTACK_PATH_QUERY_IDS.CUSTOM, paramCount: 0 }),
];
expect(pickDemoQuery(queries)).toBeUndefined();
});
it("never picks a query with required parameters", () => {
const queries = [makeQuery({ id: "needs-params", paramCount: 1 })];
expect(pickDemoQuery(queries)).toBeUndefined();
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import {
createExploreFindingsTourStepHandlers,
exploreFindingsTour,
type ExploreFindingsTourTarget,
} from "../explore-findings.tour";
// Only these carry a `data-tour-id` in the UI; welcome step has no target.
const ALLOWED_TARGETS = ["filters", "group", "resources"] as const;
const definedTargets = (): ExploreFindingsTourTarget[] =>
exploreFindingsTour.steps
.map((step) => step.target)
.filter(
(target): target is ExploreFindingsTourTarget => target !== undefined,
);
describe("exploreFindingsTour shape", () => {
it("declares the explore-findings id", () => {
expect(exploreFindingsTour.id).toBe("explore-findings");
});
it("declares a positive integer version", () => {
expect(Number.isInteger(exploreFindingsTour.version)).toBe(true);
expect(exploreFindingsTour.version).toBeGreaterThan(0);
});
it("anchors filters, then the first group, then its resources, in that order", () => {
const targets = definedTargets();
// filters mounts immediately; group/resources appear after Suspense + drill-down, so order matters
expect(targets).toEqual(["filters", "group", "resources"]);
});
it("never targets an element outside the allowed anchor set", () => {
const targets = definedTargets();
for (const target of targets) {
expect(ALLOWED_TARGETS).toContain(target);
}
});
it("includes a non-anchored welcome step (no target)", () => {
const welcomeSteps = exploreFindingsTour.steps.filter(
(step) => step.target === undefined,
);
expect(welcomeSteps.length).toBeGreaterThanOrEqual(1);
});
it("covers the findings route and component trees for the tour:check gate", () => {
expect(exploreFindingsTour.coversFiles).toContain(
"ui/app/(prowler)/findings/**",
);
expect(exploreFindingsTour.coversFiles).toContain(
"ui/components/findings/**",
);
});
});
describe("createExploreFindingsTourStepHandlers", () => {
it("opens the first group and waits for the resources anchor before advancing", async () => {
const openFirstGroup = vi.fn(() => true);
const waitForStep = vi
.fn()
.mockResolvedValue(document.createElement("div"));
const handlers = createExploreFindingsTourStepHandlers(openFirstGroup);
await handlers.group?.onNext?.({ waitForStep });
expect(openFirstGroup).toHaveBeenCalledTimes(1);
expect(waitForStep).toHaveBeenCalledWith("resources");
});
it("does not wait for resources when no group can be opened", async () => {
const openFirstGroup = vi.fn(() => false);
const waitForStep = vi.fn();
const handlers = createExploreFindingsTourStepHandlers(openFirstGroup);
await handlers.group?.onNext?.({ waitForStep });
expect(openFirstGroup).toHaveBeenCalledTimes(1);
expect(waitForStep).not.toHaveBeenCalled();
});
it("registers a handler only for the group step", () => {
const handlers = createExploreFindingsTourStepHandlers(() => true);
expect(Object.keys(handlers)).toEqual(["group"]);
});
});
@@ -0,0 +1,198 @@
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TourCompletionStore } from "../store/tour-completion-store";
import type { TourCompletionRecord, TourDefinition } from "../tour-types";
import { useDriverTour, type UseDriverTourResult } from "../use-driver-tour";
const driverHarness = vi.hoisted(() => {
const instances: Array<{
destroy: ReturnType<typeof vi.fn>;
drive: ReturnType<typeof vi.fn>;
isActive: ReturnType<typeof vi.fn>;
isLastStep: ReturnType<typeof vi.fn>;
}> = [];
const driverMock = vi.fn((config: { onDestroyed?: () => void }) => {
let active = false;
const instance = {
destroy: vi.fn(() => {
active = false;
config.onDestroyed?.();
}),
drive: vi.fn(() => {
active = true;
}),
isActive: vi.fn(() => active),
isLastStep: vi.fn(() => false),
moveNext: vi.fn(),
movePrevious: vi.fn(),
};
instances.push(instance);
return instance;
});
return { driverMock, instances };
});
vi.mock("driver.js", () => ({
driver: driverHarness.driverMock,
}));
const themeHarness = vi.hoisted(() => ({ resolvedTheme: "dark" }));
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: themeHarness.resolvedTheme }),
}));
const tour = {
id: "lifecycle-tour",
version: 1,
coversFiles: [],
steps: [
{
title: "Welcome",
description: "Tour lifecycle",
},
],
} satisfies TourDefinition;
function createStore(): TourCompletionStore {
const records = new Map<string, TourCompletionRecord>();
return {
get: ({ id, version }) => records.get(`${id}.${version}`) ?? null,
set: ({ id, version }, record) => {
records.set(`${id}.${version}`, record);
},
clear: ({ id, version }) => {
records.delete(`${id}.${version}`);
},
};
}
function HookProbe({
autoOpen,
onResult,
store,
}: {
autoOpen: boolean;
onResult: (result: UseDriverTourResult) => void;
store: TourCompletionStore;
}) {
const result = useDriverTour(tour, { autoOpen, store });
onResult(result);
return null;
}
describe("useDriverTour lifecycle", () => {
beforeEach(() => {
vi.stubEnv("NODE_ENV", "development");
themeHarness.resolvedTheme = "dark";
driverHarness.driverMock.mockClear();
driverHarness.instances.length = 0;
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("does not destroy an active manually-started tour when autoOpen changes after replay param cleanup", () => {
const store = createStore();
let latestResult: UseDriverTourResult | undefined;
const { rerender } = render(
<HookProbe
autoOpen={false}
store={store}
onResult={(result) => {
latestResult = result;
}}
/>,
);
expect(driverHarness.instances).toHaveLength(1);
act(() => {
latestResult?.start();
});
rerender(
<HookProbe
autoOpen
store={store}
onResult={(result) => {
latestResult = result;
}}
/>,
);
expect(driverHarness.instances[0].drive).toHaveBeenCalledTimes(1);
expect(driverHarness.instances[0].destroy).not.toHaveBeenCalled();
});
it("still auto-opens a new incomplete tour after the configured delay", () => {
vi.useFakeTimers();
const store = createStore();
render(
<HookProbe
autoOpen
store={store}
onResult={() => {
// Result surface is irrelevant for auto-open behavior.
}}
/>,
);
expect(driverHarness.instances).toHaveLength(1);
expect(driverHarness.instances[0].drive).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(50);
});
expect(driverHarness.instances[0].drive).toHaveBeenCalledTimes(1);
});
it("does not record a completion when an active tour is torn down by a theme change", () => {
const store = createStore();
let latestResult: UseDriverTourResult | undefined;
const { rerender } = render(
<HookProbe
autoOpen={false}
store={store}
onResult={(result) => {
latestResult = result;
}}
/>,
);
act(() => {
latestResult?.start();
});
// Toggling theme re-runs the driver effect; its cleanup destroys the active
// instance, which (like real driver.js) fires onDestroyed directly.
act(() => {
themeHarness.resolvedTheme = "light";
rerender(
<HookProbe
autoOpen={false}
store={store}
onResult={(result) => {
latestResult = result;
}}
/>,
);
});
// The first instance was destroyed and a fresh one created for the new theme...
expect(driverHarness.instances[0].destroy).toHaveBeenCalledTimes(1);
expect(driverHarness.instances).toHaveLength(2);
// ...but no completion record was persisted, so the tour can reappear later.
expect(store.get({ id: tour.id, version: tour.version })).toBeNull();
});
});
@@ -0,0 +1,96 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { addProviderTour } from "../add-provider.tour";
import type { TourCompletionRecord } from "../tour-types";
import {
adaptStep,
advanceActiveTour,
endActiveTour,
useDriverTour,
} from "../use-driver-tour";
// Hook short-circuits driver.js in NODE_ENV=test; asserts public contract only.
// Driver.js close behavior is covered by E2E (slice 9).
function HookProbe({
onClosed,
onResult,
}: {
onClosed?: (state: TourCompletionRecord["state"]) => void;
onResult: (result: ReturnType<typeof useDriverTour>) => void;
}) {
const result = useDriverTour(addProviderTour, { autoOpen: false, onClosed });
onResult(result);
return null;
}
describe("useDriverTour onClosed option", () => {
it("accepts the onClosed option and returns the existing result surface", () => {
const onClosed = vi.fn();
const results: ReturnType<typeof useDriverTour>[] = [];
render(<HookProbe onClosed={onClosed} onResult={(r) => results.push(r)} />);
const result = results.at(-1);
expect(result).toBeDefined();
expect(typeof result?.start).toBe("function");
expect(typeof result?.stop).toBe("function");
expect(typeof result?.hasCompleted).toBe("boolean");
// onClosed is input-only; it must not leak into the returned surface
expect(result && "onClosed" in result).toBe(false);
});
it("keeps the same result surface when onClosed is omitted (backward-compatible)", () => {
const results: ReturnType<typeof useDriverTour>[] = [];
render(<HookProbe onResult={(r) => results.push(r)} />);
const result = results.at(-1);
expect(result).toBeDefined();
expect(typeof result?.start).toBe("function");
expect(typeof result?.stop).toBe("function");
expect(typeof result?.hasCompleted).toBe("boolean");
});
});
describe("endActiveTour", () => {
// The actual destroy path runs only outside NODE_ENV=test (driver.js is
// short-circuited here), so this asserts the imperative escape hatch the
// provider wizard relies on is exported and safe to call unconditionally.
it("is a no-op that does not throw when no tour is active", () => {
expect(() => endActiveTour()).not.toThrow();
expect(endActiveTour()).toBeUndefined();
});
});
describe("advanceActiveTour", () => {
// Sibling escape hatch to endActiveTour: the wizard calls it on provider-type
// selection to move past the autoAdvance step. Same NODE_ENV=test short-circuit.
it("is a no-op that does not throw when no tour is active", () => {
expect(() => advanceActiveTour()).not.toThrow();
expect(advanceActiveTour()).toBeUndefined();
});
});
describe("adaptStep autoAdvance", () => {
it("limits an autoAdvance step's popover to the close button only", () => {
const driveStep = adaptStep("add-provider", {
target: "provider-type",
autoAdvance: true,
title: "Pick a provider type",
});
// No Next/Back — the covered UI drives the flow via advanceActiveTour().
expect(driveStep.popover?.showButtons).toEqual(["close"]);
});
it("leaves showButtons unset on a normal step (driver.js defaults apply)", () => {
const driveStep = adaptStep("add-provider", {
target: "trigger",
title: "Open the wizard",
});
expect(driveStep.popover?.showButtons).toBeUndefined();
});
});
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
import {
createViewComplianceTourStepHandlers,
viewComplianceTour,
type ViewComplianceTourTarget,
} from "../view-compliance.tour";
// Only these two carry a `data-tour-id` in the UI; welcome step has no target.
const ALLOWED_TARGETS = ["frameworks", "search"] as const;
const definedTargets = (): ViewComplianceTourTarget[] =>
viewComplianceTour.steps
.map((step) => step.target)
.filter(
(target): target is ViewComplianceTourTarget => target !== undefined,
);
describe("viewComplianceTour shape", () => {
it("declares the view-compliance id", () => {
expect(viewComplianceTour.id).toBe("view-compliance");
});
it("declares a positive integer version", () => {
expect(Number.isInteger(viewComplianceTour.version)).toBe(true);
expect(viewComplianceTour.version).toBeGreaterThan(0);
});
it("anchors exactly the search and frameworks steps, in that order", () => {
// Search sits above the cards in the DOM; the tour must follow top-to-bottom
// so the spotlight never jumps back up the page.
const targets = definedTargets();
expect(targets).toEqual(["search", "frameworks"]);
});
it("never targets an element outside the allowed anchor set", () => {
const targets = definedTargets();
for (const target of targets) {
expect(ALLOWED_TARGETS).toContain(target);
}
});
it("includes a non-anchored welcome step (no target)", () => {
const welcomeSteps = viewComplianceTour.steps.filter(
(step) => step.target === undefined,
);
expect(welcomeSteps.length).toBeGreaterThanOrEqual(1);
});
it("covers the compliance route and component trees for the tour:check gate", () => {
expect(viewComplianceTour.coversFiles).toContain(
"ui/app/(prowler)/compliance/**",
);
expect(viewComplianceTour.coversFiles).toContain(
"ui/components/compliance/**",
);
});
});
describe("createViewComplianceTourStepHandlers", () => {
const makeHandlers = (overrides?: {
resetSearch?: () => boolean;
openFirstFramework?: () => void;
}) =>
createViewComplianceTourStepHandlers({
resetSearch: overrides?.resetSearch ?? vi.fn(() => true),
openFirstFramework: overrides?.openFirstFramework ?? vi.fn(),
});
it("resets the search and waits for the frameworks anchor before advancing", async () => {
// An empty search result unmounts every card (anchor included); resetting first
// guarantees the next step has an element to highlight.
const resetSearch = vi.fn(() => true);
const waitForStep = vi
.fn()
.mockResolvedValue(document.createElement("div"));
const handlers = makeHandlers({ resetSearch });
await handlers.search?.onNext?.({ waitForStep });
expect(resetSearch).toHaveBeenCalledTimes(1);
expect(waitForStep).toHaveBeenCalledWith("frameworks");
});
it("does not wait for frameworks when no card can render", async () => {
const resetSearch = vi.fn(() => false);
const waitForStep = vi.fn();
const handlers = makeHandlers({ resetSearch });
await handlers.search?.onNext?.({ waitForStep });
expect(waitForStep).not.toHaveBeenCalled();
});
it("opens the first framework when leaving the last step", async () => {
const openFirstFramework = vi.fn();
const waitForStep = vi.fn();
const handlers = makeHandlers({ openFirstFramework });
await handlers.frameworks?.onNext?.({ waitForStep });
expect(openFirstFramework).toHaveBeenCalledTimes(1);
expect(waitForStep).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
buildViewFirstScanTour,
viewFirstScanTour,
type ViewFirstScanTourTarget,
} from "../view-first-scan.tour";
// These carry a `data-tour-id` in the UI; the welcome step has no target.
const ALLOWED_TARGETS = ["in-progress", "launch", "tabs"] as const;
const definedTargets = (tour = viewFirstScanTour): ViewFirstScanTourTarget[] =>
tour.steps
.map((step) => step.target)
.filter(
(target): target is ViewFirstScanTourTarget => target !== undefined,
);
describe("viewFirstScanTour shape (default, no running scan)", () => {
it("declares the view-first-scan id", () => {
expect(viewFirstScanTour.id).toBe("view-first-scan");
});
it("declares a positive integer version", () => {
expect(Number.isInteger(viewFirstScanTour.version)).toBe(true);
expect(viewFirstScanTour.version).toBeGreaterThan(0);
});
it("anchors exactly the launch and tabs steps, in that order", () => {
expect(definedTargets()).toEqual(["launch", "tabs"]);
});
it("never targets an element outside the allowed anchor set", () => {
for (const target of definedTargets()) {
expect(ALLOWED_TARGETS).toContain(target);
}
});
it("includes a non-anchored welcome step (no target)", () => {
const welcomeSteps = viewFirstScanTour.steps.filter(
(step) => step.target === undefined,
);
expect(welcomeSteps.length).toBeGreaterThanOrEqual(1);
});
it("covers the scans route and component trees for the tour:check gate", () => {
expect(viewFirstScanTour.coversFiles).toContain(
"ui/app/(prowler)/scans/**",
);
expect(viewFirstScanTour.coversFiles).toContain("ui/components/scans/**");
});
it("is the build output for the no-running-scan case", () => {
// The registry's default flow uses this variant; the scans page rebuilds live.
expect(definedTargets(buildViewFirstScanTour(false))).toEqual([
"launch",
"tabs",
]);
});
});
describe("buildViewFirstScanTour with a running scan", () => {
const tour = buildViewFirstScanTour(true);
it("anchors the in-progress row first, then launch (no tabs step)", () => {
// The tabs are mentioned in the in-progress step's copy instead of a separate step.
expect(definedTargets(tour)).toEqual(["in-progress", "launch"]);
});
it("shares the same id and version as the default variant", () => {
expect(tour.id).toBe(viewFirstScanTour.id);
expect(tour.version).toBe(viewFirstScanTour.version);
});
it("never targets an element outside the allowed anchor set", () => {
for (const target of definedTargets(tour)) {
expect(ALLOWED_TARGETS).toContain(target);
}
});
it("keeps a non-anchored welcome step", () => {
expect(
tour.steps.filter((step) => step.target === undefined).length,
).toBeGreaterThanOrEqual(1);
});
});
+67
View File
@@ -0,0 +1,67 @@
import {
defineTour,
TOUR_STEP_ALIGNMENTS,
TOUR_STEP_SIDES,
} from "./tour-types";
// Const map keeps the union narrow so `useDriverTour` can validate step keys.
export const ADD_PROVIDER_TOUR_TARGETS = {
TRIGGER: "trigger",
PROVIDER_TYPE: "provider-type",
// Wraps the whole wizard modal so the final step's spotlight covers every input
// (UID, alias) and the footer — driver.js only keeps the highlighted element and
// its descendants interactive, so anchoring here stops the overlay from freezing
// those inputs.
WIZARD_BODY: "wizard-body",
} as const;
export type AddProviderTourTarget =
(typeof ADD_PROVIDER_TOUR_TARGETS)[keyof typeof ADD_PROVIDER_TOUR_TARGETS];
export const addProviderTour = defineTour<AddProviderTourTarget>({
id: "add-provider",
// v2: the tour now drives the user into the wizard body (provider-type + wizard-body
// steps) instead of ending at type selection.
version: 2,
coversFiles: ["ui/components/providers/**"],
steps: [
{
title: "Connect your first provider",
description:
"Prowler scans the providers you connect. Let's walk through adding your first provider so scans have something to assess.",
},
{
target: "trigger",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
// No Next button: clicking the highlighted "Add a Provider" button opens the
// wizard and advances the tour (see openProviderWizard).
autoAdvance: true,
title: "Open the Add Provider wizard",
description:
"Click Add a Provider to open the setup wizard. We'll point out the first thing to choose.",
},
{
target: "provider-type",
side: TOUR_STEP_SIDES.RIGHT,
align: TOUR_STEP_ALIGNMENTS.START,
// No Next button: the wizard advances the tour when a type is picked.
autoAdvance: true,
title: "Pick a provider type",
description:
"Choose the cloud you want to connect (AWS, Azure, GCP, and more). Selecting one takes you to the account details.",
},
{
target: "wizard-body",
// Pinned to the left of the form column, mirroring the provider-type step.
side: TOUR_STEP_SIDES.LEFT,
align: TOUR_STEP_ALIGNMENTS.START,
// Final step: stays until the user closes it or advances to credentials, which
// the wizard ends the tour from. No Next button.
autoAdvance: true,
title: "Add your account details",
description:
"Enter your account ID and an optional alias, then continue. From here you'll add credentials, test the connection, and launch your first scan — at your own pace.",
},
],
});
+31
View File
@@ -0,0 +1,31 @@
import {
defineTour,
TOUR_STEP_ALIGNMENTS,
TOUR_STEP_SIDES,
} from "./tour-types";
// Companion to `attack-paths.tour.ts` with a distinct id so completing one
// does not suppress the other.
export const attackPathsEmptyTour = defineTour({
id: "attack-paths-empty",
version: 1,
coversFiles: [
"ui/app/(prowler)/attack-paths/**",
"ui/components/attack-paths/**",
],
steps: [
{
title: "Welcome to Attack Paths",
description:
"Attack Paths visualizes how a compromise in one resource could spread through your cloud. It's currently available for AWS accounts only — you'll need a completed AWS scan first.",
},
{
target: "scans-cta",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Run your first scan",
description:
"Open Scan Jobs to launch one. The Attack Paths tour will pick up here once a scan is ready.",
},
],
});
+98
View File
@@ -0,0 +1,98 @@
import type { AttackPathQuery, AttackPathScan } from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
import {
defineTour,
TOUR_STEP_ALIGNMENTS,
TOUR_STEP_SIDES,
} from "./tour-types";
export const attackPathsTour = defineTour({
id: "attack-paths",
version: 1,
coversFiles: [
"ui/app/(prowler)/attack-paths/**",
"ui/components/attack-paths/**",
],
steps: [
{
title: "Welcome to Attack Paths",
description:
"Attack Paths visualizes how a compromise in one resource could spread through your cloud. It's currently available for AWS accounts only.",
},
{
target: "intro",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Start with a scan",
description:
"Attack Paths analyses are generated from your existing AWS scans. Each scan is a point-in-time snapshot of one account.",
},
{
target: "scan-list",
side: TOUR_STEP_SIDES.TOP,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Pick a scan",
description:
"Each row is a scan. Click the radio button on the left to select one.",
},
{
target: "query-selector",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Choose a query",
description:
"Predefined queries cover common risk patterns (privilege escalation, public exposure, lateral movement). You can also write your own query.",
},
{
target: "execute-button",
side: TOUR_STEP_SIDES.TOP,
align: TOUR_STEP_ALIGNMENTS.END,
// No Next button: clicking the highlighted Execute Query button runs the query
// and advances the tour (see handleExecuteQuery in attack-paths-page).
autoAdvance: true,
title: "Run it whenever you're ready",
description:
"Click Execute Query to see the graph with the possible attack paths.",
},
{
title: "You're all set",
description:
"Explore the attack paths and dig into anything that looks risky.",
},
],
});
export type AttackPathsTourTarget = NonNullable<
(typeof attackPathsTour.steps)[number]["target"]
>;
// Demo-pick logic lives here so page code never decides what to auto-run.
// Preferred demo query: well-known and usually returns findings.
const PREFERRED_DEMO_QUERY_ID = "aws-iam-statements-allow-all-actions";
const isReadyScan = (scan: AttackPathScan): boolean =>
scan.attributes.graph_data_ready;
// Prefer a ready AWS scan; fall back to any ready scan for non-AWS tenants.
export function pickDemoScan(
scans: readonly AttackPathScan[],
): AttackPathScan | undefined {
const preferredAws = scans.find(
(scan) => isReadyScan(scan) && scan.attributes.provider_type === "aws",
);
return preferredAws ?? scans.find(isReadyScan);
}
// Runnable = no required params and not Custom (needs manual Cypher).
export function pickDemoQuery(
queries: readonly AttackPathQuery[],
): AttackPathQuery | undefined {
const isRunnable = (query: AttackPathQuery): boolean =>
query.id !== ATTACK_PATH_QUERY_IDS.CUSTOM &&
query.attributes.parameters.length === 0;
const preferredIam = queries.find(
(query) => query.id === PREFERRED_DEMO_QUERY_ID && isRunnable(query),
);
return preferredIam ?? queries.find(isRunnable);
}
+74
View File
@@ -0,0 +1,74 @@
import {
defineTour,
TOUR_STEP_ALIGNMENTS,
TOUR_STEP_SIDES,
type TourStepHandlers,
} from "./tour-types";
// Const map keeps the union narrow so `useDriverTour` can validate step keys.
export const EXPLORE_FINDINGS_TOUR_TARGETS = {
FILTERS: "filters",
// Anchored to the first finding group row (there may be only one), not the whole table.
GROUP: "group",
// The expanded resources panel revealed after opening a finding group.
RESOURCES: "resources",
} as const;
export type ExploreFindingsTourTarget =
(typeof EXPLORE_FINDINGS_TOUR_TARGETS)[keyof typeof EXPLORE_FINDINGS_TOUR_TARGETS];
export const exploreFindingsTour = defineTour<ExploreFindingsTourTarget>({
id: "explore-findings",
version: 1,
coversFiles: ["ui/app/(prowler)/findings/**", "ui/components/findings/**"],
steps: [
{
title: "Explore your findings",
description:
"Findings are the issues Prowler detected across your scans, grouped so you can act on what matters most.",
},
{
// `filters` renders immediately; `group` waits on Suspense — order matters.
target: "filters",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Focus on what matters",
description:
"Filter by provider, severity, service, and more to narrow down to the findings you care about.",
},
{
target: "group",
side: TOUR_STEP_SIDES.TOP,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Open a finding group",
description:
"Each row groups related findings from a single check. Continue and we'll open the first one for you.",
},
{
target: "resources",
side: TOUR_STEP_SIDES.TOP,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Review the affected resources",
description:
"These are the resources flagged by the check. Click any row to inspect the finding details and how to remediate it.",
},
],
});
// Step handlers are passed to `useDriverTour` at consumption time (not part of `TourDefinition`).
// `group` opens the first finding group and waits for the resources panel before advancing.
export function createExploreFindingsTourStepHandlers(
openFirstGroup: () => boolean,
): {
[K in ExploreFindingsTourTarget]?: TourStepHandlers<ExploreFindingsTourTarget>;
} {
return {
[EXPLORE_FINDINGS_TOUR_TARGETS.GROUP]: {
onNext: async ({ waitForStep }) => {
// No drillable group → skip the wait so the tour doesn't hang; driver advances next.
if (!openFirstGroup()) return;
await waitForStep(EXPLORE_FINDINGS_TOUR_TARGETS.RESOURCES);
},
},
};
}
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TOUR_COMPLETION_STATES } from "../tour-types";
import { buildStorageKey, localStorageAdapter } from "./local-storage-adapter";
const TOUR_ID = { id: "attack-paths", version: 1 };
// All tour records share one key as a single object — no per-tour proliferation.
const STORAGE_KEY = "prowler.tours";
const sampleRecord = {
tourId: "attack-paths",
version: 1,
state: TOUR_COMPLETION_STATES.COMPLETED,
completedAt: "2026-01-15T12:34:56.000Z",
} as const;
describe("buildStorageKey", () => {
it("composes the field key for the tours object", () => {
expect(buildStorageKey({ id: "attack-paths", version: 1 })).toBe(
"attack-paths.v1",
);
});
it("renders multi-digit versions verbatim", () => {
expect(buildStorageKey({ id: "lighthouse", version: 42 })).toBe(
"lighthouse.v42",
);
});
});
describe("localStorageAdapter", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
it("set/get round-trips a record inside the single tours object", () => {
localStorageAdapter.set(TOUR_ID, sampleRecord);
const raw = window.localStorage.getItem(STORAGE_KEY);
expect(raw).not.toBeNull();
expect(JSON.parse(raw as string)).toEqual({
"attack-paths.v1": sampleRecord,
});
const fetched = localStorageAdapter.get(TOUR_ID);
expect(fetched).toEqual(sampleRecord);
});
it("stores every tour under one localStorage key", () => {
localStorageAdapter.set(TOUR_ID, sampleRecord);
localStorageAdapter.set({ id: "add-provider", version: 1 }, sampleRecord);
expect(window.localStorage.length).toBe(1);
expect(window.localStorage.key(0)).toBe(STORAGE_KEY);
});
it("returns null when no record exists for that (id, version)", () => {
expect(localStorageAdapter.get(TOUR_ID)).toBeNull();
});
it("isolates records by version under the same id", () => {
localStorageAdapter.set(TOUR_ID, sampleRecord);
const v2Record = { ...sampleRecord, version: 2 };
localStorageAdapter.set({ id: TOUR_ID.id, version: 2 }, v2Record);
expect(localStorageAdapter.get(TOUR_ID)).toEqual(sampleRecord);
expect(localStorageAdapter.get({ id: TOUR_ID.id, version: 2 })).toEqual(
v2Record,
);
});
it("clear() removes only the matching record", () => {
localStorageAdapter.set(TOUR_ID, sampleRecord);
localStorageAdapter.set({ id: "other", version: 1 }, sampleRecord);
localStorageAdapter.clear(TOUR_ID);
expect(localStorageAdapter.get(TOUR_ID)).toBeNull();
expect(localStorageAdapter.get({ id: "other", version: 1 })).toEqual(
sampleRecord,
);
});
it("returns null when the stored object is malformed JSON", () => {
window.localStorage.setItem(STORAGE_KEY, "{not json");
expect(localStorageAdapter.get(TOUR_ID)).toBeNull();
});
it("returns null when the stored record is missing required fields", () => {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ "attack-paths.v1": { tourId: "attack-paths" } }),
);
expect(localStorageAdapter.get(TOUR_ID)).toBeNull();
});
});
@@ -0,0 +1,70 @@
import type { TourCompletionRecord, TourId } from "../tour-types";
import type { TourCompletionStore } from "./tour-completion-store";
// All records share ONE localStorage key, keyed by `<id>.v<version>`.
const STORAGE_KEY = "prowler.tours";
type ToursObject = Record<string, TourCompletionRecord>;
export function buildStorageKey({ id, version }: TourId): string {
return `${id}.v${version}`;
}
function isValidRecord(value: unknown): value is TourCompletionRecord {
if (typeof value !== "object" || value === null) return false;
const record = value as Partial<TourCompletionRecord>;
return (
typeof record.tourId === "string" &&
typeof record.version === "number" &&
typeof record.state === "string" &&
typeof record.completedAt === "string"
);
}
function readAll(): ToursObject {
if (typeof window === "undefined") return {};
let raw: string | null;
try {
raw = window.localStorage.getItem(STORAGE_KEY);
} catch {
return {};
}
if (raw === null) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null) {
return parsed as ToursObject;
}
return {};
} catch {
return {};
}
}
function writeAll(tours: ToursObject): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tours));
} catch {
// Non-fatal: a re-shown tour beats a thrown render.
}
}
export const localStorageAdapter: TourCompletionStore = {
get(id) {
const record = readAll()[buildStorageKey(id)];
return isValidRecord(record) ? record : null;
},
set(id, record) {
const tours = readAll();
tours[buildStorageKey(id)] = record;
writeAll(tours);
},
clear(id) {
const tours = readAll();
delete tours[buildStorageKey(id)];
writeAll(tours);
},
};
@@ -0,0 +1,8 @@
import type { TourCompletionRecord, TourId } from "../tour-types";
// Persistence backend contract; swap the adapter without touching consumers.
export interface TourCompletionStore {
get(id: TourId): TourCompletionRecord | null;
set(id: TourId, record: TourCompletionRecord): void;
clear(id: TourId): void;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
baseDriverConfig,
getDriverConfig,
TOUR_OVERLAY_COLORS,
TOUR_THEMES,
} from "./tour-config";
describe("getDriverConfig", () => {
it("returns dark overlay color for the dark theme", () => {
const config = getDriverConfig(TOUR_THEMES.DARK);
expect(config.overlayColor).toBe(TOUR_OVERLAY_COLORS[TOUR_THEMES.DARK]);
});
it("returns light overlay color for the light theme", () => {
const config = getDriverConfig(TOUR_THEMES.LIGHT);
expect(config.overlayColor).toBe(TOUR_OVERLAY_COLORS[TOUR_THEMES.LIGHT]);
});
it("preserves base config defaults when no overrides are passed", () => {
const config = getDriverConfig(TOUR_THEMES.LIGHT);
expect(config.popoverClass).toBe("prowler-theme");
expect(config.showProgress).toBe(true);
expect(config.progressText).toBe("Step {{current}} of {{total}}");
expect(config.prevBtnText).toBe("Back");
expect(config.doneBtnText).toBe("Got it");
expect(config.stagePadding).toBe(baseDriverConfig.stagePadding);
});
it("merges overrides on top of the base config", () => {
const config = getDriverConfig(TOUR_THEMES.LIGHT, {
stagePadding: 24,
doneBtnText: "Finish",
});
expect(config.stagePadding).toBe(24);
expect(config.doneBtnText).toBe("Finish");
expect(config.popoverClass).toBe("prowler-theme");
});
it("lets overrides win over the theme-derived overlayColor", () => {
const config = getDriverConfig(TOUR_THEMES.LIGHT, {
overlayColor: "#abcdef",
});
expect(config.overlayColor).toBe("#abcdef");
});
it("does not mutate the shared baseDriverConfig", () => {
const snapshot = { ...baseDriverConfig };
getDriverConfig(TOUR_THEMES.DARK, { stagePadding: 99 });
expect(baseDriverConfig).toEqual(snapshot);
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { Config } from "driver.js";
import { renderTourPopover } from "./tour-popover-render";
export const TOUR_THEMES = {
LIGHT: "light",
DARK: "dark",
} as const;
export type TourTheme = (typeof TOUR_THEMES)[keyof typeof TOUR_THEMES];
export const TOUR_OVERLAY_COLORS = {
[TOUR_THEMES.LIGHT]: "#0f172a",
[TOUR_THEMES.DARK]: "#0a0a0a",
} as const;
// Per-step overrides go on the step; per-tour overrides merge in via getDriverConfig.
export const baseDriverConfig: Config = {
popoverClass: "prowler-theme",
animate: true,
smoothScroll: true,
allowClose: true,
overlayClickBehavior: "close",
overlayOpacity: 0.72,
stagePadding: 10,
stageRadius: 10,
popoverOffset: 12,
showButtons: ["next", "previous", "close"],
showProgress: true,
progressText: "Step {{current}} of {{total}}",
nextBtnText: "Next",
prevBtnText: "Back",
doneBtnText: "Got it",
allowKeyboardControl: true,
disableActiveInteraction: false,
onPopoverRender: renderTourPopover,
};
// driver.js exposes `overlayColor` only via JS (no CSS variable hook), so the
// theme is resolved at the call site and passed in here.
export function getDriverConfig(
theme: TourTheme,
overrides?: Partial<Config>,
): Config {
return {
...baseDriverConfig,
overlayColor: TOUR_OVERLAY_COLORS[theme],
...(overrides ?? {}),
};
}
+139
View File
@@ -0,0 +1,139 @@
import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PopoverDOM } from "driver.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
renderTourPopover,
unmountActiveTourPopover,
} from "./tour-popover-render";
function createPopover(): PopoverDOM {
const wrapper = document.createElement("div");
const arrow = document.createElement("div");
const title = document.createElement("header");
const description = document.createElement("div");
const footer = document.createElement("footer");
const progress = document.createElement("span");
const previousButton = document.createElement("button");
const nextButton = document.createElement("button");
const closeButton = document.createElement("button");
const footerButtons = document.createElement("span");
title.textContent = "Explore findings";
description.textContent = "Review grouped findings and resources.";
progress.textContent = "Step 2 of 4";
previousButton.textContent = "Back";
nextButton.textContent = "Next";
closeButton.setAttribute("aria-label", "Close");
wrapper.append(title, description, footer, closeButton, arrow);
footer.append(progress, footerButtons);
footerButtons.append(previousButton, nextButton);
document.body.appendChild(wrapper);
return {
wrapper,
arrow,
title,
description,
footer,
progress,
previousButton,
nextButton,
closeButton,
footerButtons,
};
}
describe("renderTourPopover", () => {
afterEach(() => {
act(() => unmountActiveTourPopover());
document.body.innerHTML = "";
});
it("renders tour UI with existing app components", async () => {
// Given
const popover = createPopover();
// When
await act(async () => renderTourPopover(popover));
const tourUi = within(
popover.wrapper.querySelector<HTMLElement>("[data-tour-popover-root]")!,
);
// Then
expect(tourUi.getByText("Explore findings")).toBeVisible();
expect(
tourUi.getByText("Review grouped findings and resources."),
).toBeVisible();
expect(tourUi.getByText("Step 2 of 4")).toBeVisible();
expect(tourUi.getByRole("progressbar")).toHaveAttribute(
"aria-valuenow",
"50",
);
expect(tourUi.getByText("Explore findings")).toHaveAttribute(
"data-slot",
"card-title",
);
expect(tourUi.getByRole("button", { name: "Next" })).toHaveAttribute(
"data-slot",
"button",
);
expect(popover.title).not.toBeVisible();
expect(popover.footer).not.toBeVisible();
});
it("renders synchronously so driver.js measures the real card dimensions", () => {
// Given
const popover = createPopover();
// When
renderTourPopover(popover);
// Then
expect(
popover.wrapper.querySelector('[data-slot="card"]'),
).toBeInTheDocument();
});
it("applies the onboarding popover styling on the card and its slots", async () => {
// Given
const popover = createPopover();
// When
await act(async () => renderTourPopover(popover));
// Then — styling lives at this callsite, not in a shared Card variant.
const card = popover.wrapper.querySelector('[data-slot="card"]')!;
expect(card).toHaveClass("gap-0");
expect(card).toHaveClass("shadow-lg");
const footer = popover.wrapper.querySelector('[data-slot="card-footer"]')!;
expect(footer).toHaveClass("justify-end");
expect(footer).toHaveClass("gap-2");
expect(footer).toHaveClass("px-0");
});
it("delegates visible button clicks to driver.js native controls", async () => {
// Given
const user = userEvent.setup();
const popover = createPopover();
const onPrevious = vi.fn();
const onNext = vi.fn();
const onClose = vi.fn();
popover.previousButton.addEventListener("click", onPrevious);
popover.nextButton.addEventListener("click", onNext);
popover.closeButton.addEventListener("click", onClose);
await act(async () => renderTourPopover(popover));
// When
await user.click(screen.getByRole("button", { name: "Back" }));
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Close" }));
// Then
expect(onPrevious).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+197
View File
@@ -0,0 +1,197 @@
"use client";
import type { PopoverDOM } from "driver.js";
import { XIcon } from "lucide-react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import {
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Progress,
} from "@/components/shadcn";
interface TourPopoverProps {
title: string;
description: string;
progressText: string;
progressValue: number;
previousLabel: string;
nextLabel: string;
closeLabel: string;
showPrevious: boolean;
showNext: boolean;
showClose: boolean;
previousDisabled: boolean;
onPrevious: () => void;
onNext: () => void;
onClose: () => void;
}
let activeRoot: Root | null = null;
let activeWrapper: HTMLElement | null = null;
function isDisplayed(element: HTMLElement): boolean {
return element.style.display !== "none";
}
function parseProgressValue(progressText: string): number {
const match = progressText.match(/(\d+)\D+(\d+)/);
if (!match) return 0;
const current = Number(match[1]);
const total = Number(match[2]);
if (total <= 0) return 0;
return Math.min(100, Math.max(0, (current / total) * 100));
}
function getText(element: HTMLElement, fallback: string): string {
const text = element.textContent?.trim();
return text && text.length > 0 ? text : fallback;
}
function hideDriverPopoverDom(popover: PopoverDOM) {
popover.title.hidden = true;
popover.description.hidden = true;
popover.footer.hidden = true;
popover.closeButton.hidden = true;
popover.arrow.hidden = true;
}
function getOrCreateMount(popover: PopoverDOM): HTMLElement {
const existing = popover.wrapper.querySelector<HTMLElement>(
"[data-tour-popover-root]",
);
if (existing) {
existing.style.width = "100%";
return existing;
}
const mount = document.createElement("div");
mount.dataset.tourPopoverRoot = "";
mount.style.width = "100%";
popover.wrapper.appendChild(mount);
return mount;
}
function TourPopover({
title,
description,
progressText,
progressValue,
previousLabel,
nextLabel,
closeLabel,
showPrevious,
showNext,
showClose,
previousDisabled,
onPrevious,
onNext,
onClose,
}: TourPopoverProps) {
return (
<Card variant="inner" className="gap-0 shadow-lg">
<CardHeader className="mb-4">
<CardTitle className="text-base leading-snug">{title}</CardTitle>
{showClose ? (
<CardAction>
<Button
aria-label={closeLabel}
size="icon-xs"
variant="bare"
onClick={onClose}
>
<XIcon aria-hidden="true" />
</Button>
</CardAction>
) : null}
{description ? (
<CardDescription className="mt-2 text-xs leading-relaxed">
{description}
</CardDescription>
) : null}
</CardHeader>
<CardContent className="mt-2">
{progressText ? (
<>
<CardDescription className="mt-2 text-xs leading-relaxed">
{progressText}
</CardDescription>
<Progress value={progressValue} />
</>
) : null}
</CardContent>
<CardFooter className="mt-4 justify-end gap-2 px-0">
{showPrevious ? (
<Button
disabled={previousDisabled}
size="sm"
// Match the app's modal secondary action (e.g. Launch Scan's Cancel).
variant="outline"
onClick={onPrevious}
>
{previousLabel}
</Button>
) : null}
{showNext ? (
<Button size="sm" onClick={onNext}>
{nextLabel}
</Button>
) : null}
</CardFooter>
</Card>
);
}
export function unmountActiveTourPopover() {
activeRoot?.unmount();
activeRoot = null;
activeWrapper = null;
}
export function renderTourPopover(popover: PopoverDOM) {
if (activeWrapper !== popover.wrapper) {
unmountActiveTourPopover();
}
hideDriverPopoverDom(popover);
const mount = getOrCreateMount(popover);
activeRoot = activeRoot ?? createRoot(mount);
activeWrapper = popover.wrapper;
const progressText = isDisplayed(popover.progress)
? getText(popover.progress, "")
: "";
flushSync(() => {
activeRoot?.render(
<TourPopover
title={getText(popover.title, "")}
description={getText(popover.description, "")}
progressText={progressText}
progressValue={parseProgressValue(progressText)}
previousLabel={getText(popover.previousButton, "Back")}
nextLabel={getText(popover.nextButton, "Next")}
closeLabel={popover.closeButton.getAttribute("aria-label") ?? "Close"}
showPrevious={isDisplayed(popover.previousButton)}
showNext={isDisplayed(popover.nextButton)}
showClose={isDisplayed(popover.closeButton)}
previousDisabled={popover.previousButton.disabled}
onPrevious={() => {
if (!popover.previousButton.disabled) popover.previousButton.click();
}}
onNext={() => popover.nextButton.click()}
onClose={() => popover.closeButton.click()}
/>,
);
});
}
+91
View File
@@ -0,0 +1,91 @@
// Import from this module — never from `driver.js` directly — to keep the
// library a swappable implementation detail.
export const TOUR_COMPLETION_STATES = {
COMPLETED: "completed",
SKIPPED: "skipped",
DISMISSED: "dismissed",
} as const;
export type TourCompletionState =
(typeof TOUR_COMPLETION_STATES)[keyof typeof TOUR_COMPLETION_STATES];
export const TOUR_STEP_SIDES = {
TOP: "top",
RIGHT: "right",
BOTTOM: "bottom",
LEFT: "left",
OVER: "over",
} as const;
export type TourStepSide =
(typeof TOUR_STEP_SIDES)[keyof typeof TOUR_STEP_SIDES];
export const TOUR_STEP_ALIGNMENTS = {
START: "start",
CENTER: "center",
END: "end",
} as const;
export type TourStepAlignment =
(typeof TOUR_STEP_ALIGNMENTS)[keyof typeof TOUR_STEP_ALIGNMENTS];
export interface TourId {
id: string;
version: number;
}
export interface TourCompletionRecord {
tourId: string;
version: number;
state: TourCompletionState;
completedAt: string;
}
// Modal step omits `target`; anchored step provides the `data-tour-id` value (no brackets).
export interface TourStep<TTarget extends string = string> {
target?: TTarget;
title?: string;
description?: string;
side?: TourStepSide;
align?: TourStepAlignment;
disableActiveInteraction?: boolean;
// Step with no Next/Back buttons (only Close); the flow advances via imperative
// `advanceActiveTour()` calls from the covered UI instead of popover clicks. Used
// when progress is driven by a user action (e.g. picking a provider type) rather
// than the tour's own button.
autoAdvance?: boolean;
}
// `coversFiles` is consumed by the `prowler-tour` skill to scope drift checks.
export interface TourDefinition<TTarget extends string = string> {
id: string;
version: number;
coversFiles: readonly string[];
steps: ReadonlyArray<TourStep<TTarget>>;
}
// `waitForStep` resolves when `data-tour-id="<tour-id>-<target>"` appears in the document.
export interface TourStepHandlerContext<TTarget extends string = string> {
waitForStep: (
target: TTarget,
options?: WaitForStepOptions,
) => Promise<Element>;
}
export interface WaitForStepOptions {
timeoutMs?: number;
}
// Overrides driver.js's default Next/Back for the step it's registered on.
export interface TourStepHandlers<TTarget extends string = string> {
onNext?: (context: TourStepHandlerContext<TTarget>) => void | Promise<void>;
onPrev?: (context: TourStepHandlerContext<TTarget>) => void | Promise<void>;
}
// Use instead of `: TourDefinition` to preserve literal step targets for `stepHandlers`/`waitForStep` validation.
export function defineTour<const TTarget extends string>(
definition: TourDefinition<TTarget>,
): TourDefinition<TTarget> {
return definition;
}

Some files were not shown because too many files have changed in this diff Show More