mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(ui): make finding group delta indicator status-filter aware (#10647)
This commit is contained in:
1 parent
431776bcfd
commit
0d7c5f6ac5
5 files changed
+269
-5
No files matched your search
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib";
|
||||
import {
|
||||
getFindingGroupDelta,
|
||||
getFilteredFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { FindingGroupRow, ProviderType } from "@/types";
|
||||
@@ -29,6 +29,8 @@ interface GetColumnFindingGroupsOptions {
|
||||
expandedCheckId?: string | null;
|
||||
/** True when the expanded group has individually selected resources */
|
||||
hasResourceSelection?: boolean;
|
||||
/** Active URL filters — used to make the delta indicator status-aware */
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
const VISIBLE_DISABLED_CHECKBOX_CLASS =
|
||||
@@ -40,6 +42,7 @@ export function getColumnFindingGroups({
|
||||
onDrillDown,
|
||||
expandedCheckId,
|
||||
hasResourceSelection = false,
|
||||
filters = {},
|
||||
}: GetColumnFindingGroupsOptions): ColumnDef<FindingGroupRow>[] {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
@@ -80,7 +83,7 @@ export function getColumnFindingGroups({
|
||||
const group = row.original;
|
||||
const allMuted = isFindingGroupMuted(group);
|
||||
const isExpanded = expandedCheckId === group.checkId;
|
||||
const deltaKey = getFindingGroupDelta(group);
|
||||
const deltaKey = getFilteredFindingGroupDelta(group, filters);
|
||||
const delta =
|
||||
deltaKey === "new"
|
||||
? DeltaValues.NEW
|
||||
|
||||
@@ -24,7 +24,7 @@ import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
|
||||
import { cn, hasDateOrScanFilter } from "@/lib";
|
||||
import {
|
||||
getFindingGroupDelta,
|
||||
getFilteredFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
@@ -156,7 +156,7 @@ export function FindingsGroupDrillDown({
|
||||
});
|
||||
|
||||
// Delta for the sticky header
|
||||
const deltaKey = getFindingGroupDelta(group);
|
||||
const deltaKey = getFilteredFindingGroupDelta(group, filters);
|
||||
const delta =
|
||||
deltaKey === "new"
|
||||
? DeltaValues.NEW
|
||||
|
||||
@@ -173,6 +173,7 @@ export function FindingsGroupTable({
|
||||
onDrillDown: handleDrillDown,
|
||||
expandedCheckId,
|
||||
hasResourceSelection,
|
||||
filters,
|
||||
});
|
||||
|
||||
const renderAfterRow = (row: Row<FindingGroupRow>) => {
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import { getFindingGroupDelta, isFindingGroupMuted } from "./findings-groups";
|
||||
import {
|
||||
getActiveStatusFilter,
|
||||
getFilteredFindingGroupDelta,
|
||||
getFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
} from "./findings-groups";
|
||||
|
||||
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
|
||||
return {
|
||||
@@ -100,3 +105,134 @@ describe("getFindingGroupDelta", () => {
|
||||
).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveStatusFilter", () => {
|
||||
it("returns null when no status filter is active", () => {
|
||||
expect(getActiveStatusFilter({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the single value from filter[status]", () => {
|
||||
const result = getActiveStatusFilter({ "filter[status]": "FAIL" });
|
||||
expect(result).toEqual(new Set(["FAIL"]));
|
||||
});
|
||||
|
||||
it("returns the parsed set from filter[status__in]", () => {
|
||||
const result = getActiveStatusFilter({
|
||||
"filter[status__in]": "FAIL,MANUAL",
|
||||
});
|
||||
expect(result).toEqual(new Set(["FAIL", "MANUAL"]));
|
||||
});
|
||||
|
||||
it("prefers filter[status] over filter[status__in] when both are present", () => {
|
||||
const result = getActiveStatusFilter({
|
||||
"filter[status]": "PASS",
|
||||
"filter[status__in]": "FAIL,MANUAL",
|
||||
});
|
||||
expect(result).toEqual(new Set(["PASS"]));
|
||||
});
|
||||
|
||||
it("ignores unknown status values and returns null if nothing remains", () => {
|
||||
expect(
|
||||
getActiveStatusFilter({ "filter[status__in]": "UNKNOWN,FOO" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFilteredFindingGroupDelta", () => {
|
||||
it("falls back to the aggregate delta when no status filter is active", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newPassCount: 2,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("ignores deltas that belong to filtered-out statuses", () => {
|
||||
// Filter is FAIL, but the only delta is a new PASS → should be hidden.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newPassCount: 3,
|
||||
}),
|
||||
{ "filter[status__in]": "FAIL" },
|
||||
),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("surfaces FAIL deltas when the filter is FAIL", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newFailCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("counts muted breakdown counters towards the filtered status", () => {
|
||||
// A muted new FAIL still belongs to the FAIL bucket — a FAIL filter
|
||||
// should still light up the "new" indicator.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newFailMutedCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("sums multiple filtered statuses from filter[status__in]", () => {
|
||||
// Filter is FAIL+MANUAL, new delta is only in MANUAL → should still show.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newManualCount: 1,
|
||||
}),
|
||||
{ "filter[status__in]": "FAIL,MANUAL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("prefers new over changed within the filtered status", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newFailCount: 1,
|
||||
changedFailCount: 2,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("returns changed when only changed counters match the filtered status", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newPassCount: 2, // filtered out
|
||||
changedFailCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("changed");
|
||||
});
|
||||
|
||||
it("falls back to the aggregate delta when breakdowns are missing (legacy API)", () => {
|
||||
// No breakdown fields populated but legacy newCount is set. With a FAIL
|
||||
// filter active we cannot know which status bucket it belongs to, so we
|
||||
// fall back to showing the delta rather than silently hiding it.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
});
|
||||
@@ -75,3 +75,127 @@ export function getFindingGroupDelta(
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
const FINDING_GROUP_STATUSES = ["FAIL", "PASS", "MANUAL"] as const;
|
||||
type FindingGroupStatus = (typeof FINDING_GROUP_STATUSES)[number];
|
||||
|
||||
type FindingGroupFiltersRecord = Record<string, string | string[] | undefined>;
|
||||
|
||||
function parseStatusFilterValue(
|
||||
rawValue: string | string[] | undefined,
|
||||
): FindingGroupStatus[] {
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const joined = Array.isArray(rawValue) ? rawValue.join(",") : rawValue;
|
||||
|
||||
return joined
|
||||
.split(",")
|
||||
.map((status) => status.trim().toUpperCase())
|
||||
.filter((status): status is FindingGroupStatus =>
|
||||
(FINDING_GROUP_STATUSES as readonly string[]).includes(status),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of statuses the user has explicitly narrowed the findings
|
||||
* view to, or null when no status filter is active (→ all statuses should be
|
||||
* considered). Supports both `filter[status]` (single value) and
|
||||
* `filter[status__in]` (comma-separated values).
|
||||
*/
|
||||
export function getActiveStatusFilter(
|
||||
filters: FindingGroupFiltersRecord,
|
||||
): Set<FindingGroupStatus> | null {
|
||||
const direct = parseStatusFilterValue(filters["filter[status]"]);
|
||||
if (direct.length > 0) {
|
||||
return new Set(direct);
|
||||
}
|
||||
|
||||
const multi = parseStatusFilterValue(filters["filter[status__in]"]);
|
||||
if (multi.length > 0) {
|
||||
return new Set(multi);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAnyDeltaBreakdown(group: FindingGroupDeltaState): boolean {
|
||||
return (
|
||||
(group.newFailCount ?? 0) > 0 ||
|
||||
(group.newFailMutedCount ?? 0) > 0 ||
|
||||
(group.newPassCount ?? 0) > 0 ||
|
||||
(group.newPassMutedCount ?? 0) > 0 ||
|
||||
(group.newManualCount ?? 0) > 0 ||
|
||||
(group.newManualMutedCount ?? 0) > 0 ||
|
||||
(group.changedFailCount ?? 0) > 0 ||
|
||||
(group.changedFailMutedCount ?? 0) > 0 ||
|
||||
(group.changedPassCount ?? 0) > 0 ||
|
||||
(group.changedPassMutedCount ?? 0) > 0 ||
|
||||
(group.changedManualCount ?? 0) > 0 ||
|
||||
(group.changedManualMutedCount ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function getNewDeltaForStatuses(
|
||||
group: FindingGroupDeltaState,
|
||||
statuses: Set<FindingGroupStatus>,
|
||||
): number {
|
||||
let total = 0;
|
||||
if (statuses.has("FAIL")) {
|
||||
total += (group.newFailCount ?? 0) + (group.newFailMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("PASS")) {
|
||||
total += (group.newPassCount ?? 0) + (group.newPassMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("MANUAL")) {
|
||||
total += (group.newManualCount ?? 0) + (group.newManualMutedCount ?? 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function getChangedDeltaForStatuses(
|
||||
group: FindingGroupDeltaState,
|
||||
statuses: Set<FindingGroupStatus>,
|
||||
): number {
|
||||
let total = 0;
|
||||
if (statuses.has("FAIL")) {
|
||||
total += (group.changedFailCount ?? 0) + (group.changedFailMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("PASS")) {
|
||||
total += (group.changedPassCount ?? 0) + (group.changedPassMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("MANUAL")) {
|
||||
total +=
|
||||
(group.changedManualCount ?? 0) + (group.changedManualMutedCount ?? 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter-aware variant of {@link getFindingGroupDelta}. When a status filter
|
||||
* is active, only delta counters belonging to the filtered statuses contribute
|
||||
* to the indicator. When no status filter is active, or when the API response
|
||||
* lacks breakdown counters (legacy shape), this falls back to the aggregate
|
||||
* delta so rows still surface deltas correctly.
|
||||
*/
|
||||
export function getFilteredFindingGroupDelta(
|
||||
group: FindingGroupDeltaState,
|
||||
filters: FindingGroupFiltersRecord,
|
||||
): "new" | "changed" | "none" {
|
||||
const activeStatuses = getActiveStatusFilter(filters);
|
||||
|
||||
if (!activeStatuses || !hasAnyDeltaBreakdown(group)) {
|
||||
return getFindingGroupDelta(group);
|
||||
}
|
||||
|
||||
if (getNewDeltaForStatuses(group, activeStatuses) > 0) {
|
||||
return "new";
|
||||
}
|
||||
|
||||
if (getChangedDeltaForStatuses(group, activeStatuses) > 0) {
|
||||
return "changed";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
Reference in new issue
Block a user