refactor(ui): centralize Jira dispatch flow

This commit is contained in:
alejandrobailo
2026-07-22 14:27:50 +02:00
parent 33ff2a2a44
commit 757b005078
30 changed files with 1241 additions and 2644 deletions
@@ -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<T>() {
let resolve!: (value: T) => void;
@@ -42,10 +39,9 @@ function deferredPromise<T>() {
// 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(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={3}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -78,7 +74,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -117,7 +113,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -151,7 +147,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={3}
selectedFindingIds={["group-1", "group-2", "group-3"]}
onBeforeOpen={onBeforeOpen}
@@ -203,109 +199,4 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
});
});
});
it("should route Send to Jira through the action chooser without opening mute", async () => {
// Given
const onSendToJira = vi.fn();
const user = userEvent.setup();
render(
<FloatingMuteButton
selectedCount={1}
selectedFindingIds={["finding-1"]}
canSendToJira
onSendToJira={onSendToJira}
/>,
);
// 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(
<FloatingMuteButton
selectedCount={2}
selectedFindingIds={["group-1", "finding-1"]}
label="1 Group and 1 Finding selected"
muteLabel="Mute 1 Group and 1 Finding"
sendToJiraLabel="Send 1 Group and 1 Finding to Jira"
showSendToJira
/>,
);
// 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(
<FloatingMuteButton
selectedCount={1}
selectedFindingIds={["finding-1"]}
showSendToJira
canSendToJira={false}
onSendToJira={onSendToJira}
/>,
);
// 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();
});
});
@@ -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<string[]>;
/** 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<string[]>([]);
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(
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 flex gap-2 duration-300">
<div className="shadow-lg">
{showSendToJira ? (
{jiraPayload ? (
<ActionDropdown
ariaLabel="Open selection actions"
trigger={
@@ -160,14 +148,9 @@ export function FloatingMuteButton({
aria-label={muteLabel}
onSelect={() => void handleMuteClick()}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={sendToJiraLabel}
tooltip={
!canSendToJira ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
}
aria-label={sendToJiraLabel}
onSelect={handleJiraClick}
<JiraDispatchActionItem
label={jiraLabel}
payload={jiraPayload}
/>
</ActionDropdown>
) : (
@@ -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<typeof import("@/lib/deployment")>()),
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
}));
import { JiraDispatchActionItem } from "./jira-dispatch-action-item";
const renderAction = (targetIds: string[], targetType: JiraDispatchTarget) => {
const selection = createJiraTargetSelection(targetIds, targetType)!;
render(
<ActionDropdown trigger={<button type="button">Actions</button>}>
<ActionDropdownItem label="Other action" />
<JiraDispatchActionItem label="Send to Jira" payload={{ selection }} />
</ActionDropdown>,
);
};
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" },
});
});
});
@@ -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 (
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={label}
aria-label={label}
tooltip={requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined}
onSelect={handleSelect}
/>
);
};
@@ -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<typeof import("@/lib/deployment")>()),
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(<JiraDispatchModalHost />);
// 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(<JiraDispatchModalHost />);
// Then
expect(SendToJiraModalMock).not.toHaveBeenCalled();
});
});
@@ -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 (
<SendToJiraModal
isOpen
onOpenChange={(open) => !open && closeJiraDispatch()}
selection={activePayload.selection}
findingTitle={activePayload.findingTitle}
defaultDispatchMode={defaultDispatchMode}
canChooseGroupedDispatch={canChooseGroupedDispatch}
isFindingGroupSelection={activePayload.isFindingGroupSelection}
selectedResourceCount={activePayload.selectedResourceCount}
description={activePayload.description}
/>
);
};
@@ -103,6 +103,7 @@ describe("jiraDispatchTaskHandler", () => {
...task.meta,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
},
notifyHandler: true,
});
});
@@ -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<JiraDispatchTaskResult>({
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",
@@ -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<typeof createJiraBatchSelection>[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">) => (
<button {...props}>{children}</button>
ToastAction: ({ children }: { children: React.ReactNode }) => (
<button>{children}</button>
),
}));
vi.mock("@/store/task-watcher/store", () => ({
TASK_WATCHER_STATUS: {
PENDING: "pending",
READY: "ready",
ERROR: "error",
},
trackAndPollTask: trackAndPollTaskMock,
}));
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: ({
options,
@@ -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(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
findingTitle="Check A"
selection={batchSelection([
{
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,
},
])}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
selectedResourceCount={1}
description="Create Jira issues for 1 Group and 2 Findings."
/>,
);
// 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(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
findingTitle="Finding 1"
selection={targetSelection(
["finding-1", "finding-2"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
defaultDispatchMode="grouped"
canChooseGroupedDispatch
/>,
);
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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
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,
},
])}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
selectedResourceCount={1}
description="Create Jira issues for 1 Group and 2 Findings."
/>,
);
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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={targetSelection(
["finding-1"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
findingTitle="Finding 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: "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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_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: "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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_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(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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
expect(executeJiraDispatchBatchesMock).toHaveBeenCalledWith(
[
{
targetIds: ["check-a"],
targetType: "check_id",
@@ -431,318 +169,15 @@ describe("SendToJiraModal", () => {
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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={targetSelection(
["finding-1"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
findingTitle="Finding 1"
/>,
);
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(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
selection={targetSelection(
["finding-1", "finding-2"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
/>,
);
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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
])}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
])}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
expect(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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
])}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
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);
});
});
+12 -185
View File
@@ -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<JiraDispatchTaskResult>;
try {
trackedTask = await trackAndPollTask<JiraDispatchTaskResult>({
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 ? (
<ToastAction
@@ -427,13 +254,13 @@ const SendToJiraModalContent = ({
</ToastAction>
) : 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",
});
}
@@ -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) => (
<div
data-testid="jira-modal"
data-finding-id={selection.targetId}
data-open={isOpen ? "true" : "false"}
/>
)),
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<FindingTriageSummary>,
@@ -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(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-1", "finding-2"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
{CellComponent({
row: {
original: makeResource({ findingId: "finding-1" }),
},
})}
</FindingsSelectionContext.Provider>,
);
// 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(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-1", "finding-2"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
{CellComponent({
row: {
original: makeResource({ findingId: "finding-1" }),
},
})}
</FindingsSelectionContext.Provider>,
);
// 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,
);
});
});
@@ -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<string[]>([]);
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 && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={resource.checkId}
defaultDispatchMode={
displayIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL
}
canChooseGroupedDispatch={
displayIds.length > 1 && groupedJiraDispatchEnabled
}
/>
)}
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
@@ -178,14 +163,11 @@ const ResourceRowActions = ({
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
disabled={!canSendToJira}
disabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP}
onSelect={() => {
if (canSendToJira) setIsJiraModalOpen(true);
}}
<JiraDispatchActionItem
label={buildJiraActionLabel({
findingCount: displayIds.length,
})}
payload={jiraPayload}
/>
</ActionDropdown>
</div>
@@ -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 }) => (
<div>{children}</div>
@@ -56,20 +44,12 @@ vi.mock("@/components/shadcn/dropdown", () => ({
label,
onSelect,
disabled,
disabledTooltip,
tooltip,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
disabledTooltip?: string;
tooltip?: string;
}) => (
<button
onClick={onSelect}
disabled={disabled}
title={tooltip ?? disabledTooltip}
>
<button onClick={onSelect} disabled={disabled}>
{label}
</button>
),
@@ -156,8 +136,7 @@ function makeFindingRow(overrides?: Partial<FindingRowData>) {
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(
<FindingsSelectionContext.Provider
value={{
@@ -271,12 +228,13 @@ describe("DataTableRowActions", () => {
</FindingsSelectionContext.Provider>,
);
// 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(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 bucket public access",
mutedCount: 0,
resourcesFail: 2,
resourcesTotal: 2,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// 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(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 bucket public access",
mutedCount: 0,
resourcesFail: 1,
resourcesTotal: 1,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// 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(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["check-a", "check-b"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "check-a",
checkTitle: "Check A",
mutedCount: 0,
resourcesFail: 2,
resourcesTotal: 2,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// 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(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-1", "finding-2"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
<DataTableRowActions row={makeFindingRow()} />
</FindingsSelectionContext.Provider>,
);
// 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(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-2", "finding-3"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
<DataTableRowActions row={makeFindingRow()} />
</FindingsSelectionContext.Provider>,
);
// 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", () => {
@@ -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<T extends FindingRowData>({
}: DataTableRowActionsProps<T>) {
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<T extends FindingRowData>({
// 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<T extends FindingRowData>({
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<T extends FindingRowData>({
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<T extends FindingRowData>({
router.refresh();
};
const handleJiraClick = () => {
if (requiresJiraUpgrade) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
}
setIsJiraModalOpen(true);
};
return (
<>
{(!isGroup || groupedJiraDispatchEnabled) && jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingTitle={findingTitle}
selection={jiraSelection}
defaultDispatchMode={jiraDefaultDispatchMode}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
/>
)}
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={handleMuteModalOpenChange}
@@ -337,14 +296,7 @@ export function DataTableRowActions<T extends FindingRowData>({
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={getJiraLabel()}
tooltip={
requiresJiraUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
}
onSelect={handleJiraClick}
/>
<JiraDispatchActionItem label={jiraLabel} payload={jiraPayload} />
</ActionDropdown>
</div>
</>
@@ -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("<SendToJiraModal");
expect(source).toContain("JIRA_DISPATCH_TARGET.FINDING_ID");
expect(source).toContain(
"selectedFindingIds.length > 1 && groupedJiraDispatchEnabled",
);
expect(source).toContain("canSendSelectedFindingsToJira");
expect(source).toContain("JIRA_DISPATCH_MODE.GROUPED");
});
});
@@ -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 (
<FindingsSelectionContext.Provider
@@ -244,8 +246,8 @@ export function FindingsGroupDrillDown({
</div>
</div>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
{selectedFindingIds.length > 0 && jiraPayload && (
<FloatingSelectionActions
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
@@ -256,29 +258,10 @@ export function FindingsGroupDrillDown({
}}
onComplete={handleMuteComplete}
isBulkOperation={selectedFindingIds.length > 1}
showSendToJira
canSendToJira={canSendSelectedFindingsToJira}
sendToJiraLabel={`Send ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
} to Jira`}
onSendToJira={() => setIsJiraModalOpen(true)}
/>
)}
{jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={group.checkTitle}
defaultDispatchMode={
selectedFindingIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL
}
canChooseGroupedDispatch={
selectedFindingIds.length > 1 && groupedJiraDispatchEnabled
}
jiraPayload={jiraPayload}
jiraLabel={buildJiraActionLabel({
findingCount: selectedFindingIds.length,
})}
/>
)}
File diff suppressed because it is too large Load Diff
@@ -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<string[]>([]);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const inlineRef = useRef<InlineResourceContainerHandle>(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 = ({
/>
</div>
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
<FloatingMuteButton
{(selectedCheckIds.length > 0 || hasResourceSelection) && jiraPayload && (
<FloatingSelectionActions
selectedCount={
selectedCheckIds.length + activeResourceSelection.length
}
@@ -438,26 +422,8 @@ const FindingsGroupTableContent = ({
isBulkOperation={
selectedCheckIds.length > 0 || activeResourceSelection.length > 1
}
showSendToJira={hasJiraTargets}
canSendToJira={canSendToJira}
sendToJiraLabel={sendToJiraLabel}
onSendToJira={() => setIsJiraModalOpen(true)}
/>
)}
{canSendToJira && jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingTitle={jiraTitle}
selection={jiraSelection}
defaultDispatchMode={jiraDispatchMode}
isFindingGroupSelection={
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
description={jiraDescription}
jiraPayload={jiraPayload}
jiraLabel={sendToJiraLabel}
/>
)}
</FindingsSelectionContext.Provider>
@@ -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
@@ -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<string | null>(
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 && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={checkMeta.checkTitle}
/>
)}
{/* Header: keep row-backed badges visible; only hide stale check metadata */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
@@ -872,10 +868,9 @@ export function ResourceDetailDrawerContent({
disabled={f.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
<JiraDispatchActionItem
label={buildJiraActionLabel({ findingCount: 1 })}
payload={jiraPayload}
/>
{externalResourceTarget && (
<ActionDropdownItem
@@ -1572,7 +1567,6 @@ function OtherFindingRow({
onTriageUpdateAction: (input: UpdateFindingTriageInput) => Promise<void>;
}) {
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 && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={finding.checkTitle}
/>
)}
<TableRow
className="group cursor-pointer"
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
@@ -1670,10 +1656,16 @@ function OtherFindingRow({
disabled={isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
<JiraDispatchActionItem
label={buildJiraActionLabel({ findingCount: 1 })}
payload={
jiraSelection
? {
selection: jiraSelection,
findingTitle: finding.checkTitle,
}
: null
}
/>
</ActionDropdown>
</div>
@@ -11,6 +11,10 @@ vi.mock("@/components/shared/cloud-upgrade-modal", () => ({
CloudUpgradeModal: () => <div data-testid="cloud-upgrade-modal" />,
}));
vi.mock("@/components/findings/jira-dispatch-modal-host", () => ({
JiraDispatchModalHost: () => <div data-testid="jira-dispatch-modal-host" />,
}));
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();
@@ -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 }) {
<div className="relative flex h-dvh items-center justify-center overflow-hidden">
<AppSidebar />
<CloudUpgradeModal />
<JiraDispatchModalHost />
<main
// @container: <main> is the reference for the app's (container-query)
// breakpoints, so pushing it with the side panel re-evaluates them.
@@ -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 && (
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
createJiraBatchSelection,
createJiraTargetSelection,
} from "@/lib/jira-dispatch-selection";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
buildJiraActionLabel,
getJiraDispatchActionState,
} from "./jira-dispatch-action";
describe("getJiraDispatchActionState", () => {
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);
});
});
+74
View File
@@ -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";
};
+141
View File
@@ -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.",
]);
});
});
+187
View File
@@ -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<JiraDispatchExecutionResult> {
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<JiraDispatchTaskResult>;
try {
trackedTask = await trackAndPollTask<JiraDispatchTaskResult>({
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 ?? []),
),
};
}
+1
View File
@@ -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";
+16
View File
@@ -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<JiraDispatchStoreState>((set) => ({
activePayload: null,
openJiraDispatch: (activePayload) => set({ activePayload }),
closeJiraDispatch: () => set({ activePayload: null }),
}));
+9
View File
@@ -0,0 +1,9 @@
import type { JiraSelection } from "@/types/integrations";
export interface JiraDispatchModalPayload {
selection: JiraSelection;
findingTitle?: string;
selectedResourceCount?: number;
isFindingGroupSelection?: boolean;
description?: string;
}