mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 04:14:18 +00:00
feat(ui): limit locked tenants to a ten-row findings preview (#13130)
This commit is contained in:
1 parent
819f5fd7a0
commit
35d25a7218
21 files changed
+1048
-66
No files matched your search
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
Reference in new issue
Block a user