mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
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:
co-authored by
Pablo F.G
Claude Opus 4.7
alejandrobailo
Alejandro Bailo
parent
6db8ce672c
commit
49309b43d3
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
+21
-2
@@ -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";
|
||||
|
||||
+81
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -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,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>
|
||||
</>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user