From 757b0050785b94e596f4c6565c18e8bc59f943db Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Wed, 22 Jul 2026 14:27:50 +0200 Subject: [PATCH] refactor(ui): centralize Jira dispatch flow --- ...sx => floating-selection-actions.test.tsx} | 123 +- ...ton.tsx => floating-selection-actions.tsx} | 69 +- .../jira-dispatch-action-item.test.tsx | 105 ++ .../findings/jira-dispatch-action-item.tsx | 55 + .../jira-dispatch-modal-host.test.tsx | 67 + .../findings/jira-dispatch-modal-host.tsx | 33 + .../jira-dispatch-task-handler.test.tsx | 1 + .../findings/jira-dispatch-task-handler.tsx | 62 +- .../findings/send-to-jira-modal.test.tsx | 657 +--------- ui/components/findings/send-to-jira-modal.tsx | 197 +-- .../table/column-finding-resources.test.tsx | 147 +-- .../table/column-finding-resources.tsx | 50 +- .../table/data-table-row-actions.test.tsx | 328 +---- .../findings/table/data-table-row-actions.tsx | 78 +- .../table/findings-group-drill-down.test.ts | 26 - .../table/findings-group-drill-down.tsx | 51 +- .../table/findings-group-table.test.tsx | 1140 +++-------------- .../findings/table/findings-group-table.tsx | 74 +- .../resource-detail-drawer-content.test.tsx | 6 +- .../resource-detail-drawer-content.tsx | 50 +- .../layout/main-layout/main-layout.test.tsx | 5 + .../layout/main-layout/main-layout.tsx | 2 + .../table/resource-detail-content.tsx | 4 +- ui/lib/jira-dispatch-action.test.ts | 127 ++ ui/lib/jira-dispatch-action.ts | 74 ++ ui/lib/jira-dispatch-execution.test.ts | 141 ++ ui/lib/jira-dispatch-execution.ts | 187 +++ ui/store/index.ts | 1 + ui/store/jira-dispatch/store.ts | 16 + ui/types/jira-dispatch.ts | 9 + 30 files changed, 1241 insertions(+), 2644 deletions(-) rename ui/components/findings/{floating-mute-button.test.tsx => floating-selection-actions.test.tsx} (60%) rename ui/components/findings/{floating-mute-button.tsx => floating-selection-actions.tsx} (74%) create mode 100644 ui/components/findings/jira-dispatch-action-item.test.tsx create mode 100644 ui/components/findings/jira-dispatch-action-item.tsx create mode 100644 ui/components/findings/jira-dispatch-modal-host.test.tsx create mode 100644 ui/components/findings/jira-dispatch-modal-host.tsx delete mode 100644 ui/components/findings/table/findings-group-drill-down.test.ts create mode 100644 ui/lib/jira-dispatch-action.test.ts create mode 100644 ui/lib/jira-dispatch-action.ts create mode 100644 ui/lib/jira-dispatch-execution.test.ts create mode 100644 ui/lib/jira-dispatch-execution.ts create mode 100644 ui/store/jira-dispatch/store.ts create mode 100644 ui/types/jira-dispatch.ts diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-selection-actions.test.tsx similarity index 60% rename from ui/components/findings/floating-mute-button.test.tsx rename to ui/components/findings/floating-selection-actions.test.tsx index 10063d14ee..31439af85d 100644 --- a/ui/components/findings/floating-mute-button.test.tsx +++ b/ui/components/findings/floating-selection-actions.test.tsx @@ -1,10 +1,7 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render, screen, waitFor } 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 // --------------------------------------------------------------------------- @@ -25,7 +22,7 @@ vi.mock("next/navigation", () => ({ // Import after mocks // --------------------------------------------------------------------------- -import { FloatingMuteButton } from "./floating-mute-button"; +import { FloatingSelectionActions } from "./floating-selection-actions"; function deferredPromise() { let resolve!: (value: T) => void; @@ -42,10 +39,9 @@ function deferredPromise() { // Fix 3: onBeforeOpen rejection resets isResolving // --------------------------------------------------------------------------- -describe("FloatingMuteButton — onBeforeOpen error handling", () => { +describe("FloatingSelectionActions — onBeforeOpen error handling", () => { beforeEach(() => { vi.clearAllMocks(); - useCloudUpgradeStore.getState().closeCloudUpgrade(); }); it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => { @@ -54,7 +50,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { const user = userEvent.setup(); render( - { const user = userEvent.setup(); render( - { const user = userEvent.setup(); render( - { const user = userEvent.setup(); render( - { }); }); }); - - 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-selection-actions.tsx similarity index 74% rename from ui/components/findings/floating-mute-button.tsx rename to ui/components/findings/floating-selection-actions.tsx index c9eee25626..4b854e89c8 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-selection-actions.tsx @@ -4,42 +4,44 @@ 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 type { JiraDispatchModalPayload } from "@/types/jira-dispatch"; +import { JiraDispatchActionItem } from "./jira-dispatch-action-item"; import { MuteFindingsModal } from "./mute-findings-modal"; -interface FloatingMuteButtonProps { +interface FloatingSelectionActionsBaseProps { selectedCount: number; selectedFindingIds: string[]; onComplete?: () => void; - /** Async resolver that returns actual finding UUIDs before opening modal */ + /** Async resolver that returns actual finding UUIDs before opening modal. */ onBeforeOpen?: () => Promise; - /** When true, the toast warns that processing may take a few minutes */ + /** When true, the toast warns that processing may take a few minutes. */ isBulkOperation?: boolean; - /** Custom button label. Defaults to "Mute ({selectedCount})" */ + /** Custom button label. Defaults to "{selectedCount} selected". */ 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({ +type FloatingSelectionActionsProps = FloatingSelectionActionsBaseProps & + ( + | { + jiraPayload: JiraDispatchModalPayload; + jiraLabel: string; + } + | { + jiraPayload?: never; + jiraLabel?: never; + } + ); + +export function FloatingSelectionActions({ selectedCount, selectedFindingIds, onComplete, @@ -47,11 +49,9 @@ export function FloatingMuteButton({ isBulkOperation = false, label, muteLabel = "Mute", - onSendToJira, - canSendToJira = false, - showSendToJira = canSendToJira, - sendToJiraLabel = "Send to Jira", -}: FloatingMuteButtonProps) { + jiraPayload, + jiraLabel, +}: FloatingSelectionActionsProps) { const [isModalOpen, setIsModalOpen] = useState(false); const [resolvedIds, setResolvedIds] = useState([]); const [isResolving, setIsResolving] = useState(false); @@ -59,9 +59,6 @@ export function FloatingMuteButton({ const [mutePreparationError, setMutePreparationError] = useState< string | null >(null); - const openCloudUpgrade = useCloudUpgradeStore( - (state) => state.openCloudUpgrade, - ); const handleModalOpenChange = ( nextOpen: boolean | ((previousOpen: boolean) => boolean), @@ -105,15 +102,6 @@ export function FloatingMuteButton({ } }; - const handleJiraClick = () => { - if (!canSendToJira) { - openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH); - return; - } - - onSendToJira?.(); - }; - const handleComplete = () => { setResolvedIds([]); onComplete?.(); @@ -140,7 +128,7 @@ export function FloatingMuteButton({ ? createPortal(
- {showSendToJira ? ( + {jiraPayload ? ( void handleMuteClick()} /> - } - label={sendToJiraLabel} - tooltip={ - !canSendToJira ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined - } - aria-label={sendToJiraLabel} - onSelect={handleJiraClick} + ) : ( diff --git a/ui/components/findings/jira-dispatch-action-item.test.tsx b/ui/components/findings/jira-dispatch-action-item.test.tsx new file mode 100644 index 0000000000..b70d4af73a --- /dev/null +++ b/ui/components/findings/jira-dispatch-action-item.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; +import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { + JIRA_DISPATCH_TARGET, + type JiraDispatchTarget, +} from "@/types/integrations"; + +const { isGroupedJiraDispatchEnabledMock } = vi.hoisted(() => ({ + isGroupedJiraDispatchEnabledMock: vi.fn(() => false), +})); + +vi.mock("@/lib/deployment", async (importOriginal) => ({ + ...(await importOriginal()), + isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, +})); + +import { JiraDispatchActionItem } from "./jira-dispatch-action-item"; + +const renderAction = (targetIds: string[], targetType: JiraDispatchTarget) => { + const selection = createJiraTargetSelection(targetIds, targetType)!; + + render( + Actions}> + + + , + ); +}; + +describe("JiraDispatchActionItem", () => { + beforeEach(() => { + isGroupedJiraDispatchEnabledMock.mockReturnValue(false); + useCloudUpgradeStore.getState().closeCloudUpgrade(); + useJiraDispatchStore.getState().closeJiraDispatch(); + }); + + it("opens Jira modal payload for one Finding", async () => { + // Given + const user = userEvent.setup(); + renderAction(["finding-1"], JIRA_DISPATCH_TARGET.FINDING_ID); + + // When + await user.click(screen.getByRole("button", { name: "Actions" })); + await user.click(screen.getByRole("menuitem", { name: "Send to Jira" })); + + // Then + expect(useJiraDispatchStore.getState().activePayload).toMatchObject({ + selection: { targetId: "finding-1" }, + }); + expect(useCloudUpgradeStore.getState().activeFeature).toBeNull(); + }); + + it("shows Cloud tooltip and opens upgrade for grouped dispatch", async () => { + // Given + const user = userEvent.setup(); + renderAction(["check-1"], JIRA_DISPATCH_TARGET.CHECK_ID); + + // When + await user.click(screen.getByRole("button", { name: "Actions" })); + const jiraAction = screen.getByRole("menuitem", { name: "Send to Jira" }); + expect( + within(jiraAction).queryByText("Available only in Prowler Cloud"), + ).not.toBeInTheDocument(); + 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(useJiraDispatchStore.getState().activePayload).toBeNull(); + }); + + it("opens grouped Jira payload when feature is enabled", async () => { + // Given + isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + const user = userEvent.setup(); + renderAction(["check-1"], JIRA_DISPATCH_TARGET.CHECK_ID); + + // When + await user.click(screen.getByRole("button", { name: "Actions" })); + await user.click(screen.getByRole("menuitem", { name: "Send to Jira" })); + + // Then + expect(useJiraDispatchStore.getState().activePayload).toMatchObject({ + selection: { targetId: "check-1" }, + }); + }); +}); diff --git a/ui/components/findings/jira-dispatch-action-item.tsx b/ui/components/findings/jira-dispatch-action-item.tsx new file mode 100644 index 0000000000..025482a401 --- /dev/null +++ b/ui/components/findings/jira-dispatch-action-item.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { JiraIcon } from "@/components/icons/services/IconServices"; +import { ActionDropdownItem } from "@/components/shadcn/dropdown"; +import { + isGroupedJiraDispatchEnabled, + PROWLER_CLOUD_ONLY_TOOLTIP, +} from "@/lib/deployment"; +import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action"; +import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import type { JiraDispatchModalPayload } from "@/types/jira-dispatch"; + +interface JiraDispatchActionItemProps { + label: string; + payload: JiraDispatchModalPayload | null | undefined; +} + +export const JiraDispatchActionItem = ({ + label, + payload, +}: JiraDispatchActionItemProps) => { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + const openJiraDispatch = useJiraDispatchStore( + (state) => state.openJiraDispatch, + ); + + if (!payload) return null; + + const { requiresUpgrade } = getJiraDispatchActionState( + payload, + isGroupedJiraDispatchEnabled(), + ); + + const handleSelect = () => { + if (requiresUpgrade) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH); + return; + } + + openJiraDispatch(payload); + }; + + return ( + } + label={label} + aria-label={label} + tooltip={requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined} + onSelect={handleSelect} + /> + ); +}; diff --git a/ui/components/findings/jira-dispatch-modal-host.test.tsx b/ui/components/findings/jira-dispatch-modal-host.test.tsx new file mode 100644 index 0000000000..f1460e1ebe --- /dev/null +++ b/ui/components/findings/jira-dispatch-modal-host.test.tsx @@ -0,0 +1,67 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; +import { useJiraDispatchStore } from "@/store"; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; + +const { SendToJiraModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted( + () => ({ + SendToJiraModalMock: vi.fn(() => null), + isGroupedJiraDispatchEnabledMock: vi.fn(() => true), + }), +); + +vi.mock("./send-to-jira-modal", () => ({ + SendToJiraModal: SendToJiraModalMock, +})); + +vi.mock("@/lib/deployment", async (importOriginal) => ({ + ...(await importOriginal()), + isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, +})); + +import { JiraDispatchModalHost } from "./jira-dispatch-modal-host"; + +describe("JiraDispatchModalHost", () => { + beforeEach(() => { + vi.clearAllMocks(); + useJiraDispatchStore.getState().closeJiraDispatch(); + }); + + it("renders one modal with derived grouped configuration", () => { + // Given + const selection = createJiraTargetSelection( + ["check-1"], + JIRA_DISPATCH_TARGET.CHECK_ID, + )!; + useJiraDispatchStore.getState().openJiraDispatch({ + selection, + selectedResourceCount: 3, + findingTitle: "Check title", + }); + + // When + render(); + + // Then + expect(SendToJiraModalMock).toHaveBeenCalledWith( + expect.objectContaining({ + isOpen: true, + selection, + findingTitle: "Check title", + defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED, + canChooseGroupedDispatch: true, + }), + undefined, + ); + }); + + it("does not render without an active payload", () => { + // Given / When + render(); + + // Then + expect(SendToJiraModalMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/findings/jira-dispatch-modal-host.tsx b/ui/components/findings/jira-dispatch-modal-host.tsx new file mode 100644 index 0000000000..15d47c9fef --- /dev/null +++ b/ui/components/findings/jira-dispatch-modal-host.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; +import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action"; +import { useJiraDispatchStore } from "@/store"; + +import { SendToJiraModal } from "./send-to-jira-modal"; + +export const JiraDispatchModalHost = () => { + const activePayload = useJiraDispatchStore((state) => state.activePayload); + const closeJiraDispatch = useJiraDispatchStore( + (state) => state.closeJiraDispatch, + ); + + if (!activePayload) return null; + + const { defaultDispatchMode, canChooseGroupedDispatch } = + getJiraDispatchActionState(activePayload, isGroupedJiraDispatchEnabled()); + + return ( + !open && closeJiraDispatch()} + selection={activePayload.selection} + findingTitle={activePayload.findingTitle} + defaultDispatchMode={defaultDispatchMode} + canChooseGroupedDispatch={canChooseGroupedDispatch} + isFindingGroupSelection={activePayload.isFindingGroupSelection} + selectedResourceCount={activePayload.selectedResourceCount} + description={activePayload.description} + /> + ); +}; diff --git a/ui/components/findings/jira-dispatch-task-handler.test.tsx b/ui/components/findings/jira-dispatch-task-handler.test.tsx index 90b5ad2884..e77bc675df 100644 --- a/ui/components/findings/jira-dispatch-task-handler.test.tsx +++ b/ui/components/findings/jira-dispatch-task-handler.test.tsx @@ -103,6 +103,7 @@ describe("jiraDispatchTaskHandler", () => { ...task.meta, dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, }, + notifyHandler: true, }); }); diff --git a/ui/components/findings/jira-dispatch-task-handler.tsx b/ui/components/findings/jira-dispatch-task-handler.tsx index 4b42336952..159d268507 100644 --- a/ui/components/findings/jira-dispatch-task-handler.tsx +++ b/ui/components/findings/jira-dispatch-task-handler.tsx @@ -1,21 +1,15 @@ "use client"; -import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch"; import { toast, ToastAction } from "@/components/shadcn/toast"; +import { + executeJiraDispatchBatches, + getJiraRetryBatch, +} from "@/lib/jira-dispatch-execution"; 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 { parseJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task"; +import type { TaskKindHandler, WatchedTask } from "@/store/task-watcher/store"; import { JIRA_DISPATCH_MODE, - JIRA_DISPATCH_TARGET, - JIRA_DISPATCH_TASK_KIND, type JiraDispatchTaskResult, } from "@/types/integrations"; @@ -33,38 +27,32 @@ const retryFailedFindings = async ( return; } + const retryBatch = getJiraRetryBatch(failedFindingIds); + if (!retryBatch) 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, + const result = await executeJiraDispatchBatches( + [retryBatch], + { + integrationId: meta.integrationId, + projectKey: meta.projectKey, + issueType: meta.issueType, dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, - }), - }); + }, + { notifyHandler: true }, + ); + if (result.startedTaskCount === 0 && result.errors.length > 0) { + toast({ + variant: "destructive", + title: "Jira retry failed", + description: result.errors.join(" "), + }); + } } catch { toast({ variant: "destructive", diff --git a/ui/components/findings/send-to-jira-modal.test.tsx b/ui/components/findings/send-to-jira-modal.test.tsx index ccad2fee6e..feae726e8e 100644 --- a/ui/components/findings/send-to-jira-modal.test.tsx +++ b/ui/components/findings/send-to-jira-modal.test.tsx @@ -1,66 +1,40 @@ 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 { createJiraBatchSelection } from "@/lib/jira-dispatch-selection"; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } 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 { + executeJiraDispatchBatchesMock, getJiraIntegrationsMock, getJiraIssueTypesMock, - sendFindingToJiraMock, - sendJiraDispatchMock, - trackAndPollTaskMock, toastMock, } = vi.hoisted(() => ({ + executeJiraDispatchBatchesMock: vi.fn(), 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("@/lib/jira-dispatch-execution", () => ({ + executeJiraDispatchBatches: executeJiraDispatchBatchesMock, })); vi.mock("@/components/shadcn/toast", () => ({ toast: toastMock, - ToastAction: ({ children, ...props }: ComponentProps<"button">) => ( - + ToastAction: ({ children }: { children: React.ReactNode }) => ( + ), })); -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, @@ -83,6 +57,18 @@ vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({ ), })); +const selection = createJiraBatchSelection([ + { + targetIds: ["check-a"], + targetType: JIRA_DISPATCH_TARGET.CHECK_ID, + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, + }, + { + targetIds: ["finding-1", "finding-2"], + targetType: JIRA_DISPATCH_TARGET.FINDING_ID, + }, +])!; + describe("SendToJiraModal", () => { beforeEach(() => { vi.clearAllMocks(); @@ -110,128 +96,52 @@ describe("SendToJiraModal", () => { ], }); 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 }, + executeJiraDispatchBatchesMock.mockResolvedValue({ + startedTaskCount: 2, + successfulTaskCount: 2, + successfulIssueCount: 3, + successMessage: "3 Jira issues were created or updated successfully.", + warnings: [], + errors: [], }); }); - it("shows the grouped-vs-separate choice for a target batch with multiple Findings before dispatching", async () => { + it("renders the dispatch choice and custom mixed-selection description", async () => { + // Given / When render( , ); + // Then 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 separate Jira issues")).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 () => { + it("delegates mixed dispatch execution with the selected settings", 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" }), @@ -246,182 +156,10 @@ describe("SendToJiraModal", () => { // 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( - { targetIds: ["finding-1", "finding-2"], targetType: "finding_id", }, - ])} - defaultDispatchMode="grouped" - selectedResourceCount={1} - />, - ); - await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled()); - await user.click( - screen.getByRole("button", { name: "Select a Jira project" }), - ); - await user.click( - screen.getByRole("button", { name: "Select an issue type" }), - ); - - // When - await user.click(screen.getByRole("button", { name: "Send to Jira" })); - - // Then - await waitFor(() => - expect(toastMock).toHaveBeenCalledWith({ - 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.", - }), + ], + { + integrationId: "jira-1", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "individual", + }, ), ); + expect(onOpenChange).toHaveBeenCalledWith(false); }); }); diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index 8cd3b1e1ad..5b935b8745 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -9,8 +9,6 @@ import { z } from "zod"; import { getJiraIntegrations, getJiraIssueTypes, - sendFindingToJira, - sendJiraDispatch, } from "@/actions/integrations/jira-dispatch"; import { CustomBanner } from "@/components/shadcn/custom/custom-banner"; import { CustomRadio } from "@/components/shadcn/custom/custom-radio"; @@ -23,24 +21,16 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { toast, ToastAction } from "@/components/shadcn/toast"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { - evaluateJiraDispatchTask, - getJiraDispatchSuccessCount, -} from "@/lib/jira-dispatch-result"; + executeJiraDispatchBatches, + type JiraDispatchSettings, +} from "@/lib/jira-dispatch-execution"; 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"; @@ -61,27 +51,6 @@ export interface SendToJiraModalProps { 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"), @@ -108,20 +77,6 @@ const getConfiguredIssueTypes = ( : []; }; -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, selection, @@ -281,136 +236,8 @@ const SendToJiraModalContent = ({ 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 result = await executeJiraDispatchBatches(batches, settings); + const retryBatches = result.retryBatch ? [result.retryBatch] : []; const retryAction = retryBatches.length > 0 ? ( ) : undefined; - if (errors.length > 0 || warnings.length > 0) { - if (successfulOutcomes.length > 0) { + if (result.errors.length > 0 || result.warnings.length > 0) { + if (result.successfulTaskCount > 0) { toast({ title: "Partial success", - description: `${successMessage || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${[ - ...warnings, - ...errors, + description: `${result.successMessage || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${[ + ...result.warnings, + ...result.errors, ].join(" ")}`, ...(retryAction ? { action: retryAction } : {}), }); @@ -443,7 +270,7 @@ const SendToJiraModalContent = ({ toast({ variant: "destructive", title: "Error", - description: [...warnings, ...errors].join(" "), + description: [...result.warnings, ...result.errors].join(" "), ...(retryAction ? { action: retryAction } : {}), }); return; @@ -451,7 +278,7 @@ const SendToJiraModalContent = ({ toast({ title: "Success!", - description: successMessage || "Finding sent to Jira successfully", + description: result.successMessage || "Finding sent to Jira successfully", }); } diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index e66d04ae66..ce33c2a867 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -7,23 +7,9 @@ import type { } from "react"; 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), - }), -); +const { isGroupedJiraDispatchEnabledMock } = vi.hoisted(() => ({ + 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", () => ({ @@ -58,10 +44,6 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({ MuteFindingsModal: () => null, })); -vi.mock("@/components/findings/send-to-jira-modal", () => ({ - SendToJiraModal: SendToJiraModalMock, -})); - vi.mock("@/components/icons/services/IconServices", () => ({ JiraIcon: () => null, })); @@ -196,6 +178,7 @@ vi.mock("./notification-indicator", () => ({ }, })); +import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; import type { FindingResourceRow } from "@/types"; import { FINDING_TRIAGE_DISABLED_REASON, @@ -208,7 +191,6 @@ import { CLOUD_ONLY_TOOLTIP_COPY, EDITING_UNAVAILABLE_COPY, } from "./finding-triage-cells"; -import { FindingsSelectionContext } from "./findings-selection-context"; function makeTriageSummary( overrides?: Partial, @@ -300,6 +282,7 @@ describe("column-finding-resources", () => { beforeEach(() => { vi.clearAllMocks(); isGroupedJiraDispatchEnabledMock.mockReturnValue(true); + useJiraDispatchStore.getState().closeJiraDispatch(); }); it("should render actions as the last visible column after Triage without Notes", () => { @@ -492,7 +475,7 @@ describe("column-finding-resources", () => { expect(screen.getByText(CLOUD_ONLY_TOOLTIP_COPY)).toBeInTheDocument(); }); - it("should open Send to Jira modal with finding UUID directly", async () => { + it("should open Jira dispatch with the finding UUID directly", async () => { // Given const user = userEvent.setup(); @@ -525,119 +508,15 @@ describe("column-finding-resources", () => { ); // When - await user.click(screen.getByRole("button", { name: "Send to Jira" })); + await user.click( + screen.getByRole("button", { name: "Send 1 Finding to Jira" }), + ); // Then - expect(screen.getByTestId("jira-modal")).toHaveAttribute( - "data-finding-id", - "real-finding-uuid", - ); - expect(screen.getByTestId("jira-modal")).toHaveAttribute( - "data-open", - "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, + expect(useJiraDispatchStore.getState().activePayload?.selection).toEqual({ + kind: "single", + targetId: "real-finding-uuid", + targetType: "finding_id", }); - 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 e83c01e8ef..061a31d708 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -4,9 +4,8 @@ import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table"; import { CornerDownRight, VolumeOff, VolumeX } from "lucide-react"; import { useContext, useState } from "react"; +import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; -import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; -import { JiraIcon } from "@/components/icons/services/IconServices"; import { Checkbox } from "@/components/shadcn"; import { ActionDropdown, @@ -19,17 +18,14 @@ import { Spinner } from "@/components/shadcn/spinner/spinner"; import { SeverityBadge } from "@/components/shadcn/table"; import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header"; import { getFailingForLabel } from "@/lib/date-utils"; -import { - isGroupedJiraDispatchEnabled, - PROWLER_CLOUD_ONLY_TOOLTIP, -} from "@/lib/deployment"; +import { buildJiraActionLabel } from "@/lib/jira-dispatch-action"; 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 { JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { canMuteFindingResource } from "./finding-resource-selection"; import { @@ -59,7 +55,6 @@ const ResourceRowActions = ({ const resource = row.original; const canMute = canMuteFindingResource(resource); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); - const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const [resolvedIds, setResolvedIds] = useState([]); const [isResolving, setIsResolving] = useState(false); @@ -71,7 +66,6 @@ const ResourceRowActions = ({ const isCurrentSelected = selectedFindingIds.includes(resource.findingId); const hasMultipleSelected = selectedFindingIds.length > 1; - const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled(); const getDisplayIds = (): string[] => { if (isCurrentSelected && hasMultipleSelected) { @@ -87,11 +81,18 @@ const ResourceRowActions = ({ return "Mute"; }; const displayIds = getDisplayIds(); - const canSendToJira = displayIds.length === 1 || groupedJiraDispatchEnabled; const jiraSelection = createJiraTargetSelection( displayIds, JIRA_DISPATCH_TARGET.FINDING_ID, ); + const jiraPayload = jiraSelection + ? { + selection: jiraSelection, + findingTitle: findingTitle || resource.checkId, + selectedResourceCount: displayIds.length, + isFindingGroupSelection: true, + } + : undefined; const handleMuteClick = async () => { const displayIds = getDisplayIds(); @@ -132,22 +133,6 @@ const ResourceRowActions = ({ onComplete={handleMuteComplete} /> )} - {jiraSelection && ( - 1 - ? JIRA_DISPATCH_MODE.GROUPED - : JIRA_DISPATCH_MODE.INDIVIDUAL - } - canChooseGroupedDispatch={ - displayIds.length > 1 && groupedJiraDispatchEnabled - } - /> - )}
e.stopPropagation()} @@ -178,14 +163,11 @@ const ResourceRowActions = ({ disabled={!canMute || isResolving} onSelect={handleMuteClick} /> - } - label="Send to Jira" - disabled={!canSendToJira} - disabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP} - onSelect={() => { - if (canSendToJira) setIsJiraModalOpen(true); - }} +
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 ad5d416406..d4ac28b42c 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,7 @@ import { render, screen, waitFor } 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"; +import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_STATUS, @@ -16,15 +15,8 @@ import { } 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), +const { MuteFindingsModalMock } = vi.hoisted(() => ({ + MuteFindingsModalMock: vi.fn((_props: unknown) => null), })); vi.mock("next/navigation", () => ({ @@ -35,19 +27,15 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({ MuteFindingsModal: MuteFindingsModalMock, })); -vi.mock("@/components/findings/send-to-jira-modal", () => ({ - SendToJiraModal: SendToJiraModalMock, +vi.mock("@/components/icons/services/IconServices", () => ({ + JiraIcon: () => null, })); vi.mock("@/lib/deployment", () => ({ - isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, + isGroupedJiraDispatchEnabled: () => true, PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); -vi.mock("@/components/icons/services/IconServices", () => ({ - JiraIcon: () => null, -})); - vi.mock("@/components/shadcn/dropdown", () => ({ ActionDropdown: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -56,20 +44,12 @@ vi.mock("@/components/shadcn/dropdown", () => ({ label, onSelect, disabled, - disabledTooltip, - tooltip, }: { label: string; onSelect?: () => void; disabled?: boolean; - disabledTooltip?: string; - tooltip?: string; }) => ( - ), @@ -156,8 +136,7 @@ function makeFindingRow(overrides?: Partial) { describe("DataTableRowActions", () => { beforeEach(() => { vi.clearAllMocks(); - isGroupedJiraDispatchEnabledMock.mockReturnValue(true); - useCloudUpgradeStore.getState().closeCloudUpgrade(); + useJiraDispatchStore.getState().closeJiraDispatch(); }); it("opens the mute modal immediately in preparing state for finding groups", async () => { @@ -200,41 +179,18 @@ describe("DataTableRowActions", () => { ); // Then - const preparingCall = ( - MuteFindingsModalMock.mock.calls as unknown as Array< - [ - { - isOpen: boolean; - isPreparing?: boolean; - findingIds: string[]; - }, - ] - > - ).at(-1); - - expect(preparingCall?.[0]).toMatchObject({ + expect(MuteFindingsModalMock.mock.calls.at(-1)?.[0]).toMatchObject({ isOpen: true, isPreparing: true, findingIds: [], }); - // And when the resolver finishes + // When deferred.resolve(["finding-1", "finding-2"]); + // Then await waitFor(() => { - const resolvedCall = ( - MuteFindingsModalMock.mock.calls as unknown as Array< - [ - { - isOpen: boolean; - isPreparing?: boolean; - findingIds: string[]; - }, - ] - > - ).at(-1); - - expect(resolvedCall?.[0]).toMatchObject({ + expect(MuteFindingsModalMock.mock.calls.at(-1)?.[0]).toMatchObject({ isOpen: true, isPreparing: false, findingIds: ["finding-1", "finding-2"], @@ -243,6 +199,7 @@ describe("DataTableRowActions", () => { }); it("disables the mute action for groups without impacted resources", () => { + // Given / When render( { , ); + // Then expect( screen.getByRole("button", { name: "Mute Finding Group" }), ).toBeDisabled(); }); - it("allows choosing Jira dispatch mode for a group with multiple failing resources", async () => { + it("opens Jira from the row action for a finding group", async () => { // Given const user = userEvent.setup(); render( @@ -309,257 +267,19 @@ describe("DataTableRowActions", () => { // When await user.click( - screen.getByRole("button", { name: "Send Finding Group to Jira" }), + screen.getByRole("button", { name: "Send 1 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", + expect(useJiraDispatchStore.getState().activePayload).toEqual({ + selection: { + kind: "single", + targetId: "s3_bucket_public_access", + targetType: "check_id", + }, + findingTitle: "S3 bucket public access", + selectedResourceCount: 2, }); - 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", () => { diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 04f82c0d73..f429fb57eb 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -5,28 +5,22 @@ import { VolumeOff, VolumeX } from "lucide-react"; import { useRouter } from "next/navigation"; import { useContext, useState } from "react"; +import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; -import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; -import { JiraIcon } from "@/components/icons/services/IconServices"; import { ActionDropdown, 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 { buildJiraActionLabel } from "@/lib/jira-dispatch-action"; 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 { JIRA_DISPATCH_TARGET } from "@/types/integrations"; import type { ProviderType } from "@/types/providers"; import { canMuteFindingGroup } from "./finding-group-selection"; @@ -117,15 +111,11 @@ export function DataTableRowActions({ }: DataTableRowActionsProps) { const router = useRouter(); const finding = row.original; - const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false); const [mutePreparationError, setMutePreparationError] = useState< string | null >(null); - const openCloudUpgrade = useCloudUpgradeStore( - (state) => state.openCloudUpgrade, - ); const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding); const resolvedFindingContext = findingContext ?? { @@ -160,7 +150,6 @@ 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) { @@ -185,14 +174,6 @@ export function DataTableRowActions({ 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 @@ -201,21 +182,20 @@ export function DataTableRowActions({ 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 jiraPayload = jiraSelection + ? { + selection: jiraSelection, + findingTitle, + selectedResourceCount: selectedJiraResourceCount, + } + : undefined; + const jiraLabel = buildJiraActionLabel({ + findingGroupCount: isGroup ? jiraTargetIds.length : 0, + findingCount: isGroup ? 0 : jiraTargetIds.length, + }); const handleMuteModalOpenChange = ( nextOpen: boolean | ((previousOpen: boolean) => boolean), @@ -277,29 +257,8 @@ export function DataTableRowActions({ router.refresh(); }; - const handleJiraClick = () => { - if (requiresJiraUpgrade) { - openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH); - return; - } - - setIsJiraModalOpen(true); - }; - return ( <> - {(!isGroup || groupedJiraDispatchEnabled) && jiraSelection && ( - - )} - ({ disabled={!canMute || isResolving} onSelect={handleMuteClick} /> - } - 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 deleted file mode 100644 index 2c62de45d8..0000000000 --- a/ui/components/findings/table/findings-group-drill-down.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, it } from "vitest"; - -describe("findings group drill down", () => { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const filePath = path.join(currentDir, "findings-group-drill-down.tsx"); - const source = readFileSync(filePath, "utf8"); - - it("uses the shared finding-group resource state hook", () => { - 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 9f7aef3df9..a79f1d7f12 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -7,13 +7,11 @@ 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, @@ -27,17 +25,17 @@ 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 { buildJiraActionLabel } from "@/lib/jira-dispatch-action"; import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { FindingGroupRow } from "@/types"; -import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; +import { JIRA_DISPATCH_TARGET } from "@/types/integrations"; -import { FloatingMuteButton } from "../floating-mute-button"; +import { FloatingSelectionActions } from "../floating-selection-actions"; import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; @@ -56,8 +54,6 @@ 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()); @@ -120,12 +116,18 @@ 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, ); + const jiraPayload = jiraSelection + ? { + selection: jiraSelection, + findingTitle: group.checkTitle, + selectedResourceCount: selectedFindingIds.length, + isFindingGroupSelection: true, + } + : undefined; return (
- {selectedFindingIds.length > 0 && ( - 0 && jiraPayload && ( + 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 - } + jiraPayload={jiraPayload} + jiraLabel={buildJiraActionLabel({ + findingCount: selectedFindingIds.length, + })} /> )} diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 305bed902b..9eeecb2921 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -1,23 +1,22 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Fragment, type ReactNode, useState } from "react"; +import { Fragment, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; +import type { JiraDispatchModalPayload } from "@/types/jira-dispatch"; import { FindingsGroupTable } from "./findings-group-table"; const { - isGroupedJiraDispatchEnabledMock, - SendToJiraModalMock, + FloatingSelectionActionsMock, setOnDrillDownMock, triggerOnDrillDownMock, } = vi.hoisted(() => { let onDrillDown: ((checkId: string, group: unknown) => void) | undefined; return { - isGroupedJiraDispatchEnabledMock: vi.fn(() => false), - SendToJiraModalMock: vi.fn((_props: unknown) => null), + FloatingSelectionActionsMock: vi.fn((_props: unknown) => null), setOnDrillDownMock: vi.fn( (handler: ((checkId: string, group: unknown) => void) | undefined) => { onDrillDown = handler; @@ -30,9 +29,7 @@ const { }); vi.mock("next/navigation", () => ({ - useRouter: () => ({ - refresh: vi.fn(), - }), + useRouter: () => ({ refresh: vi.fn() }), useSearchParams: () => new URLSearchParams(), usePathname: () => "/findings", })); @@ -61,7 +58,6 @@ vi.mock("@/components/shadcn/table", () => ({ }) => (
{toolbarRightContent}
- 10 Total Entries {(data ?? []).map((original, index) => ( @@ -120,15 +116,6 @@ 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: ({ onDrillDown, @@ -142,14 +129,12 @@ vi.mock("./column-finding-groups", () => ({ vi.mock("./inline-resource-container", () => ({ InlineResourceContainer: ({ - columnCount, onResourceSelectionChange, }: { - columnCount?: number; onResourceSelectionChange?: (selectedResourceIds: string[]) => void; }) => ( -
+ - {isChooserOpen && ( -
- - {showSendToJira && ( - - )} -
- )} - - ); - }, +vi.mock("../floating-selection-actions", () => ({ + FloatingSelectionActions: FloatingSelectionActionsMock, })); +function makeGroup(checkId: string, resourcesFail = 2) { + return { + checkId, + checkTitle: `Title ${checkId}`, + resourcesFail, + resourcesTotal: Math.max(resourcesFail, 1), + mutedCount: 0, + } as unknown as Parameters[0]["data"][number]; +} + +function getLastFloatingActionsProps(): { + jiraPayload: JiraDispatchModalPayload; + onBeforeOpen: () => Promise; +} { + const props = FloatingSelectionActionsMock.mock.calls.at(-1)?.[0]; + expect(props).toBeDefined(); + return props as unknown as { + jiraPayload: JiraDispatchModalPayload; + onBeforeOpen: () => Promise; + }; +} + describe("FindingsGroupTable", () => { beforeEach(() => { vi.clearAllMocks(); - isGroupedJiraDispatchEnabledMock.mockReturnValue(false); }); - describe("toolbar", () => { - it("should render the muted findings checkbox inside the table toolbar", () => { - // Given - render( - , - ); + it("renders the muted findings filter in the table toolbar", () => { + // Given / When + render( + , + ); - // When - const toolbar = screen.getByTestId("table-toolbar-right"); - - // Then - expect( - screen.getByRole("checkbox", { name: "Include muted findings" }), - ).toBeInTheDocument(); - expect(toolbar).toHaveTextContent("Include muted findings"); - }); + // Then + expect( + screen.getByRole("checkbox", { name: "Include muted findings" }), + ).toBeInTheDocument(); }); - describe("explore-findings tour gating", () => { - it("does not mount the tour trigger when there are no finding groups", () => { - // Given an empty table (e.g. a scan is still running) - render( - , - ); + it("mounts the tour only when finding groups exist", () => { + // Given / When + const { rerender } = render( + , + ); - // Then the tour never starts — there is no first-row anchor for the - // "Open a finding group" step to resolve, which would otherwise throw. - expect( - screen.queryByTestId("onboarding-trigger"), - ).not.toBeInTheDocument(); - // PageReady still signals the navbar that the route's data has loaded. - expect(screen.getByTestId("page-ready")).toBeInTheDocument(); - }); + // Then + expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument(); + expect(screen.getByTestId("page-ready")).toBeInTheDocument(); - it("mounts the tour trigger once at least one finding group exists", () => { - // Given a populated table - const data = [{ checkId: "check-a" }] as unknown as Parameters< - typeof FindingsGroupTable - >[0]["data"]; + // When + rerender( + , + ); - render( - , - ); - - // Then the explore-findings tour is allowed to start. - expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument(); - }); + // Then + expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument(); }); - describe("onboarding anchor", () => { - it("anchors the finding-group tour step to the first row only", () => { - // Given two finding groups (the tour must point at the first, even if there is one) - const data = [ - { checkId: "check-a" }, - { checkId: "check-b" }, - ] as unknown as Parameters[0]["data"]; + it("anchors the finding-group tour to the first row only", () => { + // Given / When + render( + , + ); - render( - , - ); - - // Then driver.js resolves `[data-tour-id="explore-findings-group"]` to the first row. - expect(screen.getByTestId("row-0")).toHaveAttribute( - "data-tour-id", - "explore-findings-group", - ); - expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id"); - }); + // Then + expect(screen.getByTestId("row-0")).toHaveAttribute( + "data-tour-id", + "explore-findings-group", + ); + 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"]; + it("opens a drillable group from the expanded deep link", () => { + // Given / When + render( + , + ); - 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(); - }); + // Then + expect( + screen.getByRole("button", { name: "Select finding-1" }), + ).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"]; + it("builds separate Jira batches for selected groups and child findings", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); - 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" })); - // 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 = [ + // Then + expect(getLastFloatingActionsProps().jiraPayload.selection).toEqual({ + kind: "batches", + batches: [ { - checkId: "check-a", - checkTitle: "Check A", - resourcesFail: 1, - resourcesTotal: 1, + targetIds: ["check-a"], + targetType: "check_id", + dispatchMode: "grouped", }, { - 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", + targetIds: ["finding-1"], targetType: "finding_id", + dispatchMode: "individual", }, - 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"]; + it("keeps resource-only Jira selections scoped to the expanded group", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); - render( - , - ); + // When + await user.click(screen.getByRole("button", { name: "Expand check-a" })); + await user.click( + screen.getByRole("button", { name: "Select findings 1 and 2" }), + ); - // 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, - }); + // Then + expect(getLastFloatingActionsProps().jiraPayload).toMatchObject({ + selection: { + kind: "target-list", + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, + findingTitle: "Title check-a", + isFindingGroupSelection: true, + selectedResourceCount: 2, }); + }); - 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"]; + it("resolves group selections through the visible-resource query before muting", async () => { + // Given + vi.mocked(resolveFindingIdsByVisibleGroupResources).mockResolvedValue([ + "finding-a", + "finding-b", + ]); + const user = userEvent.setup(); + render( + , + ); - render( - , - ); + // When + await user.click(screen.getByRole("button", { name: "Select check-a" })); + const resolvedIds = await getLastFloatingActionsProps().onBeforeOpen(); - // 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, - }); + // Then + expect(resolvedIds).toEqual(["finding-a", "finding-b"]); + expect(resolveFindingIdsByVisibleGroupResources).toHaveBeenCalledWith({ + checkId: "check-a", + filters: { "filter[severity]": "high" }, + hasDateOrScanFilter: false, + resourceSearch: undefined, }); }); }); diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 649b670657..8ce23a56fb 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -6,11 +6,10 @@ 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 { buildJiraActionLabel } from "@/lib/jira-dispatch-action"; import { createJiraBatchSelection, createJiraTargetSelection, @@ -20,7 +19,7 @@ import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findi import { FindingGroupRow, MetaDataProps } from "@/types"; import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; -import { FloatingMuteButton } from "../floating-mute-button"; +import { FloatingSelectionActions } from "../floating-selection-actions"; import { getColumnFindingGroups } from "./column-finding-groups"; import { canMuteFindingGroup } from "./finding-group-selection"; @@ -47,13 +46,6 @@ function buildMuteActionLabel( 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, @@ -137,7 +129,6 @@ const FindingsGroupTableContent = ({ const [resourceSearchInput, setResourceSearchInput] = useState(""); const [resourceSearch, setResourceSearch] = useState(""); const [resourceSelection, setResourceSelection] = useState([]); - const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const inlineRef = useRef(null); const safeData = data ?? EMPTY_FINDING_GROUPS; @@ -152,7 +143,6 @@ const FindingsGroupTableContent = ({ 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) @@ -190,16 +180,6 @@ const FindingsGroupTableContent = ({ ? 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 @@ -227,16 +207,20 @@ const FindingsGroupTableContent = ({ 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 jiraPayload = jiraSelection + ? { + selection: jiraSelection, + findingTitle: jiraTitle, + selectedResourceCount: selectedJiraResourceCount, + isFindingGroupSelection: + !jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup), + description: jiraDescription, + } + : undefined; + const sendToJiraLabel = buildJiraActionLabel({ + findingGroupCount: selectedCheckIds.length, + findingCount: activeResourceSelection.length, + }); const selectableRowCount = safeData.filter((g) => canMuteFindingGroup({ @@ -409,8 +393,8 @@ const FindingsGroupTableContent = ({ /> - {(selectedCheckIds.length > 0 || hasResourceSelection) && ( - 0 || hasResourceSelection) && jiraPayload && ( + 0 || activeResourceSelection.length > 1 } - showSendToJira={hasJiraTargets} - canSendToJira={canSendToJira} - sendToJiraLabel={sendToJiraLabel} - onSendToJira={() => setIsJiraModalOpen(true)} - /> - )} - - {canSendToJira && jiraSelection && ( - )} diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index afedde6c55..858852e6d5 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -665,7 +665,9 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => { within(row as HTMLElement).getByRole("button", { name: "Mute" }), ).toBeInTheDocument(); expect( - within(row as HTMLElement).getByRole("button", { name: "Send to Jira" }), + within(row as HTMLElement).getByRole("button", { + name: "Send 1 Finding to Jira", + }), ).toBeInTheDocument(); }); @@ -702,7 +704,7 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => { const row = screen.getByText("EC2 Check").closest("tr"); expect(row).not.toBeNull(); const actionsCell = within(row as HTMLElement) - .getByRole("button", { name: "Send to Jira" }) + .getByRole("button", { name: "Send 1 Finding to Jira" }) .closest("td"); // Then 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 98ca0ea133..2511faf6ac 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 @@ -21,11 +21,10 @@ import { type ResourceDrawerFinding, updateFindingTriage, } from "@/actions/findings"; +import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { MarkdownContainer } from "@/components/findings/markdown-container"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; -import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { getComplianceIcon } from "@/components/icons"; -import { JiraIcon } from "@/components/icons/services/IconServices"; import { Badge, Button, @@ -73,6 +72,7 @@ import { import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; import { getFailingForLabel, formatDuration } from "@/lib/date-utils"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; +import { buildJiraActionLabel } from "@/lib/jira-dispatch-action"; import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts"; import { getRegionFlag } from "@/lib/region-flags"; @@ -363,7 +363,6 @@ export function ResourceDetailDrawerContent({ }: ResourceDetailDrawerContentProps) { const searchParams = useSearchParams(); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); - const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const [resolvingFramework, setResolvingFramework] = useState( null, ); @@ -416,6 +415,12 @@ export function ResourceDetailDrawerContent({ const jiraSelection = f ? createJiraTargetSelection([f.id], JIRA_DISPATCH_TARGET.FINDING_ID) : null; + const jiraPayload = jiraSelection + ? { + selection: jiraSelection, + findingTitle: checkMeta.checkTitle, + } + : null; const isCheckMetaFresh = !currentResource?.checkId || currentResource.checkId === checkMeta.checkId; const showCheckMetaContent = !isNavigating || isCheckMetaFresh; @@ -550,15 +555,6 @@ export function ResourceDetailDrawerContent({ }} /> )} - {f && jiraSelection && ( - - )} - {/* Header: keep row-backed badges visible; only hide stale check metadata */}
@@ -872,10 +868,9 @@ export function ResourceDetailDrawerContent({ disabled={f.isMuted} onSelect={() => setIsMuteModalOpen(true)} /> - } - label="Send to Jira" - onSelect={() => setIsJiraModalOpen(true)} + {externalResourceTarget && ( Promise; }) { const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); - const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const isMuted = finding.isMuted || isOptimisticallyMuted; const jiraSelection = createJiraTargetSelection( [finding.id], @@ -1594,14 +1588,6 @@ function OtherFindingRow({ }} /> )} - {jiraSelection && ( - - )} window.open(findingUrl, "_blank", "noopener,noreferrer")} @@ -1670,10 +1656,16 @@ function OtherFindingRow({ disabled={isMuted} onSelect={() => setIsMuteModalOpen(true)} /> - } - label="Send to Jira" - onSelect={() => setIsJiraModalOpen(true)} +
diff --git a/ui/components/layout/main-layout/main-layout.test.tsx b/ui/components/layout/main-layout/main-layout.test.tsx index 26ebcc55e6..6198f0daf9 100644 --- a/ui/components/layout/main-layout/main-layout.test.tsx +++ b/ui/components/layout/main-layout/main-layout.test.tsx @@ -11,6 +11,10 @@ vi.mock("@/components/shared/cloud-upgrade-modal", () => ({ CloudUpgradeModal: () =>
, })); +vi.mock("@/components/findings/jira-dispatch-modal-host", () => ({ + JiraDispatchModalHost: () =>
, +})); + describe("MainLayout", () => { it("mounts the shared Cloud upgrade modal with page content", () => { render( @@ -20,6 +24,7 @@ describe("MainLayout", () => { ); expect(screen.getByTestId("cloud-upgrade-modal")).toBeInTheDocument(); + expect(screen.getByTestId("jira-dispatch-modal-host")).toBeInTheDocument(); expect(screen.getByTestId("sidebar")).toBeInTheDocument(); expect(screen.getByText("Page content")).toBeVisible(); expect(screen.getByRole("main")).toBeVisible(); diff --git a/ui/components/layout/main-layout/main-layout.tsx b/ui/components/layout/main-layout/main-layout.tsx index 09141c8daa..68193326ed 100644 --- a/ui/components/layout/main-layout/main-layout.tsx +++ b/ui/components/layout/main-layout/main-layout.tsx @@ -3,6 +3,7 @@ import { usePathname } from "next/navigation"; import { type ReactNode, Suspense } from "react"; +import { JiraDispatchModalHost } from "@/components/findings/jira-dispatch-modal-host"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal"; import { useMediaQuery } from "@/hooks/use-media-query"; @@ -38,6 +39,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
+
is the reference for the app's (container-query) // breakpoints, so pushing it with the side panel re-evaluates them. diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index a820215d5b..b4811b8f1b 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -8,7 +8,7 @@ import { loadLatestFindingTriageNote, updateFindingTriage, } from "@/actions/findings"; -import { FloatingMuteButton } from "@/components/findings/floating-mute-button"; +import { FloatingSelectionActions } from "@/components/findings/floating-selection-actions"; import { FindingDetailDrawer } from "@/components/findings/table"; import { Tabs, @@ -406,7 +406,7 @@ export const ResourceDetailContent = ({ isLoading={findingsLoading} /> {selectedFindingIds.length > 0 && ( - { + it("allows one Finding without grouped dispatch", () => { + // Given + const selection = createJiraTargetSelection( + ["finding-1"], + JIRA_DISPATCH_TARGET.FINDING_ID, + )!; + + // When + const state = getJiraDispatchActionState({ selection }, false); + + // Then + expect(state).toEqual({ + canChooseGroupedDispatch: false, + defaultDispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + requiresUpgrade: false, + }); + }); + + it("offers grouped choice for multiple Findings in Cloud", () => { + // Given + const selection = createJiraTargetSelection( + ["finding-1", "finding-2"], + JIRA_DISPATCH_TARGET.FINDING_ID, + )!; + + // When + const state = getJiraDispatchActionState({ selection }, true); + + // Then + expect(state).toEqual({ + canChooseGroupedDispatch: true, + defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED, + requiresUpgrade: false, + }); + }); + + it("requires upgrade for multiple Findings outside Cloud", () => { + // Given + const selection = createJiraTargetSelection( + ["finding-1", "finding-2"], + JIRA_DISPATCH_TARGET.FINDING_ID, + )!; + + // When + const state = getJiraDispatchActionState({ selection }, false); + + // Then + expect(state.requiresUpgrade).toBe(true); + expect(state.canChooseGroupedDispatch).toBe(false); + }); + + it("offers grouped choice for one Finding Group with multiple resources", () => { + // Given + const selection = createJiraTargetSelection( + ["check-1"], + JIRA_DISPATCH_TARGET.CHECK_ID, + )!; + + // When + const state = getJiraDispatchActionState( + { selection, selectedResourceCount: 2 }, + true, + ); + + // Then + expect(state).toEqual({ + canChooseGroupedDispatch: true, + defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED, + requiresUpgrade: false, + }); + }); + + it("keeps mixed batches grouped without offering one global choice", () => { + // Given + const selection = createJiraBatchSelection([ + { + targetIds: ["check-1"], + targetType: JIRA_DISPATCH_TARGET.CHECK_ID, + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, + }, + { + targetIds: ["finding-1"], + targetType: JIRA_DISPATCH_TARGET.FINDING_ID, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }, + ])!; + + // When + const state = getJiraDispatchActionState({ selection }, true); + + // Then + expect(state.defaultDispatchMode).toBe(JIRA_DISPATCH_MODE.GROUPED); + expect(state.canChooseGroupedDispatch).toBe(false); + }); +}); + +describe("buildJiraActionLabel", () => { + it.each([ + [{ findingCount: 1 }, "Send 1 Finding to Jira"], + [{ findingCount: 2 }, "Send 2 Findings to Jira"], + [{ findingGroupCount: 1 }, "Send 1 Finding Group to Jira"], + [ + { findingGroupCount: 2, findingCount: 1 }, + "Send 2 Finding Groups and 1 Finding to Jira", + ], + ])("builds consistent Jira action copy", (counts, expected) => { + // Given / When + const label = buildJiraActionLabel(counts); + + // Then + expect(label).toBe(expected); + }); +}); diff --git a/ui/lib/jira-dispatch-action.ts b/ui/lib/jira-dispatch-action.ts new file mode 100644 index 0000000000..69d8675ccf --- /dev/null +++ b/ui/lib/jira-dispatch-action.ts @@ -0,0 +1,74 @@ +import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection"; +import { + JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, + JIRA_TARGET_SELECTION_KIND, + type JiraDispatchMode, +} from "@/types/integrations"; +import type { JiraDispatchModalPayload } from "@/types/jira-dispatch"; + +export interface JiraDispatchActionState { + defaultDispatchMode: JiraDispatchMode; + canChooseGroupedDispatch: boolean; + requiresUpgrade: boolean; +} + +export interface JiraActionLabelCounts { + findingGroupCount?: number; + findingCount?: number; +} + +export const getJiraDispatchActionState = ( + payload: JiraDispatchModalPayload, + groupedDispatchEnabled: boolean, +): JiraDispatchActionState => { + const batches = getJiraSelectionBatches(payload.selection); + const targetCount = batches.reduce( + (count, batch) => count + batch.targetIds.length, + 0, + ); + const hasFindingGroupTargets = batches.some( + (batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID, + ); + const requiresGroupedFeature = + hasFindingGroupTargets || batches.length > 1 || targetCount > 1; + const firstBatch = batches[0]; + const canChooseGroupedDispatch = + groupedDispatchEnabled && + payload.selection.kind !== JIRA_TARGET_SELECTION_KIND.BATCHES && + (firstBatch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID + ? firstBatch.targetIds.length > 1 + : firstBatch.targetIds.length === 1 && + (payload.selectedResourceCount ?? 0) > 1); + + return { + defaultDispatchMode: requiresGroupedFeature + ? JIRA_DISPATCH_MODE.GROUPED + : JIRA_DISPATCH_MODE.INDIVIDUAL, + canChooseGroupedDispatch, + requiresUpgrade: requiresGroupedFeature && !groupedDispatchEnabled, + }; +}; + +const buildEntityLabel = ( + count: number, + singular: string, + plural: string, +): string | null => { + if (count === 0) return null; + return `${count} ${count === 1 ? singular : plural}`; +}; + +export const buildJiraActionLabel = ({ + findingGroupCount = 0, + findingCount = 0, +}: JiraActionLabelCounts): string => { + const entities = [ + buildEntityLabel(findingGroupCount, "Finding Group", "Finding Groups"), + buildEntityLabel(findingCount, "Finding", "Findings"), + ].filter(Boolean); + + return entities.length > 0 + ? `Send ${entities.join(" and ")} to Jira` + : "Send to Jira"; +}; diff --git a/ui/lib/jira-dispatch-execution.test.ts b/ui/lib/jira-dispatch-execution.test.ts new file mode 100644 index 0000000000..b96d84c893 --- /dev/null +++ b/ui/lib/jira-dispatch-execution.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + JIRA_DISPATCH_MODE, + type JiraDispatchTargetBatch, +} from "@/types/integrations"; + +import { executeJiraDispatchBatches } from "./jira-dispatch-execution"; + +const { sendJiraDispatchMock, trackAndPollTaskMock } = vi.hoisted(() => ({ + sendJiraDispatchMock: vi.fn(), + trackAndPollTaskMock: vi.fn(), +})); + +vi.mock("@/actions/integrations/jira-dispatch", () => ({ + sendJiraDispatch: sendJiraDispatchMock, +})); + +vi.mock("@/store/task-watcher/store", () => ({ + TASK_WATCHER_STATUS: { READY: "ready" }, + trackAndPollTask: trackAndPollTaskMock, +})); + +const settings = { + integrationId: "jira-1", + projectKey: "SEC", + issueType: "Task", + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, +}; + +describe("executeJiraDispatchBatches", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendJiraDispatchMock.mockResolvedValue({ + success: true, + taskId: "task-1", + message: "Started", + }); + trackAndPollTaskMock.mockResolvedValue({ + status: "ready", + result: { created_count: 2, failed_count: 0 }, + }); + }); + + it("uses one dispatch path and aggregates successful batches", async () => { + // Given + const batches: JiraDispatchTargetBatch[] = [ + { + targetIds: ["check-a"], + targetType: "check_id" as const, + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, + }, + { + targetIds: ["finding-a"], + targetType: "finding_id" as const, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }, + ]; + + // When + const result = await executeJiraDispatchBatches(batches, settings); + + // Then + expect(sendJiraDispatchMock).toHaveBeenCalledTimes(2); + expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(2, { + integrationId: "jira-1", + targetIds: ["finding-a"], + filter: "finding_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "individual", + }); + expect(result).toMatchObject({ + startedTaskCount: 2, + successfulTaskCount: 2, + successfulIssueCount: 4, + successMessage: "4 Jira issues were created or updated successfully.", + errors: [], + warnings: [], + }); + }); + + it("returns only failed finding IDs as an individual retry batch", async () => { + // Given + trackAndPollTaskMock.mockResolvedValue({ + status: "ready", + result: { + created_count: 1, + failed_count: 2, + failed_finding_ids: ["finding-b", "finding-b", "finding-c"], + error: "Two issues failed.", + }, + }); + + // When + const result = await executeJiraDispatchBatches( + [ + { + targetIds: ["check-a"], + targetType: "check_id", + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, + }, + ], + settings, + ); + + // Then + expect(result.retryBatch).toEqual({ + targetIds: ["finding-b", "finding-c"], + targetType: "finding_id", + dispatchMode: "individual", + }); + expect(result.warnings).toEqual([ + "Two issues failed. Jira dispatch completed with 3 failed and 1 created/updated issue.", + ]); + }); + + it("does not offer an automatic retry after an unknown launch failure", async () => { + // Given + sendJiraDispatchMock.mockRejectedValue(new Error("Connection closed")); + + // When + const result = await executeJiraDispatchBatches( + [ + { + targetIds: ["finding-a"], + targetType: "finding_id", + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }, + ], + settings, + ); + + // Then + expect(result.startedTaskCount).toBe(0); + expect(result.retryBatch).toBeUndefined(); + expect(result.errors).toEqual([ + "The Jira dispatch status is unknown after a connection error. Check Jira before retrying.", + ]); + }); +}); diff --git a/ui/lib/jira-dispatch-execution.ts b/ui/lib/jira-dispatch-execution.ts new file mode 100644 index 0000000000..74dfd7f616 --- /dev/null +++ b/ui/lib/jira-dispatch-execution.ts @@ -0,0 +1,187 @@ +import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch"; +import { + evaluateJiraDispatchTask, + getJiraDispatchSuccessCount, +} from "@/lib/jira-dispatch-result"; +import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task"; +import { + TASK_WATCHER_STATUS, + type TaskTrackingResult, + trackAndPollTask, +} from "@/store/task-watcher/store"; +import { + JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, + JIRA_DISPATCH_TASK_KIND, + type JiraDispatchMode, + type JiraDispatchTargetBatch, + type JiraDispatchTaskResult, +} from "@/types/integrations"; + +export interface JiraDispatchSettings { + integrationId: string; + projectKey: string; + issueType: string; + dispatchMode: JiraDispatchMode; +} + +export interface JiraDispatchExecutionResult { + startedTaskCount: number; + successfulTaskCount: number; + successfulIssueCount: number; + successMessage?: string; + warnings: string[]; + errors: string[]; + retryBatch?: JiraDispatchTargetBatch; +} + +interface JiraTrackedOutcome { + success: boolean; + message?: string; + error?: string; + warning?: string; + failedFindingIds?: string[]; + successfulCount?: number; +} + +export function getJiraRetryBatch( + failedFindingIds: string[] | undefined, +): JiraDispatchTargetBatch | undefined { + const [firstTargetId, ...remainingTargetIds] = Array.from( + new Set(failedFindingIds?.filter(Boolean) ?? []), + ); + if (!firstTargetId) return undefined; + + return { + targetIds: [firstTargetId, ...remainingTargetIds], + targetType: JIRA_DISPATCH_TARGET.FINDING_ID, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }; +} + +export async function executeJiraDispatchBatches( + batches: JiraDispatchTargetBatch[], + settings: JiraDispatchSettings, + options: { notifyHandler?: boolean } = {}, +): Promise { + const startedTasks: Array<{ + taskId: string; + dispatchMode: JiraDispatchMode; + }> = []; + const launchErrors: string[] = []; + + for (const batch of batches) { + const dispatchMode = batch.dispatchMode ?? settings.dispatchMode; + try { + const result = 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. An + // automatic retry could create duplicate issues. + launchErrors.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: options.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: false, + error: outcome.error, + failedFindingIds: outcome.failedFindingIds, + } satisfies JiraTrackedOutcome; + } + + return { + success: true, + message: outcome.message, + warning: outcome.warning, + failedFindingIds: outcome.failedFindingIds, + successfulCount: getJiraDispatchSuccessCount(trackedTask.result), + } satisfies JiraTrackedOutcome; + }), + ); + + const successfulOutcomes = trackedOutcomes.filter( + (outcome) => outcome.success, + ); + const successfulIssueCount = successfulOutcomes.reduce( + (count, outcome) => count + (outcome.successfulCount ?? 0), + 0, + ); + const successMessage = + successfulOutcomes.length === 1 + ? successfulOutcomes[0].message + : successfulOutcomes.length > 1 + ? `${successfulIssueCount} Jira issues were created or updated successfully.` + : undefined; + + return { + startedTaskCount: startedTasks.length, + successfulTaskCount: successfulOutcomes.length, + successfulIssueCount, + successMessage, + warnings: Array.from( + new Set( + trackedOutcomes.flatMap((outcome) => + outcome.warning ? [outcome.warning] : [], + ), + ), + ), + errors: Array.from( + new Set([ + ...trackedOutcomes.flatMap((outcome) => + outcome.error ? [outcome.error] : [], + ), + ...launchErrors, + ]), + ), + retryBatch: getJiraRetryBatch( + trackedOutcomes.flatMap((outcome) => outcome.failedFindingIds ?? []), + ), + }; +} diff --git a/ui/store/index.ts b/ui/store/index.ts index 1c181a35c7..572af57593 100644 --- a/ui/store/index.ts +++ b/ui/store/index.ts @@ -1,4 +1,5 @@ export * from "./cloud-upgrade/store"; +export * from "./jira-dispatch/store"; export * from "./organizations/store"; export * from "./provider-wizard/store"; export * from "./scans/store"; diff --git a/ui/store/jira-dispatch/store.ts b/ui/store/jira-dispatch/store.ts new file mode 100644 index 0000000000..291ddd64cd --- /dev/null +++ b/ui/store/jira-dispatch/store.ts @@ -0,0 +1,16 @@ +import { create } from "zustand"; + +import type { JiraDispatchModalPayload } from "@/types/jira-dispatch"; + +interface JiraDispatchStoreState { + activePayload: JiraDispatchModalPayload | null; + openJiraDispatch: (payload: JiraDispatchModalPayload) => void; + closeJiraDispatch: () => void; +} + +// Jira dispatch is ephemeral and globally hosted so every action uses one modal. +export const useJiraDispatchStore = create((set) => ({ + activePayload: null, + openJiraDispatch: (activePayload) => set({ activePayload }), + closeJiraDispatch: () => set({ activePayload: null }), +})); diff --git a/ui/types/jira-dispatch.ts b/ui/types/jira-dispatch.ts new file mode 100644 index 0000000000..0823073d4b --- /dev/null +++ b/ui/types/jira-dispatch.ts @@ -0,0 +1,9 @@ +import type { JiraSelection } from "@/types/integrations"; + +export interface JiraDispatchModalPayload { + selection: JiraSelection; + findingTitle?: string; + selectedResourceCount?: number; + isFindingGroupSelection?: boolean; + description?: string; +}