diff --git a/ui/actions/findings/findings-filters.ts b/ui/actions/findings/findings-filters.ts index 268d71e7cd..caa56a6744 100644 --- a/ui/actions/findings/findings-filters.ts +++ b/ui/actions/findings/findings-filters.ts @@ -3,6 +3,8 @@ import { FILTER_FIELD, FilterParam } from "@/types/filters"; /** Findings-only filter fields not shared with other views. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const FINDINGS_EXTRA_FIELD = { + CHECK_ID: "check_id", + CHECK_ID_IN: "check_id__in", DELTA_IN: "delta__in", SCAN_EXACT: "scan", SCAN_ID: "scan_id", diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 608ad97cd6..ffdbbdb17b 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -42,10 +42,16 @@ interface FindingsFiltersProps { uniqueResourceTypes: string[]; uniqueCategories: string[]; uniqueGroups: string[]; + checkOptions?: FindingCheckFilterOption[]; trailingControls?: ReactNode; variant?: "default" | "alerts-edit"; } +interface FindingCheckFilterOption { + checkId: string; + checkTitle?: string; +} + interface FindingsFilterBatchControlsProps extends FindingsFiltersProps { appliedFilters: Record; pendingFilters: Record; @@ -72,6 +78,10 @@ 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 @@ -85,6 +95,7 @@ export const FindingsFilterBatchControls = ({ uniqueResourceTypes, uniqueCategories, uniqueGroups, + checkOptions = [], trailingControls, appliedFilters, pendingFilters, @@ -102,6 +113,17 @@ export const FindingsFilterBatchControls = ({ }: FindingsFilterBatchControlsProps) => { const [isExpanded, setIsExpanded] = useState(false); const isAlertsEdit = variant === "alerts-edit"; + const checkTitles = Object.fromEntries( + checkOptions.map(({ checkId, checkTitle }) => [ + checkId, + checkTitle || checkId, + ]), + ); + const checkFilterValues = uniqueNonEmptyValues([ + ...checkOptions.map((option) => option.checkId), + ...getFilterValue("filter[check_id]"), + ...getFilterValue("filter[check_id__in]"), + ]); const customFilters = [ ...filterFindings @@ -112,39 +134,54 @@ export const FindingsFilterBatchControls = ({ getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, { providers, scans: scanDetails, + checkTitles, }), })), + ...(checkFilterValues.length > 0 + ? [ + { + key: "check_id", + labelCheckboxGroup: "Finding Group", + values: checkFilterValues, + labelFormatter: (value: string) => + getFindingsFilterDisplayValue("filter[check_id]", value, { + checkTitles, + }), + index: 3, + }, + ] + : []), { key: FILTER_FIELD.REGION, labelCheckboxGroup: "Regions", values: uniqueRegions, - index: 3, + index: 4, }, { key: FILTER_FIELD.SERVICE, labelCheckboxGroup: "Services", values: uniqueServices, - index: 4, + index: 5, }, { key: FILTER_FIELD.RESOURCE_TYPE, labelCheckboxGroup: "Resource Type", values: uniqueResourceTypes, - index: 8, + index: 9, }, { key: FILTER_FIELD.CATEGORY, labelCheckboxGroup: "Category", values: uniqueCategories, labelFormatter: getCategoryLabel, - index: 5, + index: 6, }, { key: FILTER_FIELD.RESOURCE_GROUPS, labelCheckboxGroup: "Resource Group", values: uniqueGroups, labelFormatter: getGroupLabel, - index: 6, + index: 7, }, ...(isAlertsEdit ? [] @@ -164,7 +201,7 @@ export const FindingsFilterBatchControls = ({ scans: scanDetails, }, ), - index: 7, + index: 8, }, ]), ]; @@ -177,6 +214,7 @@ export const FindingsFilterBatchControls = ({ providers, providerGroups, scans: scanDetails, + checkTitles, }, ); const pendingFilterChips: FilterChip[] = buildFindingsFilterChips( @@ -185,6 +223,7 @@ export const FindingsFilterBatchControls = ({ providers, providerGroups, scans: scanDetails, + checkTitles, }, ); const appliedCount = countVisibleFilterKeys(appliedFilters); diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index 69ce0605d2..cf9d77dd85 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -164,6 +164,30 @@ describe("getFindingsFilterDisplayValue", () => { ); }); + it("uses the finding group title for check_id filters when available", () => { + expect( + getFindingsFilterDisplayValue( + "filter[check_id]", + "teams_external_users_can_join", + { + checkTitles: { + teams_external_users_can_join: + "External Teams users can join meetings", + }, + }, + ), + ).toBe("External Teams users can join meetings"); + }); + + it("keeps the check id when no finding group title is available", () => { + expect( + getFindingsFilterDisplayValue( + "filter[check_id]", + "teams_external_users_can_join", + ), + ).toBe("teams_external_users_can_join"); + }); + it("uses the provider display name regardless of account alias/uid", () => { expect( getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", { @@ -298,6 +322,45 @@ describe("buildFindingsFilterChips", () => { expect(chipsPlural[0].displayValues).toEqual(["New", "Changed"]); }); + it("renders filter[check_id] as a first-class Finding Group chip", () => { + // Given - exact deep-link params from the grouped findings page. + const chips = buildFindingsFilterChips( + { + "filter[check_id]": ["teams_external_users_can_join"], + }, + { + checkTitles: { + teams_external_users_can_join: + "External Teams users can join meetings", + }, + }, + ); + + expect(chips).toEqual([ + { + key: "filter[check_id]", + label: "Finding Group", + value: "teams_external_users_can_join", + displayValue: "External Teams users can join meetings", + }, + ]); + }); + + it("renders filter[check_id__in] with the Finding Group chip label", () => { + const chips = buildFindingsFilterChips({ + "filter[check_id__in]": ["teams_external_users_can_join"], + }); + + expect(chips).toEqual([ + { + key: "filter[check_id__in]", + label: "Finding Group", + value: "teams_external_users_can_join", + displayValue: "teams_external_users_can_join", + }, + ]); + }); + it("skips muted filters because the table toolbar owns that control", () => { const chips = buildFindingsFilterChips({ "filter[muted]": ["include"], diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 4cb9eef394..966e1a8bd6 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -15,6 +15,7 @@ interface GetFindingsFilterDisplayValueOptions { providers?: ProviderProps[]; scans?: Array<{ [scanId: string]: ScanEntity }>; providerGroups?: ProviderGroup[]; + checkTitles?: Record; } const FINDING_DELTA_DISPLAY_NAMES: Record = { @@ -64,6 +65,12 @@ export function getFindingsFilterDisplayValue( if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") { return getScanDisplayValue(value, options.scans || []); } + if ( + filterKey === "filter[check_id]" || + filterKey === "filter[check_id__in]" + ) { + return options.checkTitles?.[value] || value; + } if (filterKey === "filter[severity__in]") { return ( SEVERITY_DISPLAY_NAMES[ @@ -108,6 +115,8 @@ export function getFindingsFilterDisplayValue( * label is missing. */ export const FILTER_KEY_LABELS: Record = { + "filter[check_id]": "Finding Group", + "filter[check_id__in]": "Finding Group", "filter[provider_type__in]": "Provider", "filter[provider_id__in]": "Account", "filter[provider_groups__in]": "Provider Group", @@ -134,6 +143,7 @@ interface BuildFindingsFilterChipsOptions { providers?: ProviderProps[]; scans?: Array<{ [scanId: string]: ScanEntity }>; providerGroups?: ProviderGroup[]; + checkTitles?: Record; includeMuted?: boolean; } diff --git a/ui/components/findings/send-to-jira-modal.test.tsx b/ui/components/findings/send-to-jira-modal.test.tsx index f26353ea79..c5304052b3 100644 --- a/ui/components/findings/send-to-jira-modal.test.tsx +++ b/ui/components/findings/send-to-jira-modal.test.tsx @@ -28,8 +28,8 @@ vi.mock("@/actions/integrations/jira-dispatch", () => ({ sendJiraDispatch: sendJiraDispatchMock, })); -vi.mock("@/components/shadcn", () => ({ - useToast: () => ({ toast: toastMock }), +vi.mock("@/components/shadcn/toast", () => ({ + toast: toastMock, })); vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({ diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index 7cab8770ea..a490c02ee5 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -13,7 +13,6 @@ import { sendFindingToJira, sendJiraDispatch, } from "@/actions/integrations/jira-dispatch"; -import { useToast } from "@/components/shadcn"; import { CustomBanner } from "@/components/shadcn/custom/custom-banner"; import { Form, FormField, FormMessage } from "@/components/shadcn/form"; import { FormButtons } from "@/components/shadcn/form/form-buttons"; @@ -24,6 +23,7 @@ import { } from "@/components/shadcn/radio-group/radio-group"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { toast } from "@/components/shadcn/toast"; import { IntegrationProps, JIRA_DISPATCH_MODE, @@ -102,7 +102,6 @@ export const SendToJiraModal = ({ selectedResourceCount, description, }: SendToJiraModalProps) => { - const { toast } = useToast(); const [integrations, setIntegrations] = useState([]); const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false); const [fetchedIssueTypes, setFetchedIssueTypes] = useState< diff --git a/ui/hooks/use-filter-batch.test.ts b/ui/hooks/use-filter-batch.test.ts index 0607c041ef..952641fb52 100644 --- a/ui/hooks/use-filter-batch.test.ts +++ b/ui/hooks/use-filter-batch.test.ts @@ -98,6 +98,25 @@ describe("useFilterBatch", () => { "filter[delta]": ["new"], }); }); + + it("should parse filter[check_id] from grouped finding deep links", () => { + // Given - URL produced by the grouped finding resources panel deep link. + setSearchParams({ + "filter[check_id]": "teams_external_users_can_join", + expandedCheckId: "teams_external_users_can_join", + }); + + // When + const { result } = renderHook(() => useFilterBatch()); + + // Then - expandedCheckId is not a filter chip, but check_id is URL-backed. + expect(result.current.pendingFilters).toEqual({ + "filter[check_id]": ["teams_external_users_can_join"], + }); + expect(result.current.getFilterValue("filter[check_id]")).toEqual([ + "teams_external_users_can_join", + ]); + }); }); // ── Excluded keys ──────────────────────────────────────────────────────────