fix(ui): address grouped Jira review comments

This commit is contained in:
Hugo P.Brito
2026-07-17 11:53:24 +01:00
parent 07d591db64
commit 1acf9bf417
18 changed files with 162 additions and 68 deletions
+4 -11
View File
@@ -8,16 +8,9 @@ import type {
JiraDispatchMode,
JiraDispatchRequest,
JiraDispatchResponse,
JiraDispatchTarget,
} from "@/types/integrations";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
const JIRA_DISPATCH_FILTER = {
CHECK_ID: "check_id",
FINDING_ID: "finding_id",
} as const;
type JiraDispatchFilter =
(typeof JIRA_DISPATCH_FILTER)[keyof typeof JIRA_DISPATCH_FILTER];
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
type JiraTaskResult = NonNullable<
JiraDispatchResponse["data"]["attributes"]["result"]
@@ -26,7 +19,7 @@ type JiraTaskResult = NonNullable<
interface JiraDispatchInput {
integrationId: string;
targetIds: string[];
filter: JiraDispatchFilter;
filter: JiraDispatchTarget;
projectKey: string;
issueType: string;
dispatchMode?: JiraDispatchMode;
@@ -150,7 +143,7 @@ export const sendFindingToJira = async (
return sendJiraDispatch({
integrationId,
targetIds: [findingId],
filter: JIRA_DISPATCH_FILTER.FINDING_ID,
filter: JIRA_DISPATCH_TARGET.FINDING_ID,
projectKey,
issueType,
});
+7 -1
View File
@@ -47,7 +47,13 @@ describe("findings page", () => {
it("loads finding groups as selectable Finding Group filter options", () => {
expect(source).toContain("fetchFindingGroupFilterOptions");
expect(source).toContain("pageSize: 100");
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("page <= totalPages");
});
});
+56 -8
View File
@@ -29,6 +29,60 @@ import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
const FINDING_GROUP_OWN_FILTER_KEYS = [
"filter[check_id]",
"filter[check_id__in]",
] as const;
type FindingGroupFilterFetcher = typeof getFindingGroups;
function excludeFindingGroupOwnFilters(
filters: Record<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;
let totalPages = 1;
do {
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,
});
}
totalPages = response?.meta?.pagination?.pages ?? page;
page += 1;
} while (page <= totalPages);
return Array.from(options.values());
}
export default async function Findings({
searchParams,
}: {
@@ -71,16 +125,10 @@ export default async function Findings({
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const findingGroupFilterOptionsData = await fetchFindingGroupFilterOptions({
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
pageSize: 100,
});
const checkOptions = adaptFindingGroupsResponse(
findingGroupFilterOptionsData,
).map((group) => ({
checkId: group.checkId,
checkTitle: group.checkTitle,
}));
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -408,7 +408,7 @@ describe("buildFindingGroupFilterOption", () => {
// Then
expect(filter).toMatchObject({
key: "check_id",
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values: ["teams_external_users_can_join", "s3_bucket_public_access"],
index: 3,
@@ -139,7 +139,7 @@ export function buildFindingGroupFilterOption({
}
return {
key: "check_id",
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values,
labelFormatter: (value: string) =>
@@ -61,7 +61,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// When — click the button (triggers onBeforeOpen which rejects)
await user.click(button);
await user.click(screen.getByRole("button", { name: "Mute" }));
// Then — button should NOT be disabled (isResolving reset to false)
await waitFor(() => {
@@ -84,7 +83,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("button", { name: "Mute" }));
// Then
await waitFor(() => {
@@ -124,7 +122,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("button", { name: "Mute" }));
// Then — modal opened (MuteFindingsModal called with isOpen=true)
await waitFor(() => {
@@ -159,7 +156,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// When
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("button", { name: "Mute" }));
// Then
const preparingCall = (
@@ -219,7 +215,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
);
// When
await user.click(screen.getByRole("button", { name: "Mute (1)" }));
await user.click(screen.getByRole("button", { name: "1 selected" }));
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
@@ -281,7 +277,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
);
// When
await user.click(screen.getByRole("button", { name: "Mute (1)" }));
await user.click(screen.getByRole("button", { name: "1 selected" }));
const jiraButton = screen.getByRole("button", { name: "Send to Jira" });
// Then
@@ -14,6 +14,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { PROWLER_CLOUD_ONLY_TOOLTIP } from "@/lib/deployment";
import { cn } from "@/lib/utils";
import { MuteFindingsModal } from "./mute-findings-modal";
@@ -42,8 +43,6 @@ interface FloatingMuteButtonProps {
jiraDisabledTooltip?: string;
}
export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud";
const CloudFeatureBadgeLink = () => (
<Badge variant="cloud" asChild>
<a
@@ -136,6 +135,15 @@ export function FloatingMuteButton({
};
const findingIds = onBeforeOpen ? resolvedIds : selectedFindingIds;
const hasMultipleActions = showSendToJira;
const handlePrimaryClick = () => {
if (hasMultipleActions) {
setIsActionChooserOpen(true);
return;
}
void handleMuteClick();
};
return (
<>
@@ -205,7 +213,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">
<Button
onClick={() => setIsActionChooserOpen(true)}
onClick={handlePrimaryClick}
disabled={isResolving}
size="lg"
className="shadow-lg"
@@ -215,7 +223,9 @@ export function FloatingMuteButton({
) : (
<Ellipsis className="size-5" />
)}
{label ?? `Mute (${selectedCount})`}
{hasMultipleActions
? (label ?? `${selectedCount} selected`)
: `Mute (${selectedCount})`}
</Button>
</div>,
document.body,
@@ -483,7 +483,7 @@ describe("SendToJiraModal", () => {
);
});
it("shows an error toast when one mixed dispatch batch fails", async () => {
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();
@@ -542,10 +542,9 @@ describe("SendToJiraModal", () => {
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Error",
title: "Partial success",
description:
"Jira dispatch completed with 1 failed and 1 created issue.",
"Finding successfully sent to Jira! Some Jira dispatches failed: Jira dispatch completed with 1 failed and 1 created issue.",
}),
);
expect(toastMock).not.toHaveBeenCalledWith(
@@ -605,9 +604,9 @@ describe("SendToJiraModal", () => {
);
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Error",
description: "Failed to launch Finding batch.",
title: "Partial success",
description:
"Finding successfully sent to Jira! Some Jira dispatches failed: Failed to launch Finding batch.",
}),
);
});
+38 -14
View File
@@ -27,7 +27,9 @@ import { toast } from "@/components/shadcn/toast";
import {
IntegrationProps,
JIRA_DISPATCH_MODE,
JIRA_DISPATCH_TARGET,
type JiraDispatchMode,
type JiraDispatchTarget,
} from "@/types/integrations";
import {
@@ -35,28 +37,17 @@ import {
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
const JIRA_DISPATCH_TARGET = {
CHECK_ID: "check_id",
FINDING_ID: "finding_id",
} as const;
type JiraDispatchTarget =
(typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET];
interface JiraDispatchTargetBatch {
targetIds: string[];
targetType: JiraDispatchTarget;
dispatchMode?: JiraDispatchMode;
}
interface SendToJiraModalProps {
interface SendToJiraModalBaseProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
findingId: string;
findingTitle?: string;
targetIds?: string[];
targetType?: JiraDispatchTarget;
targetBatches?: JiraDispatchTargetBatch[];
defaultDispatchMode?: JiraDispatchMode;
canChooseGroupedDispatch?: boolean;
isFindingGroupSelection?: boolean;
@@ -64,6 +55,29 @@ interface SendToJiraModalProps {
description?: string;
}
interface SendToJiraSingleTargetProps extends SendToJiraModalBaseProps {
targetIds?: never;
targetType?: never;
targetBatches?: never;
}
interface SendToJiraTargetListProps extends SendToJiraModalBaseProps {
targetIds?: string[];
targetType?: JiraDispatchTarget;
targetBatches?: never;
}
interface SendToJiraBatchProps extends SendToJiraModalBaseProps {
targetIds?: string[];
targetType?: JiraDispatchTarget;
targetBatches?: JiraDispatchTargetBatch[];
}
type SendToJiraModalProps =
| SendToJiraSingleTargetProps
| SendToJiraTargetListProps
| SendToJiraBatchProps;
const sendToJiraSchema = z.object({
integration: z.string().min(1, "Please select a Jira integration"),
project: z.string().min(1, "Please select a project"),
@@ -217,7 +231,7 @@ export const SendToJiraModal = ({
form.reset();
setFetchedIssueTypes({});
}
}, [isOpen, form, toast]);
}, [isOpen, form]);
useEffect(() => {
if (isOpen) {
@@ -281,6 +295,17 @@ export const SendToJiraModal = ({
];
if (errors.length > 0) {
const successfulTask = taskResults.find(
(taskResult) => taskResult.success,
);
if (successfulTask) {
toast({
title: "Partial success",
description: `${successfulTask.message || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${errors.join(" ")}`,
});
return;
}
throw new Error(errors.join(" "));
}
@@ -377,7 +402,6 @@ export const SendToJiraModal = ({
selectedProject,
issueTypesFromConfig.length,
fetchedIssueTypes,
toast,
]);
const issueTypeOptions = issueTypesForProject.map((type) => ({
@@ -181,6 +181,7 @@ vi.mock("@/lib/date-utils", () => ({
vi.mock("@/lib/deployment", () => ({
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
}));
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
@@ -19,7 +19,10 @@ 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 } from "@/lib/deployment";
import {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { FindingResourceRow } from "@/types";
import type {
FindingTriageLoadedNote,
@@ -27,7 +30,6 @@ import type {
} from "@/types/findings-triage";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import { PROWLER_CLOUD_ONLY_TOOLTIP } from "../floating-mute-button";
import { canMuteFindingResource } from "./finding-resource-selection";
import {
FindingNoteActionItem,
@@ -27,6 +27,7 @@ vi.mock("@/components/findings/send-to-jira-modal", () => ({
vi.mock("@/lib/deployment", () => ({
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
}));
vi.mock("@/components/icons/services/IconServices", () => ({
@@ -13,7 +13,10 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
import {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { isFindingGroupMuted } from "@/lib/findings-groups";
import { getOptionalText } from "@/lib/utils";
import type {
@@ -23,7 +26,6 @@ import type {
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import type { ProviderType } from "@/types/providers";
import { PROWLER_CLOUD_ONLY_TOOLTIP } from "../floating-mute-button";
import { canMuteFindingGroup } from "./finding-group-selection";
import type { FindingTriageContext } from "./finding-note-modal";
import { FindingNoteActionItem } from "./finding-triage-cells";
@@ -26,7 +26,10 @@ import {
import { SeverityBadge, StatusFindingBadge } from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
import {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import {
getFilteredFindingGroupDelta,
getFindingGroupImpactedCounts,
@@ -35,10 +38,7 @@ import {
import { FindingGroupRow } from "@/types";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import {
FloatingMuteButton,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "../floating-mute-button";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingResources } from "./column-finding-resources";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
@@ -122,6 +122,7 @@ vi.mock("@/actions/findings/findings-by-resource", () => ({
vi.mock("@/lib/deployment", () => ({
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
}));
vi.mock("../send-to-jira-modal", () => ({
@@ -9,17 +9,17 @@ import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbo
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 {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import {
FloatingMuteButton,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "../floating-mute-button";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingGroups } from "./column-finding-groups";
import { canMuteFindingGroup } from "./finding-group-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
@@ -29,6 +29,7 @@ import {
} from "./inline-resource-container";
const exploreFindingsFlow = getFlowById("explore-findings")!;
const EMPTY_FINDING_GROUPS: FindingGroupRow[] = [];
function buildSelectionSummary(
groupCount: number,
@@ -105,7 +106,7 @@ export function FindingsGroupTable({
undefined,
);
const safeData = data ?? [];
const safeData = data ?? EMPTY_FINDING_GROUPS;
const hasResourceSelection = resourceSelection.length > 0;
const filters = resolvedFilters;
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
+2
View File
@@ -10,6 +10,8 @@ export const ENTERPRISE_FEATURE_ENV = {
POSTHOG_ENABLED: "NEXT_PUBLIC_PROWLER_ENTERPRISE_POSTHOG_ENABLED",
} as const;
export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud";
export type DeploymentMode =
(typeof DEPLOYMENT_MODE)[keyof typeof DEPLOYMENT_MODE];
+8
View File
@@ -12,6 +12,14 @@ export const JIRA_DISPATCH_MODE = {
export type JiraDispatchMode =
(typeof JIRA_DISPATCH_MODE)[keyof typeof JIRA_DISPATCH_MODE];
export const JIRA_DISPATCH_TARGET = {
CHECK_ID: "check_id",
FINDING_ID: "finding_id",
} as const;
export type JiraDispatchTarget =
(typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET];
export interface IntegrationProps {
type: "integrations";
id: string;