Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions ui/app/(prowler)/findings/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,4 @@ describe("findings page", () => {
it("applies the shared default muted filter so muted findings are hidden unless the caller opts in", () => {
expect(source).toContain("applyDefaultMutedFilter");
});

it("loads finding groups as selectable Finding Group filter options", () => {
expect(source).toContain("fetchFindingGroupFilterOptions");
expect(source).toContain("FINDING_GROUP_FILTER_OPTION_PAGE_SIZE");
expect(source).toContain("checkOptions={checkOptions}");
});

it("excludes Finding Group's own filters while loading all option pages", () => {
expect(source).toContain("excludeFindingGroupOwnFilters");
expect(source).toContain('"filter[check_id__in]"');
expect(source).toContain("if (page >= totalPages) break");
expect(source).toContain("page += 1");
});
});
55 changes: 1 addition & 54 deletions ui/app/(prowler)/findings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,65 +24,12 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";

const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
const FINDING_GROUP_OWN_FILTER_KEYS = [
"filter[check_id]",
"filter[check_id__in]",
] as const;

type FindingGroupFilterFetcher = typeof getFindingGroups;

function excludeFindingGroupOwnFilters(
filters: Record<string, string | string[] | undefined>,
) {
return Object.fromEntries(
Object.entries(filters).filter(
([key]) =>
!FINDING_GROUP_OWN_FILTER_KEYS.includes(
key as (typeof FINDING_GROUP_OWN_FILTER_KEYS)[number],
),
),
);
}

async function getFindingGroupFilterOptions({
fetchFindingGroups,
filters,
}: {
fetchFindingGroups: FindingGroupFilterFetcher;
filters: Record<string, string | string[] | undefined>;
}) {
const optionFilters = excludeFindingGroupOwnFilters(filters);
const options = new Map<string, { checkId: string; checkTitle: string }>();
let page = 1;

while (true) {
const response = await fetchFindingGroups({
filters: optionFilters,
page,
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
});

for (const group of adaptFindingGroupsResponse(response)) {
options.set(group.checkId, {
checkId: group.checkId,
checkTitle: group.checkTitle,
});
}

const totalPages = response?.meta?.pagination?.pages ?? page;
if (page >= totalPages) break;
page += 1;
}

return Array.from(options.values());
}

export default async function Findings({
searchParams,
}: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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;
Expand All @@ -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 () => {
Expand All @@ -54,7 +50,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();

render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={3}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
Expand All @@ -78,7 +74,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();

render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
Expand Down Expand Up @@ -117,7 +113,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();

render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,61 @@ 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,
onBeforeOpen,
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);
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);

const handleModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
Expand Down Expand Up @@ -105,15 +102,6 @@ export function FloatingMuteButton({
}
};

const handleJiraClick = () => {
if (!canSendToJira) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
}

onSendToJira?.();
};

const handleComplete = () => {
setResolvedIds([]);
onComplete?.();
Expand All @@ -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={
Expand All @@ -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>
) : (
Expand Down
Loading
Loading