feat(ui): limit locked tenants to a ten-row findings preview (#13130)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-23 10:58:15 +02:00
1 parent 819f5fd7a0
commit 35d25a7218
21 files changed
+1048 -66

No files matched your search

+11
View File
@@ -37,6 +37,17 @@ describe("findings page", () => {
expect(source).toContain("applyDefaultMutedFilter(filtersWithScanDates)");
});
it("pins a locked findings preview to the first page and hands the limit to the table", () => {
expect(source).toContain("await isFindingsPreviewLocked()");
expect(source).toContain("const page = previewLocked\n ? 1");
expect(source).toContain(
"const pageSize = previewLocked\n ? FINDINGS_PREVIEW_ROW_LIMIT * 2",
);
expect(source).toContain(
"previewRowLimit={previewLocked ? FINDINGS_PREVIEW_ROW_LIMIT : undefined}",
);
});
it("guards errors array access with a length check", () => {
expect(source).toContain("errors?.length > 0");
});
+15 -2
View File
@@ -19,6 +19,10 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import {
FINDINGS_PREVIEW_ROW_LIMIT,
isFindingsPreviewLocked,
} from "@/lib/findings-access";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
@@ -110,8 +114,16 @@ const SSRDataTable = async ({
searchParams: SearchParamsProps;
filters: Record<string, string>;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
// A locked preview ignores the URL paging: it always shows the first page,
// the readable rows plus one blurred page as a teaser. Filters and sort
// still apply, so the readable rows are the most critical of the selection.
const previewLocked = await isFindingsPreviewLocked();
const page = previewLocked
? 1
: parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = previewLocked
? FINDINGS_PREVIEW_ROW_LIMIT * 2
: parseInt(searchParams.pageSize?.toString() || "10", 10);
const expandedCheckIdParam = searchParams.expandedCheckId;
const expandedCheckId = Array.isArray(expandedCheckIdParam)
? expandedCheckIdParam[0]
@@ -150,6 +162,7 @@ const SSRDataTable = async ({
resolvedFilters={filters}
hasHistoricalData={hasHistoricalData}
expandedCheckId={expandedCheckId}
previewRowLimit={previewLocked ? FINDINGS_PREVIEW_ROW_LIMIT : undefined}
/>
</>
);
@@ -1,9 +1,11 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } 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";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { useCloudUpgradeStore } from "@/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
FINDINGS_ROW_TYPE,
type FindingGroupRow,
@@ -43,8 +45,10 @@ vi.mock("@/components/shadcn/table", () => ({
data,
toolbarRightContent,
getRowAttributes,
getRowCanSelect,
onRowSelectionChange,
renderAfterRow,
previewLock,
}: {
data?: Array<{ checkId?: string }>;
toolbarRightContent?: ReactNode;
@@ -52,6 +56,11 @@ vi.mock("@/components/shadcn/table", () => ({
index: number;
original: { checkId?: string };
}) => Record<string, string | undefined>;
getRowCanSelect?: (row: {
index: number;
original: { checkId?: string };
}) => boolean;
previewLock?: { visibleRowCount: number; overlay: ReactNode };
onRowSelectionChange?: (
updater: (previous: Record<string, boolean>) => Record<string, boolean>,
) => void;
@@ -62,12 +71,23 @@ vi.mock("@/components/shadcn/table", () => ({
}) => (
<div>
<div data-testid="table-toolbar-right">{toolbarRightContent}</div>
{previewLock && (
<div
data-testid="preview-lock"
data-visible={previewLock.visibleRowCount}
>
{previewLock.overlay}
</div>
)}
<table>
<tbody>
{(data ?? []).map((original, index) => (
<Fragment key={original.checkId ?? index}>
<tr
data-testid={`row-${index}`}
data-can-select={String(
getRowCanSelect?.({ index, original }) ?? true,
)}
{...getRowAttributes?.({ index, original })}
>
<td>{original.checkId}</td>
@@ -117,7 +137,35 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({
}));
vi.mock("@/components/onboarding", () => ({
OnboardingTrigger: () => <div data-testid="onboarding-trigger" />,
// 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" />,
}));
@@ -421,4 +469,141 @@ describe("FindingsGroupTable", () => {
resourceSearch: undefined,
});
});
describe("when the table is locked to a preview", () => {
const lockedGroups = [
makeGroup("check-a"),
makeGroup("check-b"),
makeGroup("check-c"),
];
beforeEach(() => {
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("floats the unlock prompt over the table and keeps locked groups unselectable", () => {
// Given / When
render(
<FindingsGroupTable
data={lockedGroups}
metadata={{
pagination: { page: 1, pages: 1, count: 40 },
version: "v1",
}}
resolvedFilters={{}}
hasHistoricalData={false}
previewRowLimit={2}
/>,
);
// Then
expect(screen.getByTestId("preview-lock")).toHaveAttribute(
"data-visible",
"2",
);
expect(
screen.getByRole("button", { name: "Unlock All Findings" }),
).toBeInTheDocument();
expect(screen.getByTestId("row-1")).toHaveAttribute(
"data-can-select",
"true",
);
expect(screen.getByTestId("row-2")).toHaveAttribute(
"data-can-select",
"false",
);
});
it("still opens an expanded deep link inside the preview", () => {
// Given / When
render(
<FindingsGroupTable
data={lockedGroups}
resolvedFilters={{}}
hasHistoricalData={false}
expandedCheckId="check-b"
previewRowLimit={2}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Select finding-1" }),
).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(
<FindingsGroupTable
data={lockedGroups}
resolvedFilters={{}}
hasHistoricalData={false}
expandedCheckId="check-c"
previewRowLimit={2}
/>,
);
// Then
expect(
screen.queryByRole("button", { name: "Select finding-1" }),
).not.toBeInTheDocument();
});
it("answers a drill-down past the preview with the upgrade prompt", async () => {
// Given
const user = userEvent.setup();
render(
<FindingsGroupTable
data={lockedGroups}
resolvedFilters={{}}
hasHistoricalData={false}
previewRowLimit={2}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Expand check-c" }));
// Then
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
);
expect(
screen.queryByRole("button", { name: "Select finding-1" }),
).not.toBeInTheDocument();
});
});
});
@@ -21,7 +21,9 @@ import {
} 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";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { LIGHTHOUSE_CONTEXT_LIMIT } from "@/types/lighthouse-context";
@@ -29,6 +31,7 @@ import { FloatingSelectionActions } from "../floating-selection-actions";
import { getColumnFindingGroups } from "./column-finding-groups";
import { canMuteFindingGroup } from "./finding-group-selection";
import { FindingsPreviewLock } from "./findings-preview-lock";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
InlineResourceContainer,
@@ -75,12 +78,24 @@ function buildEntityCountLabel(
return `${count} ${count === 1 ? singular : plural}`;
}
// Groups a locked tenant can open; the rest of the page is a blurred teaser.
function getPreviewedGroups(
groups: FindingGroupRow[],
previewRowLimit: number | undefined,
): FindingGroupRow[] {
return previewRowLimit === undefined
? groups
: groups.slice(0, previewRowLimit);
}
interface FindingsGroupTableProps {
data: FindingGroupRow[];
metadata?: MetaDataProps;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
expandedCheckId?: string;
/** Rows the user can read; the rest render locked behind the upgrade prompt. */
previewRowLimit?: number;
}
export function FindingsGroupTable({
@@ -89,10 +104,13 @@ export function FindingsGroupTable({
resolvedFilters,
hasHistoricalData,
expandedCheckId: requestedExpandedCheckId,
previewRowLimit,
}: FindingsGroupTableProps) {
const safeData = data ?? EMPTY_FINDING_GROUPS;
const requestedGroup = requestedExpandedCheckId
? safeData.find((group) => group.checkId === requestedExpandedCheckId)
? getPreviewedGroups(safeData, previewRowLimit).find(
(group) => group.checkId === requestedExpandedCheckId,
)
: undefined;
const initialExpandedCheckId =
requestedGroup && canDrillDownFindingGroup(requestedGroup)
@@ -107,6 +125,7 @@ export function FindingsGroupTable({
resolvedFilters={resolvedFilters}
hasHistoricalData={hasHistoricalData}
initialExpandedCheckId={initialExpandedCheckId}
previewRowLimit={previewRowLimit}
/>
);
}
@@ -117,6 +136,7 @@ interface FindingsGroupTableContentProps {
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
initialExpandedCheckId: string | null;
previewRowLimit?: number;
}
const FindingsGroupTableContent = ({
@@ -125,9 +145,13 @@ const FindingsGroupTableContent = ({
resolvedFilters,
hasHistoricalData,
initialExpandedCheckId,
previewRowLimit,
}: FindingsGroupTableContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState<
string | null
@@ -139,8 +163,13 @@ const FindingsGroupTableContent = ({
const inlineRef = useRef<InlineResourceContainerHandle>(null);
const safeData = data ?? EMPTY_FINDING_GROUPS;
const previewedGroups = getPreviewedGroups(safeData, previewRowLimit);
const isPreviewedRow = (index: number) =>
previewRowLimit === undefined || index < previewRowLimit;
const openFindingsPreviewUpgrade = () =>
openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW);
const expandedGroupCandidate = selectedExpandedCheckId
? safeData.find((group) => group.checkId === selectedExpandedCheckId)
? previewedGroups.find((group) => group.checkId === selectedExpandedCheckId)
: undefined;
const expandedGroup =
expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate)
@@ -236,7 +265,7 @@ const FindingsGroupTableContent = ({
findingCount: activeResourceSelection.length,
});
const selectableRowCount = safeData.filter((g) =>
const selectableRowCount = previewedGroups.filter((g) =>
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
resourcesTotal: g.resourcesTotal,
@@ -247,12 +276,15 @@ const FindingsGroupTableContent = ({
const getRowCanSelect = (row: Row<FindingGroupRow>): boolean => {
const group = row.original;
return canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
muted: group.muted,
mutedCount: group.mutedCount,
});
return (
isPreviewedRow(row.index) &&
canMuteFindingGroup({
resourcesFail: group.resourcesFail,
resourcesTotal: group.resourcesTotal,
muted: group.muted,
mutedCount: group.mutedCount,
})
);
};
const clearSelection = () => {
@@ -293,6 +325,11 @@ const FindingsGroupTableContent = ({
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
// Locked rows are inert, so this only guards programmatic callers.
if (!previewedGroups.some((candidate) => candidate.checkId === checkId)) {
openFindingsPreviewUpgrade();
return;
}
if (!canDrillDownFindingGroup(group)) return;
// Toggle: same group collapses, different group switches
@@ -314,13 +351,14 @@ const FindingsGroupTableContent = ({
};
// Drives the onboarding "Open a finding group" step: opens the first row when
// drillable, otherwise the first drillable group. Returns false when none can
// open so the tour skips the resources step instead of hanging.
// 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 =
safeData[0] && canDrillDownFindingGroup(safeData[0])
? safeData[0]
: safeData.find((group) => canDrillDownFindingGroup(group));
previewedGroups[0] && canDrillDownFindingGroup(previewedGroups[0])
? previewedGroups[0]
: previewedGroups.find((group) => canDrillDownFindingGroup(group));
if (!target) return false;
handleDrillDown(target.checkId, target);
return true;
@@ -424,6 +462,19 @@ const FindingsGroupTableContent = ({
getRowAttributes={(row) =>
row.index === 0 ? { "data-tour-id": "explore-findings-group" } : {}
}
previewLock={
previewRowLimit !== undefined
? {
visibleRowCount: previewRowLimit,
overlay: (
<FindingsPreviewLock
totalCount={metadata?.pagination.count}
/>
),
onLockedInteraction: openFindingsPreviewUpgrade,
}
: undefined
}
/>
</div>
@@ -0,0 +1,212 @@
import type { ColumnDef } from "@tanstack/react-table";
import { afterEach, describe, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "@/__tests__/render-browser";
import { DataTable } from "@/components/shadcn/table/data-table";
import { useCloudUpgradeStore } from "@/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { FindingsPreviewLock } from "./findings-preview-lock";
interface PreviewRow {
name: string;
}
const columns: ColumnDef<PreviewRow>[] = [
{ accessorKey: "name", header: "Finding group" },
];
describe("Findings preview layout", () => {
afterEach(async () => {
useCloudUpgradeStore.getState().closeCloudUpgrade();
await page.viewport(1280, 800);
});
it.each([
{ count: 11, width: 1280 },
{ count: 13, width: 1280 },
{ count: 20, width: 1280 },
{ count: 11, width: 375 },
{ count: 13, width: 375 },
{ count: 20, width: 375 },
])(
"keeps the unlock card above pagination with $count groups at $width px",
async ({ count, width }) => {
// Given: filtered results may leave too few blurred rows to contain the card.
await page.viewport(width, 1400);
const screen = await render(
<DataTable
columns={columns}
data={Array.from({ length: count }, (_, index) => ({
name: `Finding ${index + 1}`,
}))}
metadata={{
pagination: { page: 1, pages: 1, count },
version: "v1",
}}
previewLock={{
visibleRowCount: 10,
overlay: <FindingsPreviewLock totalCount={count} />,
onLockedInteraction: vi.fn(),
}}
/>,
);
// When
const card = screen.getByRole("region", { name: "Findings preview" });
await expect.element(card).toBeVisible();
const tableScrollport = screen
.getByRole("table")
.element().parentElement!;
// Then: the CTA needs no hidden vertical scroll inside the table.
await expect
.poll(
() =>
card.element().getBoundingClientRect().bottom -
tableScrollport.getBoundingClientRect().bottom,
)
.toBeLessThanOrEqual(0);
expect(tableScrollport.scrollHeight).toBeLessThanOrEqual(
tableScrollport.clientHeight,
);
await screen.getByRole("button", { name: "Unlock All Findings" }).click();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
);
},
);
it("keeps the card visible when a wide table scrolls and the viewport shrinks", async () => {
// Given
await page.viewport(1280, 1400);
const screen = await render(
<DataTable
columns={[
{
accessorKey: "name",
header: "Finding group",
cell: ({ row }) => (
<span className="block min-w-[1600px]">{row.original.name}</span>
),
},
]}
data={Array.from({ length: 11 }, (_, index) => ({
name: `Finding ${index + 1}`,
}))}
previewLock={{
visibleRowCount: 10,
overlay: <FindingsPreviewLock totalCount={11} />,
onLockedInteraction: vi.fn(),
}}
/>,
);
const card = screen.getByRole("region", { name: "Findings preview" });
await expect.element(card).toBeVisible();
const tableScrollport = screen.getByRole("table").element().parentElement!;
const originalLeft = card.element().getBoundingClientRect().left;
// When
tableScrollport.scrollLeft = 400;
// Then: horizontal scrolling preserves the card's position.
expect(tableScrollport.scrollLeft).toBeGreaterThan(0);
await expect
.poll(() => card.element().getBoundingClientRect().left)
.toBe(originalLeft);
// When: wrapping makes the card taller after mount.
await page.viewport(375, 1400);
// Then: both the horizontal pin and the reserved height follow the resize.
await expect
.poll(
() =>
card.element().getBoundingClientRect().right -
tableScrollport.getBoundingClientRect().right,
)
.toBeLessThanOrEqual(0);
await expect
.poll(
() =>
card.element().getBoundingClientRect().bottom -
tableScrollport.getBoundingClientRect().bottom,
)
.toBeLessThanOrEqual(0);
expect(tableScrollport.scrollHeight).toBeLessThanOrEqual(
tableScrollport.clientHeight,
);
await screen.getByRole("button", { name: "Unlock All Findings" }).click();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
);
});
it("opens the upgrade prompt when clicking a blurred row without firing its actions", async () => {
// Given
await page.viewport(1280, 1800);
const onLockedInteraction = vi.fn();
const onRowAction = vi.fn();
const onRowClick = vi.fn();
const screen = await render(
<div>
<DataTable
columns={[
{
accessorKey: "name",
header: "Finding group",
cell: ({ row }) => (
<button type="button" onClick={onRowAction}>
{row.original.name}
</button>
),
},
]}
data={Array.from({ length: 20 }, (_, index) => ({
name: `Finding ${index + 1}`,
}))}
onRowClick={onRowClick}
previewLock={{
visibleRowCount: 10,
overlay: <FindingsPreviewLock totalCount={20} />,
onLockedInteraction,
}}
/>
<button type="button">After table</button>
</div>,
);
// When: a real pointer click at the inert content's position, outside the card.
await screen
.getByText("Finding 20", { exact: true })
.click({ force: true });
// Then
expect(onLockedInteraction).toHaveBeenCalledOnce();
expect(onRowAction).not.toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
// When / Then: keyboard focus skips every blurred action, keeping the CTA usable.
(
screen
.getByRole("button", { name: "Finding 10", exact: true })
.element() as HTMLButtonElement
).focus();
await userEvent.tab();
await expect
.element(screen.getByRole("button", { name: "Unlock All Findings" }))
.toHaveFocus();
await userEvent.tab();
await expect
.element(screen.getByRole("button", { name: "After table" }))
.toHaveFocus();
// Visible row actions still work.
await screen
.getByRole("button", { name: "Finding 1", exact: true })
.click();
expect(onRowAction).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { useCloudUpgradeStore } from "@/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { FindingsPreviewLock } from "./findings-preview-lock";
describe("FindingsPreviewLock", () => {
beforeEach(() => {
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("tells the user how much of the list is hidden and opens the paid plan upgrade", async () => {
// Given
const user = userEvent.setup();
render(<FindingsPreviewLock totalCount={1234} />);
// When
await user.click(
screen.getByRole("button", { name: "Unlock All Findings" }),
);
// Then
expect(
screen.getByText("Showing the 10 most critical of 1,234 finding groups"),
).toBeInTheDocument();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
);
});
});
@@ -0,0 +1,53 @@
"use client";
import { Lock } from "lucide-react";
import { Button } from "@/components/shadcn/button/button";
import { FINDINGS_PREVIEW_ROW_LIMIT } from "@/lib/findings-access";
import { useCloudUpgradeStore } from "@/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
interface FindingsPreviewLockProps {
totalCount?: number;
}
// Floats over the blurred rows of a locked /findings table.
export function FindingsPreviewLock({ totalCount }: FindingsPreviewLockProps) {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const scope =
totalCount !== undefined
? `of ${totalCount.toLocaleString()} finding groups`
: "finding groups";
return (
<div
role="region"
aria-label="Findings preview"
className="bg-bg-neutral-secondary border-border-neutral-secondary flex w-full max-w-md flex-col items-center gap-3 rounded-2xl border p-6 text-center shadow-lg"
>
<div className="bg-bg-neutral-tertiary text-text-neutral-primary flex size-10 items-center justify-center rounded-xl">
<Lock aria-hidden="true" className="size-5" />
</div>
<p className="text-text-neutral-primary text-sm font-semibold">
Showing the {FINDINGS_PREVIEW_ROW_LIMIT} most critical {scope}
</p>
<p className="text-text-neutral-secondary text-sm">
Upgrade to unlock the full list, pagination, and every affected
resource.
</p>
<Button
type="button"
onClick={(event) =>
openCloudUpgrade(
PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
event.currentTarget,
)
}
>
Unlock All Findings
</Button>
</div>
);
}
@@ -19,8 +19,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { TableCell, TableRow } from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { useScrollportPin } from "@/hooks/use-scrollport-pin";
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
import { cn } from "@/lib/utils";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -224,31 +224,9 @@ export function InlineResourceContainer({
// moves the group columns while this block stays in place — which also
// lets the sub-table's own sticky actions column anchor to a scrollport
// that is actually visible from the start.
const [scrollportPin, setScrollportPin] = useState<{
width: number;
left: number;
} | null>(null);
useMountEffect(() => {
const scrollParent = scrollContainerRef.current?.closest(
"[data-table-scroll-container]",
);
if (!(scrollParent instanceof HTMLElement)) return;
const measure = () => {
const styles = getComputedStyle(scrollParent);
const paddingLeft = parseFloat(styles.paddingLeft);
setScrollportPin({
width:
scrollParent.clientWidth -
paddingLeft -
parseFloat(styles.paddingRight),
left: paddingLeft,
});
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(scrollParent);
return () => observer.disconnect();
});
const scrollportPin = useScrollportPin(() =>
scrollContainerRef.current?.closest("[data-table-scroll-container]"),
);
// Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
const combinedScrollRef = (node: HTMLDivElement | null) => {
@@ -1,13 +1,17 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MetaDataProps } from "@/types";
const mocks = vi.hoisted(() => ({ searchParams: new URLSearchParams() }));
const mocks = vi.hoisted(() => ({
searchParams: new URLSearchParams(),
push: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: mocks.push }),
useSearchParams: () => mocks.searchParams,
}));
@@ -176,4 +180,54 @@ describe("DataTablePagination", () => {
"20",
);
});
describe("when the table is locked to a preview", () => {
const lockedMetadata: MetaDataProps = {
pagination: { page: 1, pages: 2, count: 25, itemsPerPage: [10, 20, 50] },
version: "latest",
};
it("counts pages by the visible rows and routes navigation to the lock handler", async () => {
// Given
const user = userEvent.setup();
const onLockedInteraction = vi.fn();
render(
<DataTablePagination
metadata={lockedMetadata}
lock={{ visibleRowCount: 10, onLockedInteraction }}
/>,
);
// When
await user.click(screen.getByLabelText("Go to next page"));
await user.click(screen.getByLabelText("Go to last page"));
// Then
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
expect(onLockedInteraction).toHaveBeenCalledTimes(2);
expect(mocks.push).not.toHaveBeenCalled();
expect(screen.getByLabelText("Go to next page")).not.toHaveAttribute(
"href",
);
});
it("opens the lock handler instead of the rows-per-page menu", async () => {
// Given
const user = userEvent.setup();
const onLockedInteraction = vi.fn();
render(
<DataTablePagination
metadata={lockedMetadata}
lock={{ visibleRowCount: 10, onLockedInteraction }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "10" }));
// Then
expect(onLockedInteraction).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("page-size-select")).not.toBeInTheDocument();
});
});
});
@@ -5,6 +5,7 @@ import {
ChevronLast,
ChevronLeft,
ChevronRight,
Lock,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -20,11 +21,22 @@ import { getPaginationInfo } from "@/lib";
import { cn } from "@/lib/utils";
import { MetaDataProps } from "@/types";
/**
* Preview lock: the table only shows `visibleRowCount` rows, so every control
* reports pages by that count and hands the click to `onLockedInteraction`
* (an upgrade prompt) instead of paging.
*/
export interface DataTablePaginationLock {
visibleRowCount: number;
onLockedInteraction: () => void;
}
interface DataTablePaginationProps {
metadata?: MetaDataProps;
disableScroll?: boolean;
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
lock?: DataTablePaginationLock;
/*
* Controlled mode: Use these props to manage pagination via React state
@@ -44,10 +56,21 @@ const NAV_BUTTON_STYLES = {
disabled: "text-text-neutral-tertiary cursor-not-allowed pointer-events-none",
} as const;
const PAGE_SIZE_TRIGGER_CLASS_NAME =
"bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]";
const LOCKED_NAV_CONTROLS = [
{ label: "Go to first page", Icon: ChevronFirst },
{ label: "Go to previous page", Icon: ChevronLeft },
{ label: "Go to next page", Icon: ChevronRight },
{ label: "Go to last page", Icon: ChevronLast },
] as const;
export function DataTablePagination({
metadata,
disableScroll = false,
paramPrefix = "",
lock,
controlledPage,
controlledPageSize,
onPageChange,
@@ -73,10 +96,15 @@ export function DataTablePagination({
const {
currentPage: metaCurrentPage,
totalPages,
totalPages: metaTotalPages,
totalEntries,
itemsPerPageOptions,
} = getPaginationInfo(metadata);
// A locked table shows fewer rows than it fetched: count pages by what the
// user actually sees so "Page 1 of N" stays honest.
const totalPages = lock
? Math.ceil(totalEntries / lock.visibleRowCount)
: metaTotalPages;
// Gated separately: a large page size collapses the table to one page, and the
// selector is the only way back to a smaller one.
@@ -90,11 +118,13 @@ export function DataTablePagination({
if (!showPageSizeSelector && !showPageNavigation) return null;
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
const currentPage = isControlled
? controlledPage
: paramPrefix
? parseInt(searchParams.get(pageParam) || "1", 10)
: metaCurrentPage;
const currentPage = lock
? 1
: isControlled
? controlledPage
: paramPrefix
? parseInt(searchParams.get(pageParam) || "1", 10)
: metaCurrentPage;
const createPageUrl = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
@@ -138,7 +168,25 @@ export function DataTablePagination({
return (
<div className="flex w-full items-center justify-end gap-6 py-1.5">
{/* Rows per page selector */}
{showPageSizeSelector && (
{showPageSizeSelector && lock && (
<div className="flex items-center gap-3">
<span className="text-text-neutral-secondary text-xs font-medium whitespace-nowrap">
Rows per page
</span>
<button
type="button"
className={cn(
"flex items-center rounded-full border",
PAGE_SIZE_TRIGGER_CLASS_NAME,
)}
onClick={lock.onLockedInteraction}
>
{lock.visibleRowCount}
<Lock aria-hidden="true" className="size-3" />
</button>
</div>
)}
{showPageSizeSelector && !lock && (
<div className="flex items-center gap-3">
<span className="text-text-neutral-secondary text-xs font-medium whitespace-nowrap">
Rows per page
@@ -178,7 +226,7 @@ export function DataTablePagination({
>
<SelectTrigger
iconSize="sm"
className="bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]"
className={PAGE_SIZE_TRIGGER_CLASS_NAME}
>
<SelectValue />
</SelectTrigger>
@@ -203,7 +251,23 @@ export function DataTablePagination({
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-3">
{isControlled ? (
{lock ? (
// Every arrow looks enabled on purpose: the click is the upsell.
LOCKED_NAV_CONTROLS.map(({ label, Icon }) => (
<button
key={label}
type="button"
aria-label={label}
className={cn(
NAV_BUTTON_STYLES.base,
NAV_BUTTON_STYLES.enabled,
)}
onClick={lock.onLockedInteraction}
>
<Icon className="size-6" aria-hidden="true" />
</button>
))
) : isControlled ? (
<>
<button
type="button"
@@ -175,4 +175,69 @@ describe("DataTable", () => {
expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary");
});
});
describe("when a preview lock limits the visible rows", () => {
it("blurs the rows past the limit, keeps their content inert, and floats the overlay over them", () => {
// Given
const renderAfterRow = vi.fn(() => null);
render(
<DataTable
columns={columns}
data={[
{ name: "Finding A" },
{ name: "Finding B" },
{ name: "Finding C" },
]}
metadata={metadata}
renderAfterRow={renderAfterRow}
previewLock={{
visibleRowCount: 2,
overlay: <div>Unlock every finding</div>,
onLockedInteraction: vi.fn(),
}}
/>,
);
// When
const visibleRow = screen.getByText("Finding B").closest("tr");
const lockedRow = screen.getByText("Finding C").closest("tr");
// Then
expect(visibleRow?.querySelector("[inert]")).toBeNull();
expect(lockedRow?.querySelector("td > div")).toHaveAttribute("inert");
expect(lockedRow).toHaveAttribute("aria-hidden", "true");
expect(lockedRow).toHaveClass("select-none");
expect(screen.getByText("Unlock every finding")).toBeInTheDocument();
expect(renderAfterRow).toHaveBeenCalledWith(
expect.objectContaining({ index: 1 }),
);
expect(renderAfterRow).not.toHaveBeenCalledWith(
expect.objectContaining({ index: 2 }),
);
});
it("renders no overlay when every row fits in the preview", () => {
// Given / When
render(
<DataTable
columns={columns}
data={[{ name: "Finding A" }]}
metadata={metadata}
previewLock={{
visibleRowCount: 2,
overlay: <div>Unlock every finding</div>,
onLockedInteraction: vi.fn(),
}}
/>,
);
// Then
expect(
screen.queryByText("Unlock every finding"),
).not.toBeInTheDocument();
expect(screen.getByText("Finding A").closest("tr")).not.toHaveAttribute(
"inert",
);
});
});
});
+151 -10
View File
@@ -28,9 +28,13 @@ import {
TableRow,
} from "@/components/shadcn/table";
import { DataTableAnimatedRow } from "@/components/shadcn/table/data-table-animated-row";
import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
import {
DataTablePagination,
type DataTablePaginationLock,
} from "@/components/shadcn/table/data-table-pagination";
import { DataTableSearch } from "@/components/shadcn/table/data-table-search";
import { useFilterTransitionOptional } from "@/contexts";
import { useScrollportPin } from "@/hooks/use-scrollport-pin";
import { cn } from "@/lib/utils";
import { FilterOption, MetaDataProps } from "@/types";
@@ -49,6 +53,20 @@ const TABLE_COLUMN_GAP_CLASS = "pr-6";
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
// Locked cells stay in the DOM as a teaser but cannot be read or focused.
// The row receives clicks to open the upgrade prompt instead of cell actions.
// The blur goes on the cells: browsers do not reliably filter table rows.
const LOCKED_ROW_CLASS_NAME =
"cursor-pointer select-none opacity-70 hover:bg-transparent [&>td]:blur-[6px]";
/**
* Preview lock: rows past `visibleRowCount` render blurred with inert content,
* `overlay` floats over them, and row/pagination clicks call `onLockedInteraction`.
*/
export interface DataTablePreviewLock extends DataTablePaginationLock {
overlay: ReactNode;
}
const getTableColumnClassName = (
columnId: string,
variant: "header" | "cell",
@@ -136,6 +154,7 @@ interface DataTableProviderProps<TData, TValue> {
header?: ReactNode;
/** Optional content rendered in the toolbar before the total entries count. */
toolbarRightContent?: ReactNode;
previewLock?: DataTablePreviewLock;
}
export function DataTable<TData, TValue>({
@@ -169,6 +188,7 @@ export function DataTable<TData, TValue>({
getRowAttributes,
header,
toolbarRightContent,
previewLock,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -212,6 +232,17 @@ export function DataTable<TData, TValue>({
// Track whether initial expansion has been applied
const hasInitiallyExpanded = useRef(false);
// The preview overlay pins itself to the visible scrollport: centered on the
// full table width it would scroll away (and clip) on wide tables. Measured
// on mount only, which is when a locked table arrives from the server.
const scrollContainerRef = useRef<HTMLDivElement>(null);
const previewOverlayRef = useRef<HTMLDivElement>(null);
const lastRowRef = useRef<HTMLTableRowElement>(null);
const [previewPaddingBottom, setPreviewPaddingBottom] = useState(0);
const scrollportPin = useScrollportPin(() =>
previewLock ? scrollContainerRef.current : null,
);
// Expand all rows on mount when defaultExpanded={true}
useEffect(() => {
if (
@@ -244,8 +275,54 @@ export function DataTable<TData, TValue>({
const showToolbar = showSearch || metadata || toolbarRightContent;
const rows = table.getRowModel().rows;
// Position in the rendered order, not row.index: the lock hides what the
// user would see next, whatever the data order is.
const isLockedPosition = (position: number) =>
previewLock !== undefined && position >= previewLock.visibleRowCount;
const overlayPosition =
previewLock !== undefined && rows.length > previewLock.visibleRowCount
? previewLock.visibleRowCount - 1
: null;
// A short result set may not have enough blurred rows to contain the card.
// Reserve its overflow inside the table so the scrollport cannot clip the CTA.
useEffect(() => {
const overlay = previewOverlayRef.current;
const lastRow = lastRowRef.current;
const scrollContainer = scrollContainerRef.current;
if (overlayPosition === null || !overlay || !lastRow || !scrollContainer) {
return;
}
const measure = () => {
setPreviewPaddingBottom(
Math.max(
0,
Math.ceil(
overlay.getBoundingClientRect().bottom -
lastRow.getBoundingClientRect().bottom,
),
),
);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(overlay);
observer.observe(scrollContainer);
return () => observer.disconnect();
}, [overlayPosition, rows.length]);
const handleRowClick = (
row: Row<TData>,
target: HTMLElement | null,
position: number,
) => {
if (isLockedPosition(position)) {
previewLock?.onLockedInteraction();
return;
}
const handleRowClick = (row: Row<TData>, target: HTMLElement | null) => {
if (!onRowClick) {
return;
}
@@ -259,6 +336,7 @@ export function DataTable<TData, TValue>({
return (
<div
ref={scrollContainerRef}
// Expanded sub-rows pin themselves to this scrollport (sticky left) so
// horizontal scrolling moves the group columns, not the expanded panel.
data-table-scroll-container=""
@@ -299,7 +377,14 @@ export function DataTable<TData, TValue>({
</div>
</div>
)}
<Table className={getSubRows ? "table-fixed" : undefined}>
<Table
className={getSubRows ? "table-fixed" : undefined}
style={
overlayPosition !== null
? { paddingBottom: previewPaddingBottom }
: undefined
}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={`${headerGroup.id}-${selectionKey}-${expansionKey}`}>
@@ -334,7 +419,7 @@ export function DataTable<TData, TValue>({
<TableBody>
<AnimatePresence initial={false}>
{rows?.length ? (
rows.map((row) =>
rows.map((row, position) =>
getSubRows && row.depth > 0 ? (
<DataTableAnimatedRow
key={row.id}
@@ -345,11 +430,26 @@ export function DataTable<TData, TValue>({
) : (
<Fragment key={row.id}>
<TableRow
ref={
position === rows.length - 1 ? lastRowRef : undefined
}
{...getRowAttributes?.(row)}
data-state={row.getIsSelected() && "selected"}
className={cn("group", onRowClick && "cursor-pointer")}
data-locked-row={
isLockedPosition(position) ? "" : undefined
}
aria-hidden={isLockedPosition(position) || undefined}
className={cn(
"group",
onRowClick && "cursor-pointer",
isLockedPosition(position) && LOCKED_ROW_CLASS_NAME,
)}
onClick={(event) =>
handleRowClick(row, event.target as HTMLElement)
handleRowClick(
row,
event.target as HTMLElement,
position,
)
}
>
{row.getVisibleCells().map((cell) => (
@@ -360,14 +460,54 @@ export function DataTable<TData, TValue>({
"cell",
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
{isLockedPosition(position) ? (
<div inert className="pointer-events-none">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
) : (
flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)
)}
</TableCell>
))}
</TableRow>
{renderAfterRow?.(row)}
{!isLockedPosition(position) && renderAfterRow?.(row)}
{position === overlayPosition && previewLock && (
// Zero-height row: the overlay floats over the locked
// rows below without pushing them down.
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
// p-0! beats first:pl-3/last:pr-3: any padding shrinks
// the range the sticky wrapper can pin across.
className="h-0 overflow-visible border-0 p-0! after:hidden"
>
{/* Sticky wrapper sized to the scrollport; without a
measurement it spans the row. z-30 clears the
sticky actions cells (z-20) of the locked rows. */}
<div
className="sticky left-0 z-30"
style={
scrollportPin && scrollportPin.width > 0
? { width: scrollportPin.width }
: undefined
}
>
<div
ref={previewOverlayRef}
className="absolute inset-x-0 top-8 flex justify-center px-4"
>
{previewLock.overlay}
</div>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
),
)
@@ -393,6 +533,7 @@ export function DataTable<TData, TValue>({
controlledPageSize={controlledPageSize}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
lock={previewLock}
/>
)}
</div>
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { useMountEffect } from "./use-mount-effect";
export interface ScrollportPin {
/** Visible content width of the scrollport (padding excluded). */
width: number;
/** Left padding of the scrollport, for a sticky child that keeps the inset. */
left: number;
}
/**
* Measures a horizontally scrolling container once on mount and on resize, so
* a sticky child can span exactly the visible area instead of the full
* scroll width. Resolves to null until measured (or without ResizeObserver).
*/
export function useScrollportPin(
resolveScrollport: () => Element | null | undefined,
): ScrollportPin | null {
const [pin, setPin] = useState<ScrollportPin | null>(null);
useMountEffect(() => {
const scrollport = resolveScrollport();
if (
!(scrollport instanceof HTMLElement) ||
typeof ResizeObserver === "undefined"
) {
return;
}
const measure = () => {
const styles = getComputedStyle(scrollport);
const paddingLeft = parseFloat(styles.paddingLeft);
setPin({
width:
scrollport.clientWidth -
paddingLeft -
parseFloat(styles.paddingRight),
left: paddingLeft,
});
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(scrollport);
return () => observer.disconnect();
});
return pin;
}
+23 -1
View File
@@ -1,12 +1,18 @@
import { describe, expect, it } from "vitest";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
CLOUD_UPGRADE_FEATURE,
PAID_PLAN_UPGRADE_FEATURE,
} from "@/types/cloud-upgrade";
import {
CLOUD_UPGRADE_CONTENT,
CLOUD_UPGRADE_FOOTER_NOTE,
getCloudUpgradeCompareUrl,
getCloudUpgradePrimaryUrl,
getPaidPlanUpgradeBillingHref,
isPaidPlanUpgradeFeature,
PAID_PLAN_UPGRADE_CONTENT,
} from "./cloud-upgrade";
describe("cloud upgrade content", () => {
@@ -90,3 +96,19 @@ describe("cloud upgrade URLs", () => {
expect(url.searchParams.get("utm_content")).toBe(contentSlug);
});
});
describe("paid plan upgrade content", () => {
it("sends the findings preview upgrade to billing with its feature", () => {
// Given / When / Then
expect(
isPaidPlanUpgradeFeature(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW),
).toBe(true);
expect(
getPaidPlanUpgradeBillingHref(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW),
).toBe("/billing?feature=findings_preview");
expect(
PAID_PLAN_UPGRADE_CONTENT[PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]
.title,
).toBe("See Every Finding in Your Account");
});
});
+13
View File
@@ -7,6 +7,8 @@ import {
} from "@/types/cloud-upgrade";
import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml";
import { FINDINGS_PREVIEW_ROW_LIMIT } from "./findings-access";
interface CloudUpgradeContent {
title: string;
description: string;
@@ -215,11 +217,22 @@ export const PAID_PLAN_UPGRADE_ADMIN_NOTE =
const CLOUD_UTM_SOURCE = "prowler-cloud";
const PAID_PLAN_UPGRADE_UTM_CONTENT = {
[PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]: "findings-preview",
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: "registry",
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: "report-download",
} as const satisfies Record<PaidPlanUpgradeFeature, string>;
export const PAID_PLAN_UPGRADE_CONTENT = {
[PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]: {
title: "See Every Finding in Your Account",
description: `Trial accounts preview the ${FINDINGS_PREVIEW_ROW_LIMIT} most critical finding groups. Paid plans unlock the full list.`,
benefits: [
"Browse, sort, and page through every finding group",
"Drill into each affected resource across all your providers",
"Download reports and compliance evidence for the full scan",
],
primaryCta: "Upgrade to See All Findings",
},
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: {
title: "Unlock Prowler Registry",
description: "Prowler Registry is included in paid plans.",
+1
View File
@@ -7,6 +7,7 @@ describe("FEATURE_FLAG", () => {
expect(FEATURE_FLAG).toEqual({
ISSUES: "issues-feature",
BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
FINDINGS_TRIAL_PREVIEW: "findings-trial-preview",
});
});
+3
View File
@@ -9,6 +9,9 @@ export const FEATURE_FLAG = {
// Boolean flag evaluated server-side per tenant in cloud. When on, it pins
// the tenant to legacy billing; off or absent means the new Metronome system.
BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
// Boolean flag evaluated server-side per tenant in cloud. When on, trial
// tenants only preview the most critical finding groups on /findings.
FINDINGS_TRIAL_PREVIEW: "findings-trial-preview",
} as const;
export type FeatureFlag = (typeof FEATURE_FLAG)[keyof typeof FEATURE_FLAG];
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
FINDINGS_PREVIEW_ROW_LIMIT,
isFindingsPreviewLocked,
} from "./findings-access";
describe("findings access", () => {
it("never locks the findings preview on self-hosted deployments", async () => {
// Given / When
const locked = await isFindingsPreviewLocked();
// Then
expect(locked).toBe(false);
});
it("previews the ten most critical finding groups when locked", () => {
expect(FINDINGS_PREVIEW_ROW_LIMIT).toBe(10);
});
});
+10
View File
@@ -0,0 +1,10 @@
/** Finding groups a locked tenant can read on /findings; the rest of the page renders blurred. */
export const FINDINGS_PREVIEW_ROW_LIMIT = 10;
/**
* Whether the current tenant only previews the most critical finding groups.
* Self-hosted deployments never lock the list; the Prowler Cloud overlay
* replaces this body with its billing and feature-flag lookup.
*/
export const isFindingsPreviewLocked = (): Promise<boolean> =>
Promise.resolve(false);
+1
View File
@@ -19,6 +19,7 @@ export type CloudUpgradeFeature =
// Prowler Cloud features gated behind a paid plan.
export const PAID_PLAN_UPGRADE_FEATURE = {
FINDINGS_PREVIEW: "findings_preview",
REGISTRY: "registry",
REPORT_DOWNLOAD: "report_download",
} as const;
+2
View File
@@ -77,6 +77,8 @@ if (!Range.prototype.getBoundingClientRect) {
}
class MockResizeObserver implements ResizeObserver {
// Same signature as the real constructor; the callback never fires in jsdom.
constructor(_callback: ResizeObserverCallback) {}
observe() {}
unobserve() {}
disconnect() {}