mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
refactor(ui): centralize Jira dispatch flow
This commit is contained in:
+7
-116
@@ -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();
|
||||
});
|
||||
});
|
||||
+26
-43
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-2
@@ -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
-29
@@ -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} ${
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { JiraSelection } from "@/types/integrations";
|
||||
|
||||
export interface JiraDispatchModalPayload {
|
||||
selection: JiraSelection;
|
||||
findingTitle?: string;
|
||||
selectedResourceCount?: number;
|
||||
isFindingGroupSelection?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user