diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 419bcc80fb..4ef8a63827 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -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}"); + }); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 2b0eaac475..9d976c6035 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -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={ ; pendingFilters: Record; @@ -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", diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index cf9d77dd85..101dcbfa84 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -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(); + }); +}); diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 966e1a8bd6..7039c4ccf4 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -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; +}): 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. diff --git a/ui/components/findings/send-to-jira-modal-copy.test.ts b/ui/components/findings/send-to-jira-modal-copy.test.ts index c0de90dcdd..4e49e98402 100644 --- a/ui/components/findings/send-to-jira-modal-copy.test.ts +++ b/ui/components/findings/send-to-jira-modal-copy.test.ts @@ -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.", diff --git a/ui/components/findings/send-to-jira-modal-copy.ts b/ui/components/findings/send-to-jira-modal-copy.ts index d975d93ff6..f74bc472c7 100644 --- a/ui/components/findings/send-to-jira-modal-copy.ts +++ b/ui/components/findings/send-to-jira-modal-copy.ts @@ -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", diff --git a/ui/components/findings/send-to-jira-modal.test.tsx b/ui/components/findings/send-to-jira-modal.test.tsx index 1812cafe1b..aeb4925ffc 100644 --- a/ui/components/findings/send-to-jira-modal.test.tsx +++ b/ui/components/findings/send-to-jira-modal.test.tsx @@ -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( + , + ); + 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.", }), ); }); diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index 19c610f121..38ca59cde8 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -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({