From ff45f46047bbec3b48ca14a7086c0e6d25236ef9 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:58:12 +0100 Subject: [PATCH] feat(ui): add Jira dispatch choices for finding selections (#12001) Co-authored-by: alejandrobailo --- ui/actions/findings/findings-filters.ts | 2 + ui/actions/integrations/jira-dispatch.test.ts | 213 ++-- ui/actions/integrations/jira-dispatch.ts | 76 +- ui/app/(prowler)/findings/page.test.ts | 13 + ui/app/(prowler)/findings/page.tsx | 67 ++ .../grouped-jira-dispatch-ui.added.md | 1 + ui/components/findings/findings-filters.tsx | 34 +- .../findings/findings-filters.utils.test.ts | 108 ++ .../findings/findings-filters.utils.ts | 53 + .../findings/floating-mute-button.test.tsx | 111 +- .../findings/floating-mute-button.tsx | 96 +- .../jira-dispatch-task-handler.test.tsx | 127 +++ .../findings/jira-dispatch-task-handler.tsx | 117 ++ .../findings/send-to-jira-modal-copy.test.ts | 61 + .../findings/send-to-jira-modal-copy.ts | 59 + .../findings/send-to-jira-modal.test.tsx | 748 ++++++++++++ ui/components/findings/send-to-jira-modal.tsx | 672 +++++++---- .../table/column-finding-resources.test.tsx | 156 ++- .../table/column-finding-resources.tsx | 65 +- .../table/data-table-row-actions.test.tsx | 342 +++++- .../findings/table/data-table-row-actions.tsx | 82 +- .../table/findings-group-drill-down.test.ts | 10 + .../table/findings-group-drill-down.tsx | 43 +- .../table/findings-group-table.test.tsx | 1012 ++++++++++++++++- .../findings/table/findings-group-table.tsx | 238 +++- .../table/inline-resource-container.tsx | 6 +- .../resource-detail-drawer-content.tsx | 27 +- .../table/resource-detail-content.tsx | 3 + ui/components/shadcn/custom/custom-radio.tsx | 9 +- .../shadcn/dropdown/action-dropdown.tsx | 35 +- .../shared/cloud-upgrade-modal.test.tsx | 32 + ui/components/shared/task-polling-watcher.tsx | 3 + ui/components/ui/toast/index.test.ts | 14 + ui/hooks/use-filter-batch.test.ts | 61 + ui/hooks/use-filter-batch.ts | 59 +- ui/lib/cloud-upgrade.test.ts | 2 + ui/lib/cloud-upgrade.ts | 12 + ui/lib/deployment.test.ts | 50 + ui/lib/deployment.ts | 55 + ui/lib/jira-dispatch-result.ts | 141 +++ ui/lib/jira-dispatch-selection.ts | 84 ++ ui/lib/jira-dispatch-task.ts | 46 + ui/store/task-watcher/store.test.ts | 64 ++ ui/store/task-watcher/store.ts | 210 ++-- ui/types/cloud-upgrade.ts | 1 + ui/types/integrations.ts | 85 +- 46 files changed, 4982 insertions(+), 523 deletions(-) create mode 100644 ui/changelog.d/grouped-jira-dispatch-ui.added.md create mode 100644 ui/components/findings/jira-dispatch-task-handler.test.tsx create mode 100644 ui/components/findings/jira-dispatch-task-handler.tsx create mode 100644 ui/components/findings/send-to-jira-modal-copy.test.ts create mode 100644 ui/components/findings/send-to-jira-modal-copy.ts create mode 100644 ui/components/findings/send-to-jira-modal.test.tsx create mode 100644 ui/components/ui/toast/index.test.ts create mode 100644 ui/lib/deployment.test.ts create mode 100644 ui/lib/deployment.ts create mode 100644 ui/lib/jira-dispatch-result.ts create mode 100644 ui/lib/jira-dispatch-selection.ts create mode 100644 ui/lib/jira-dispatch-task.ts 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/actions/integrations/jira-dispatch.test.ts b/ui/actions/integrations/jira-dispatch.test.ts index 05c3188e47..c691a04c43 100644 --- a/ui/actions/integrations/jira-dispatch.test.ts +++ b/ui/actions/integrations/jira-dispatch.test.ts @@ -1,126 +1,167 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { pollTaskUntilSettledMock } = vi.hoisted(() => ({ +const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), pollTaskUntilSettledMock: vi.fn(), })); +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: () => ({ error: "An error occurred" }), +})); + vi.mock("@/actions/task/poll", () => ({ pollTaskUntilSettled: pollTaskUntilSettledMock, })); -vi.mock("@/lib", () => ({ - apiBaseUrl: "https://api.example.com/api/v1", - getAuthHeaders: vi.fn(), -})); +import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch"; -vi.mock("@/lib/server-actions-helper", () => ({ - handleApiError: vi.fn(), -})); - -import { pollJiraDispatchTask } from "./jira-dispatch"; - -describe("pollJiraDispatchTask", () => { +describe("sendJiraDispatch", () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: { + id: "task-1", + type: "tasks", + attributes: { result: null }, + }, + }), + { status: 202 }, + ), + ); }); - it("should return the backend error when a completed task has failed Jira dispatches", async () => { + it("should send grouped dispatch mode with multiple finding IDs", async () => { + // Given / When + await sendJiraDispatch({ + integrationId: "jira-1", + targetIds: ["finding-1", "finding-2"], + filter: "finding_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "grouped", + }); + + // Then + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bfinding_id__in%5D=finding-1%2Cfinding-2", + ); + expect(JSON.parse(init.body as string)).toMatchObject({ + data: { + attributes: { + dispatch_mode: "grouped", + issue_type: "Task", + project_key: "SEC", + }, + }, + }); + }); + + it("should send grouped dispatch mode with a finding group check ID", async () => { + // Given / When + await sendJiraDispatch({ + integrationId: "jira-1", + targetIds: ["s3_bucket_public_access"], + filter: "check_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "grouped", + }); + + // Then + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bcheck_id%5D=s3_bucket_public_access", + ); + expect(JSON.parse(init.body as string)).toMatchObject({ + data: { attributes: { dispatch_mode: "grouped" } }, + }); + }); + + it("should preserve partial success when Jira dispatch has created and failed issues", async () => { // Given pollTaskUntilSettledMock.mockResolvedValue({ ok: true, state: "completed", result: { - created_count: 0, + created_count: 2, failed_count: 1, - error: "Jira project requires custom fields: Team is required", + failed_finding_ids: ["finding-3"], + error: "Jira rejected one Finding.", }, }); // When - const result = await pollJiraDispatchTask("task-123"); + const result = await pollJiraDispatchTask("task-1"); // Then expect(result).toEqual({ - success: false, - error: "Jira project requires custom fields: Team is required", + success: true, + message: "2 Jira issues were created or updated successfully.", + warning: + "Jira rejected one Finding. Jira dispatch completed with 1 failed and 2 created/updated issues.", + failedFindingIds: ["finding-3"], }); }); - it("should return a fallback error when a completed task has failures without an error", async () => { + it("should include updated issues in partial failure summaries", async () => { // Given pollTaskUntilSettledMock.mockResolvedValue({ ok: true, state: "completed", - result: { - created_count: 0, - failed_count: 1, - }, + result: { created_count: 0, updated_count: 2, failed_count: 1 }, }); // When - const result = await pollJiraDispatchTask("task-123"); + const result = await pollJiraDispatchTask("task-1"); // Then expect(result).toEqual({ - success: false, - error: "Failed to create Jira issue.", + success: true, + message: "2 Jira issues were created or updated successfully.", + warning: + "Jira dispatch completed with 1 failed and 2 created/updated issues.", }); }); - it("should return a plural fallback error when a completed task has multiple failures without an error", async () => { + it("should fail completed task polling when grouped dispatch reports failed groups", async () => { // Given pollTaskUntilSettledMock.mockResolvedValue({ ok: true, state: "completed", - result: { - created_count: 0, - failed_count: 3, - }, + result: { created_count: 1, failed_groups: [{ check_id: "check-a" }] }, }); // When - const result = await pollJiraDispatchTask("task-123"); + const result = await pollJiraDispatchTask("task-1"); // Then expect(result).toEqual({ - success: false, - error: "Failed to create 3 Jira issues.", + success: true, + message: "Finding successfully sent to Jira!", + warning: + "Jira dispatch completed with 1 failed and 1 created/updated issue.", }); }); - it("should surface task failure result errors", async () => { - // Given - pollTaskUntilSettledMock.mockResolvedValue({ - ok: true, - state: "failed", - result: { - error: "Jira credentials are invalid.", - }, - }); - - // When - const result = await pollJiraDispatchTask("task-123"); - - // Then - expect(result).toEqual({ - success: false, - error: "Jira credentials are invalid.", - }); - }); - - it("should return success when a completed task has no failures", async () => { + it("should succeed completed task polling when grouped dispatch reports no failed groups", async () => { // Given pollTaskUntilSettledMock.mockResolvedValue({ ok: true, state: "completed", - result: { - created_count: 1, - failed_count: 0, - }, + result: { created_count: 1, failed_groups: [] }, }); // When - const result = await pollJiraDispatchTask("task-123"); + const result = await pollJiraDispatchTask("task-1"); // Then expect(result).toEqual({ @@ -129,24 +170,68 @@ describe("pollJiraDispatchTask", () => { }); }); - it("should return a fallback error when no Jira issue was created", async () => { + it("should succeed completed task polling with grouped created and updated issue result shape", async () => { // Given pollTaskUntilSettledMock.mockResolvedValue({ ok: true, state: "completed", result: { - created_count: 0, + created_count: 1, + updated_count: 1, failed_count: 0, + created_issues: [{ key: "SEC-1" }], + updated_issues: [{ key: "SEC-2" }], + failed_groups: [], }, }); // When - const result = await pollJiraDispatchTask("task-123"); + const result = await pollJiraDispatchTask("task-1"); + + // Then + expect(pollTaskUntilSettledMock).toHaveBeenCalledWith("task-1", { + maxAttempts: 5, + delayMs: 2000, + }); + expect(result).toEqual({ + success: true, + message: "2 Jira issues were created or updated successfully.", + }); + }); + + it("should fail completed task polling when Jira dispatch completed as a no-op", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { created_count: 0, updated_count: 0, failed_count: 0 }, + }); + + // When + const result = await pollJiraDispatchTask("task-1"); // Then expect(result).toEqual({ success: false, - error: "Failed to create Jira issue.", + error: "Jira dispatch completed but did not create or update any issues.", + }); + }); + + it("should fail completed task polling when Jira dispatch has no result payload", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "completed", + result: null, + }); + + // When + const result = await pollJiraDispatchTask("task-1"); + + // Then + expect(result).toEqual({ + success: false, + error: "Jira dispatch completed but did not create or update any issues.", }); }); }); diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index b6d3215e13..486ef5bb68 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -2,12 +2,26 @@ import { pollTaskUntilSettled } from "@/actions/task/poll"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result"; import { handleApiError } from "@/lib/server-actions-helper"; import type { IntegrationProps, + JiraDispatchMode, JiraDispatchRequest, JiraDispatchResponse, + JiraDispatchTarget, + JiraDispatchTaskResult, } from "@/types/integrations"; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; + +interface JiraDispatchInput { + integrationId: string; + targetIds: string[]; + filter: JiraDispatchTarget; + projectKey: string; + issueType: string; + dispatchMode?: JiraDispatchMode; +} export const getJiraIssueTypes = async ( integrationId: string, @@ -87,14 +101,37 @@ export const sendFindingToJira = async ( ): Promise< | { success: true; taskId: string; message: string } | { success: false; error: string } +> => { + return sendJiraDispatch({ + integrationId, + targetIds: [findingId], + filter: JIRA_DISPATCH_TARGET.FINDING_ID, + projectKey, + issueType, + }); +}; + +export const sendJiraDispatch = async ({ + integrationId, + targetIds, + filter, + projectKey, + issueType, + dispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL, +}: JiraDispatchInput): Promise< + | { success: true; taskId: string; message: string } + | { success: false; error: string } > => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL( `${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`, ); - // Single finding: use direct filter without array notation - url.searchParams.append("filter[finding_id]", findingId); + if (targetIds.length === 1) { + url.searchParams.append(`filter[${filter}]`, targetIds[0]); + } else { + url.searchParams.append(`filter[${filter}__in]`, targetIds.join(",")); + } const payload: JiraDispatchRequest = { data: { @@ -102,6 +139,7 @@ export const sendFindingToJira = async ( attributes: { project_key: projectKey, issue_type: issueType, + dispatch_mode: dispatchMode, }, }, }; @@ -145,38 +183,18 @@ export const sendFindingToJira = async ( export const pollJiraDispatchTask = async ( taskId: string, ): Promise< - { success: true; message: string } | { success: false; error: string } + | { success: true; message: string; warning?: string } + | { success: false; error: string } > => { const res = await pollTaskUntilSettled(taskId, { - maxAttempts: 30, + maxAttempts: 5, delayMs: 2000, }); if (!res.ok) { return { success: false, error: res.error }; } - const { state, result } = res; - type JiraTaskResult = JiraDispatchResponse["data"]["attributes"]["result"]; - const jiraResult = result as JiraTaskResult | undefined; - - if (state === "completed") { - const createdCount = jiraResult?.created_count ?? 0; - const failedCount = jiraResult?.failed_count ?? 0; - if (!jiraResult?.error && failedCount === 0 && createdCount > 0) { - return { success: true, message: "Finding successfully sent to Jira!" }; - } - return { - success: false, - error: - jiraResult?.error || - (failedCount > 1 - ? `Failed to create ${failedCount} Jira issues.` - : "Failed to create Jira issue."), - }; - } - - if (state === "failed") { - return { success: false, error: jiraResult?.error || "Task failed." }; - } - - return { success: false, error: `Unknown task state: ${state}` }; + return evaluateJiraDispatchTask( + res.state, + res.result as JiraDispatchTaskResult | undefined, + ); }; diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 419bcc80fb..24ddc53b72 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -44,4 +44,17 @@ 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("FINDING_GROUP_FILTER_OPTION_PAGE_SIZE"); + expect(source).toContain("checkOptions={checkOptions}"); + }); + + it("excludes Finding Group's own filters while loading all option pages", () => { + expect(source).toContain("excludeFindingGroupOwnFilters"); + expect(source).toContain('"filter[check_id__in]"'); + expect(source).toContain("if (page >= totalPages) break"); + expect(source).toContain("page += 1"); + }); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 77cb1d8d0e..4e2595d432 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -29,6 +29,60 @@ import { isCloud } from "@/lib/shared/env"; import { ScanEntity, ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; +const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100; +const FINDING_GROUP_OWN_FILTER_KEYS = [ + "filter[check_id]", + "filter[check_id__in]", +] as const; + +type FindingGroupFilterFetcher = typeof getFindingGroups; + +function excludeFindingGroupOwnFilters( + filters: Record, +) { + return Object.fromEntries( + Object.entries(filters).filter( + ([key]) => + !FINDING_GROUP_OWN_FILTER_KEYS.includes( + key as (typeof FINDING_GROUP_OWN_FILTER_KEYS)[number], + ), + ), + ); +} + +async function getFindingGroupFilterOptions({ + fetchFindingGroups, + filters, +}: { + fetchFindingGroups: FindingGroupFilterFetcher; + filters: Record; +}) { + const optionFilters = excludeFindingGroupOwnFilters(filters); + const options = new Map(); + let page = 1; + + while (true) { + const response = await fetchFindingGroups({ + filters: optionFilters, + page, + pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE, + }); + + for (const group of adaptFindingGroupsResponse(response)) { + options.set(group.checkId, { + checkId: group.checkId, + checkTitle: group.checkTitle, + }); + } + + const totalPages = response?.meta?.pagination?.pages ?? page; + if (page >= totalPages) break; + page += 1; + } + + return Array.from(options.values()); +} + export default async function Findings({ searchParams, }: { @@ -68,6 +122,13 @@ 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 checkOptions = await getFindingGroupFilterOptions({ + fetchFindingGroups: fetchFindingGroupFilterOptions, + filters: resolvedFilters, + }); const completedScans = scansData?.data?.filter( (scan: ScanProps) => @@ -110,6 +171,7 @@ export default async function Findings({ uniqueResourceTypes={uniqueResourceTypes} uniqueCategories={uniqueCategories} uniqueGroups={uniqueGroups} + checkOptions={checkOptions} trailingControls={ { const page = parseInt(searchParams.page?.toString() || "1", 10); const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const expandedCheckIdParam = searchParams.expandedCheckId; + const expandedCheckId = Array.isArray(expandedCheckIdParam) + ? expandedCheckIdParam[0] + : expandedCheckIdParam; const { encodedSort } = extractSortAndKey(searchParams); const hasHistoricalData = hasDateOrScanFilter(filters); @@ -178,6 +244,7 @@ const SSRDataTable = async ({ metadata={findingGroupsData?.meta} resolvedFilters={filters} hasHistoricalData={hasHistoricalData} + expandedCheckId={expandedCheckId} /> ); diff --git a/ui/changelog.d/grouped-jira-dispatch-ui.added.md b/ui/changelog.d/grouped-jira-dispatch-ui.added.md new file mode 100644 index 0000000000..e314c0547b --- /dev/null +++ b/ui/changelog.d/grouped-jira-dispatch-ui.added.md @@ -0,0 +1 @@ +Finding Groups and grouped selections can be sent to Jira in Cloud with deep links, filter chip display, and Jira feedback toasts diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 608ad97cd6..fc444bb32e 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -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"; @@ -42,6 +44,7 @@ interface FindingsFiltersProps { uniqueResourceTypes: string[]; uniqueCategories: string[]; uniqueGroups: string[]; + checkOptions?: FindingCheckFilterOption[]; trailingControls?: ReactNode; variant?: "default" | "alerts-edit"; } @@ -71,6 +74,7 @@ const countVisibleFilterKeys = (filters: Record): number => 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"; +const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"]; export const FindingsFilterBatchControls = ({ providers, @@ -85,6 +89,7 @@ export const FindingsFilterBatchControls = ({ uniqueResourceTypes, uniqueCategories, uniqueGroups, + checkOptions = [], trailingControls, appliedFilters, pendingFilters, @@ -102,6 +107,18 @@ 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 findingGroupFilterOption = buildFindingGroupFilterOption({ + checkOptions, + selectedCheckIds: getFilterValue("filter[check_id]"), + selectedCheckIdsIn: getFilterValue("filter[check_id__in]"), + checkTitles, + }); const customFilters = [ ...filterFindings @@ -112,39 +129,41 @@ export const FindingsFilterBatchControls = ({ getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, { providers, scans: scanDetails, + checkTitles, }), })), + ...(findingGroupFilterOption ? [findingGroupFilterOption] : []), { 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 +183,7 @@ export const FindingsFilterBatchControls = ({ scans: scanDetails, }, ), - index: 7, + index: 8, }, ]), ]; @@ -177,6 +196,7 @@ export const FindingsFilterBatchControls = ({ providers, providerGroups, scans: scanDetails, + checkTitles, }, ); const pendingFilterChips: FilterChip[] = buildFindingsFilterChips( @@ -185,6 +205,7 @@ export const FindingsFilterBatchControls = ({ providers, providerGroups, scans: scanDetails, + checkTitles, }, ); const appliedCount = countVisibleFilterKeys(appliedFilters); @@ -347,6 +368,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => { getFilterValue, } = useFilterBatch({ defaultParams: { "filter[muted]": "false" }, + exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS], }); return ( diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index 69ce0605d2..5b0fe99cb2 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"; @@ -164,6 +165,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 +323,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"], @@ -324,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__in", + 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 4cb9eef394..09188e65e6 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -7,14 +7,21 @@ 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 }>; providerGroups?: ProviderGroup[]; + checkTitles?: Record; } const FINDING_DELTA_DISPLAY_NAMES: Record = { @@ -64,6 +71,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[ @@ -100,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__in", + 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. @@ -108,6 +158,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 +186,7 @@ interface BuildFindingsFilterChipsOptions { providers?: ProviderProps[]; scans?: Array<{ [scanId: string]: ScanEntity }>; providerGroups?: ProviderGroup[]; + checkTitles?: Record; includeMuted?: boolean; } diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-mute-button.test.tsx index b953b47b43..10063d14ee 100644 --- a/ui/components/findings/floating-mute-button.test.tsx +++ b/ui/components/findings/floating-mute-button.test.tsx @@ -1,7 +1,10 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + // --------------------------------------------------------------------------- // Hoist mocks to avoid deep dependency chains // --------------------------------------------------------------------------- @@ -42,6 +45,7 @@ function deferredPromise() { describe("FloatingMuteButton — onBeforeOpen error handling", () => { beforeEach(() => { vi.clearAllMocks(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => { @@ -199,4 +203,109 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { }); }); }); + + it("should route Send to Jira through the action chooser without opening mute", async () => { + // Given + const onSendToJira = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "1 selected" })); + await user.click(screen.getByRole("menuitem", { name: "Send to Jira" })); + + // Then + expect(onSendToJira).toHaveBeenCalledTimes(1); + const modalCalls = MuteFindingsModalMock.mock.calls as unknown as Array< + [{ isOpen?: boolean }] + >; + expect(modalCalls.some(([props]) => props.isOpen === true)).toBe(false); + }); + + it("should render custom mixed-selection action labels", async () => { + // Given + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { + name: "1 Group and 1 Finding selected", + }), + ); + + // Then + expect( + screen.getByRole("menuitem", { + name: "Mute 1 Group and 1 Finding", + }), + ).toBeVisible(); + expect( + screen.getByRole("menuitem", { + name: "Send 1 Group and 1 Finding to Jira", + }), + ).toHaveTextContent("Send 1 Group and 1 Finding to Jira"); + }); + + it("should show the Cloud Jira tooltip and open the upgrade modal", async () => { + // Given + const onSendToJira = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "1 selected" })); + const jiraAction = screen.getByRole("menuitem", { name: "Send to Jira" }); + + // Then + expect(jiraAction).toBeVisible(); + expect(jiraAction).not.toHaveAttribute("aria-disabled"); + expect( + within(jiraAction).queryByText("Available only in Prowler Cloud"), + ).not.toBeInTheDocument(); + + // When + await user.hover(jiraAction); + + // Then + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Available only in Prowler Cloud", + ); + + // When + await user.click(jiraAction); + + // Then + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, + ); + expect(onSendToJira).not.toHaveBeenCalled(); + }); }); diff --git a/ui/components/findings/floating-mute-button.tsx b/ui/components/findings/floating-mute-button.tsx index 68c0afa95e..c9eee25626 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-mute-button.tsx @@ -1,11 +1,19 @@ "use client"; -import { VolumeX } from "lucide-react"; +import { Ellipsis, VolumeX } from "lucide-react"; import { useState } from "react"; import { createPortal } from "react-dom"; +import { JiraIcon } from "@/components/icons/services/IconServices"; import { Button } from "@/components/shadcn"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown/action-dropdown"; import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { PROWLER_CLOUD_ONLY_TOOLTIP } from "@/lib/deployment"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { MuteFindingsModal } from "./mute-findings-modal"; @@ -19,6 +27,16 @@ interface FloatingMuteButtonProps { isBulkOperation?: boolean; /** Custom button label. Defaults to "Mute ({selectedCount})" */ label?: string; + /** Custom mute action label. Defaults to "Mute". */ + muteLabel?: string; + /** Opens the Jira flow for the current selection. */ + onSendToJira?: () => void; + /** Whether the Jira action is available for the current selection. */ + canSendToJira?: boolean; + /** Whether the Jira action should be displayed in the action menu. */ + showSendToJira?: boolean; + /** Custom Jira action label. Defaults to "Send to Jira". */ + sendToJiraLabel?: string; } export function FloatingMuteButton({ @@ -28,6 +46,11 @@ export function FloatingMuteButton({ onBeforeOpen, isBulkOperation = false, label, + muteLabel = "Mute", + onSendToJira, + canSendToJira = false, + showSendToJira = canSendToJira, + sendToJiraLabel = "Send to Jira", }: FloatingMuteButtonProps) { const [isModalOpen, setIsModalOpen] = useState(false); const [resolvedIds, setResolvedIds] = useState([]); @@ -36,6 +59,9 @@ export function FloatingMuteButton({ const [mutePreparationError, setMutePreparationError] = useState< string | null >(null); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const handleModalOpenChange = ( nextOpen: boolean | ((previousOpen: boolean) => boolean), @@ -51,7 +77,7 @@ export function FloatingMuteButton({ } }; - const handleClick = async () => { + const handleMuteClick = async () => { if (onBeforeOpen) { setResolvedIds([]); setMutePreparationError(null); @@ -79,6 +105,15 @@ export function FloatingMuteButton({ } }; + const handleJiraClick = () => { + if (!canSendToJira) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH); + return; + } + + onSendToJira?.(); + }; + const handleComplete = () => { setResolvedIds([]); onComplete?.(); @@ -103,20 +138,53 @@ export function FloatingMuteButton({ with the content. */} {typeof document !== "undefined" ? createPortal( -
- + } + > + } + label={muteLabel} + aria-label={muteLabel} + onSelect={() => void handleMuteClick()} + /> + } + label={sendToJiraLabel} + tooltip={ + !canSendToJira ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined + } + aria-label={sendToJiraLabel} + onSelect={handleJiraClick} + /> + ) : ( - + )} - {label ?? `Mute (${selectedCount})`} - +
, document.body, ) diff --git a/ui/components/findings/jira-dispatch-task-handler.test.tsx b/ui/components/findings/jira-dispatch-task-handler.test.tsx new file mode 100644 index 0000000000..90b5ad2884 --- /dev/null +++ b/ui/components/findings/jira-dispatch-task-handler.test.tsx @@ -0,0 +1,127 @@ +import { type ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { WatchedTask } from "@/store/task-watcher/store"; +import { JIRA_DISPATCH_MODE } from "@/types/integrations"; + +import { jiraDispatchTaskHandler } from "./jira-dispatch-task-handler"; + +interface ToastActionMockProps extends ComponentProps<"button"> { + altText: string; +} + +const { sendJiraDispatchMock, toastMock, trackAndPollTaskMock } = vi.hoisted( + () => ({ + sendJiraDispatchMock: vi.fn(), + toastMock: vi.fn(), + trackAndPollTaskMock: vi.fn(), + }), +); + +vi.mock("@/actions/integrations/jira-dispatch", () => ({ + sendJiraDispatch: sendJiraDispatchMock, +})); + +vi.mock("@/components/shadcn/toast", () => ({ + toast: toastMock, + ToastAction: ({ + altText: _altText, + children, + ...props + }: ToastActionMockProps) => , +})); + +vi.mock("@/store/task-watcher/store", () => ({ + trackAndPollTask: trackAndPollTaskMock, +})); + +const buildTask = (result: unknown): WatchedTask => ({ + taskId: "task-1", + kind: "jira-dispatch", + status: "ready", + startedAt: Date.now(), + meta: { + integrationId: "jira-1", + projectKey: "SEC", + issueType: "Task", + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, + }, + result, +}); + +describe("jiraDispatchTaskHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendJiraDispatchMock.mockResolvedValue({ + success: true, + taskId: "retry-task", + message: "Started", + }); + }); + + it("shows the completed Jira result after a persisted task resumes", () => { + // Given + const task = buildTask({ created_count: 2, failed_count: 0 }); + + // When + jiraDispatchTaskHandler.onReady(task); + + // Then + expect(toastMock).toHaveBeenCalledWith({ + title: "Success!", + description: "2 Jira issues were created or updated successfully.", + }); + }); + + it("retries only failed Findings from a resumed partial task", async () => { + // Given + const task = buildTask({ + created_count: 1, + failed_count: 2, + failed_finding_ids: ["finding-2", "finding-3"], + error: "Two Jira issues failed.", + }); + jiraDispatchTaskHandler.onReady(task); + const partialToast = toastMock.mock.calls.at(-1)?.[0]; + + // When + await partialToast.action.props.onClick(); + + // Then + expect(sendJiraDispatchMock).toHaveBeenCalledWith({ + integrationId: "jira-1", + targetIds: ["finding-2", "finding-3"], + filter: "finding_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "individual", + }); + expect(trackAndPollTaskMock).toHaveBeenCalledWith({ + taskId: "retry-task", + kind: "jira-dispatch", + meta: { + ...task.meta, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }, + }); + }); + + it("surfaces task watcher errors without offering an unsafe retry", () => { + // Given + const task = { + ...buildTask(undefined), + status: "error", + error: "Tracking the task failed unexpectedly. Try again later.", + } as WatchedTask; + + // When + jiraDispatchTaskHandler.onError(task); + + // Then + expect(toastMock).toHaveBeenCalledWith({ + variant: "destructive", + title: "Jira dispatch failed", + description: "Tracking the task failed unexpectedly. Try again later.", + }); + }); +}); diff --git a/ui/components/findings/jira-dispatch-task-handler.tsx b/ui/components/findings/jira-dispatch-task-handler.tsx new file mode 100644 index 0000000000..4b42336952 --- /dev/null +++ b/ui/components/findings/jira-dispatch-task-handler.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch"; +import { toast, ToastAction } from "@/components/shadcn/toast"; +import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result"; +import { + buildJiraDispatchTaskMeta, + parseJiraDispatchTaskMeta, +} from "@/lib/jira-dispatch-task"; +import { + type TaskKindHandler, + trackAndPollTask, + type WatchedTask, +} from "@/store/task-watcher/store"; +import { + JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, + JIRA_DISPATCH_TASK_KIND, + type JiraDispatchTaskResult, +} from "@/types/integrations"; + +const retryFailedFindings = async ( + task: WatchedTask, + failedFindingIds: string[], +): Promise => { + const meta = parseJiraDispatchTaskMeta(task); + if (!meta) { + toast({ + variant: "destructive", + title: "Jira retry failed", + description: "The original Jira dispatch configuration is unavailable.", + }); + return; + } + + try { + const response = await sendJiraDispatch({ + integrationId: meta.integrationId, + targetIds: failedFindingIds, + filter: JIRA_DISPATCH_TARGET.FINDING_ID, + projectKey: meta.projectKey, + issueType: meta.issueType, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }); + + if (!response.success) { + toast({ + variant: "destructive", + title: "Jira retry failed", + description: response.error, + }); + return; + } + + toast({ + title: "Retry started", + description: `Retrying ${failedFindingIds.length} failed Finding${failedFindingIds.length === 1 ? "" : "s"}.`, + }); + + await trackAndPollTask({ + taskId: response.taskId, + kind: JIRA_DISPATCH_TASK_KIND, + meta: buildJiraDispatchTaskMeta({ + ...meta, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }), + }); + } catch { + toast({ + variant: "destructive", + title: "Jira retry failed", + description: "The retry could not be started. Try again later.", + }); + } +}; + +const buildRetryAction = (task: WatchedTask, failedFindingIds?: string[]) => + failedFindingIds?.length ? ( + retryFailedFindings(task, failedFindingIds)} + > + Retry failed + + ) : undefined; + +export const jiraDispatchTaskHandler: TaskKindHandler = { + onReady: (task) => { + const outcome = evaluateJiraDispatchTask( + "completed", + task.result as JiraDispatchTaskResult | undefined, + ); + + if (!outcome.success) { + toast({ + variant: "destructive", + title: "Jira dispatch failed", + description: outcome.error, + action: buildRetryAction(task, outcome.failedFindingIds), + }); + return; + } + + toast({ + title: outcome.warning ? "Jira dispatch partially completed" : "Success!", + description: outcome.warning ?? outcome.message, + action: buildRetryAction(task, outcome.failedFindingIds), + }); + }, + onError: (task) => { + toast({ + variant: "destructive", + title: "Jira dispatch failed", + description: task.error || "The Jira dispatch task failed unexpectedly.", + }); + }, +}; diff --git a/ui/components/findings/send-to-jira-modal-copy.test.ts b/ui/components/findings/send-to-jira-modal-copy.test.ts new file mode 100644 index 0000000000..4e49e98402 --- /dev/null +++ b/ui/components/findings/send-to-jira-modal-copy.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + buildJiraDispatchChoiceCopy, + JIRA_SELECTION_KIND, +} from "./send-to-jira-modal-copy"; + +describe("buildJiraDispatchChoiceCopy", () => { + it("uses Finding Group copy for selected Findings grouped Jira choice", () => { + expect( + buildJiraDispatchChoiceCopy({ + selectedCount: 2, + isSelectedFindingGroupFlow: true, + }), + ).toEqual({ + description: + "Create Jira issue(s) for 2 selected Findings from this Finding Group.", + groupedTitle: + "Create one Jira issue for all selected Findings in this Finding Group", + groupedHelp: + "Recommended. The issue will include every selected Finding from this Finding Group.", + individualHelp: + "Use this when each selected Finding should be tracked independently.", + }); + }); + + it("preserves resource copy for resource-based grouped Jira choice", () => { + expect( + buildJiraDispatchChoiceCopy({ + selectedCount: 2, + isSelectedFindingGroupFlow: false, + }), + ).toEqual({ + description: + "Create Jira issue(s) for 2 selected affected failing resources.", + groupedTitle: + "Create one Jira issue for all selected affected failing resources", + groupedHelp: + "Recommended. The issue will include every selected resource from this finding group.", + individualHelp: + "Use this when each selected resource should be tracked independently.", + }); + }); + + it("uses neutral Findings copy outside a single Finding Group", () => { + expect( + buildJiraDispatchChoiceCopy({ + selectedCount: 2, + isSelectedFindingGroupFlow: false, + selectionKind: JIRA_SELECTION_KIND.FINDINGS, + }), + ).toEqual({ + description: "Create Jira issue(s) for 2 selected Findings.", + groupedTitle: "Create one Jira issue for all selected Findings", + groupedHelp: + "Recommended. The issue will include every selected Finding.", + individualHelp: + "Use this when each selected Finding should be tracked independently.", + }); + }); +}); diff --git a/ui/components/findings/send-to-jira-modal-copy.ts b/ui/components/findings/send-to-jira-modal-copy.ts new file mode 100644 index 0000000000..f74bc472c7 --- /dev/null +++ b/ui/components/findings/send-to-jira-modal-copy.ts @@ -0,0 +1,59 @@ +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?: JiraSelectionKind; +} + +interface JiraDispatchChoiceCopy { + description: string; + groupedTitle: string; + groupedHelp: string; + individualHelp: string; +} + +export const buildJiraDispatchChoiceCopy = ({ + selectedCount, + isSelectedFindingGroupFlow, + selectionKind = JIRA_SELECTION_KIND.RESOURCES, +}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => { + if (isSelectedFindingGroupFlow) { + return { + description: `Create Jira issue(s) for ${selectedCount} selected Findings from this Finding Group.`, + groupedTitle: + "Create one Jira issue for all selected Findings in this Finding Group", + groupedHelp: + "Recommended. The issue will include every selected Finding from this Finding Group.", + individualHelp: + "Use this when each selected Finding should be tracked independently.", + }; + } + + 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", + groupedHelp: + "Recommended. The issue will include every selected Finding.", + individualHelp: + "Use this when each selected Finding should be tracked independently.", + }; + } + + return { + description: `Create Jira issue(s) for ${selectedCount} selected affected failing resources.`, + groupedTitle: + "Create one Jira issue for all selected affected failing resources", + groupedHelp: + "Recommended. The issue will include every selected resource from this finding group.", + individualHelp: + "Use this when each selected resource should be tracked independently.", + }; +}; diff --git a/ui/components/findings/send-to-jira-modal.test.tsx b/ui/components/findings/send-to-jira-modal.test.tsx new file mode 100644 index 0000000000..ccad2fee6e --- /dev/null +++ b/ui/components/findings/send-to-jira-modal.test.tsx @@ -0,0 +1,748 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { type ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createJiraBatchSelection, + createJiraTargetSelection, +} from "@/lib/jira-dispatch-selection"; +import { + JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, + type JiraDispatchTarget, +} from "@/types/integrations"; + +import { SendToJiraModal } from "./send-to-jira-modal"; + +const targetSelection = (targetIds: string[], targetType: JiraDispatchTarget) => + createJiraTargetSelection(targetIds, targetType)!; + +const batchSelection = ( + batches: Parameters[0], +) => createJiraBatchSelection(batches)!; + +const { + getJiraIntegrationsMock, + getJiraIssueTypesMock, + sendFindingToJiraMock, + sendJiraDispatchMock, + trackAndPollTaskMock, + toastMock, +} = vi.hoisted(() => ({ + getJiraIntegrationsMock: vi.fn(), + getJiraIssueTypesMock: vi.fn(), + sendFindingToJiraMock: vi.fn(), + sendJiraDispatchMock: vi.fn(), + trackAndPollTaskMock: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/actions/integrations/jira-dispatch", () => ({ + getJiraIntegrations: getJiraIntegrationsMock, + getJiraIssueTypes: getJiraIssueTypesMock, + sendFindingToJira: sendFindingToJiraMock, + sendJiraDispatch: sendJiraDispatchMock, +})); + +vi.mock("@/components/shadcn/toast", () => ({ + toast: toastMock, + ToastAction: ({ children, ...props }: ComponentProps<"button">) => ( + + ), +})); + +vi.mock("@/store/task-watcher/store", () => ({ + TASK_WATCHER_STATUS: { + PENDING: "pending", + READY: "ready", + ERROR: "error", + }, + trackAndPollTask: trackAndPollTaskMock, +})); + +vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({ + EnhancedMultiSelect: ({ + options, + onValueChange, + placeholder, + disabled, + }: { + options: { value: string; label: string }[]; + onValueChange: (values: string[]) => void; + placeholder: string; + disabled?: boolean; + }) => ( + + ), +})); + +describe("SendToJiraModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + getJiraIntegrationsMock.mockResolvedValue({ + success: true, + data: [ + { + type: "integrations", + id: "jira-1", + attributes: { + inserted_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + enabled: true, + connected: true, + connection_last_checked_at: null, + integration_type: "jira", + configuration: { + domain: "example.atlassian.net", + projects: { SEC: "Security" }, + issue_types: { SEC: ["Task"] }, + }, + }, + links: { self: "/integrations/jira-1" }, + }, + ], + }); + getJiraIssueTypesMock.mockResolvedValue({ success: true, issueTypes: [] }); + sendFindingToJiraMock.mockResolvedValue({ + success: true, + taskId: "task-1", + message: "Started", + }); + sendJiraDispatchMock.mockResolvedValue({ + success: true, + taskId: "task-1", + message: "Started", + }); + trackAndPollTaskMock.mockResolvedValue({ + status: "ready", + result: { created_count: 1, failed_count: 0 }, + }); + }); + + it("shows the grouped-vs-separate choice for a target batch with multiple Findings before dispatching", async () => { + render( + , + ); + + expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument(); + expect( + 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", + ), + ).not.toBeInTheDocument(); + expect( + screen.getByText("Create Jira issues for 1 Group and 2 Findings."), + ).toBeInTheDocument(); + expect(screen.getByText("Create separate Jira issues")).toBeInTheDocument(); + expect(sendFindingToJiraMock).not.toHaveBeenCalled(); + expect(sendJiraDispatchMock).not.toHaveBeenCalled(); + await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled()); + }); + + it("uses neutral Findings copy for ordinary multi-Finding selections", async () => { + render( + , + ); + + expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument(); + expect( + 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", + ), + ).not.toBeInTheDocument(); + await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled()); + }); + + it("submits mixed Group and Finding batches with the correct dispatch filters and modes", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + sendJiraDispatchMock + .mockResolvedValueOnce({ + success: true, + taskId: "group-task", + message: "Group started", + }) + .mockResolvedValueOnce({ + success: true, + taskId: "finding-task", + message: "Findings started", + }); + + 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" }), + ); + await user.click( + screen.getByRole("radio", { name: "Create separate Jira issues" }), + ); + + // When + await user.click(screen.getByRole("button", { name: "Send to Jira" })); + + // Then + await waitFor(() => expect(sendJiraDispatchMock).toHaveBeenCalledTimes(2)); + expect(sendFindingToJiraMock).not.toHaveBeenCalled(); + expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(1, { + integrationId: "jira-1", + targetIds: ["check-a"], + filter: "check_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "grouped", + }); + expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(2, { + integrationId: "jira-1", + targetIds: ["finding-1", "finding-2"], + filter: "finding_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "individual", + }); + expect(trackAndPollTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "group-task", notifyHandler: false }), + ); + expect(trackAndPollTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "finding-task", notifyHandler: false }), + ); + }); + + it("shows a success toast after individual Finding dispatch succeeds", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + 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({ + title: "Success!", + description: "Finding successfully sent to Jira!", + }), + ); + expect(sendFindingToJiraMock).toHaveBeenCalledWith( + "jira-1", + "finding-1", + "SEC", + "Task", + ); + }); + + it("shows a success toast after grouped Finding Group dispatch succeeds", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + sendJiraDispatchMock.mockResolvedValueOnce({ + success: true, + taskId: "group-task", + message: "Group started", + }); + 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({ + title: "Success!", + description: "Finding successfully sent to Jira!", + }), + ); + expect(sendJiraDispatchMock).toHaveBeenCalledWith({ + integrationId: "jira-1", + targetIds: ["check-a"], + filter: "check_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "grouped", + }); + }); + + it("delegates grouped Finding Group task tracking to the shared watcher", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + sendJiraDispatchMock.mockResolvedValueOnce({ + success: true, + taskId: "group-task", + message: "Group started", + }); + 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(trackAndPollTaskMock).toHaveBeenCalledOnce()); + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Success!", + description: "Finding successfully sent to Jira!", + }), + ); + }); + + it("shows one success toast after mixed Group and Finding batches all succeed", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + sendJiraDispatchMock + .mockResolvedValueOnce({ + success: true, + taskId: "group-task", + message: "Group started", + }) + .mockResolvedValueOnce({ + success: true, + taskId: "finding-task", + message: "Findings started", + }); + 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({ + title: "Success!", + description: "2 Jira issues were created or updated successfully.", + }), + ); + expect(toastMock).toHaveBeenCalledTimes(1); + }); + + it("shows a partial success toast when a task reports created and failed issues", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + trackAndPollTaskMock.mockResolvedValue({ + status: "ready", + result: { created_count: 2, failed_count: 1 }, + }); + render( + , + ); + await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled()); + + // When + await user.click( + screen.getByRole("button", { name: "Select a Jira project" }), + ); + await user.click( + screen.getByRole("button", { name: "Select an issue type" }), + ); + await user.click(screen.getByRole("button", { name: "Send to Jira" })); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + title: "Partial success", + description: + "2 Jira issues were created or updated successfully. Some Jira dispatches failed: Jira dispatch completed with 1 failed and 2 created/updated issues.", + }), + ); + expect(toastMock).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Success!" }), + ); + }); + + it("retries only failed Findings after a partial task result", async () => { + // Given + const user = userEvent.setup(); + trackAndPollTaskMock.mockResolvedValueOnce({ + status: "ready", + result: { + created_count: 1, + failed_count: 1, + failed_finding_ids: ["finding-2"], + error: "Jira rejected one Finding.", + }, + }); + 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(trackAndPollTaskMock).toHaveBeenCalled()); + const partialToast = toastMock.mock.calls.find( + ([toast]) => toast.title === "Partial success", + )?.[0]; + expect(partialToast?.action).toBeDefined(); + + await partialToast.action.props.onClick(); + + expect(sendFindingToJiraMock).toHaveBeenLastCalledWith( + "jira-1", + "finding-2", + "SEC", + "Task", + ); + }); + + it("shows a partial success toast when one mixed dispatch batch fails after another succeeds", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + sendJiraDispatchMock + .mockResolvedValueOnce({ + success: true, + taskId: "group-task", + message: "Group started", + }) + .mockResolvedValueOnce({ + success: true, + taskId: "finding-task", + message: "Findings started", + }); + trackAndPollTaskMock + .mockResolvedValueOnce({ + status: "ready", + result: { created_count: 1, failed_count: 0 }, + }) + .mockResolvedValueOnce({ + status: "ready", + result: { created_count: 0, failed_count: 1 }, + }); + 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({ + title: "Partial success", + description: + "Finding successfully sent to Jira! Some Jira dispatches failed: Jira dispatch completed with 1 failed and 0 created/updated issues.", + }), + ); + expect(toastMock).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Success!" }), + ); + }); + + it("polls started tasks when a later mixed dispatch batch fails to launch", 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.", + }); + 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(trackAndPollTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "group-task" }), + ), + ); + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Partial success", + description: + "Finding successfully sent to Jira! Some Jira dispatches failed: Failed to launch Finding batch.", + }), + ), + ); + const partialToast = toastMock.mock.calls.find( + ([toast]) => toast.title === "Partial success", + )?.[0]; + expect(partialToast?.action).toBeUndefined(); + }); + + 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.", + }); + trackAndPollTaskMock.mockResolvedValueOnce({ + status: "error", + 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( + expect.objectContaining({ + variant: "destructive", + title: "Error", + description: + "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 aa6774da45..8cd3b1e1ad 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -2,49 +2,138 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Send } from "lucide-react"; -import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; +import { type Dispatch, type SetStateAction, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { getJiraIntegrations, getJiraIssueTypes, - pollJiraDispatchTask, sendFindingToJira, + sendJiraDispatch, } from "@/actions/integrations/jira-dispatch"; -import { useToast } from "@/components/shadcn"; import { CustomBanner } from "@/components/shadcn/custom/custom-banner"; +import { CustomRadio } from "@/components/shadcn/custom/custom-radio"; import { Form, FormField, FormMessage } from "@/components/shadcn/form"; import { FormButtons } from "@/components/shadcn/form/form-buttons"; import { Modal } from "@/components/shadcn/modal"; +import { RadioGroup } from "@/components/shadcn/radio-group/radio-group"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -import { IntegrationProps } from "@/types/integrations"; +import { toast, ToastAction } from "@/components/shadcn/toast"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { + evaluateJiraDispatchTask, + getJiraDispatchSuccessCount, +} from "@/lib/jira-dispatch-result"; +import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection"; +import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task"; +import { + TASK_WATCHER_STATUS, + type TaskTrackingResult, + trackAndPollTask, +} from "@/store/task-watcher/store"; +import { + type IntegrationProps, + JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, + JIRA_DISPATCH_TASK_KIND, + type JiraDispatchMode, + type JiraDispatchTargetBatch, + type JiraDispatchTaskResult, + type JiraSelection, +} from "@/types/integrations"; -interface SendToJiraModalProps { +import { + buildJiraDispatchChoiceCopy, + JIRA_SELECTION_KIND, +} from "./send-to-jira-modal-copy"; + +export interface SendToJiraModalProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - findingId: string; + selection: JiraSelection; findingTitle?: string; + defaultDispatchMode?: JiraDispatchMode; + canChooseGroupedDispatch?: boolean; + isFindingGroupSelection?: boolean; + selectedResourceCount?: number; + description?: string; +} + +interface JiraDispatchSettings { + integrationId: string; + projectKey: string; + issueType: string; + dispatchMode: JiraDispatchMode; +} + +interface StartedJiraTask { + taskId: string; + dispatchMode: JiraDispatchMode; +} + +interface JiraTrackedOutcome { + success: boolean; + message?: string; + error?: string; + warning?: string; + retryBatch?: JiraDispatchTargetBatch; + successfulCount?: number; } const sendToJiraSchema = z.object({ integration: z.string().min(1, "Please select a Jira integration"), project: z.string().min(1, "Please select a project"), issueType: z.string().min(1, "Please select an issue type"), + dispatchMode: z.enum([ + JIRA_DISPATCH_MODE.GROUPED, + JIRA_DISPATCH_MODE.INDIVIDUAL, + ]), }); type SendToJiraFormData = z.infer; -export const SendToJiraModal = ({ - isOpen, +const getConfiguredIssueTypes = ( + integration: IntegrationProps | undefined, + projectKey: string, +) => { + const configuredIssueTypes = integration?.attributes.configuration + .issue_types as Record | undefined; + + return configuredIssueTypes && + typeof configuredIssueTypes === "object" && + !Array.isArray(configuredIssueTypes) + ? (configuredIssueTypes[projectKey] ?? []) + : []; +}; + +const getRetryBatch = ( + failedFindingIds: string[] | undefined, +): JiraDispatchTargetBatch | undefined => { + const [firstFindingId, ...remainingFindingIds] = + failedFindingIds?.filter(Boolean) ?? []; + if (!firstFindingId) return undefined; + + return { + targetIds: [firstFindingId, ...remainingFindingIds], + targetType: JIRA_DISPATCH_TARGET.FINDING_ID, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }; +}; + +const SendToJiraModalContent = ({ onOpenChange, - findingId, + selection, findingTitle, -}: SendToJiraModalProps) => { - const { toast } = useToast(); + defaultDispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL, + canChooseGroupedDispatch = false, + isFindingGroupSelection = false, + selectedResourceCount, + description, +}: Omit) => { const [integrations, setIntegrations] = useState([]); - const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false); + const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(true); const [fetchedIssueTypes, setFetchedIssueTypes] = useState< Record >({}); @@ -56,191 +145,342 @@ export const SendToJiraModal = ({ integration: "", project: "", issueType: "", + dispatchMode: defaultDispatchMode, }, }); - const selectedIntegration = form.watch("integration"); + const jiraTargetBatches = getJiraSelectionBatches(selection); + const findingTargetCount = jiraTargetBatches + .filter((batch) => batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID) + .reduce((count, batch) => count + batch.targetIds.length, 0); + const jiraSelectedResourceCount = selectedResourceCount ?? findingTargetCount; + const shouldShowDispatchChoice = + (canChooseGroupedDispatch || findingTargetCount > 1) && + (findingTargetCount > 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 && + (isFindingGroupSelection || hasOnlySingleFindingGroupBatch); + const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({ + selectedCount: + findingTargetCount > 1 ? findingTargetCount : jiraSelectedResourceCount, + isSelectedFindingGroupFlow, + selectionKind: + findingTargetCount > 1 + ? JIRA_SELECTION_KIND.FINDINGS + : JIRA_SELECTION_KIND.RESOURCES, + }); + const selectedIntegration = form.watch("integration"); + const selectedProject = form.watch("project"); + const selectedIntegrationData = integrations.find( + (integration) => integration.id === selectedIntegration, + ); + const projects = + selectedIntegrationData?.attributes.configuration.projects ?? {}; + const projectEntries = Object.entries(projects); + const configuredIssueTypes = getConfiguredIssueTypes( + selectedIntegrationData, + selectedProject, + ); + const issueTypesForProject = + configuredIssueTypes.length > 0 + ? configuredIssueTypes + : (fetchedIssueTypes[`${selectedIntegration}:${selectedProject}`] ?? []); const hasConnectedIntegration = integrations.some( - (i) => i.attributes.connected === true, + (integration) => integration.attributes.connected === true, ); - const setOpenForFormButtons: Dispatch> = (value) => { - const next = typeof value === "function" ? value(isOpen) : value; - onOpenChange(next); - }; - - // Fetch Jira integrations when modal opens - useEffect(() => { - if (isOpen) { - const fetchJiraIntegrations = async () => { - setIsFetchingIntegrations(true); - - try { - const result = await getJiraIntegrations(); - if (!result.success) { - throw new Error( - result.error || "Unable to fetch Jira integrations", - ); - } - setIntegrations(result.data); - // Auto-select if only one integration - if (result.data.length === 1) { - form.setValue("integration", result.data[0].id); - } - } catch (error) { - const message = - error instanceof Error && error.message - ? error.message - : "Failed to load Jira integrations"; - toast({ - variant: "destructive", - title: "Failed to load integrations", - description: message, - }); - } finally { - setIsFetchingIntegrations(false); - } - }; - - fetchJiraIntegrations(); - } else { - // Reset form and fetched data when modal closes - form.reset(); - setFetchedIssueTypes({}); - } - }, [isOpen, form, toast]); - - const handleSubmit = async (data: SendToJiraFormData) => { - // Close modal immediately; continue processing in background - onOpenChange(false); + useMountEffect(() => { + let active = true; void (async () => { try { - // Send the finding to Jira - const result = await sendFindingToJira( - data.integration, - findingId, - data.project, - data.issueType, - ); - + const result = await getJiraIntegrations(); + if (!active) return; if (!result.success) { - throw new Error(result.error || "Failed to send to Jira"); + throw new Error(result.error || "Unable to fetch Jira integrations"); } - // Poll for task completion and notify once - const taskResult = await pollJiraDispatchTask(result.taskId); - - if (!taskResult.success) { - throw new Error(taskResult.error || "Failed to create Jira issue"); + setIntegrations(result.data); + if (result.data.length === 1) { + form.setValue("integration", result.data[0].id); } - - toast({ - title: "Success!", - description: - taskResult.message || "Finding sent to Jira successfully", - }); } catch (error) { + if (!active) return; const message = error instanceof Error && error.message ? error.message - : "Failed to send finding to Jira"; + : "Failed to load Jira integrations"; toast({ variant: "destructive", - title: "Error", + title: "Failed to load integrations", description: message, }); + } finally { + if (active) setIsFetchingIntegrations(false); } })(); - }; - - const selectedProject = form.watch("project"); - - const selectedIntegrationData = integrations.find( - (i) => i.id === selectedIntegration, - ); - - const projects: Record = - selectedIntegrationData?.attributes.configuration.projects ?? - ({} as Record); - - const projectEntries = Object.entries(projects); - - // Get issue types from config (new dict format), falling back to fetched data - const configIssueTypes = selectedIntegrationData?.attributes.configuration - .issue_types as Record | undefined; - const issueTypesFromConfig = - configIssueTypes && - typeof configIssueTypes === "object" && - !Array.isArray(configIssueTypes) - ? (configIssueTypes[selectedProject] ?? []) - : []; - const issueTypesForProject = - issueTypesFromConfig.length > 0 - ? issueTypesFromConfig - : (fetchedIssueTypes[selectedProject] ?? []); - - // Fetch issue types from API when project is selected but no types are available - useEffect(() => { - let ignore = false; - - if ( - selectedIntegration && - selectedProject && - issueTypesFromConfig.length === 0 && - !fetchedIssueTypes[selectedProject] - ) { - const fetchIssueTypes = async () => { - setIsFetchingIssueTypes(true); - try { - const result = await getJiraIssueTypes( - selectedIntegration, - selectedProject, - ); - if (ignore) return; - if (result.success) { - setFetchedIssueTypes((prev) => ({ - ...prev, - [selectedProject]: result.issueTypes, - })); - } else { - toast({ - variant: "destructive", - title: "Failed to load issue types", - description: - result.error || "Unable to fetch issue types for this project", - }); - } - } finally { - if (!ignore) setIsFetchingIssueTypes(false); - } - }; - - fetchIssueTypes(); - } return () => { - ignore = true; + active = false; }; - }, [ - selectedIntegration, - selectedProject, - issueTypesFromConfig.length, - fetchedIssueTypes, - toast, - ]); + }); + + const setOpenForFormButtons: Dispatch> = (value) => { + const nextOpen = typeof value === "function" ? value(true) : value; + onOpenChange(nextOpen); + }; + + const loadIssueTypes = async (integrationId: string, projectKey: string) => { + const integration = integrations.find((item) => item.id === integrationId); + if ( + !integrationId || + !projectKey || + getConfiguredIssueTypes(integration, projectKey).length > 0 || + fetchedIssueTypes[`${integrationId}:${projectKey}`] + ) { + return; + } + + setIsFetchingIssueTypes(true); + try { + const result = await getJiraIssueTypes(integrationId, projectKey); + if (result.success) { + setFetchedIssueTypes((current) => ({ + ...current, + [`${integrationId}:${projectKey}`]: result.issueTypes, + })); + return; + } + + toast({ + variant: "destructive", + title: "Failed to load issue types", + description: + result.error || "Unable to fetch issue types for this project", + }); + } catch { + toast({ + variant: "destructive", + title: "Failed to load issue types", + description: "Unable to fetch issue types for this project", + }); + } finally { + setIsFetchingIssueTypes(false); + } + }; + + async function processBatches( + batches: JiraDispatchTargetBatch[], + settings: JiraDispatchSettings, + ) { + const startedTasks: StartedJiraTask[] = []; + const launchErrors: string[] = []; + const unknownLaunchErrors: string[] = []; + + for (const batch of batches) { + const dispatchMode = batch.dispatchMode ?? settings.dispatchMode; + try { + const result = + batch.targetIds.length === 1 && + batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID && + dispatchMode === JIRA_DISPATCH_MODE.INDIVIDUAL + ? await sendFindingToJira( + settings.integrationId, + batch.targetIds[0], + settings.projectKey, + settings.issueType, + ) + : await sendJiraDispatch({ + integrationId: settings.integrationId, + targetIds: batch.targetIds, + filter: batch.targetType, + projectKey: settings.projectKey, + issueType: settings.issueType, + dispatchMode, + }); + + if (!result.success) { + launchErrors.push(result.error || "Failed to send to Jira"); + continue; + } + + startedTasks.push({ taskId: result.taskId, dispatchMode }); + } catch { + // The request may have reached the server before the RPC failed. Do + // not offer an automatic retry because that could create duplicates. + unknownLaunchErrors.push( + "The Jira dispatch status is unknown after a connection error. Check Jira before retrying.", + ); + } + } + + const trackedOutcomes = await Promise.all( + startedTasks.map(async ({ taskId, dispatchMode }) => { + let trackedTask: TaskTrackingResult; + try { + trackedTask = await trackAndPollTask({ + taskId, + kind: JIRA_DISPATCH_TASK_KIND, + meta: buildJiraDispatchTaskMeta({ + integrationId: settings.integrationId, + projectKey: settings.projectKey, + issueType: settings.issueType, + dispatchMode, + }), + notifyHandler: false, + }); + } catch { + return { + success: false, + error: + "Tracking the Jira dispatch failed unexpectedly. Check Jira before retrying.", + } satisfies JiraTrackedOutcome; + } + + if (trackedTask.status !== TASK_WATCHER_STATUS.READY) { + return { + success: false, + error: trackedTask.error || "Failed to track Jira issue creation.", + } satisfies JiraTrackedOutcome; + } + + const outcome = evaluateJiraDispatchTask( + "completed", + trackedTask.result, + ); + if (outcome.success) { + return { + success: true, + message: outcome.message, + warning: outcome.warning, + retryBatch: getRetryBatch(outcome.failedFindingIds), + successfulCount: getJiraDispatchSuccessCount(trackedTask.result), + } satisfies JiraTrackedOutcome; + } + + return { + success: false, + error: outcome.error, + retryBatch: getRetryBatch(outcome.failedFindingIds), + } satisfies JiraTrackedOutcome; + }), + ); + + const successfulOutcomes = trackedOutcomes.filter( + (outcome) => outcome.success, + ); + const warnings = Array.from( + new Set( + trackedOutcomes.flatMap((outcome) => + outcome.warning ? [outcome.warning] : [], + ), + ), + ); + const successfulIssueCount = successfulOutcomes.reduce( + (count, outcome) => count + (outcome.successfulCount ?? 0), + 0, + ); + const successMessage = + successfulOutcomes.length === 1 + ? successfulOutcomes[0].message + : `${successfulIssueCount} Jira issues were created or updated successfully.`; + const errors = Array.from( + new Set([ + ...trackedOutcomes.flatMap((outcome) => + outcome.error ? [outcome.error] : [], + ), + ...launchErrors, + ...unknownLaunchErrors, + ]), + ); + const retryBatch = getRetryBatch( + Array.from( + new Set( + trackedOutcomes.flatMap( + (outcome) => outcome.retryBatch?.targetIds ?? [], + ), + ), + ), + ); + const retryBatches = retryBatch ? [retryBatch] : []; + const retryAction = + retryBatches.length > 0 ? ( + { + toast({ + title: "Retry started", + description: "Retrying only the Jira dispatches that failed.", + }); + await processBatches(retryBatches, settings); + }} + > + Retry failed + + ) : undefined; + + if (errors.length > 0 || warnings.length > 0) { + if (successfulOutcomes.length > 0) { + toast({ + title: "Partial success", + description: `${successMessage || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${[ + ...warnings, + ...errors, + ].join(" ")}`, + ...(retryAction ? { action: retryAction } : {}), + }); + return; + } + + toast({ + variant: "destructive", + title: "Error", + description: [...warnings, ...errors].join(" "), + ...(retryAction ? { action: retryAction } : {}), + }); + return; + } + + toast({ + title: "Success!", + description: successMessage || "Finding sent to Jira successfully", + }); + } + + const handleSubmit = async (data: SendToJiraFormData) => { + onOpenChange(false); + + void processBatches(jiraTargetBatches, { + integrationId: data.integration, + projectKey: data.project, + issueType: data.issueType, + dispatchMode: data.dispatchMode, + }).catch(() => { + toast({ + variant: "destructive", + title: "Error", + description: + "The Jira dispatch could not be processed. Check Jira before retrying.", + }); + }); + }; const issueTypeOptions = issueTypesForProject.map((type) => ({ value: type, label: type, })); - const integrationOptions = integrations.map((integration) => ({ value: integration.id, label: integration.attributes.configuration.domain || integration.id, })); - const projectOptions = projectEntries.map(([key, name]) => ({ value: key, label: `${key} - ${name}`, @@ -248,13 +488,17 @@ export const SendToJiraModal = ({ return (
@@ -262,7 +506,6 @@ export const SendToJiraModal = ({ onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4" > - {/* Loading skeleton for project selector */} {isFetchingIntegrations && (
@@ -270,7 +513,6 @@ export const SendToJiraModal = ({
)} - {/* Integration Selection */} {!isFetchingIntegrations && integrations.length > 1 && ( { - const selectedValue = values.at(-1) ?? ""; - field.onChange(selectedValue); - // Reset dependent fields + field.onChange(values.at(-1) ?? ""); form.setValue("project", ""); form.setValue("issueType", ""); setFetchedIssueTypes({}); }} defaultValue={field.value ? [field.value] : []} placeholder="Select a Jira integration" - searchable={true} + searchable emptyIndicator="No integrations found." disabled={isFetchingIntegrations} - hideSelectAll={true} + hideSelectAll maxCount={1} - closeOnSelect={true} - resetOnDefaultValueChange={true} + closeOnSelect + resetOnDefaultValueChange /> @@ -310,7 +550,6 @@ export const SendToJiraModal = ({ /> )} - {/* Project Selection */} {!isFetchingIntegrations && selectedIntegration && projectEntries.length > 0 && ( @@ -329,19 +568,19 @@ export const SendToJiraModal = ({ id="jira-project-select" options={projectOptions} onValueChange={(values) => { - const selectedValue = values.at(-1) ?? ""; - field.onChange(selectedValue); - // Reset issue type when project changes + const projectKey = values.at(-1) ?? ""; + field.onChange(projectKey); form.setValue("issueType", ""); + void loadIssueTypes(selectedIntegration, projectKey); }} defaultValue={field.value ? [field.value] : []} placeholder="Select a Jira project" - searchable={true} + searchable emptyIndicator="No projects found." - hideSelectAll={true} + hideSelectAll maxCount={1} - closeOnSelect={true} - resetOnDefaultValueChange={true} + closeOnSelect + resetOnDefaultValueChange /> @@ -349,7 +588,6 @@ export const SendToJiraModal = ({ /> )} - {/* Issue Type Selection */} {selectedProject && ( { - const selectedValue = values.at(-1) ?? ""; - field.onChange(selectedValue); - }} + onValueChange={(values) => + field.onChange(values.at(-1) ?? "") + } defaultValue={field.value ? [field.value] : []} placeholder={ isFetchingIssueTypes ? "Loading issue types..." : "Select an issue type" } - searchable={true} + searchable emptyIndicator="No issue types found." disabled={isFetchingIssueTypes} - hideSelectAll={true} + hideSelectAll maxCount={1} - closeOnSelect={true} - resetOnDefaultValueChange={true} + closeOnSelect + resetOnDefaultValueChange /> @@ -389,7 +626,52 @@ export const SendToJiraModal = ({ /> )} - {/* No integrations or none connected message */} + {shouldShowDispatchChoice && ( + ( +
+ + Jira issue creation mode + + + + + + {jiraDispatchChoiceCopy.groupedTitle} + + + {jiraDispatchChoiceCopy.groupedHelp} + + + + + + + Create separate Jira issues + + + {jiraDispatchChoiceCopy.individualHelp} + + + + + +
+ )} + /> + )} + {!isFetchingIntegrations && (integrations.length === 0 || !hasConnectedIntegration) ? ( ); }; + +export const SendToJiraModal = ({ isOpen, ...props }: SendToJiraModalProps) => { + if (!isOpen) return null; + + return ; +}; diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index 877d83c186..e66d04ae66 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -5,7 +5,25 @@ import type { InputHTMLAttributes, ReactNode, } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface JiraModalMockProps { + selection: { targetId?: string }; + isOpen: boolean; +} + +const { SendToJiraModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted( + () => ({ + SendToJiraModalMock: vi.fn(({ selection, isOpen }: JiraModalMockProps) => ( +
+ )), + isGroupedJiraDispatchEnabledMock: vi.fn(() => true), + }), +); // CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. vi.mock("@/components/shadcn/custom/custom-link", () => ({ @@ -14,8 +32,7 @@ vi.mock("@/components/shadcn/custom/custom-link", () => ({ ), })); -vi.mock("@/components/shadcn", async (importOriginal) => ({ - ...(await importOriginal>()), +vi.mock("@/components/shadcn", () => ({ Button: ({ children, ...props }: ButtonHTMLAttributes) => ( ), @@ -42,19 +59,7 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({ })); vi.mock("@/components/findings/send-to-jira-modal", () => ({ - SendToJiraModal: ({ - findingId, - isOpen, - }: { - findingId: string; - isOpen: boolean; - }) => ( -
- ), + SendToJiraModal: SendToJiraModalMock, })); vi.mock("@/components/icons/services/IconServices", () => ({ @@ -69,12 +74,14 @@ vi.mock("@/components/shadcn/dropdown", () => ({ label, onSelect, disabled, + disabledTooltip, }: { label: string; onSelect?: () => void; disabled?: boolean; + disabledTooltip?: string; }) => ( - ), @@ -175,6 +182,11 @@ vi.mock("@/lib/date-utils", () => ({ getFailingForLabel: () => "2d", })); +vi.mock("@/lib/deployment", () => ({ + isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, + PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", +})); + const notificationIndicatorMock = vi.fn((_props: unknown) => null); vi.mock("./notification-indicator", () => ({ @@ -196,6 +208,7 @@ import { CLOUD_ONLY_TOOLTIP_COPY, EDITING_UNAVAILABLE_COPY, } from "./finding-triage-cells"; +import { FindingsSelectionContext } from "./findings-selection-context"; function makeTriageSummary( overrides?: Partial, @@ -284,6 +297,11 @@ function renderResourceActionsCell({ } describe("column-finding-resources", () => { + beforeEach(() => { + vi.clearAllMocks(); + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + }); + it("should render actions as the last visible column after Triage without Notes", () => { // Given const columns = getColumnFindingResources({ @@ -296,6 +314,7 @@ describe("column-finding-resources", () => { // Then expect(columnIds.slice(-2)).toEqual(["triage", "actions"]); + expect(columnIds).not.toContain("status"); expect(columnIds).not.toContain("notes"); expect( (columns.at(-1) as { id?: string; size?: number } | undefined)?.size, @@ -518,4 +537,107 @@ describe("column-finding-resources", () => { "true", ); }); + + it("should pass selected same-group affected failing resources as grouped Jira targets", async () => { + // Given + const user = userEvent.setup(); + const columns = getColumnFindingResources({ + rowSelection: {}, + selectableRowCount: 2, + }); + const actionColumn = columns.find( + (col) => (col as { id?: string }).id === "actions", + ); + if (!actionColumn?.cell) { + throw new Error("actions column not found"); + } + const CellComponent = actionColumn.cell as (props: { + row: { original: FindingResourceRow }; + }) => ReactNode; + + render( + + {CellComponent({ + row: { + original: makeResource({ findingId: "finding-1" }), + }, + })} + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Send to Jira" })); + + // Then + expect(SendToJiraModalMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + selection: { + kind: "target-list", + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: true, + }), + undefined, + ); + }); + + it("should disable selected multi-finding Jira dispatch outside cloud", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(false); + const user = userEvent.setup(); + const columns = getColumnFindingResources({ + rowSelection: {}, + selectableRowCount: 2, + }); + const actionColumn = columns.find( + (col) => (col as { id?: string }).id === "actions", + ); + if (!actionColumn?.cell) { + throw new Error("actions column not found"); + } + const CellComponent = actionColumn.cell as (props: { + row: { original: FindingResourceRow }; + }) => ReactNode; + + render( + + {CellComponent({ + row: { + original: makeResource({ findingId: "finding-1" }), + }, + })} + , + ); + + // When + const jiraButton = screen.getByRole("button", { name: "Send to Jira" }); + await user.click(jiraButton); + + // Then + expect(jiraButton).toBeDisabled(); + expect(jiraButton).toHaveAttribute( + "title", + "Available only in Prowler Cloud", + ); + expect(SendToJiraModalMock).not.toHaveBeenCalledWith( + expect.objectContaining({ isOpen: true }), + undefined, + ); + }); }); diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index 255abea929..e83c01e8ef 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -18,16 +18,18 @@ import { InfoField } from "@/components/shadcn/info-field/info-field"; import { Spinner } from "@/components/shadcn/spinner/spinner"; import { SeverityBadge } from "@/components/shadcn/table"; import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header"; -import { - type FindingStatus, - StatusFindingBadge, -} from "@/components/shadcn/table/status-finding-badge"; import { getFailingForLabel } from "@/lib/date-utils"; +import { + isGroupedJiraDispatchEnabled, + PROWLER_CLOUD_ONLY_TOOLTIP, +} from "@/lib/deployment"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { FindingResourceRow } from "@/types"; import type { FindingTriageLoadedNote, FindingTriageSummary, } from "@/types/findings-triage"; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { canMuteFindingResource } from "./finding-resource-selection"; import { @@ -69,6 +71,7 @@ const ResourceRowActions = ({ const isCurrentSelected = selectedFindingIds.includes(resource.findingId); const hasMultipleSelected = selectedFindingIds.length > 1; + const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled(); const getDisplayIds = (): string[] => { if (isCurrentSelected && hasMultipleSelected) { @@ -83,6 +86,12 @@ const ResourceRowActions = ({ if (ids.length > 1) return `Mute ${ids.length}`; return "Mute"; }; + const displayIds = getDisplayIds(); + const canSendToJira = displayIds.length === 1 || groupedJiraDispatchEnabled; + const jiraSelection = createJiraTargetSelection( + displayIds, + JIRA_DISPATCH_TARGET.FINDING_ID, + ); const handleMuteClick = async () => { const displayIds = getDisplayIds(); @@ -123,12 +132,22 @@ const ResourceRowActions = ({ onComplete={handleMuteComplete} /> )} - + {jiraSelection && ( + 1 + ? JIRA_DISPATCH_MODE.GROUPED + : JIRA_DISPATCH_MODE.INDIVIDUAL + } + canChooseGroupedDispatch={ + displayIds.length > 1 && groupedJiraDispatchEnabled + } + /> + )}
e.stopPropagation()} @@ -162,7 +181,11 @@ const ResourceRowActions = ({ } label="Send to Jira" - onSelect={() => setIsJiraModalOpen(true)} + disabled={!canSendToJira} + disabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP} + onSelect={() => { + if (canSendToJira) setIsJiraModalOpen(true); + }} />
@@ -243,24 +266,14 @@ export function getColumnFindingResources({ enableSorting: false, enableHiding: false, }, - // Status - { - id: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( - - ); - }, - enableSorting: false, - }, - // Resource — name + uid + // Affected failing resource — name + uid { id: "resource", header: ({ column }) => ( - + ), cell: ({ row }) => (
diff --git a/ui/components/findings/table/data-table-row-actions.test.tsx b/ui/components/findings/table/data-table-row-actions.test.tsx index 5f63d640b9..ad5d416406 100644 --- a/ui/components/findings/table/data-table-row-actions.test.tsx +++ b/ui/components/findings/table/data-table-row-actions.test.tsx @@ -2,8 +2,29 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { MuteFindingsModalMock } = vi.hoisted(() => ({ - MuteFindingsModalMock: vi.fn(() => null), +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { + FINDING_TRIAGE_DISABLED_REASON, + FINDING_TRIAGE_STATUS, + type FindingTriageSummary, +} from "@/types/findings-triage"; + +import { + DataTableRowActions, + type FindingRowData, +} from "./data-table-row-actions"; +import { FindingsSelectionContext } from "./findings-selection-context"; + +const { MuteFindingsModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted( + () => ({ + MuteFindingsModalMock: vi.fn(() => null), + isGroupedJiraDispatchEnabledMock: vi.fn(() => true), + }), +); + +const { SendToJiraModalMock } = vi.hoisted(() => ({ + SendToJiraModalMock: vi.fn(() => null), })); vi.mock("next/navigation", () => ({ @@ -15,7 +36,12 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({ })); vi.mock("@/components/findings/send-to-jira-modal", () => ({ - SendToJiraModal: () => null, + SendToJiraModal: SendToJiraModalMock, +})); + +vi.mock("@/lib/deployment", () => ({ + isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, + PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); vi.mock("@/components/icons/services/IconServices", () => ({ @@ -30,12 +56,20 @@ vi.mock("@/components/shadcn/dropdown", () => ({ label, onSelect, disabled, + disabledTooltip, + tooltip, }: { label: string; onSelect?: () => void; disabled?: boolean; + disabledTooltip?: string; + tooltip?: string; }) => ( - ), @@ -74,18 +108,6 @@ vi.mock("./finding-note-modal", () => ({ ) : null, })); -import { - FINDING_TRIAGE_DISABLED_REASON, - FINDING_TRIAGE_STATUS, - type FindingTriageSummary, -} from "@/types/findings-triage"; - -import { - DataTableRowActions, - type FindingRowData, -} from "./data-table-row-actions"; -import { FindingsSelectionContext } from "./findings-selection-context"; - function deferredPromise() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -134,6 +156,8 @@ function makeFindingRow(overrides?: Partial) { describe("DataTableRowActions", () => { beforeEach(() => { vi.clearAllMocks(); + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); it("opens the mute modal immediately in preparing state for finding groups", async () => { @@ -252,6 +276,292 @@ describe("DataTableRowActions", () => { ).toBeDisabled(); }); + it("allows choosing Jira dispatch mode for a group with multiple failing resources", async () => { + // Given + const user = userEvent.setup(); + render( + + + , + ); + + // When + await user.click( + screen.getByRole("button", { name: "Send Finding Group to Jira" }), + ); + + // Then + expect(SendToJiraModalMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + selection: { + kind: "single", + targetId: "s3_bucket_public_access", + targetType: "check_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: true, + selectedResourceCount: 2, + }), + undefined, + ); + }); + + it("shows the Cloud tooltip and opens the upgrade modal for groups outside cloud", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(false); + const user = userEvent.setup(); + render( + + + , + ); + + // When + const jiraButton = screen.getByRole("button", { + name: "Send Finding Group to Jira", + }); + await user.click(jiraButton); + + // Then + expect(jiraButton).toBeVisible(); + expect(jiraButton).toBeEnabled(); + expect(jiraButton).toHaveAttribute( + "title", + "Available only in Prowler Cloud", + ); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, + ); + expect(SendToJiraModalMock).not.toHaveBeenCalledWith( + expect.objectContaining({ isOpen: true }), + undefined, + ); + }); + + it("does not offer Jira dispatch mode choice for a group with one failing resource", async () => { + // Given + const user = userEvent.setup(); + render( + + + , + ); + + // When + await user.click( + screen.getByRole("button", { name: "Send Finding Group to Jira" }), + ); + + // Then + expect(SendToJiraModalMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + selection: { + kind: "single", + targetId: "s3_bucket_public_access", + targetType: "check_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: false, + selectedResourceCount: 1, + }), + undefined, + ); + }); + + it("uses grouped Jira dispatch for mixed selected finding groups", async () => { + // Given + const user = userEvent.setup(); + render( + + + , + ); + + // When + await user.click( + screen.getByRole("button", { name: "Send 2 Finding Groups to Jira" }), + ); + + // Then + expect(SendToJiraModalMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + selection: { + kind: "target-list", + targetIds: ["check-a", "check-b"], + targetType: "check_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: false, + }), + undefined, + ); + }); + + it("allows choosing Jira dispatch mode for multiple selected findings", async () => { + // Given + const user = userEvent.setup(); + render( + + + , + ); + + // When + await user.click( + screen.getByRole("button", { name: "Send 2 Findings to Jira" }), + ); + + // Then + expect(SendToJiraModalMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + selection: { + kind: "target-list", + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: true, + }), + undefined, + ); + }); + + it("keeps single finding Jira dispatch enabled when other rows are selected outside cloud", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(false); + const user = userEvent.setup(); + render( + + + , + ); + + // When + await user.click( + screen.getByRole("button", { name: "Send 1 Finding to Jira" }), + ); + + // Then + expect( + screen.getByRole("button", { name: "Send 1 Finding to Jira" }), + ).toBeEnabled(); + expect(SendToJiraModalMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + isOpen: true, + selection: { + kind: "single", + targetId: "finding-1", + targetType: "finding_id", + }, + defaultDispatchMode: "individual", + canChooseGroupedDispatch: false, + }), + undefined, + ); + }); + it("shows Add Triage Note for editable findings without a note", () => { // Given / When render( diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index f0d6f10e6f..04f82c0d73 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -13,12 +13,20 @@ import { ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { + isGroupedJiraDispatchEnabled, + PROWLER_CLOUD_ONLY_TOOLTIP, +} from "@/lib/deployment"; import { isFindingGroupMuted } from "@/lib/findings-groups"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { getOptionalText } from "@/lib/utils"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import type { FindingTriageLoadedNote, FindingTriageSummary, } from "@/types/findings-triage"; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; import type { ProviderType } from "@/types/providers"; import { canMuteFindingGroup } from "./finding-group-selection"; @@ -115,6 +123,9 @@ export function DataTableRowActions({ const [mutePreparationError, setMutePreparationError] = useState< string | null >(null); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding); const resolvedFindingContext = findingContext ?? { @@ -149,6 +160,7 @@ export function DataTableRowActions({ // Otherwise, just mute this single finding const isCurrentSelected = selectedFindingIds.includes(muteKey); const hasMultipleSelected = selectedFindingIds.length > 1; + const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled(); const getDisplayIds = (): string[] => { if (isCurrentSelected && hasMultipleSelected) { @@ -166,6 +178,45 @@ export function DataTableRowActions({ return isGroup ? "Mute Finding Group" : "Mute Finding"; }; + const getJiraTargetIds = (): string[] => { + if (isCurrentSelected && hasMultipleSelected) { + return selectedFindingIds; + } + return [muteKey]; + }; + + const getJiraLabel = () => { + const ids = getJiraTargetIds(); + if (ids.length > 1) { + return `Send ${ids.length} ${isGroup ? "Finding Groups" : "Findings"} to Jira`; + } + return isGroup ? "Send Finding Group to Jira" : "Send 1 Finding to Jira"; + }; + + const jiraTargetIds = getJiraTargetIds(); + const jiraTargetType = isGroup + ? JIRA_DISPATCH_TARGET.CHECK_ID + : JIRA_DISPATCH_TARGET.FINDING_ID; + const jiraSelection = createJiraTargetSelection( + jiraTargetIds, + jiraTargetType, + ); + const requiresJiraUpgrade = + (isGroup || jiraTargetIds.length > 1) && !groupedJiraDispatchEnabled; + const selectedJiraResourceCount = isGroup + ? (finding.resourcesFail ?? 0) + : undefined; + const hasMultipleSelectedFindings = !isGroup && jiraTargetIds.length > 1; + const jiraDefaultDispatchMode = + isGroup || hasMultipleSelectedFindings + ? JIRA_DISPATCH_MODE.GROUPED + : JIRA_DISPATCH_MODE.INDIVIDUAL; + const canChooseGroupedJiraDispatch = groupedJiraDispatchEnabled + ? isGroup + ? jiraTargetIds.length === 1 && (selectedJiraResourceCount ?? 0) > 1 + : hasMultipleSelectedFindings + : false; + const handleMuteModalOpenChange = ( nextOpen: boolean | ((previousOpen: boolean) => boolean), ) => { @@ -226,14 +277,26 @@ export function DataTableRowActions({ router.refresh(); }; + const handleJiraClick = () => { + if (requiresJiraUpgrade) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH); + return; + } + + setIsJiraModalOpen(true); + }; + return ( <> - {!isGroup && ( + {(!isGroup || groupedJiraDispatchEnabled) && jiraSelection && ( )} @@ -274,13 +337,14 @@ export function DataTableRowActions({ disabled={!canMute || isResolving} onSelect={handleMuteClick} /> - {!isGroup && ( - } - label="Send to Jira" - onSelect={() => setIsJiraModalOpen(true)} - /> - )} + } + label={getJiraLabel()} + tooltip={ + requiresJiraUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined + } + onSelect={handleJiraClick} + />
diff --git a/ui/components/findings/table/findings-group-drill-down.test.ts b/ui/components/findings/table/findings-group-drill-down.test.ts index 816e894e0a..2c62de45d8 100644 --- a/ui/components/findings/table/findings-group-drill-down.test.ts +++ b/ui/components/findings/table/findings-group-drill-down.test.ts @@ -13,4 +13,14 @@ describe("findings group drill down", () => { expect(source).toContain("useFindingGroupResourceState"); expect(source).not.toContain("useInfiniteResources"); }); + + it("routes selected child findings through the Send to Jira modal with issue creation mode", () => { + expect(source).toContain(" 1 && groupedJiraDispatchEnabled", + ); + expect(source).toContain("canSendSelectedFindingsToJira"); + expect(source).toContain("JIRA_DISPATCH_MODE.GROUPED"); + }); }); diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index b71787ef37..9f7aef3df9 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -7,11 +7,13 @@ import { } from "@tanstack/react-table"; import { ChevronLeft } from "lucide-react"; import { useSearchParams } from "next/navigation"; +import { useState } from "react"; import { loadLatestFindingTriageNote, updateFindingTriage, } from "@/actions/findings"; +import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { LoadingState } from "@/components/shadcn/spinner/loading-state"; import { Table, @@ -25,12 +27,15 @@ import { } from "@/components/shadcn/table"; import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state"; import { cn, hasHistoricalFindingFilter } from "@/lib"; +import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; import { getFilteredFindingGroupDelta, getFindingGroupImpactedCounts, isFindingGroupMuted, } from "@/lib/findings-groups"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { FindingGroupRow } from "@/types"; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { FloatingMuteButton } from "../floating-mute-button"; @@ -51,6 +56,8 @@ export function FindingsGroupDrillDown({ onCollapse, }: FindingsGroupDrillDownProps) { const searchParams = useSearchParams(); + const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); + const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled(); // Keep drill-down endpoint selection aligned with the grouped findings page. const currentParams = Object.fromEntries(searchParams.entries()); @@ -113,6 +120,12 @@ export function FindingsGroupDrillDown({ const impactedCounts = getFindingGroupImpactedCounts(group); const rows = table.getRowModel().rows; + const canSendSelectedFindingsToJira = + selectedFindingIds.length === 1 || groupedJiraDispatchEnabled; + const jiraSelection = createJiraTargetSelection( + selectedFindingIds, + JIRA_DISPATCH_TARGET.FINDING_ID, + ); return (
@@ -235,11 +248,37 @@ export function FindingsGroupDrillDown({ { return resolveSelectedFindingIds(selectedFindingIds); }} onComplete={handleMuteComplete} - isBulkOperation + isBulkOperation={selectedFindingIds.length > 1} + showSendToJira + canSendToJira={canSendSelectedFindingsToJira} + sendToJiraLabel={`Send ${selectedFindingIds.length} ${ + selectedFindingIds.length === 1 ? "Finding" : "Findings" + } to Jira`} + onSendToJira={() => setIsJiraModalOpen(true)} + /> + )} + + {jiraSelection && ( + 1 + ? JIRA_DISPATCH_MODE.GROUPED + : JIRA_DISPATCH_MODE.INDIVIDUAL + } + canChooseGroupedDispatch={ + selectedFindingIds.length > 1 && groupedJiraDispatchEnabled + } /> )} diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 3d31ac0fb2..305bed902b 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -1,9 +1,34 @@ -import { render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Fragment, type ReactNode, useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; import { FindingsGroupTable } from "./findings-group-table"; +const { + isGroupedJiraDispatchEnabledMock, + SendToJiraModalMock, + setOnDrillDownMock, + triggerOnDrillDownMock, +} = vi.hoisted(() => { + let onDrillDown: ((checkId: string, group: unknown) => void) | undefined; + + return { + isGroupedJiraDispatchEnabledMock: vi.fn(() => false), + SendToJiraModalMock: vi.fn((_props: unknown) => null), + setOnDrillDownMock: vi.fn( + (handler: ((checkId: string, group: unknown) => void) | undefined) => { + onDrillDown = handler; + }, + ), + triggerOnDrillDownMock: vi.fn((checkId: string, group: unknown) => { + onDrillDown?.(checkId, group); + }), + }; +}); + vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), @@ -17,6 +42,8 @@ vi.mock("@/components/shadcn/table", () => ({ data, toolbarRightContent, getRowAttributes, + onRowSelectionChange, + renderAfterRow, }: { data?: Array<{ checkId?: string }>; toolbarRightContent?: ReactNode; @@ -24,6 +51,13 @@ vi.mock("@/components/shadcn/table", () => ({ index: number; original: { checkId?: string }; }) => Record; + onRowSelectionChange?: ( + updater: (previous: Record) => Record, + ) => void; + renderAfterRow?: (row: { + index: number; + original: { checkId?: string }; + }) => ReactNode; }) => (
{toolbarRightContent}
@@ -31,13 +65,36 @@ vi.mock("@/components/shadcn/table", () => ({ {(data ?? []).map((original, index) => ( - - - + + + + + + {renderAfterRow?.({ index, original })} + ))}
{original.checkId}
{original.checkId} + + +
@@ -63,19 +120,110 @@ vi.mock("@/actions/findings/findings-by-resource", () => ({ resolveFindingIdsByVisibleGroupResources: vi.fn(), })); +vi.mock("@/lib/deployment", () => ({ + isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, + PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", +})); + +vi.mock("../send-to-jira-modal", () => ({ + SendToJiraModal: SendToJiraModalMock, +})); + vi.mock("./column-finding-groups", () => ({ - getColumnFindingGroups: () => [], + getColumnFindingGroups: ({ + onDrillDown, + }: { + onDrillDown?: (checkId: string, group: unknown) => void; + }) => { + setOnDrillDownMock(onDrillDown); + return []; + }, })); vi.mock("./inline-resource-container", () => ({ - InlineResourceContainer: () => null, + InlineResourceContainer: ({ + columnCount, + onResourceSelectionChange, + }: { + columnCount?: number; + onResourceSelectionChange?: (selectedResourceIds: string[]) => void; + }) => ( + + + + + + + ), })); vi.mock("../floating-mute-button", () => ({ - FloatingMuteButton: () => null, + FloatingMuteButton: ({ + label, + muteLabel, + sendToJiraLabel, + onBeforeOpen, + onSendToJira, + canSendToJira, + showSendToJira, + }: { + label?: string; + muteLabel?: string; + sendToJiraLabel?: string; + onBeforeOpen?: () => Promise; + onSendToJira?: () => void; + canSendToJira?: boolean; + showSendToJira?: boolean; + }) => { + const [isChooserOpen, setIsChooserOpen] = useState(false); + + return ( +
+ + {isChooserOpen && ( +
+ + {showSendToJira && ( + + )} +
+ )} +
+ ); + }, })); describe("FindingsGroupTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + isGroupedJiraDispatchEnabledMock.mockReturnValue(false); + }); + describe("toolbar", () => { it("should render the muted findings checkbox inside the table toolbar", () => { // Given @@ -169,4 +317,842 @@ describe("FindingsGroupTable", () => { expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id"); }); }); + + describe("expanded deep link", () => { + it("opens the matching drillable group from expandedCheckId", () => { + // Given + const data = [ + { checkId: "check-a", resourcesTotal: 1 }, + { checkId: "check-b", resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // Then + expect( + screen.getByRole("button", { name: "Select finding-1" }), + ).toBeInTheDocument(); + }); + + it("ignores a missing expandedCheckId", () => { + // Given + const data = [ + { checkId: "check-a", resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // Then + expect( + screen.queryByRole("button", { name: "Select finding-1" }), + ).not.toBeInTheDocument(); + }); + + it("ignores a non-drillable expandedCheckId", () => { + // Given + const data = [ + { checkId: "check-a", resourcesTotal: 0 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // Then + expect( + screen.queryByRole("button", { name: "Select finding-1" }), + ).not.toBeInTheDocument(); + }); + + it("allows manual collapse after opening from expandedCheckId", async () => { + // Given + const user = userEvent.setup(); + const data = [ + { checkId: "check-a", resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + + // Then + expect( + screen.queryByRole("button", { name: "Select finding-1" }), + ).not.toBeInTheDocument(); + }); + + it("clears the expanded group when expandedCheckId is removed", () => { + // Given + const data = [ + { checkId: "check-a", resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + const { rerender } = render( + , + ); + expect( + screen.getByRole("button", { name: "Select finding-1" }), + ).toBeInTheDocument(); + + // When + rerender( + , + ); + + // Then + expect( + screen.queryByRole("button", { name: "Select finding-1" }), + ).not.toBeInTheDocument(); + }); + }); + + describe("bulk Jira action", () => { + it("should summarize group-only selections", async () => { + // Given + const user = userEvent.setup(); + const data = [ + { checkId: "check-a", resourcesFail: 1, resourcesTotal: 1 }, + { checkId: "check-b", resourcesFail: 1, resourcesTotal: 1 }, + { checkId: "check-c", resourcesFail: 1, resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Select check-a" })); + await user.click(screen.getByRole("button", { name: "Select check-b" })); + await user.click(screen.getByRole("button", { name: "Select check-c" })); + + // Then + expect( + screen.getByRole("button", { name: "3 Groups selected" }), + ).toBeInTheDocument(); + }); + + it("should summarize selected groups with nested findings", async () => { + // Given + const user = userEvent.setup(); + const data = [ + { checkId: "check-a", resourcesFail: 1, resourcesTotal: 1 }, + { checkId: "check-b", resourcesFail: 1, resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Select check-a" })); + await user.click(screen.getByRole("button", { name: "Expand check-b" })); + await user.click( + screen.getByRole("button", { name: "Select finding-1" }), + ); + + // Then + expect( + screen.getByRole("button", { + name: "1 Group and 1 Finding selected", + }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { + name: "1 Group and 1 Finding selected", + }), + ); + const actionChooser = screen.getByRole("dialog", { + name: "Choose action", + }); + expect( + within(actionChooser).getByRole("button", { + name: "Send 1 Group and 1 Finding to Jira", + }), + ).toBeInTheDocument(); + expect( + within(actionChooser).getByRole("button", { + name: "Mute 1 Group and 1 Finding", + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Send 1 Group to Jira" }), + ).not.toBeInTheDocument(); + }); + + it("should pass both group and child finding batches to Jira for mixed selections", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 1, + resourcesTotal: 1, + }, + { + checkId: "check-b", + checkTitle: "Check B", + resourcesFail: 1, + resourcesTotal: 1, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Select check-a" })); + await user.click(screen.getByRole("button", { name: "Expand check-b" })); + await user.click( + screen.getByRole("button", { name: "Select finding-1" }), + ); + + // When + await user.click( + screen.getByRole("button", { + name: "1 Group and 1 Finding selected", + }), + ); + await user.click( + screen.getByRole("button", { + name: "Send 1 Group and 1 Finding to Jira", + }), + ); + + // Then + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "batches", + batches: [ + { + targetIds: ["check-a"], + targetType: "check_id", + dispatchMode: "grouped", + }, + { + targetIds: ["finding-1"], + targetType: "finding_id", + dispatchMode: "individual", + }, + ], + }, + canChooseGroupedDispatch: false, + description: "Create Jira issues for 1 Group and 1 Finding.", + }); + }); + + it("should pass both child finding and group batches to Jira when the child finding is selected first", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 1, + resourcesTotal: 1, + }, + { + checkId: "check-b", + checkTitle: "Check B", + resourcesFail: 1, + resourcesTotal: 1, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + await user.click( + screen.getByRole("button", { name: "Select finding-1" }), + ); + await user.click(screen.getByRole("button", { name: "Select check-b" })); + + // When + await user.click( + screen.getByRole("button", { + name: "1 Group and 1 Finding selected", + }), + ); + await user.click( + screen.getByRole("button", { + name: "Send 1 Group and 1 Finding to Jira", + }), + ); + + // Then + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "batches", + batches: [ + { + targetIds: ["check-b"], + targetType: "check_id", + dispatchMode: "grouped", + }, + { + targetIds: ["finding-1"], + targetType: "finding_id", + dispatchMode: "individual", + }, + ], + }, + canChooseGroupedDispatch: false, + description: "Create Jira issues for 1 Group and 1 Finding.", + }); + }); + + it("should leave multi child Finding dispatch mode to the Jira modal for mixed bulk selections", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 2, + resourcesTotal: 2, + }, + { + checkId: "check-b", + checkTitle: "Check B", + resourcesFail: 1, + resourcesTotal: 1, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + await user.click( + screen.getByRole("button", { name: "Select findings 1 and 2" }), + ); + await user.click(screen.getByRole("button", { name: "Select check-b" })); + + // When + await user.click( + screen.getByRole("button", { + name: "1 Group and 2 Findings selected", + }), + ); + await user.click( + screen.getByRole("button", { + name: "Send 1 Group and 2 Findings to Jira", + }), + ); + + // Then + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0] as { + selection: { batches: Array> }; + }; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "batches", + batches: [ + { + targetIds: ["check-b"], + targetType: "check_id", + dispatchMode: "grouped", + }, + { + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, + ], + }, + canChooseGroupedDispatch: false, + description: "Create Jira issues for 1 Group and 2 Findings.", + }); + expect(lastCall.selection.batches[1]).not.toHaveProperty("dispatchMode"); + }); + + it("should route choosing Mute through the existing mute resolver", async () => { + // Given + const user = userEvent.setup(); + vi.mocked(resolveFindingIdsByVisibleGroupResources).mockResolvedValue([ + "finding-a", + ]); + const data = [ + { checkId: "check-a", resourcesFail: 1, resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Select check-a" })); + await user.click( + screen.getByRole("button", { name: "1 Group selected" }), + ); + await user.click( + within(screen.getByRole("dialog", { name: "Choose action" })).getByRole( + "button", + { name: "Mute 1 Group" }, + ), + ); + + // Then + expect(resolveFindingIdsByVisibleGroupResources).toHaveBeenCalledWith({ + checkId: "check-a", + filters: {}, + hasDateOrScanFilter: false, + resourceSearch: undefined, + }); + }); + + it("should clear nested selections when expanding another group and preserve selected groups", async () => { + // Given + const user = userEvent.setup(); + const data = [ + { checkId: "check-a", resourcesFail: 1, resourcesTotal: 1 }, + { checkId: "check-b", resourcesFail: 1, resourcesTotal: 1 }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Select check-b" })); + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + await user.click( + screen.getByRole("button", { name: "Select finding-1" }), + ); + + // Then nested and group selections are both visible/actionable. + expect( + screen.getByRole("button", { + name: "1 Group and 1 Finding selected", + }), + ).toBeInTheDocument(); + + // When switching groups, nested selection clears while the selected group remains. + await user.click(screen.getByRole("button", { name: "Expand check-b" })); + + // Then + expect( + screen.queryByRole("button", { + name: "1 Group and 1 Finding selected", + }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "1 Group selected" }), + ).toBeInTheDocument(); + }); + + it("should open Jira modal for resource-only selections", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 1, + resourcesTotal: 1, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + await user.click( + screen.getByRole("button", { name: "Select finding-1" }), + ); + + // Then + expect( + screen.getByRole("button", { name: "1 Finding selected" }), + ).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "1 Finding selected" }), + ); + await user.click( + screen.getByRole("button", { name: "Send 1 Finding to Jira" }), + ); + + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "single", + targetId: "finding-1", + targetType: "finding_id", + }, + defaultDispatchMode: "individual", + canChooseGroupedDispatch: false, + }); + }); + + it("should allow grouped Jira dispatch choice for multiple selected resources in one finding group", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 2, + resourcesTotal: 2, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + await user.click( + screen.getByRole("button", { name: "Select findings 1 and 2" }), + ); + + // Then + await user.click( + screen.getByRole("button", { name: "2 Findings selected" }), + ); + await user.click( + screen.getByRole("button", { name: "Send 2 Findings to Jira" }), + ); + + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "target-list", + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: true, + }); + }); + + it("should show selected multi-finding Jira tooltip when grouped dispatch is disabled", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(false); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 2, + resourcesTotal: 2, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + await user.click( + screen.getByRole("button", { name: "Select findings 1 and 2" }), + ); + await user.click( + screen.getByRole("button", { name: "2 Findings selected" }), + ); + const jiraButton = screen.getByRole("button", { + name: "Send 2 Findings to Jira", + }); + await user.click(jiraButton); + + // Then + expect(jiraButton).not.toBeDisabled(); + expect(jiraButton).toHaveAttribute( + "title", + "Available only in Prowler Cloud", + ); + expect( + within(jiraButton).queryByText("Available only in Prowler Cloud"), + ).not.toBeInTheDocument(); + expect(SendToJiraModalMock).not.toHaveBeenCalledWith( + expect.objectContaining({ isOpen: true }), + undefined, + ); + }); + + it("should render Cloud-only bulk Jira tooltip when grouped Jira dispatch is disabled", async () => { + // Given + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + resourcesFail: 1, + resourcesTotal: 1, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Select check-a" })); + await user.click( + screen.getByRole("button", { name: "1 Group selected" }), + ); + + // Then + const jiraButton = screen.getByRole("button", { + name: "Send 1 Group to Jira", + }); + expect(jiraButton).toBeVisible(); + expect(jiraButton).not.toBeDisabled(); + expect(jiraButton).toHaveAttribute( + "title", + "Available only in Prowler Cloud", + ); + expect( + within(jiraButton).queryByText("Available only in Prowler Cloud"), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Mute 1 Group" }), + ).toBeInTheDocument(); + expect(SendToJiraModalMock).not.toHaveBeenCalledWith( + expect.objectContaining({ isOpen: true }), + undefined, + ); + }); + + it("should allow grouped Jira dispatch choice for one selected finding group with multiple failing resources", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 2, + resourcesTotal: 2, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Select check-a" })); + + // When + await user.click( + screen.getByRole("button", { name: "1 Group selected" }), + ); + await user.click( + screen.getByRole("button", { name: "Send 1 Group to Jira" }), + ); + + // Then + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "single", + targetId: "check-a", + targetType: "check_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: true, + selectedResourceCount: 2, + }); + }); + + it("should not require grouped Jira dispatch choice for one selected finding group with one failing resource", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 1, + resourcesTotal: 1, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Select check-a" })); + + // When + await user.click( + screen.getByRole("button", { name: "1 Group selected" }), + ); + await user.click( + screen.getByRole("button", { name: "Send 1 Group to Jira" }), + ); + + // Then + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "single", + targetId: "check-a", + targetType: "check_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: false, + selectedResourceCount: 1, + }); + }); + + it("should use grouped dispatch mode for multiple selected finding groups", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + const data = [ + { + checkId: "check-a", + checkTitle: "Check A", + resourcesFail: 2, + resourcesTotal: 2, + }, + { + checkId: "check-b", + checkTitle: "Check B", + resourcesFail: 3, + resourcesTotal: 3, + }, + ] as unknown as Parameters[0]["data"]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Select check-a" })); + await user.click(screen.getByRole("button", { name: "Select check-b" })); + + // When + await user.click( + screen.getByRole("button", { name: "2 Groups selected" }), + ); + await user.click( + screen.getByRole("button", { name: "Send 2 Groups to Jira" }), + ); + + // Then + const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + isOpen: true, + selection: { + kind: "target-list", + targetIds: ["check-a", "check-b"], + targetType: "check_id", + }, + defaultDispatchMode: "grouped", + canChooseGroupedDispatch: false, + selectedResourceCount: 2, + }); + }); + }); }); diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 986cba7f09..649b670657 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -6,12 +6,19 @@ import { Suspense, useRef, useState } from "react"; import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings"; +import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { OnboardingTrigger, PageReady } from "@/components/onboarding"; import { DataTable } from "@/components/shadcn/table"; +import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; import { canDrillDownFindingGroup } from "@/lib/findings-groups"; +import { + createJiraBatchSelection, + createJiraTargetSelection, +} from "@/lib/jira-dispatch-selection"; import { getFlowById } from "@/lib/onboarding"; import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour"; import { FindingGroupRow, MetaDataProps } from "@/types"; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { FloatingMuteButton } from "../floating-mute-button"; @@ -24,18 +31,49 @@ import { } from "./inline-resource-container"; const exploreFindingsFlow = getFlowById("explore-findings")!; +const EMPTY_FINDING_GROUPS: FindingGroupRow[] = []; -function buildMuteLabel(groupCount: number, resourceCount: number): string { - const parts: string[] = []; - if (groupCount > 0) { - parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`); - } - if (resourceCount > 0) { - parts.push( - `${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`, - ); - } - return `Mute ${parts.join(" and ")}`; +function buildSelectionSummary( + groupCount: number, + findingCount: number, +): string { + return `${buildSelectionEntityLabel(groupCount, findingCount)} selected`; +} + +function buildMuteActionLabel( + groupCount: number, + findingCount: number, +): string { + return `Mute ${buildSelectionEntityLabel(groupCount, findingCount)}`; +} + +function buildJiraActionLabel( + groupCount: number, + findingCount: number, +): string { + return `Send ${buildSelectionEntityLabel(groupCount, findingCount)} to Jira`; +} + +function buildSelectionEntityLabel( + groupCount: number, + findingCount: number, +): string { + const parts = [ + buildEntityCountLabel(groupCount, "Group", "Groups"), + buildEntityCountLabel(findingCount, "Finding", "Findings"), + ].filter(Boolean); + + return parts.join(" and "); +} + +function buildEntityCountLabel( + count: number, + singular: string, + plural: string, +): string | null { + if (count === 0) return null; + + return `${count} ${count === 1 ? singular : plural}`; } interface FindingsGroupTableProps { @@ -43,6 +81,7 @@ interface FindingsGroupTableProps { metadata?: MetaDataProps; resolvedFilters: Record; hasHistoricalData: boolean; + expandedCheckId?: string; } export function FindingsGroupTable({ @@ -50,23 +89,70 @@ export function FindingsGroupTable({ metadata, resolvedFilters, hasHistoricalData, + expandedCheckId: requestedExpandedCheckId, }: FindingsGroupTableProps) { + const safeData = data ?? EMPTY_FINDING_GROUPS; + const requestedGroup = requestedExpandedCheckId + ? safeData.find((group) => group.checkId === requestedExpandedCheckId) + : undefined; + const initialExpandedCheckId = + requestedGroup && canDrillDownFindingGroup(requestedGroup) + ? requestedGroup.checkId + : null; + + return ( + + ); +} + +interface FindingsGroupTableContentProps { + data: FindingGroupRow[]; + metadata?: MetaDataProps; + resolvedFilters: Record; + hasHistoricalData: boolean; + initialExpandedCheckId: string | null; +} + +const FindingsGroupTableContent = ({ + data, + metadata, + resolvedFilters, + hasHistoricalData, + initialExpandedCheckId, +}: FindingsGroupTableContentProps) => { const router = useRouter(); const searchParams = useSearchParams(); const [rowSelection, setRowSelection] = useState({}); - const [expandedCheckId, setExpandedCheckId] = useState(null); - const [expandedGroup, setExpandedGroup] = useState( - null, - ); + const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState< + string | null + >(initialExpandedCheckId); // Separate input (keystroke) from committed search (Enter) to avoid remounting InlineResourceContainer. const [resourceSearchInput, setResourceSearchInput] = useState(""); const [resourceSearch, setResourceSearch] = useState(""); const [resourceSelection, setResourceSelection] = useState([]); + const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const inlineRef = useRef(null); - const safeData = data ?? []; - const hasResourceSelection = resourceSelection.length > 0; + const safeData = data ?? EMPTY_FINDING_GROUPS; + const expandedGroupCandidate = selectedExpandedCheckId + ? safeData.find((group) => group.checkId === selectedExpandedCheckId) + : undefined; + const expandedGroup = + expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate) + ? expandedGroupCandidate + : null; + const expandedCheckId = expandedGroup?.checkId ?? null; + const activeResourceSelection = expandedCheckId ? resourceSelection : []; + const hasResourceSelection = activeResourceSelection.length > 0; const filters = resolvedFilters; + const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled(); // Exclude expanded group from group-level mutes when it has resource selections. const selectedCheckIds = Object.keys(rowSelection) @@ -82,6 +168,76 @@ export function FindingsGroupTable({ .map((idx) => safeData[parseInt(idx)]) .filter(Boolean); + const selectedGroupTitle = + selectedFindings.length === 1 ? selectedFindings[0]?.checkTitle : undefined; + const hasMixedJiraSelection = + selectedCheckIds.length > 0 && hasResourceSelection; + const jiraGroupSelectionTakesPrecedence = selectedCheckIds.length > 0; + const jiraTargetIds = jiraGroupSelectionTakesPrecedence + ? selectedCheckIds + : activeResourceSelection; + const jiraTargetType = jiraGroupSelectionTakesPrecedence + ? JIRA_DISPATCH_TARGET.CHECK_ID + : JIRA_DISPATCH_TARGET.FINDING_ID; + const singleSelectedGroup = + selectedCheckIds.length === 1 + ? selectedFindings.find( + (finding) => finding.checkId === selectedCheckIds[0], + ) + : undefined; + const selectedJiraResourceCount = jiraGroupSelectionTakesPrecedence + ? singleSelectedGroup + ? singleSelectedGroup.resourcesFail + : selectedCheckIds.length + : activeResourceSelection.length; + const jiraDispatchMode = jiraGroupSelectionTakesPrecedence + ? JIRA_DISPATCH_MODE.GROUPED + : activeResourceSelection.length > 1 + ? JIRA_DISPATCH_MODE.GROUPED + : JIRA_DISPATCH_MODE.INDIVIDUAL; + const canChooseGroupedJiraDispatch = jiraGroupSelectionTakesPrecedence + ? !hasMixedJiraSelection && + selectedCheckIds.length === 1 && + selectedJiraResourceCount > 1 + : activeResourceSelection.length > 1; + const jiraTitle = hasMixedJiraSelection + ? undefined + : jiraGroupSelectionTakesPrecedence + ? selectedGroupTitle + : expandedGroup?.checkTitle; + const jiraSelection = hasMixedJiraSelection + ? createJiraBatchSelection([ + { + targetIds: selectedCheckIds, + targetType: JIRA_DISPATCH_TARGET.CHECK_ID, + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, + }, + { + targetIds: activeResourceSelection, + targetType: JIRA_DISPATCH_TARGET.FINDING_ID, + ...(activeResourceSelection.length > 1 + ? {} + : { dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL }), + }, + ]) + : createJiraTargetSelection(jiraTargetIds, jiraTargetType); + const jiraDescription = hasMixedJiraSelection + ? `Create Jira issues for ${buildSelectionEntityLabel( + selectedCheckIds.length, + activeResourceSelection.length, + )}.` + : undefined; + const hasJiraTargets = jiraTargetIds.length > 0; + const isSingleFindingJiraDispatch = + !jiraGroupSelectionTakesPrecedence && activeResourceSelection.length === 1; + const canSendToJira = + hasJiraTargets && + (isSingleFindingJiraDispatch || groupedJiraDispatchEnabled); + const sendToJiraLabel = buildJiraActionLabel( + selectedCheckIds.length, + activeResourceSelection.length, + ); + const selectableRowCount = safeData.filter((g) => canMuteFindingGroup({ resourcesFail: g.resourcesFail, @@ -146,16 +302,14 @@ export function FindingsGroupTable({ handleCollapse(); return; } - setExpandedCheckId(checkId); - setExpandedGroup(group); + setSelectedExpandedCheckId(checkId); setResourceSearchInput(""); setResourceSearch(""); setResourceSelection([]); }; const handleCollapse = () => { - setExpandedCheckId(null); - setExpandedGroup(null); + setSelectedExpandedCheckId(null); setResourceSearchInput(""); setResourceSearch(""); setResourceSelection([]); @@ -257,27 +411,55 @@ export function FindingsGroupTable({ {(selectedCheckIds.length > 0 || hasResourceSelection) && ( { const [groupIds, resourceIds] = await Promise.all([ selectedCheckIds.length > 0 ? resolveGroupMuteIds(selectedCheckIds) : Promise.resolve([]), - Promise.resolve(hasResourceSelection ? resourceSelection : []), + Promise.resolve( + hasResourceSelection ? activeResourceSelection : [], + ), ]); return [...groupIds, ...resourceIds]; }} onComplete={handleMuteComplete} isBulkOperation={ - selectedCheckIds.length > 0 || resourceSelection.length > 1 + selectedCheckIds.length > 0 || activeResourceSelection.length > 1 } + showSendToJira={hasJiraTargets} + canSendToJira={canSendToJira} + sendToJiraLabel={sendToJiraLabel} + onSendToJira={() => setIsJiraModalOpen(true)} + /> + )} + + {canSendToJira && jiraSelection && ( + )} ); -} +}; diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index 91c4f3b225..32ce06cdc1 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -91,11 +91,7 @@ function ResourceSkeletonRow({
- {/* Status */} - - - - {/* Resource: name + uid */} + {/* Affected failing resource: name + uid */}
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index f18f0b671a..98ca0ea133 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -73,12 +73,14 @@ import { import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; import { getFailingForLabel, formatDuration } from "@/lib/date-utils"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts"; import { getRegionFlag } from "@/lib/region-flags"; import { getRecommendationLinkLabel } from "@/lib/vulnerability-references"; import type { ComplianceOverviewData } from "@/types/compliance"; import type { FindingResourceRow } from "@/types/findings-table"; import type { UpdateFindingTriageInput } from "@/types/findings-triage"; +import { JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; @@ -411,6 +413,9 @@ export function ResourceDetailDrawerContent({ // During carousel navigation we only trust row-backed data until the next // finding payload is fully ready, otherwise stale details flash briefly. const f = isNavigating ? null : currentFinding; + const jiraSelection = f + ? createJiraTargetSelection([f.id], JIRA_DISPATCH_TARGET.FINDING_ID) + : null; const isCheckMetaFresh = !currentResource?.checkId || currentResource.checkId === checkMeta.checkId; const showCheckMetaContent = !isNavigating || isCheckMetaFresh; @@ -545,11 +550,11 @@ export function ResourceDetailDrawerContent({ }} /> )} - {f && ( + {f && jiraSelection && ( )} @@ -1569,6 +1574,10 @@ function OtherFindingRow({ const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const isMuted = finding.isMuted || isOptimisticallyMuted; + const jiraSelection = createJiraTargetSelection( + [finding.id], + JIRA_DISPATCH_TARGET.FINDING_ID, + ); const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`; @@ -1585,12 +1594,14 @@ function OtherFindingRow({ }} /> )} - + {jiraSelection && ( + + )} window.open(findingUrl, "_blank", "noopener,noreferrer")} diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index 3bb7566d11..a820215d5b 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -409,6 +409,9 @@ export const ResourceDetailContent = ({ )} diff --git a/ui/components/shadcn/custom/custom-radio.tsx b/ui/components/shadcn/custom/custom-radio.tsx index 53fd0e6bd6..d6bf6dec2c 100644 --- a/ui/components/shadcn/custom/custom-radio.tsx +++ b/ui/components/shadcn/custom/custom-radio.tsx @@ -4,12 +4,17 @@ import { RadioGroupItem } from "@/components/shadcn/radio-group/radio-group"; import { cn } from "@/lib/utils"; interface CustomRadioProps { + ariaLabel?: string; description?: string; value?: string; children?: React.ReactNode; } -export const CustomRadio = ({ value, children }: CustomRadioProps) => { +export const CustomRadio = ({ + ariaLabel, + value, + children, +}: CustomRadioProps) => { return ( ); diff --git a/ui/components/shadcn/dropdown/action-dropdown.tsx b/ui/components/shadcn/dropdown/action-dropdown.tsx index 47c72b3afd..2b6436608c 100644 --- a/ui/components/shadcn/dropdown/action-dropdown.tsx +++ b/ui/components/shadcn/dropdown/action-dropdown.tsx @@ -5,6 +5,8 @@ import { ComponentProps, ReactNode, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"; + import { DropdownMenu, DropdownMenuContent, @@ -104,6 +106,10 @@ interface ActionDropdownItemProps description?: string; /** Whether the item is destructive (danger styling) */ destructive?: boolean; + /** Tooltip shown while the item remains interactive. */ + tooltip?: string; + /** Tooltip shown when the item is disabled. */ + disabledTooltip?: string; } export function ActionDropdownItem({ @@ -112,9 +118,13 @@ export function ActionDropdownItem({ description, destructive = false, className, + tooltip, + disabledTooltip, + disabled, + onSelect, ...props }: ActionDropdownItemProps) { - return ( + const item = ( { + if (disabled) { + event.preventDefault(); + return; + } + + onSelect?.(event); + }} {...props} > {icon && ( @@ -149,6 +169,19 @@ export function ActionDropdownItem({
); + + const tooltipContent = tooltip ?? (disabled ? disabledTooltip : undefined); + + if (tooltipContent) { + return ( + + {item} + {tooltipContent} + + ); + } + + return item; } export function ActionDropdownDangerZone({ diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx index ecfc381796..2ac1ec1c68 100644 --- a/ui/components/shared/cloud-upgrade-modal.test.tsx +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -116,6 +116,38 @@ describe("CloudUpgradeModal", () => { ); }); + it("renders the contextual Jira dispatch upgrade", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH); + + // When + render(); + + // Then + expect( + await screen.findByRole("dialog", { + name: "Send Findings to Jira at Scale", + }), + ).toBeVisible(); + expect( + screen.getByRole("link", { + name: "Send Findings to Jira in Prowler Cloud", + }), + ).toHaveAttribute( + "href", + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=jira-dispatch", + ); + expect( + screen.getByRole("link", { name: "View Plans & Pricing" }), + ).toHaveAttribute( + "href", + "https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=jira-dispatch", + ); + }); + it("closes the active upgrade and returns focus to its trigger", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "false"); diff --git a/ui/components/shared/task-polling-watcher.tsx b/ui/components/shared/task-polling-watcher.tsx index 18f9749854..8624f0d9c4 100644 --- a/ui/components/shared/task-polling-watcher.tsx +++ b/ui/components/shared/task-polling-watcher.tsx @@ -4,16 +4,19 @@ import { CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler, } from "@/app/(prowler)/compliance/_lib/cross-provider-pdf"; +import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { registerTaskKindHandler, resumePendingTasks, } from "@/store/task-watcher/store"; +import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations"; // Kind registrations happen at module scope, before any task can settle in // this tab. Adding a new watched task kind (integration tests, scan exports, // …) is one line here plus a handler next to the feature that owns it. registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler); +registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler); /** * Mounted once in the app layout (next to `Toaster`): resumes polling any diff --git a/ui/components/ui/toast/index.test.ts b/ui/components/ui/toast/index.test.ts new file mode 100644 index 0000000000..c2b253e5b6 --- /dev/null +++ b/ui/components/ui/toast/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { + toast as shadcnToast, + Toaster as ShadcnToaster, +} from "@/components/shadcn/toast"; +import { toast as uiToast, Toaster as UiToaster } from "@/components/ui/toast"; + +describe("components/ui/toast", () => { + it("uses the mounted shadcn toast store and provider", () => { + expect(uiToast).toBe(shadcnToast); + expect(UiToaster).toBe(ShadcnToaster); + }); +}); diff --git a/ui/hooks/use-filter-batch.test.ts b/ui/hooks/use-filter-batch.test.ts index 0607c041ef..cd59291081 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 ────────────────────────────────────────────────────────── @@ -179,6 +198,30 @@ describe("useFilterBatch", () => { // Then expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]); }); + + it("should replace exclusive legacy filters when the new grouped filter is edited", () => { + // Given - a legacy deep link with the exact check_id filter applied. + setSearchParams({ + "filter[check_id]": "teams_external_users_can_join", + }); + const { result } = renderHook(() => + useFilterBatch({ + exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]], + }), + ); + + // When - the Finding Group multi-select writes the __in filter. + act(() => { + result.current.setPending("filter[check_id__in]", [ + "teams_external_users_cannot_join", + ]); + }); + + // Then - the stale exact filter is removed from pending state. + expect(result.current.pendingFilters).toEqual({ + "filter[check_id__in]": ["teams_external_users_cannot_join"], + }); + }); }); // ── getFilterValue ───────────────────────────────────────────────────────── @@ -227,6 +270,24 @@ describe("useFilterBatch", () => { // Then expect(values).toEqual(["critical"]); }); + + it("should expose legacy exclusive filter values through the replacement key", () => { + // Given - a legacy grouped finding deep link uses filter[check_id]. + setSearchParams({ + "filter[check_id]": "teams_external_users_can_join", + }); + const { result } = renderHook(() => + useFilterBatch({ + exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]], + }), + ); + + // When - the new Finding Group control asks for filter[check_id__in]. + const values = result.current.getFilterValue("filter[check_id__in]"); + + // Then - the existing exact value appears selected in the control. + expect(values).toEqual(["teams_external_users_can_join"]); + }); }); // ── hasChanges & changeCount ─────────────────────────────────────────────── diff --git a/ui/hooks/use-filter-batch.ts b/ui/hooks/use-filter-batch.ts index 8e13ee8bc8..4451b29c61 100644 --- a/ui/hooks/use-filter-batch.ts +++ b/ui/hooks/use-filter-batch.ts @@ -151,6 +151,23 @@ export interface UseFilterBatchOptions { * (e.g. `{ "filter[muted]": "false" }` on the Findings page). */ defaultParams?: Record; + /** + * Filter keys that represent the same logical control. Updating one removes + * the others so legacy exact params and new multi-select params cannot be + * applied together. + */ + exclusiveFilterGroups?: string[][]; +} + +function normalizeFilterKey(key: string): string { + return key.startsWith("filter[") ? key : `filter[${key}]`; +} + +function getExclusiveFilterGroup( + filterKey: string, + exclusiveFilterGroups: string[][] | undefined, +): string[] | undefined { + return exclusiveFilterGroups?.find((group) => group.includes(filterKey)); } /** @@ -187,15 +204,27 @@ export const useFilterBatch = ( }, [searchParams]); const setPending = (key: string, values: string[]) => { - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; - setPendingFilters((prev) => ({ - ...prev, - [filterKey]: values, - })); + const filterKey = normalizeFilterKey(key); + const exclusiveGroup = getExclusiveFilterGroup( + filterKey, + options?.exclusiveFilterGroups, + ); + setPendingFilters((prev) => { + const next = { ...prev }; + + exclusiveGroup + ?.filter((exclusiveKey) => exclusiveKey !== filterKey) + .forEach((exclusiveKey) => { + delete next[exclusiveKey]; + }); + + next[filterKey] = values; + return next; + }); }; const removePending = (key: string) => { - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; + const filterKey = normalizeFilterKey(key); setPendingFilters((prev) => { const next = { ...prev }; delete next[filterKey]; @@ -265,7 +294,7 @@ export const useFilterBatch = ( }; const removeAppliedAndApply = (key: string, value?: string) => { - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; + const filterKey = normalizeFilterKey(key); const applied = deriveAppliedFromUrl( new URLSearchParams(searchParams.toString()), ); @@ -286,8 +315,20 @@ export const useFilterBatch = ( }; const getFilterValue = (key: string): string[] => { - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; - return pendingFilters[filterKey] ?? []; + const filterKey = normalizeFilterKey(key); + const values = pendingFilters[filterKey]; + if (values) return values; + + const exclusiveGroup = getExclusiveFilterGroup( + filterKey, + options?.exclusiveFilterGroups, + ); + const alternateKey = exclusiveGroup?.find( + (exclusiveKey) => + exclusiveKey !== filterKey && pendingFilters[exclusiveKey], + ); + + return alternateKey ? pendingFilters[alternateKey] : []; }; const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters); diff --git a/ui/lib/cloud-upgrade.test.ts b/ui/lib/cloud-upgrade.test.ts index f16f54413b..233c5dffaf 100644 --- a/ui/lib/cloud-upgrade.test.ts +++ b/ui/lib/cloud-upgrade.test.ts @@ -24,6 +24,7 @@ describe("cloud upgrade content", () => { "Bring CLI Findings into One Cloud View", "See Compliance Across Every Provider", "Coordinate Finding Remediation", + "Send Findings to Jira at Scale", "Use The Agent Cloud Defender", "Scale Prowler Without Operating It", "Configure Every Scan Once", @@ -71,6 +72,7 @@ describe("cloud upgrade URLs", () => { "cross-provider-compliance", ], [CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"], + [CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, "jira-dispatch"], [CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"], [CLOUD_UPGRADE_FEATURE.GENERAL, "general"], [CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION, "scan-configuration"], diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts index a8993cdfbd..b5ae3e55cb 100644 --- a/ui/lib/cloud-upgrade.ts +++ b/ui/lib/cloud-upgrade.ts @@ -26,6 +26,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = { [CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]: "cross-provider-compliance", [CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings", + [CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: "jira-dispatch", [CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai", [CLOUD_UPGRADE_FEATURE.GENERAL]: "general", [CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: "scan-configuration", @@ -98,6 +99,17 @@ export const CLOUD_UPGRADE_CONTENT = { ], primaryCta: "Triage Findings in Prowler Cloud", }, + [CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: { + title: "Send Findings to Jira at Scale", + description: + "Create Jira issues from selected findings and finding groups without handling each item separately.", + benefits: [ + "Send selected findings or finding groups in one action", + "Choose between grouped and individual Jira issues", + "Track dispatch progress and retry failed findings", + ], + primaryCta: "Send Findings to Jira in Prowler Cloud", + }, [CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: { title: "Use The Agent Cloud Defender", description: diff --git a/ui/lib/deployment.test.ts b/ui/lib/deployment.test.ts new file mode 100644 index 0000000000..6d5c9ee16f --- /dev/null +++ b/ui/lib/deployment.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const importFresh = async () => { + vi.resetModules(); + return import("./deployment"); +}; + +describe("enterprise feature flags", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("should keep grouped Jira dispatch disabled by default", async () => { + // Given / When + const { isGroupedJiraDispatchEnabled } = await importFresh(); + + // Then + expect(isGroupedJiraDispatchEnabled()).toBe(false); + }); + + it("should enable grouped Jira dispatch from the enterprise env flag in cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "cloud"); + vi.stubEnv( + "NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED", + "true", + ); + + // When + const { isGroupedJiraDispatchEnabled } = await importFresh(); + + // Then + expect(isGroupedJiraDispatchEnabled()).toBe(true); + }); + + it("should keep grouped Jira dispatch disabled outside cloud", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "onpremise"); + vi.stubEnv( + "NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED", + "true", + ); + + // When + const { isGroupedJiraDispatchEnabled } = await importFresh(); + + // Then + expect(isGroupedJiraDispatchEnabled()).toBe(false); + }); +}); diff --git a/ui/lib/deployment.ts b/ui/lib/deployment.ts new file mode 100644 index 0000000000..a1c97aec4e --- /dev/null +++ b/ui/lib/deployment.ts @@ -0,0 +1,55 @@ +export const DEPLOYMENT_MODE = { + CLOUD: "cloud", + ON_PREMISE: "onpremise", +} as const; + +export const ENTERPRISE_FEATURE_ENV = { + GROUPED_JIRA_DISPATCH_ENABLED: + "NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED", +} as const; + +export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud"; + +export type DeploymentMode = + (typeof DEPLOYMENT_MODE)[keyof typeof DEPLOYMENT_MODE]; + +type EnterpriseFeatureEnv = + (typeof ENTERPRISE_FEATURE_ENV)[keyof typeof ENTERPRISE_FEATURE_ENV]; + +const getEnterpriseFeatureValue = ( + envName: EnterpriseFeatureEnv, +): string | undefined => { + if (envName === ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED) { + return process.env + .NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED; + } + + return undefined; +}; + +const getBooleanEnv = ( + envName: EnterpriseFeatureEnv, + defaultValue: boolean, +): boolean => { + const value = getEnterpriseFeatureValue(envName); + + if (value === undefined || value === "") { + return defaultValue; + } + + return value === "true"; +}; + +export const getDeploymentMode = (): DeploymentMode | undefined => { + const mode = process.env.NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE; + + if (mode === DEPLOYMENT_MODE.CLOUD || mode === DEPLOYMENT_MODE.ON_PREMISE) { + return mode; + } + + return undefined; +}; + +export const isGroupedJiraDispatchEnabled = (): boolean => + getDeploymentMode() === DEPLOYMENT_MODE.CLOUD && + getBooleanEnv(ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED, false); diff --git a/ui/lib/jira-dispatch-result.ts b/ui/lib/jira-dispatch-result.ts new file mode 100644 index 0000000000..2ee30d9d84 --- /dev/null +++ b/ui/lib/jira-dispatch-result.ts @@ -0,0 +1,141 @@ +import type { JiraDispatchTaskResult } from "@/types/integrations"; +import type { TaskState } from "@/types/tasks"; + +export interface JiraDispatchSuccessOutcome { + success: true; + message: string; + warning?: string; + failedFindingIds?: string[]; +} + +export interface JiraDispatchFailureOutcome { + success: false; + error: string; + failedFindingIds?: string[]; +} + +export type JiraDispatchOutcome = + | JiraDispatchSuccessOutcome + | JiraDispatchFailureOutcome; + +const getArrayCount = (value: unknown[] | undefined) => + Array.isArray(value) ? value.length : 0; + +const getFailedCount = (result: JiraDispatchTaskResult | undefined) => { + if (!result) return 0; + return Math.max( + result.failed_count ?? 0, + getArrayCount(result.failed_groups), + getArrayCount(result.failed_batches), + getArrayCount(result.failed_finding_ids), + ); +}; + +export const getJiraDispatchSuccessCount = ( + result: JiraDispatchTaskResult | undefined, +) => { + if (!result) return 0; + + const createdCount = Math.max( + result.created_count ?? 0, + getArrayCount(result.created_issues), + ); + const updatedCount = Math.max( + result.updated_count ?? 0, + getArrayCount(result.updated_issues), + ); + + return Math.max( + result.successful_count ?? 0, + createdCount + updatedCount, + result.issue_key || result.issue_url ? 1 : 0, + ); +}; + +const ensureSentence = (message: string) => + /[.!?]$/.test(message.trim()) ? message.trim() : `${message.trim()}.`; + +const buildFailureMessage = ( + result: JiraDispatchTaskResult | undefined, + failedCount: number, +) => { + const successCount = getJiraDispatchSuccessCount(result); + const summary = `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`; + + return result?.error ? `${ensureSentence(result.error)} ${summary}` : summary; +}; + +const buildSuccessMessage = (result: JiraDispatchTaskResult | undefined) => { + const successCount = getJiraDispatchSuccessCount(result); + if (successCount > 1) { + return `${successCount} Jira issues were created or updated successfully.`; + } + + return "Finding successfully sent to Jira!"; +}; + +const getFailedFindingIds = (result: JiraDispatchTaskResult | undefined) => + Array.from(new Set(result?.failed_finding_ids?.filter(Boolean) ?? [])); + +const withFailedFindingIds = (failedFindingIds: string[]) => + failedFindingIds.length > 0 ? { failedFindingIds } : {}; + +export const evaluateJiraDispatchTask = ( + state: TaskState, + result: JiraDispatchTaskResult | null | undefined, +): JiraDispatchOutcome => { + const jiraResult = result ?? undefined; + const failedFindingIds = getFailedFindingIds(jiraResult); + + if (state === "completed") { + const failedCount = getFailedCount(jiraResult); + if (failedCount > 0) { + const successCount = getJiraDispatchSuccessCount(jiraResult); + if (successCount > 0) { + return { + success: true, + message: buildSuccessMessage(jiraResult), + warning: buildFailureMessage(jiraResult, failedCount), + ...withFailedFindingIds(failedFindingIds), + }; + } + + return { + success: false, + error: buildFailureMessage(jiraResult, failedCount), + ...withFailedFindingIds(failedFindingIds), + }; + } + + if (jiraResult?.success === false || jiraResult?.error) { + return { + success: false, + error: jiraResult.error || "Failed to create Jira issue.", + ...withFailedFindingIds(failedFindingIds), + }; + } + + if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) { + return { + success: false, + error: + "Jira dispatch completed but did not create or update any issues.", + }; + } + + return { + success: true, + message: buildSuccessMessage(jiraResult), + }; + } + + if (state === "failed") { + return { + success: false, + error: jiraResult?.error || "Task failed.", + ...withFailedFindingIds(failedFindingIds), + }; + } + + return { success: false, error: `Unknown task state: ${state}` }; +}; diff --git a/ui/lib/jira-dispatch-selection.ts b/ui/lib/jira-dispatch-selection.ts new file mode 100644 index 0000000000..df561d5bba --- /dev/null +++ b/ui/lib/jira-dispatch-selection.ts @@ -0,0 +1,84 @@ +import { + JIRA_TARGET_SELECTION_KIND, + type JiraBatchSelection, + type JiraDispatchTarget, + type JiraDispatchTargetBatch, + type JiraSelection, + type NonEmptyStringArray, +} from "@/types/integrations"; + +export interface JiraDispatchTargetBatchInput { + targetIds: string[]; + targetType: JiraDispatchTarget; + dispatchMode?: JiraDispatchTargetBatch["dispatchMode"]; +} + +export const toNonEmptyStringArray = ( + values: string[], +): NonEmptyStringArray | null => { + const [first, ...rest] = values.filter(Boolean); + return first ? [first, ...rest] : null; +}; + +export const createJiraTargetSelection = ( + targetIds: string[], + targetType: JiraDispatchTarget, +): JiraSelection | null => { + const nonEmptyTargetIds = toNonEmptyStringArray(targetIds); + if (!nonEmptyTargetIds) return null; + + if (nonEmptyTargetIds.length === 1) { + return { + kind: JIRA_TARGET_SELECTION_KIND.SINGLE, + targetId: nonEmptyTargetIds[0], + targetType, + }; + } + + return { + kind: JIRA_TARGET_SELECTION_KIND.TARGET_LIST, + targetIds: nonEmptyTargetIds, + targetType, + }; +}; + +export const createJiraBatchSelection = ( + batches: JiraDispatchTargetBatchInput[], +): JiraBatchSelection | null => { + const normalizedBatches = batches.flatMap((batch) => { + const targetIds = toNonEmptyStringArray(batch.targetIds); + return targetIds ? [{ ...batch, targetIds }] : []; + }); + const [firstBatch, ...remainingBatches] = normalizedBatches; + + return firstBatch + ? { + kind: JIRA_TARGET_SELECTION_KIND.BATCHES, + batches: [firstBatch, ...remainingBatches], + } + : null; +}; + +export const getJiraSelectionBatches = ( + selection: JiraSelection, +): [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]] => { + if (selection.kind === JIRA_TARGET_SELECTION_KIND.BATCHES) { + return selection.batches; + } + + if (selection.kind === JIRA_TARGET_SELECTION_KIND.SINGLE) { + return [ + { + targetIds: [selection.targetId], + targetType: selection.targetType, + }, + ]; + } + + return [ + { + targetIds: selection.targetIds, + targetType: selection.targetType, + }, + ]; +}; diff --git a/ui/lib/jira-dispatch-task.ts b/ui/lib/jira-dispatch-task.ts new file mode 100644 index 0000000000..053455baee --- /dev/null +++ b/ui/lib/jira-dispatch-task.ts @@ -0,0 +1,46 @@ +import type { WatchedTask } from "@/store/task-watcher/store"; +import { + JIRA_DISPATCH_MODE, + type JiraDispatchMode, +} from "@/types/integrations"; + +export interface JiraDispatchTaskMeta { + integrationId: string; + projectKey: string; + issueType: string; + dispatchMode: JiraDispatchMode; +} + +export const buildJiraDispatchTaskMeta = ({ + integrationId, + projectKey, + issueType, + dispatchMode, +}: JiraDispatchTaskMeta): Record => ({ + integrationId, + projectKey, + issueType, + dispatchMode, +}); + +export const parseJiraDispatchTaskMeta = ( + task: WatchedTask, +): JiraDispatchTaskMeta | null => { + const { integrationId, projectKey, issueType, dispatchMode } = task.meta; + if ( + !integrationId || + !projectKey || + !issueType || + (dispatchMode !== JIRA_DISPATCH_MODE.GROUPED && + dispatchMode !== JIRA_DISPATCH_MODE.INDIVIDUAL) + ) { + return null; + } + + return { + integrationId, + projectKey, + issueType, + dispatchMode, + }; +}; diff --git a/ui/store/task-watcher/store.test.ts b/ui/store/task-watcher/store.test.ts index d1fbdb6188..d260e35357 100644 --- a/ui/store/task-watcher/store.test.ts +++ b/ui/store/task-watcher/store.test.ts @@ -49,6 +49,37 @@ describe("task watcher store", () => { expect(onError).not.toHaveBeenCalled(); }); + it("returns and persists a completed task result while allowing the caller to own notifications", async () => { + // Given + pollMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { created_count: 1, failed_count: 1 }, + }); + + // When + const result = await trackAndPollTask<{ + created_count: number; + failed_count: number; + }>({ + taskId: "jira-task", + kind: "test-kind", + meta: {}, + notifyHandler: false, + }); + + // Then + expect(result).toEqual({ + status: TASK_WATCHER_STATUS.READY, + result: { created_count: 1, failed_count: 1 }, + }); + expect(useTaskWatcherStore.getState().tasks["jira-task"]?.result).toEqual({ + created_count: 1, + failed_count: 1, + }); + expect(onReady).not.toHaveBeenCalled(); + }); + it("replaces settled results of the same kind when tracking new work", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed" }); @@ -201,6 +232,39 @@ describe("task watcher store", () => { expect(onReady).toHaveBeenCalledTimes(1); }); + it("passes a persisted task result to its handler after resuming", async () => { + // Given + const taskResult = { + created_count: 1, + failed_count: 1, + failed_finding_ids: ["finding-2"], + }; + pollMock.mockResolvedValue({ + ok: true, + state: "completed", + result: taskResult, + }); + useTaskWatcherStore.setState({ + tasks: { + "resumed-jira-task": { + taskId: "resumed-jira-task", + kind: "test-kind", + status: TASK_WATCHER_STATUS.PENDING, + meta: {}, + startedAt: Date.now(), + }, + }, + }); + + // When + await resumePendingTasks(); + + // Then + expect(onReady).toHaveBeenCalledWith( + expect.objectContaining({ result: taskResult }), + ); + }); + it("discards settled tasks before resuming persisted work", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed" }); diff --git a/ui/store/task-watcher/store.ts b/ui/store/task-watcher/store.ts index b979907456..e95389872e 100644 --- a/ui/store/task-watcher/store.ts +++ b/ui/store/task-watcher/store.ts @@ -28,6 +28,9 @@ export interface WatchedTask { meta: Record; startedAt: number; error?: string; + /** Serializable task result. Persisted so another tab or a reload can + * finish feature-specific handling without polling the task again. */ + result?: unknown; } export interface TaskKindHandler { @@ -35,6 +38,21 @@ export interface TaskKindHandler { onError: (task: WatchedTask) => void; } +export interface TaskTrackingResult { + status: TaskWatcherStatus; + error?: string; + result?: R; +} + +export interface TrackAndPollTaskInput { + taskId: string; + kind: string; + meta: Record; + /** Let the awaiting caller aggregate notifications. If this tab reloads, + * the persisted task resumes with its registered handler as usual. */ + notifyHandler?: boolean; +} + interface TaskWatcherState { tasks: Record; upsertTask: (task: WatchedTask) => void; @@ -42,6 +60,7 @@ interface TaskWatcherState { taskId: string, status: TaskWatcherStatus, error?: string, + result?: unknown, ) => void; dismissTask: (taskId: string) => void; } @@ -69,12 +88,15 @@ export const useTaskWatcherStore = create()( tasks: {}, upsertTask: (task) => set((state) => ({ tasks: { ...state.tasks, [task.taskId]: task } })), - resolveTask: (taskId, status, error) => + resolveTask: (taskId, status, error, result) => set((state) => { const task = state.tasks[taskId]; if (!task) return state; return { - tasks: { ...state.tasks, [taskId]: { ...task, status, error } }, + tasks: { + ...state.tasks, + [taskId]: { ...task, status, error, result }, + }, }; }), dismissTask: (taskId) => @@ -92,25 +114,35 @@ export const useTaskWatcherStore = create()( // In-memory only: poll loops alive in THIS tab. Never persisted, so a reload // naturally re-enters through resumePendingTasks without double-polling. -const activePolls = new Set(); +const activePolls = new Map>>(); +const suppressedHandlers = new Set(); const settleTask = ( taskId: string, status: TaskWatcherStatus, error?: string, -) => { + result?: unknown, +): TaskTrackingResult => { const store = useTaskWatcherStore.getState(); const currentTask = store.tasks[taskId]; if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) { - return; + return { + status: currentTask?.status ?? TASK_WATCHER_STATUS.ERROR, + ...(currentTask?.error ? { error: currentTask.error } : {}), + ...(currentTask?.result !== undefined + ? { result: currentTask.result } + : {}), + }; } - store.resolveTask(taskId, status, error); + store.resolveTask(taskId, status, error, result); const task = useTaskWatcherStore.getState().tasks[taskId]; - if (!task) return; + if (!task) { + return { status: TASK_WATCHER_STATUS.ERROR, error: "Task unavailable." }; + } const handler = handlers.get(task.kind); - if (handler) { + if (handler && !suppressedHandlers.has(taskId)) { if (status === TASK_WATCHER_STATUS.READY) handler.onReady(task); else handler.onError(task); } @@ -120,107 +152,141 @@ const settleTask = ( if (status === TASK_WATCHER_STATUS.ERROR) { store.dismissTask(taskId); } + + return { + status, + ...(error ? { error } : {}), + ...(result !== undefined ? { result } : {}), + }; }; -const runPollLoop = async (taskId: string): Promise => { +const runPollLoop = async ( + taskId: string, +): Promise> => { for (let round = 0; round < MAX_POLL_ROUNDS; round++) { - const result = await pollTaskUntilSettled(taskId); + const result = await pollTaskUntilSettled(taskId); if (result.ok) { if (result.state === "completed") { - settleTask(taskId, TASK_WATCHER_STATUS.READY); + return settleTask( + taskId, + TASK_WATCHER_STATUS.READY, + undefined, + result.result, + ) as TaskTrackingResult; } else { - settleTask( + return settleTask( taskId, TASK_WATCHER_STATUS.ERROR, `Task ended in state "${result.state}".`, - ); + result.result, + ) as TaskTrackingResult; } - return; } // "Task timeout" just means this server round expired while the task // is still running — keep polling. Real errors settle immediately. if (result.error !== "Task timeout") { - settleTask(taskId, TASK_WATCHER_STATUS.ERROR, result.error); - return; + return settleTask( + taskId, + TASK_WATCHER_STATUS.ERROR, + result.error, + result.result, + ) as TaskTrackingResult; } } - settleTask( + return settleTask( taskId, TASK_WATCHER_STATUS.ERROR, "The task is taking too long. Try again later.", - ); + ) as TaskTrackingResult; }; -const pollUntilDone = async (taskId: string): Promise => { - if (activePolls.has(taskId)) return; - activePolls.add(taskId); +const pollUntilDone = (taskId: string): Promise> => { + const existingPoll = activePolls.get(taskId); + if (existingPoll) return existingPoll as Promise>; - try { - const runIfPending = async () => { - // A different tab may have completed the task while this one waited for - // the cross-tab lock. Refresh persisted state before polling or notifying. - await useTaskWatcherStore.persist.rehydrate(); - const task = useTaskWatcherStore.getState().tasks[taskId]; - if (task?.status !== TASK_WATCHER_STATUS.PENDING) return; - await runPollLoop(taskId); - }; + const pollPromise = (async (): Promise> => { + try { + const runIfPending = async (): Promise> => { + // A different tab may have completed the task while this one waited for + // the cross-tab lock. Refresh persisted state before polling or notifying. + await useTaskWatcherStore.persist.rehydrate(); + const task = useTaskWatcherStore.getState().tasks[taskId]; + if (task?.status !== TASK_WATCHER_STATUS.PENDING) { + return { + status: task?.status ?? TASK_WATCHER_STATUS.ERROR, + ...(task?.error ? { error: task.error } : {}), + ...(task?.result !== undefined ? { result: task.result as R } : {}), + }; + } + return runPollLoop(taskId); + }; - if (typeof navigator !== "undefined" && navigator.locks) { - await navigator.locks.request(`task-watcher:${taskId}`, runIfPending); - } else { - await runIfPending(); + if (typeof navigator !== "undefined" && navigator.locks) { + return await navigator.locks.request( + `task-watcher:${taskId}`, + runIfPending, + ); + } + + return await runIfPending(); + } catch { + // A thrown poll (e.g. the server-action RPC failing on a network drop) + // must still settle the task, or it stays PENDING in the persisted + // store and blocks the UI until the staleness ceiling. + return settleTask( + taskId, + TASK_WATCHER_STATUS.ERROR, + "Tracking the task failed unexpectedly. Try again later.", + ) as TaskTrackingResult; + } finally { + activePolls.delete(taskId); } - } catch { - // A thrown poll (e.g. the server-action RPC failing on a network drop) - // must still settle the task, or it stays PENDING in the persisted - // store and blocks the UI until the staleness ceiling. - settleTask( - taskId, - TASK_WATCHER_STATUS.ERROR, - "Tracking the task failed unexpectedly. Try again later.", - ); - } finally { - activePolls.delete(taskId); - } + })(); + + activePolls.set(taskId, pollPromise); + return pollPromise; }; /** Track a freshly dispatched backend task and poll it to completion. The * poll loop lives at module scope (fired from the click handler), so it * survives client-side navigation without any effect subscriptions. */ -export const trackAndPollTask = async ({ +export const trackAndPollTask = async ({ taskId, kind, meta, -}: { - taskId: string; - kind: string; - meta: Record; -}): Promise => { + notifyHandler = true, +}: TrackAndPollTaskInput): Promise> => { + if (!notifyHandler) suppressedHandlers.add(taskId); + const existing = useTaskWatcherStore.getState().tasks[taskId]; - if (existing?.status === TASK_WATCHER_STATUS.PENDING) { - return pollUntilDone(taskId); + try { + if (existing?.status === TASK_WATCHER_STATUS.PENDING) { + return await pollUntilDone(taskId); + } + + const store = useTaskWatcherStore.getState(); + Object.values(store.tasks) + .filter( + (task) => + task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING, + ) + .forEach((task) => store.dismissTask(task.taskId)); + + store.upsertTask({ + taskId, + kind, + status: TASK_WATCHER_STATUS.PENDING, + meta, + startedAt: Date.now(), + }); + + return await pollUntilDone(taskId); + } finally { + suppressedHandlers.delete(taskId); } - - const store = useTaskWatcherStore.getState(); - Object.values(store.tasks) - .filter( - (task) => - task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING, - ) - .forEach((task) => store.dismissTask(task.taskId)); - - store.upsertTask({ - taskId, - kind, - status: TASK_WATCHER_STATUS.PENDING, - meta, - startedAt: Date.now(), - }); - - return pollUntilDone(taskId); }; /** Resume polling every persisted pending task after a hard reload; tasks diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts index 8da89babf8..39c56d269b 100644 --- a/ui/types/cloud-upgrade.ts +++ b/ui/types/cloud-upgrade.ts @@ -5,6 +5,7 @@ export const CLOUD_UPGRADE_FEATURE = { CLI_IMPORT: "cli_import", CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance", FINDING_TRIAGE: "finding_triage", + JIRA_DISPATCH: "jira_dispatch", LIGHTHOUSE_AI: "lighthouse_ai", GENERAL: "general", SCAN_CONFIGURATION: "scan_configuration", diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index b84869af1c..77ee623ad3 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -4,6 +4,63 @@ import type { TaskState } from "@/types/tasks"; export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira"; +export const JIRA_DISPATCH_MODE = { + INDIVIDUAL: "individual", + GROUPED: "grouped", +} as const; + +export type JiraDispatchMode = + (typeof JIRA_DISPATCH_MODE)[keyof typeof JIRA_DISPATCH_MODE]; + +export const JIRA_DISPATCH_TARGET = { + CHECK_ID: "check_id", + FINDING_ID: "finding_id", +} as const; + +export type JiraDispatchTarget = + (typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET]; + +export const JIRA_TARGET_SELECTION_KIND = { + SINGLE: "single", + TARGET_LIST: "target-list", + BATCHES: "batches", +} as const; + +export type JiraTargetSelectionKind = + (typeof JIRA_TARGET_SELECTION_KIND)[keyof typeof JIRA_TARGET_SELECTION_KIND]; + +export type NonEmptyStringArray = [string, ...string[]]; + +export interface JiraDispatchTargetBatch { + targetIds: NonEmptyStringArray; + targetType: JiraDispatchTarget; + dispatchMode?: JiraDispatchMode; +} + +export interface JiraSingleTargetSelection { + kind: typeof JIRA_TARGET_SELECTION_KIND.SINGLE; + targetId: string; + targetType: JiraDispatchTarget; +} + +export interface JiraTargetListSelection { + kind: typeof JIRA_TARGET_SELECTION_KIND.TARGET_LIST; + targetIds: NonEmptyStringArray; + targetType: JiraDispatchTarget; +} + +export interface JiraBatchSelection { + kind: typeof JIRA_TARGET_SELECTION_KIND.BATCHES; + batches: [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]]; +} + +export type JiraSelection = + | JiraSingleTargetSelection + | JiraTargetListSelection + | JiraBatchSelection; + +export const JIRA_DISPATCH_TASK_KIND = "jira-dispatch"; + export interface IntegrationProps { type: "integrations"; id: string; @@ -45,6 +102,7 @@ export interface JiraDispatchRequest { attributes: { project_key: string; issue_type: string; + dispatch_mode?: JiraDispatchMode; }; }; } @@ -58,21 +116,30 @@ export interface JiraDispatchResponse { completed_at: string | null; name: string; state: TaskState; - result: { - success?: boolean; - error?: string; - message?: string; - issue_url?: string; - issue_key?: string; - created_count?: number; - failed_count?: number; - } | null; + result: JiraDispatchTaskResult | null; task_args: Record | null; metadata: Record | null; }; }; } +export interface JiraDispatchTaskResult { + success?: boolean; + error?: string; + message?: string; + successful_count?: number; + created_count?: number; + updated_count?: number; + failed_count?: number; + created_issues?: unknown[]; + updated_issues?: unknown[]; + failed_groups?: unknown[]; + failed_batches?: unknown[]; + failed_finding_ids?: string[]; + issue_url?: string; + issue_key?: string; +} + // Shared AWS credential fields schema const awsCredentialFields = { credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]),