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