fix(ui): expose finding group filter

This commit is contained in:
Hugo P.Brito
2026-07-17 08:24:09 +01:00
parent 5e09dec669
commit 07d591db64
9 changed files with 225 additions and 50 deletions
+6
View File
@@ -44,4 +44,10 @@ describe("findings page", () => {
it("applies the shared default muted filter so muted findings are hidden unless the caller opts in", () => {
expect(source).toContain("applyDefaultMutedFilter");
});
it("loads finding groups as selectable Finding Group filter options", () => {
expect(source).toContain("fetchFindingGroupFilterOptions");
expect(source).toContain("pageSize: 100");
expect(source).toContain("checkOptions={checkOptions}");
});
});
+14
View File
@@ -68,6 +68,19 @@ export default async function Findings({
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const findingGroupFilterOptionsData = await fetchFindingGroupFilterOptions({
filters: resolvedFilters,
pageSize: 100,
});
const checkOptions = adaptFindingGroupsResponse(
findingGroupFilterOptionsData,
).map((group) => ({
checkId: group.checkId,
checkTitle: group.checkTitle,
}));
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -110,6 +123,7 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
+9 -28
View File
@@ -26,7 +26,9 @@ import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
type FindingCheckFilterOption,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -47,11 +49,6 @@ interface FindingsFiltersProps {
variant?: "default" | "alerts-edit";
}
interface FindingCheckFilterOption {
checkId: string;
checkTitle?: string;
}
interface FindingsFilterBatchControlsProps extends FindingsFiltersProps {
appliedFilters: Record<string, string[]>;
pendingFilters: Record<string, string[]>;
@@ -78,10 +75,6 @@ const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
function uniqueNonEmptyValues(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
export const FindingsFilterBatchControls = ({
providers,
// Undefined = caller opted out (the alert editor shares this component but
@@ -119,11 +112,12 @@ export const FindingsFilterBatchControls = ({
checkTitle || checkId,
]),
);
const checkFilterValues = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId),
...getFilterValue("filter[check_id]"),
...getFilterValue("filter[check_id__in]"),
]);
const findingGroupFilterOption = buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds: getFilterValue("filter[check_id]"),
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
checkTitles,
});
const customFilters = [
...filterFindings
@@ -137,20 +131,7 @@ export const FindingsFilterBatchControls = ({
checkTitles,
}),
})),
...(checkFilterValues.length > 0
? [
{
key: "check_id",
labelCheckboxGroup: "Finding Group",
values: checkFilterValues,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue("filter[check_id]", value, {
checkTitles,
}),
index: 3,
},
]
: []),
...(findingGroupFilterOption ? [findingGroupFilterOption] : []),
{
key: FILTER_FIELD.REGION,
labelCheckboxGroup: "Regions",
@@ -5,6 +5,7 @@ import { ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -387,3 +388,47 @@ describe("buildFindingsFilterChips", () => {
]);
});
});
describe("buildFindingGroupFilterOption", () => {
it("builds a selectable Finding Group filter from fetched options and URL-backed values", () => {
// Given
const filter = buildFindingGroupFilterOption({
checkOptions: [
{
checkId: "teams_external_users_can_join",
checkTitle: "External Teams users can join meetings",
},
],
selectedCheckIds: ["s3_bucket_public_access"],
selectedCheckIdsIn: ["teams_external_users_can_join"],
checkTitles: {
teams_external_users_can_join: "External Teams users can join meetings",
},
});
// Then
expect(filter).toMatchObject({
key: "check_id",
labelCheckboxGroup: "Finding Group",
values: ["teams_external_users_can_join", "s3_bucket_public_access"],
index: 3,
});
expect(filter?.labelFormatter?.("teams_external_users_can_join")).toBe(
"External Teams users can join meetings",
);
expect(filter?.labelFormatter?.("s3_bucket_public_access")).toBe(
"s3_bucket_public_access",
);
});
it("omits the Finding Group filter when there are no selectable or URL-backed values", () => {
expect(
buildFindingGroupFilterOption({
checkOptions: [],
selectedCheckIds: [],
selectedCheckIdsIn: [],
checkTitles: {},
}),
).toBeNull();
});
});
@@ -7,10 +7,16 @@ import {
} from "@/lib/helper-filters";
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
import { ProviderGroup } from "@/types/components";
import type { FilterOption } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
export interface FindingCheckFilterOption {
checkId: string;
checkTitle?: string;
}
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
@@ -107,6 +113,43 @@ export function getFindingsFilterDisplayValue(
return formatLabel(value);
}
function uniqueNonEmptyValues(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
export function buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds,
selectedCheckIdsIn,
checkTitles,
}: {
checkOptions: FindingCheckFilterOption[];
selectedCheckIds: string[];
selectedCheckIdsIn: string[];
checkTitles: Record<string, string>;
}): FilterOption | null {
const values = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId),
...selectedCheckIds,
...selectedCheckIdsIn,
]);
if (values.length === 0) {
return null;
}
return {
key: "check_id",
labelCheckboxGroup: "Finding Group",
values,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue("filter[check_id]", value, {
checkTitles,
}),
index: 3,
};
}
/**
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
* Used to render chips in the FilterSummaryStrip.
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildJiraDispatchChoiceCopy } from "./send-to-jira-modal-copy";
import {
buildJiraDispatchChoiceCopy,
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
describe("buildJiraDispatchChoiceCopy", () => {
it("uses Finding Group copy for selected Findings grouped Jira choice", () => {
@@ -44,7 +47,7 @@ describe("buildJiraDispatchChoiceCopy", () => {
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: false,
selectionKind: "findings",
selectionKind: JIRA_SELECTION_KIND.FINDINGS,
}),
).toEqual({
description: "Create Jira issue(s) for 2 selected Findings.",
@@ -1,7 +1,15 @@
export const JIRA_SELECTION_KIND = {
FINDINGS: "findings",
RESOURCES: "resources",
} as const;
type JiraSelectionKind =
(typeof JIRA_SELECTION_KIND)[keyof typeof JIRA_SELECTION_KIND];
interface JiraDispatchChoiceCopyParams {
selectedCount: number;
isSelectedFindingGroupFlow: boolean;
selectionKind?: "findings" | "resources";
selectionKind?: JiraSelectionKind;
}
interface JiraDispatchChoiceCopy {
@@ -14,7 +22,7 @@ interface JiraDispatchChoiceCopy {
export const buildJiraDispatchChoiceCopy = ({
selectedCount,
isSelectedFindingGroupFlow,
selectionKind = "resources",
selectionKind = JIRA_SELECTION_KIND.RESOURCES,
}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
if (isSelectedFindingGroupFlow) {
return {
@@ -28,7 +36,7 @@ export const buildJiraDispatchChoiceCopy = ({
};
}
if (selectionKind === "findings") {
if (selectionKind === JIRA_SELECTION_KIND.FINDINGS) {
return {
description: `Create Jira issue(s) for ${selectedCount} selected Findings.`,
groupedTitle: "Create one Jira issue for all selected Findings",
@@ -125,10 +125,13 @@ describe("SendToJiraModal", () => {
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
expect(
screen.getByText(
screen.getByText("Create one Jira issue for all selected Findings"),
).toBeInTheDocument();
expect(
screen.queryByText(
"Create one Jira issue for all selected Findings in this Finding Group",
),
).toBeInTheDocument();
).not.toBeInTheDocument();
expect(
screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
).toBeInTheDocument();
@@ -600,12 +603,72 @@ describe("SendToJiraModal", () => {
await waitFor(() =>
expect(pollJiraDispatchTaskMock).toHaveBeenCalledWith("group-task"),
);
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Error",
description: "Failed to launch Finding batch.",
}),
);
});
it("reports polling and launch failures together for mixed dispatches", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock
.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
})
.mockResolvedValueOnce({
success: false,
error: "Failed to launch Finding batch.",
});
pollJiraDispatchTaskMock.mockResolvedValueOnce({
success: false,
error: "Jira dispatch completed with 1 failed issue.",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
]}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Error",
description:
"Some Jira dispatches started, but Failed to launch Finding batch.",
"Jira dispatch completed with 1 failed issue. Failed to launch Finding batch.",
}),
);
});
+26 -14
View File
@@ -30,7 +30,10 @@ import {
type JiraDispatchMode,
} from "@/types/integrations";
import { buildJiraDispatchChoiceCopy } from "./send-to-jira-modal-copy";
import {
buildJiraDispatchChoiceCopy,
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
const JIRA_DISPATCH_TARGET = {
CHECK_ID: "check_id",
@@ -142,16 +145,26 @@ export const SendToJiraModal = ({
const shouldShowDispatchChoice =
(canChooseGroupedDispatch || hasMultipleFindingTargets) &&
(multiFindingTargetCount > 1 || jiraSelectedResourceCount > 1);
const checkIdBatches = jiraTargetBatches.filter(
(batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
);
const hasOnlySingleFindingGroupBatch =
jiraTargetBatches.length === 1 &&
checkIdBatches.length === 1 &&
checkIdBatches[0].targetIds.length === 1;
const isSelectedFindingGroupFlow =
shouldShowDispatchChoice &&
(targetType === JIRA_DISPATCH_TARGET.CHECK_ID || isFindingGroupSelection);
(isFindingGroupSelection || hasOnlySingleFindingGroupBatch);
const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({
selectedCount:
multiFindingTargetCount > 1
? multiFindingTargetCount
: jiraSelectedResourceCount,
isSelectedFindingGroupFlow,
selectionKind: multiFindingTargetCount > 1 ? "findings" : "resources",
selectionKind:
multiFindingTargetCount > 1
? JIRA_SELECTION_KIND.FINDINGS
: JIRA_SELECTION_KIND.RESOURCES,
});
const selectedIntegration = form.watch("integration");
@@ -258,18 +271,17 @@ export const SendToJiraModal = ({
const taskResults = await Promise.all(
taskIds.map((taskId) => pollJiraDispatchTaskUntilDone(taskId)),
);
const failedTask = taskResults.find(
(taskResult) => !taskResult.success,
);
const errors = [
...taskResults
.filter((taskResult) => !taskResult.success)
.map(
(taskResult) => taskResult.error || "Failed to create Jira issue",
),
...launchErrors,
];
if (failedTask && !failedTask.success) {
throw new Error(failedTask.error || "Failed to create Jira issue");
}
if (launchErrors.length > 0) {
throw new Error(
`Some Jira dispatches started, but ${launchErrors.join(" ")}`,
);
if (errors.length > 0) {
throw new Error(errors.join(" "));
}
toast({