Compare commits

...
Author SHA1 Message Date
alejandrobailo 5baf3d06b7 refactor(ui): extract finding group filter options 2026-07-22 14:27:54 +02:00
alejandrobailo 757b005078 refactor(ui): centralize Jira dispatch flow 2026-07-22 14:27:50 +02:00
Hugo P.Brito 33ff2a2a44 merge master into grouped Jira UI 2026-07-22 12:12:46 +01:00
Hugo P.Brito b0328ab637 fix(ui): resolve grouped Jira pipeline failures 2026-07-22 11:42:44 +01:00
alejandrobailo 0729fed780 fix(ui): refine grouped Jira upgrade prompts 2026-07-22 00:13:50 +02:00
alejandrobailo a63f8c3a34 fix(ui): improve Cloud Jira upgrade action 2026-07-21 23:27:26 +02:00
alejandrobailo b6e1dd02c0 fix(ui): complete grouped Jira dispatch review 2026-07-21 13:06:25 +02:00
alejandrobailo f1d26805d8 fix(api): expose failed Jira dispatch findings 2026-07-21 13:06:13 +02:00
Hugo P.Brito e6f47a9bd4 fix(ui): address grouped Jira follow-up feedback 2026-07-17 12:48:56 +01:00
Hugo P.Brito 1acf9bf417 fix(ui): address grouped Jira review comments 2026-07-17 11:53:24 +01:00
Hugo P.Brito 07d591db64 fix(ui): expose finding group filter 2026-07-17 08:24:09 +01:00
Hugo P.Brito 5e09dec669 fix(ui): address grouped Jira review feedback 2026-07-16 22:16:47 +01:00
Hugo P.Brito 0b62694bcd Merge remote-tracking branch 'origin/master' into feat/grouped-jira-dispatch-ui 2026-07-16 16:12:57 +01:00
Hugo P.Brito d9ec413216 docs(changelog): add grouped Jira dispatch UI entry 2026-07-16 16:07:01 +01:00
Hugo P.Brito 11229b5e73 test(ui): guard toast provider alias 2026-07-16 15:38:33 +01:00
Hugo P.Brito 0faeca5586 fix(ui): restore Jira toasts and check filter chips 2026-07-16 15:01:11 +01:00
Hugo P.Brito 3eebded9e1 fix(ui): shorten Jira dispatch polling rounds 2026-07-16 14:08:04 +01:00
Hugo P.Brito 00bba5af7d fix(ui): keep polling slow Jira dispatches 2026-07-16 12:34:01 +01:00
Hugo P.Brito bb676a3b72 fix(ui): handle grouped Jira dispatch failures 2026-07-16 12:10:24 +01:00
Hugo P.Brito 5f26463746 fix(ui): render Jira Cloud badge copy 2026-07-16 11:20:24 +01:00
Hugo P.Brito 999a8d1428 feat(ui): expand finding groups from deep links 2026-07-16 09:56:29 +01:00
Hugo P.Brito fea4ce2048 test(ui): cover mixed Jira dispatch submission 2026-07-16 09:23:27 +01:00
Hugo P.Brito f9ac342dae fix(ui): keep auth flow out of Jira dispatch changes 2026-07-16 09:22:00 +01:00
Hugo P.Brito 3f8e82eba8 feat(ui): add Jira dispatch choices for finding selections 2026-07-15 15:52:27 +01:00
61 changed files with 3867 additions and 720 deletions
+2
View File
@@ -3,6 +3,8 @@ import { FILTER_FIELD, FilterParam } from "@/types/filters";
/** Findings-only filter fields not shared with other views. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const FINDINGS_EXTRA_FIELD = {
CHECK_ID: "check_id",
CHECK_ID_IN: "check_id__in",
DELTA_IN: "delta__in",
SCAN_EXACT: "scan",
SCAN_ID: "scan_id",
+149 -64
View File
@@ -1,126 +1,167 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { pollTaskUntilSettledMock } = vi.hoisted(() => ({
const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
pollTaskUntilSettledMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: () => ({ error: "An error occurred" }),
}));
vi.mock("@/actions/task/poll", () => ({
pollTaskUntilSettled: pollTaskUntilSettledMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: vi.fn(),
}));
import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch";
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: vi.fn(),
}));
import { pollJiraDispatchTask } from "./jira-dispatch";
describe("pollJiraDispatchTask", () => {
describe("sendJiraDispatch", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: {
id: "task-1",
type: "tasks",
attributes: { result: null },
},
}),
{ status: 202 },
),
);
});
it("should return the backend error when a completed task has failed Jira dispatches", async () => {
it("should send grouped dispatch mode with multiple finding IDs", async () => {
// Given / When
await sendJiraDispatch({
integrationId: "jira-1",
targetIds: ["finding-1", "finding-2"],
filter: "finding_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
// Then
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bfinding_id__in%5D=finding-1%2Cfinding-2",
);
expect(JSON.parse(init.body as string)).toMatchObject({
data: {
attributes: {
dispatch_mode: "grouped",
issue_type: "Task",
project_key: "SEC",
},
},
});
});
it("should send grouped dispatch mode with a finding group check ID", async () => {
// Given / When
await sendJiraDispatch({
integrationId: "jira-1",
targetIds: ["s3_bucket_public_access"],
filter: "check_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
// Then
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bcheck_id%5D=s3_bucket_public_access",
);
expect(JSON.parse(init.body as string)).toMatchObject({
data: { attributes: { dispatch_mode: "grouped" } },
});
});
it("should preserve partial success when Jira dispatch has created and failed issues", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
created_count: 2,
failed_count: 1,
error: "Jira project requires custom fields: Team is required",
failed_finding_ids: ["finding-3"],
error: "Jira rejected one Finding.",
},
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Jira project requires custom fields: Team is required",
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira rejected one Finding. Jira dispatch completed with 1 failed and 2 created/updated issues.",
failedFindingIds: ["finding-3"],
});
});
it("should return a fallback error when a completed task has failures without an error", async () => {
it("should include updated issues in partial failure summaries", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
failed_count: 1,
},
result: { created_count: 0, updated_count: 2, failed_count: 1 },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create Jira issue.",
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
it("should return a plural fallback error when a completed task has multiple failures without an error", async () => {
it("should fail completed task polling when grouped dispatch reports failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
failed_count: 3,
},
result: { created_count: 1, failed_groups: [{ check_id: "check-a" }] },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create 3 Jira issues.",
success: true,
message: "Finding successfully sent to Jira!",
warning:
"Jira dispatch completed with 1 failed and 1 created/updated issue.",
});
});
it("should surface task failure result errors", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "failed",
result: {
error: "Jira credentials are invalid.",
},
});
// When
const result = await pollJiraDispatchTask("task-123");
// Then
expect(result).toEqual({
success: false,
error: "Jira credentials are invalid.",
});
});
it("should return success when a completed task has no failures", async () => {
it("should succeed completed task polling when grouped dispatch reports no failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 1,
failed_count: 0,
},
result: { created_count: 1, failed_groups: [] },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
@@ -129,24 +170,68 @@ describe("pollJiraDispatchTask", () => {
});
});
it("should return a fallback error when no Jira issue was created", async () => {
it("should succeed completed task polling with grouped created and updated issue result shape", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
created_count: 1,
updated_count: 1,
failed_count: 0,
created_issues: [{ key: "SEC-1" }],
updated_issues: [{ key: "SEC-2" }],
failed_groups: [],
},
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(pollTaskUntilSettledMock).toHaveBeenCalledWith("task-1", {
maxAttempts: 5,
delayMs: 2000,
});
expect(result).toEqual({
success: true,
message: "2 Jira issues were created or updated successfully.",
});
});
it("should fail completed task polling when Jira dispatch completed as a no-op", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: { created_count: 0, updated_count: 0, failed_count: 0 },
});
// When
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create Jira issue.",
error: "Jira dispatch completed but did not create or update any issues.",
});
});
it("should fail completed task polling when Jira dispatch has no result payload", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: null,
});
// When
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Jira dispatch completed but did not create or update any issues.",
});
});
});
+47 -29
View File
@@ -2,12 +2,26 @@
import { pollTaskUntilSettled } from "@/actions/task/poll";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
import { handleApiError } from "@/lib/server-actions-helper";
import type {
IntegrationProps,
JiraDispatchMode,
JiraDispatchRequest,
JiraDispatchResponse,
JiraDispatchTarget,
JiraDispatchTaskResult,
} from "@/types/integrations";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
interface JiraDispatchInput {
integrationId: string;
targetIds: string[];
filter: JiraDispatchTarget;
projectKey: string;
issueType: string;
dispatchMode?: JiraDispatchMode;
}
export const getJiraIssueTypes = async (
integrationId: string,
@@ -87,14 +101,37 @@ export const sendFindingToJira = async (
): Promise<
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
return sendJiraDispatch({
integrationId,
targetIds: [findingId],
filter: JIRA_DISPATCH_TARGET.FINDING_ID,
projectKey,
issueType,
});
};
export const sendJiraDispatch = async ({
integrationId,
targetIds,
filter,
projectKey,
issueType,
dispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
}: JiraDispatchInput): Promise<
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
);
// Single finding: use direct filter without array notation
url.searchParams.append("filter[finding_id]", findingId);
if (targetIds.length === 1) {
url.searchParams.append(`filter[${filter}]`, targetIds[0]);
} else {
url.searchParams.append(`filter[${filter}__in]`, targetIds.join(","));
}
const payload: JiraDispatchRequest = {
data: {
@@ -102,6 +139,7 @@ export const sendFindingToJira = async (
attributes: {
project_key: projectKey,
issue_type: issueType,
dispatch_mode: dispatchMode,
},
},
};
@@ -145,38 +183,18 @@ export const sendFindingToJira = async (
export const pollJiraDispatchTask = async (
taskId: string,
): Promise<
{ success: true; message: string } | { success: false; error: string }
| { success: true; message: string; warning?: string }
| { success: false; error: string }
> => {
const res = await pollTaskUntilSettled(taskId, {
maxAttempts: 30,
maxAttempts: 5,
delayMs: 2000,
});
if (!res.ok) {
return { success: false, error: res.error };
}
const { state, result } = res;
type JiraTaskResult = JiraDispatchResponse["data"]["attributes"]["result"];
const jiraResult = result as JiraTaskResult | undefined;
if (state === "completed") {
const createdCount = jiraResult?.created_count ?? 0;
const failedCount = jiraResult?.failed_count ?? 0;
if (!jiraResult?.error && failedCount === 0 && createdCount > 0) {
return { success: true, message: "Finding successfully sent to Jira!" };
}
return {
success: false,
error:
jiraResult?.error ||
(failedCount > 1
? `Failed to create ${failedCount} Jira issues.`
: "Failed to create Jira issue."),
};
}
if (state === "failed") {
return { success: false, error: jiraResult?.error || "Task failed." };
}
return { success: false, error: `Unknown task state: ${state}` };
return evaluateJiraDispatchTask(
res.state,
res.result as JiraDispatchTaskResult | undefined,
);
};
+14
View File
@@ -24,6 +24,7 @@ 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";
@@ -68,6 +69,13 @@ export default async function Findings({
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -110,6 +118,7 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
@@ -145,6 +154,10 @@ const SSRDataTable = async ({
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const expandedCheckIdParam = searchParams.expandedCheckId;
const expandedCheckId = Array.isArray(expandedCheckIdParam)
? expandedCheckIdParam[0]
: expandedCheckIdParam;
const { encodedSort } = extractSortAndKey(searchParams);
const hasHistoricalData = hasDateOrScanFilter(filters);
@@ -178,6 +191,7 @@ const SSRDataTable = async ({
metadata={findingGroupsData?.meta}
resolvedFilters={filters}
hasHistoricalData={hasHistoricalData}
expandedCheckId={expandedCheckId}
/>
</>
);
@@ -0,0 +1 @@
Finding Groups and grouped selections can be sent to Jira in Cloud with deep links, filter chip display, and Jira feedback toasts
+28 -6
View File
@@ -26,7 +26,9 @@ import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
type FindingCheckFilterOption,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -42,6 +44,7 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
checkOptions?: FindingCheckFilterOption[];
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
@@ -71,6 +74,7 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
export const FindingsFilterBatchControls = ({
providers,
@@ -85,6 +89,7 @@ export const FindingsFilterBatchControls = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
checkOptions = [],
trailingControls,
appliedFilters,
pendingFilters,
@@ -102,6 +107,18 @@ export const FindingsFilterBatchControls = ({
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
const checkTitles = Object.fromEntries(
checkOptions.map(({ checkId, checkTitle }) => [
checkId,
checkTitle || checkId,
]),
);
const findingGroupFilterOption = buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds: getFilterValue("filter[check_id]"),
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
checkTitles,
});
const customFilters = [
...filterFindings
@@ -112,39 +129,41 @@ export const FindingsFilterBatchControls = ({
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
checkTitles,
}),
})),
...(findingGroupFilterOption ? [findingGroupFilterOption] : []),
{
key: FILTER_FIELD.REGION,
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 3,
index: 4,
},
{
key: FILTER_FIELD.SERVICE,
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 4,
index: 5,
},
{
key: FILTER_FIELD.RESOURCE_TYPE,
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
index: 8,
index: 9,
},
{
key: FILTER_FIELD.CATEGORY,
labelCheckboxGroup: "Category",
values: uniqueCategories,
labelFormatter: getCategoryLabel,
index: 5,
index: 6,
},
{
key: FILTER_FIELD.RESOURCE_GROUPS,
labelCheckboxGroup: "Resource Group",
values: uniqueGroups,
labelFormatter: getGroupLabel,
index: 6,
index: 7,
},
...(isAlertsEdit
? []
@@ -164,7 +183,7 @@ export const FindingsFilterBatchControls = ({
scans: scanDetails,
},
),
index: 7,
index: 8,
},
]),
];
@@ -177,6 +196,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
checkTitles,
},
);
const pendingFilterChips: FilterChip[] = buildFindingsFilterChips(
@@ -185,6 +205,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
checkTitles,
},
);
const appliedCount = countVisibleFilterKeys(appliedFilters);
@@ -347,6 +368,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS],
});
return (
@@ -5,6 +5,7 @@ import { ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -164,6 +165,30 @@ describe("getFindingsFilterDisplayValue", () => {
);
});
it("uses the finding group title for check_id filters when available", () => {
expect(
getFindingsFilterDisplayValue(
"filter[check_id]",
"teams_external_users_can_join",
{
checkTitles: {
teams_external_users_can_join:
"External Teams users can join meetings",
},
},
),
).toBe("External Teams users can join meetings");
});
it("keeps the check id when no finding group title is available", () => {
expect(
getFindingsFilterDisplayValue(
"filter[check_id]",
"teams_external_users_can_join",
),
).toBe("teams_external_users_can_join");
});
it("uses the provider display name regardless of account alias/uid", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
@@ -298,6 +323,45 @@ describe("buildFindingsFilterChips", () => {
expect(chipsPlural[0].displayValues).toEqual(["New", "Changed"]);
});
it("renders filter[check_id] as a first-class Finding Group chip", () => {
// Given - exact deep-link params from the grouped findings page.
const chips = buildFindingsFilterChips(
{
"filter[check_id]": ["teams_external_users_can_join"],
},
{
checkTitles: {
teams_external_users_can_join:
"External Teams users can join meetings",
},
},
);
expect(chips).toEqual([
{
key: "filter[check_id]",
label: "Finding Group",
value: "teams_external_users_can_join",
displayValue: "External Teams users can join meetings",
},
]);
});
it("renders filter[check_id__in] with the Finding Group chip label", () => {
const chips = buildFindingsFilterChips({
"filter[check_id__in]": ["teams_external_users_can_join"],
});
expect(chips).toEqual([
{
key: "filter[check_id__in]",
label: "Finding Group",
value: "teams_external_users_can_join",
displayValue: "teams_external_users_can_join",
},
]);
});
it("skips muted filters because the table toolbar owns that control", () => {
const chips = buildFindingsFilterChips({
"filter[muted]": ["include"],
@@ -324,3 +388,47 @@ describe("buildFindingsFilterChips", () => {
]);
});
});
describe("buildFindingGroupFilterOption", () => {
it("builds a selectable Finding Group filter from fetched options and URL-backed values", () => {
// Given
const filter = buildFindingGroupFilterOption({
checkOptions: [
{
checkId: "teams_external_users_can_join",
checkTitle: "External Teams users can join meetings",
},
],
selectedCheckIds: ["s3_bucket_public_access"],
selectedCheckIdsIn: ["teams_external_users_can_join"],
checkTitles: {
teams_external_users_can_join: "External Teams users can join meetings",
},
});
// Then
expect(filter).toMatchObject({
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values: ["teams_external_users_can_join", "s3_bucket_public_access"],
index: 3,
});
expect(filter?.labelFormatter?.("teams_external_users_can_join")).toBe(
"External Teams users can join meetings",
);
expect(filter?.labelFormatter?.("s3_bucket_public_access")).toBe(
"s3_bucket_public_access",
);
});
it("omits the Finding Group filter when there are no selectable or URL-backed values", () => {
expect(
buildFindingGroupFilterOption({
checkOptions: [],
selectedCheckIds: [],
selectedCheckIdsIn: [],
checkTitles: {},
}),
).toBeNull();
});
});
@@ -7,14 +7,21 @@ import {
} from "@/lib/helper-filters";
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
import { ProviderGroup } from "@/types/components";
import type { FilterOption } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
export interface FindingCheckFilterOption {
checkId: string;
checkTitle?: string;
}
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
checkTitles?: Record<string, string>;
}
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
@@ -64,6 +71,12 @@ export function getFindingsFilterDisplayValue(
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
return getScanDisplayValue(value, options.scans || []);
}
if (
filterKey === "filter[check_id]" ||
filterKey === "filter[check_id__in]"
) {
return options.checkTitles?.[value] || value;
}
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
@@ -100,6 +113,43 @@ export function getFindingsFilterDisplayValue(
return formatLabel(value);
}
function uniqueNonEmptyValues(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
export function buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds,
selectedCheckIdsIn,
checkTitles,
}: {
checkOptions: FindingCheckFilterOption[];
selectedCheckIds: string[];
selectedCheckIdsIn: string[];
checkTitles: Record<string, string>;
}): FilterOption | null {
const values = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId),
...selectedCheckIds,
...selectedCheckIdsIn,
]);
if (values.length === 0) {
return null;
}
return {
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue("filter[check_id]", value, {
checkTitles,
}),
index: 3,
};
}
/**
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
* Used to render chips in the FilterSummaryStrip.
@@ -108,6 +158,8 @@ export function getFindingsFilterDisplayValue(
* label is missing.
*/
export const FILTER_KEY_LABELS: Record<FindingsFilterParam, string> = {
"filter[check_id]": "Finding Group",
"filter[check_id__in]": "Finding Group",
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[provider_groups__in]": "Provider Group",
@@ -134,6 +186,7 @@ interface BuildFindingsFilterChipsOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
checkTitles?: Record<string, string>;
includeMuted?: boolean;
}
@@ -22,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;
@@ -39,7 +39,7 @@ function deferredPromise<T>() {
// Fix 3: onBeforeOpen rejection resets isResolving
// ---------------------------------------------------------------------------
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
describe("FloatingSelectionActions — onBeforeOpen error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -50,7 +50,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={3}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -74,7 +74,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -113,7 +113,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
const user = userEvent.setup();
render(
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={2}
selectedFindingIds={[]}
onBeforeOpen={onBeforeOpen}
@@ -147,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}
@@ -1,34 +1,57 @@
"use client";
import { VolumeX } from "lucide-react";
import { Ellipsis, VolumeX } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown/action-dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
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;
}
export function FloatingMuteButton({
type FloatingSelectionActionsProps = FloatingSelectionActionsBaseProps &
(
| {
jiraPayload: JiraDispatchModalPayload;
jiraLabel: string;
}
| {
jiraPayload?: never;
jiraLabel?: never;
}
);
export function FloatingSelectionActions({
selectedCount,
selectedFindingIds,
onComplete,
onBeforeOpen,
isBulkOperation = false,
label,
}: FloatingMuteButtonProps) {
muteLabel = "Mute",
jiraPayload,
jiraLabel,
}: FloatingSelectionActionsProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
@@ -51,7 +74,7 @@ export function FloatingMuteButton({
}
};
const handleClick = async () => {
const handleMuteClick = async () => {
if (onBeforeOpen) {
setResolvedIds([]);
setMutePreparationError(null);
@@ -103,20 +126,48 @@ export function FloatingMuteButton({
with the content. */}
{typeof document !== "undefined"
? createPortal(
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={handleClick}
disabled={isResolving}
size="lg"
className="shadow-lg"
>
{isResolving ? (
<Spinner className="size-5" />
<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">
{jiraPayload ? (
<ActionDropdown
ariaLabel="Open selection actions"
trigger={
<Button disabled={isResolving} size="lg">
{isResolving ? (
<Spinner className="size-5" />
) : (
<Ellipsis className="size-5" />
)}
{label ?? `${selectedCount} selected`}
</Button>
}
>
<ActionDropdownItem
icon={<VolumeX />}
label={muteLabel}
aria-label={muteLabel}
onSelect={() => void handleMuteClick()}
/>
<JiraDispatchActionItem
label={jiraLabel}
payload={jiraPayload}
/>
</ActionDropdown>
) : (
<VolumeX className="size-5" />
<Button
onClick={() => void handleMuteClick()}
disabled={isResolving}
size="lg"
>
{isResolving ? (
<Spinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedCount})
</Button>
)}
{label ?? `Mute (${selectedCount})`}
</Button>
</div>
</div>,
document.body,
)
@@ -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}
/>
);
};
@@ -0,0 +1,128 @@
import { type ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WatchedTask } from "@/store/task-watcher/store";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import { jiraDispatchTaskHandler } from "./jira-dispatch-task-handler";
interface ToastActionMockProps extends ComponentProps<"button"> {
altText: string;
}
const { sendJiraDispatchMock, toastMock, trackAndPollTaskMock } = vi.hoisted(
() => ({
sendJiraDispatchMock: vi.fn(),
toastMock: vi.fn(),
trackAndPollTaskMock: vi.fn(),
}),
);
vi.mock("@/actions/integrations/jira-dispatch", () => ({
sendJiraDispatch: sendJiraDispatchMock,
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: toastMock,
ToastAction: ({
altText: _altText,
children,
...props
}: ToastActionMockProps) => <button {...props}>{children}</button>,
}));
vi.mock("@/store/task-watcher/store", () => ({
trackAndPollTask: trackAndPollTaskMock,
}));
const buildTask = (result: unknown): WatchedTask => ({
taskId: "task-1",
kind: "jira-dispatch",
status: "ready",
startedAt: Date.now(),
meta: {
integrationId: "jira-1",
projectKey: "SEC",
issueType: "Task",
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
result,
});
describe("jiraDispatchTaskHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
sendJiraDispatchMock.mockResolvedValue({
success: true,
taskId: "retry-task",
message: "Started",
});
});
it("shows the completed Jira result after a persisted task resumes", () => {
// Given
const task = buildTask({ created_count: 2, failed_count: 0 });
// When
jiraDispatchTaskHandler.onReady(task);
// Then
expect(toastMock).toHaveBeenCalledWith({
title: "Success!",
description: "2 Jira issues were created or updated successfully.",
});
});
it("retries only failed Findings from a resumed partial task", async () => {
// Given
const task = buildTask({
created_count: 1,
failed_count: 2,
failed_finding_ids: ["finding-2", "finding-3"],
error: "Two Jira issues failed.",
});
jiraDispatchTaskHandler.onReady(task);
const partialToast = toastMock.mock.calls.at(-1)?.[0];
// When
await partialToast.action.props.onClick();
// Then
expect(sendJiraDispatchMock).toHaveBeenCalledWith({
integrationId: "jira-1",
targetIds: ["finding-2", "finding-3"],
filter: "finding_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "individual",
});
expect(trackAndPollTaskMock).toHaveBeenCalledWith({
taskId: "retry-task",
kind: "jira-dispatch",
meta: {
...task.meta,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
},
notifyHandler: true,
});
});
it("surfaces task watcher errors without offering an unsafe retry", () => {
// Given
const task = {
...buildTask(undefined),
status: "error",
error: "Tracking the task failed unexpectedly. Try again later.",
} as WatchedTask;
// When
jiraDispatchTaskHandler.onError(task);
// Then
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Jira dispatch failed",
description: "Tracking the task failed unexpectedly. Try again later.",
});
});
});
@@ -0,0 +1,105 @@
"use client";
import { toast, ToastAction } from "@/components/shadcn/toast";
import {
executeJiraDispatchBatches,
getJiraRetryBatch,
} from "@/lib/jira-dispatch-execution";
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
import { parseJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
import type { TaskKindHandler, WatchedTask } from "@/store/task-watcher/store";
import {
JIRA_DISPATCH_MODE,
type JiraDispatchTaskResult,
} from "@/types/integrations";
const retryFailedFindings = async (
task: WatchedTask,
failedFindingIds: string[],
): Promise<void> => {
const meta = parseJiraDispatchTaskMeta(task);
if (!meta) {
toast({
variant: "destructive",
title: "Jira retry failed",
description: "The original Jira dispatch configuration is unavailable.",
});
return;
}
const retryBatch = getJiraRetryBatch(failedFindingIds);
if (!retryBatch) return;
try {
toast({
title: "Retry started",
description: `Retrying ${failedFindingIds.length} failed Finding${failedFindingIds.length === 1 ? "" : "s"}.`,
});
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",
title: "Jira retry failed",
description: "The retry could not be started. Try again later.",
});
}
};
const buildRetryAction = (task: WatchedTask, failedFindingIds?: string[]) =>
failedFindingIds?.length ? (
<ToastAction
altText="Retry failed Findings"
onClick={() => retryFailedFindings(task, failedFindingIds)}
>
Retry failed
</ToastAction>
) : undefined;
export const jiraDispatchTaskHandler: TaskKindHandler = {
onReady: (task) => {
const outcome = evaluateJiraDispatchTask(
"completed",
task.result as JiraDispatchTaskResult | undefined,
);
if (!outcome.success) {
toast({
variant: "destructive",
title: "Jira dispatch failed",
description: outcome.error,
action: buildRetryAction(task, outcome.failedFindingIds),
});
return;
}
toast({
title: outcome.warning ? "Jira dispatch partially completed" : "Success!",
description: outcome.warning ?? outcome.message,
action: buildRetryAction(task, outcome.failedFindingIds),
});
},
onError: (task) => {
toast({
variant: "destructive",
title: "Jira dispatch failed",
description: task.error || "The Jira dispatch task failed unexpectedly.",
});
},
};
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildJiraDispatchChoiceCopy,
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
describe("buildJiraDispatchChoiceCopy", () => {
it("uses Finding Group copy for selected Findings grouped Jira choice", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: true,
}),
).toEqual({
description:
"Create Jira issue(s) for 2 selected Findings from this Finding Group.",
groupedTitle:
"Create one Jira issue for all selected Findings in this Finding Group",
groupedHelp:
"Recommended. The issue will include every selected Finding from this Finding Group.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
});
});
it("preserves resource copy for resource-based grouped Jira choice", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: false,
}),
).toEqual({
description:
"Create Jira issue(s) for 2 selected affected failing resources.",
groupedTitle:
"Create one Jira issue for all selected affected failing resources",
groupedHelp:
"Recommended. The issue will include every selected resource from this finding group.",
individualHelp:
"Use this when each selected resource should be tracked independently.",
});
});
it("uses neutral Findings copy outside a single Finding Group", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: false,
selectionKind: JIRA_SELECTION_KIND.FINDINGS,
}),
).toEqual({
description: "Create Jira issue(s) for 2 selected Findings.",
groupedTitle: "Create one Jira issue for all selected Findings",
groupedHelp:
"Recommended. The issue will include every selected Finding.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
});
});
});
@@ -0,0 +1,59 @@
export const JIRA_SELECTION_KIND = {
FINDINGS: "findings",
RESOURCES: "resources",
} as const;
type JiraSelectionKind =
(typeof JIRA_SELECTION_KIND)[keyof typeof JIRA_SELECTION_KIND];
interface JiraDispatchChoiceCopyParams {
selectedCount: number;
isSelectedFindingGroupFlow: boolean;
selectionKind?: JiraSelectionKind;
}
interface JiraDispatchChoiceCopy {
description: string;
groupedTitle: string;
groupedHelp: string;
individualHelp: string;
}
export const buildJiraDispatchChoiceCopy = ({
selectedCount,
isSelectedFindingGroupFlow,
selectionKind = JIRA_SELECTION_KIND.RESOURCES,
}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
if (isSelectedFindingGroupFlow) {
return {
description: `Create Jira issue(s) for ${selectedCount} selected Findings from this Finding Group.`,
groupedTitle:
"Create one Jira issue for all selected Findings in this Finding Group",
groupedHelp:
"Recommended. The issue will include every selected Finding from this Finding Group.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
};
}
if (selectionKind === JIRA_SELECTION_KIND.FINDINGS) {
return {
description: `Create Jira issue(s) for ${selectedCount} selected Findings.`,
groupedTitle: "Create one Jira issue for all selected Findings",
groupedHelp:
"Recommended. The issue will include every selected Finding.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
};
}
return {
description: `Create Jira issue(s) for ${selectedCount} selected affected failing resources.`,
groupedTitle:
"Create one Jira issue for all selected affected failing resources",
groupedHelp:
"Recommended. The issue will include every selected resource from this finding group.",
individualHelp:
"Use this when each selected resource should be tracked independently.",
};
};
@@ -0,0 +1,183 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
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 {
executeJiraDispatchBatchesMock,
getJiraIntegrationsMock,
getJiraIssueTypesMock,
toastMock,
} = vi.hoisted(() => ({
executeJiraDispatchBatchesMock: vi.fn(),
getJiraIntegrationsMock: vi.fn(),
getJiraIssueTypesMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/integrations/jira-dispatch", () => ({
getJiraIntegrations: getJiraIntegrationsMock,
getJiraIssueTypes: getJiraIssueTypesMock,
}));
vi.mock("@/lib/jira-dispatch-execution", () => ({
executeJiraDispatchBatches: executeJiraDispatchBatchesMock,
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: toastMock,
ToastAction: ({ children }: { children: React.ReactNode }) => (
<button>{children}</button>
),
}));
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: ({
options,
onValueChange,
placeholder,
disabled,
}: {
options: { value: string; label: string }[];
onValueChange: (values: string[]) => void;
placeholder: string;
disabled?: boolean;
}) => (
<button
type="button"
disabled={disabled}
onClick={() => onValueChange([options[0]?.value ?? ""])}
>
{placeholder}
</button>
),
}));
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();
getJiraIntegrationsMock.mockResolvedValue({
success: true,
data: [
{
type: "integrations",
id: "jira-1",
attributes: {
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: null,
integration_type: "jira",
configuration: {
domain: "example.atlassian.net",
projects: { SEC: "Security" },
issue_types: { SEC: ["Task"] },
},
},
links: { self: "/integrations/jira-1" },
},
],
});
getJiraIssueTypesMock.mockResolvedValue({ success: true, issueTypes: [] });
executeJiraDispatchBatchesMock.mockResolvedValue({
startedTaskCount: 2,
successfulTaskCount: 2,
successfulIssueCount: 3,
successMessage: "3 Jira issues were created or updated successfully.",
warnings: [],
errors: [],
});
});
it("renders the dispatch choice and custom mixed-selection description", async () => {
// Given / When
render(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
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.getByText("Create separate Jira issues")).toBeInTheDocument();
expect(
screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
).toBeInTheDocument();
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
});
it("delegates mixed dispatch execution with the selected settings", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={selection}
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
/>,
);
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" }),
);
await user.click(
screen.getByRole("radio", { name: "Create separate Jira issues" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
expect(executeJiraDispatchBatchesMock).toHaveBeenCalledWith(
[
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
],
{
integrationId: "jira-1",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "individual",
},
),
);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
+308 -193
View File
@@ -2,49 +2,93 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Send } from "lucide-react";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { type Dispatch, type SetStateAction, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
getJiraIntegrations,
getJiraIssueTypes,
pollJiraDispatchTask,
sendFindingToJira,
} from "@/actions/integrations/jira-dispatch";
import { useToast } from "@/components/shadcn";
import { CustomBanner } from "@/components/shadcn/custom/custom-banner";
import { CustomRadio } from "@/components/shadcn/custom/custom-radio";
import { Form, FormField, FormMessage } from "@/components/shadcn/form";
import { FormButtons } from "@/components/shadcn/form/form-buttons";
import { Modal } from "@/components/shadcn/modal";
import { RadioGroup } from "@/components/shadcn/radio-group/radio-group";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { IntegrationProps } from "@/types/integrations";
import { toast, ToastAction } from "@/components/shadcn/toast";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
executeJiraDispatchBatches,
type JiraDispatchSettings,
} from "@/lib/jira-dispatch-execution";
import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
import {
type IntegrationProps,
JIRA_DISPATCH_MODE,
JIRA_DISPATCH_TARGET,
type JiraDispatchMode,
type JiraDispatchTargetBatch,
type JiraSelection,
} from "@/types/integrations";
interface SendToJiraModalProps {
import {
buildJiraDispatchChoiceCopy,
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
export interface SendToJiraModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
findingId: string;
selection: JiraSelection;
findingTitle?: string;
defaultDispatchMode?: JiraDispatchMode;
canChooseGroupedDispatch?: boolean;
isFindingGroupSelection?: boolean;
selectedResourceCount?: number;
description?: string;
}
const sendToJiraSchema = z.object({
integration: z.string().min(1, "Please select a Jira integration"),
project: z.string().min(1, "Please select a project"),
issueType: z.string().min(1, "Please select an issue type"),
dispatchMode: z.enum([
JIRA_DISPATCH_MODE.GROUPED,
JIRA_DISPATCH_MODE.INDIVIDUAL,
]),
});
type SendToJiraFormData = z.infer<typeof sendToJiraSchema>;
export const SendToJiraModal = ({
isOpen,
const getConfiguredIssueTypes = (
integration: IntegrationProps | undefined,
projectKey: string,
) => {
const configuredIssueTypes = integration?.attributes.configuration
.issue_types as Record<string, string[]> | undefined;
return configuredIssueTypes &&
typeof configuredIssueTypes === "object" &&
!Array.isArray(configuredIssueTypes)
? (configuredIssueTypes[projectKey] ?? [])
: [];
};
const SendToJiraModalContent = ({
onOpenChange,
findingId,
selection,
findingTitle,
}: SendToJiraModalProps) => {
const { toast } = useToast();
defaultDispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
canChooseGroupedDispatch = false,
isFindingGroupSelection = false,
selectedResourceCount,
description,
}: Omit<SendToJiraModalProps, "isOpen">) => {
const [integrations, setIntegrations] = useState<IntegrationProps[]>([]);
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(true);
const [fetchedIssueTypes, setFetchedIssueTypes] = useState<
Record<string, string[]>
>({});
@@ -56,191 +100,214 @@ export const SendToJiraModal = ({
integration: "",
project: "",
issueType: "",
dispatchMode: defaultDispatchMode,
},
});
const selectedIntegration = form.watch("integration");
const jiraTargetBatches = getJiraSelectionBatches(selection);
const findingTargetCount = jiraTargetBatches
.filter((batch) => batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID)
.reduce((count, batch) => count + batch.targetIds.length, 0);
const jiraSelectedResourceCount = selectedResourceCount ?? findingTargetCount;
const shouldShowDispatchChoice =
(canChooseGroupedDispatch || findingTargetCount > 1) &&
(findingTargetCount > 1 || jiraSelectedResourceCount > 1);
const checkIdBatches = jiraTargetBatches.filter(
(batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
);
const hasOnlySingleFindingGroupBatch =
jiraTargetBatches.length === 1 &&
checkIdBatches.length === 1 &&
checkIdBatches[0].targetIds.length === 1;
const isSelectedFindingGroupFlow =
shouldShowDispatchChoice &&
(isFindingGroupSelection || hasOnlySingleFindingGroupBatch);
const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({
selectedCount:
findingTargetCount > 1 ? findingTargetCount : jiraSelectedResourceCount,
isSelectedFindingGroupFlow,
selectionKind:
findingTargetCount > 1
? JIRA_SELECTION_KIND.FINDINGS
: JIRA_SELECTION_KIND.RESOURCES,
});
const selectedIntegration = form.watch("integration");
const selectedProject = form.watch("project");
const selectedIntegrationData = integrations.find(
(integration) => integration.id === selectedIntegration,
);
const projects =
selectedIntegrationData?.attributes.configuration.projects ?? {};
const projectEntries = Object.entries(projects);
const configuredIssueTypes = getConfiguredIssueTypes(
selectedIntegrationData,
selectedProject,
);
const issueTypesForProject =
configuredIssueTypes.length > 0
? configuredIssueTypes
: (fetchedIssueTypes[`${selectedIntegration}:${selectedProject}`] ?? []);
const hasConnectedIntegration = integrations.some(
(i) => i.attributes.connected === true,
(integration) => integration.attributes.connected === true,
);
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (value) => {
const next = typeof value === "function" ? value(isOpen) : value;
onOpenChange(next);
};
// Fetch Jira integrations when modal opens
useEffect(() => {
if (isOpen) {
const fetchJiraIntegrations = async () => {
setIsFetchingIntegrations(true);
try {
const result = await getJiraIntegrations();
if (!result.success) {
throw new Error(
result.error || "Unable to fetch Jira integrations",
);
}
setIntegrations(result.data);
// Auto-select if only one integration
if (result.data.length === 1) {
form.setValue("integration", result.data[0].id);
}
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: "Failed to load Jira integrations";
toast({
variant: "destructive",
title: "Failed to load integrations",
description: message,
});
} finally {
setIsFetchingIntegrations(false);
}
};
fetchJiraIntegrations();
} else {
// Reset form and fetched data when modal closes
form.reset();
setFetchedIssueTypes({});
}
}, [isOpen, form, toast]);
const handleSubmit = async (data: SendToJiraFormData) => {
// Close modal immediately; continue processing in background
onOpenChange(false);
useMountEffect(() => {
let active = true;
void (async () => {
try {
// Send the finding to Jira
const result = await sendFindingToJira(
data.integration,
findingId,
data.project,
data.issueType,
);
const result = await getJiraIntegrations();
if (!active) return;
if (!result.success) {
throw new Error(result.error || "Failed to send to Jira");
throw new Error(result.error || "Unable to fetch Jira integrations");
}
// Poll for task completion and notify once
const taskResult = await pollJiraDispatchTask(result.taskId);
if (!taskResult.success) {
throw new Error(taskResult.error || "Failed to create Jira issue");
setIntegrations(result.data);
if (result.data.length === 1) {
form.setValue("integration", result.data[0].id);
}
toast({
title: "Success!",
description:
taskResult.message || "Finding sent to Jira successfully",
});
} catch (error) {
if (!active) return;
const message =
error instanceof Error && error.message
? error.message
: "Failed to send finding to Jira";
: "Failed to load Jira integrations";
toast({
variant: "destructive",
title: "Error",
title: "Failed to load integrations",
description: message,
});
} finally {
if (active) setIsFetchingIntegrations(false);
}
})();
};
const selectedProject = form.watch("project");
const selectedIntegrationData = integrations.find(
(i) => i.id === selectedIntegration,
);
const projects: Record<string, string> =
selectedIntegrationData?.attributes.configuration.projects ??
({} as Record<string, string>);
const projectEntries = Object.entries(projects);
// Get issue types from config (new dict format), falling back to fetched data
const configIssueTypes = selectedIntegrationData?.attributes.configuration
.issue_types as Record<string, string[]> | undefined;
const issueTypesFromConfig =
configIssueTypes &&
typeof configIssueTypes === "object" &&
!Array.isArray(configIssueTypes)
? (configIssueTypes[selectedProject] ?? [])
: [];
const issueTypesForProject =
issueTypesFromConfig.length > 0
? issueTypesFromConfig
: (fetchedIssueTypes[selectedProject] ?? []);
// Fetch issue types from API when project is selected but no types are available
useEffect(() => {
let ignore = false;
if (
selectedIntegration &&
selectedProject &&
issueTypesFromConfig.length === 0 &&
!fetchedIssueTypes[selectedProject]
) {
const fetchIssueTypes = async () => {
setIsFetchingIssueTypes(true);
try {
const result = await getJiraIssueTypes(
selectedIntegration,
selectedProject,
);
if (ignore) return;
if (result.success) {
setFetchedIssueTypes((prev) => ({
...prev,
[selectedProject]: result.issueTypes,
}));
} else {
toast({
variant: "destructive",
title: "Failed to load issue types",
description:
result.error || "Unable to fetch issue types for this project",
});
}
} finally {
if (!ignore) setIsFetchingIssueTypes(false);
}
};
fetchIssueTypes();
}
return () => {
ignore = true;
active = false;
};
}, [
selectedIntegration,
selectedProject,
issueTypesFromConfig.length,
fetchedIssueTypes,
toast,
]);
});
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (value) => {
const nextOpen = typeof value === "function" ? value(true) : value;
onOpenChange(nextOpen);
};
const loadIssueTypes = async (integrationId: string, projectKey: string) => {
const integration = integrations.find((item) => item.id === integrationId);
if (
!integrationId ||
!projectKey ||
getConfiguredIssueTypes(integration, projectKey).length > 0 ||
fetchedIssueTypes[`${integrationId}:${projectKey}`]
) {
return;
}
setIsFetchingIssueTypes(true);
try {
const result = await getJiraIssueTypes(integrationId, projectKey);
if (result.success) {
setFetchedIssueTypes((current) => ({
...current,
[`${integrationId}:${projectKey}`]: result.issueTypes,
}));
return;
}
toast({
variant: "destructive",
title: "Failed to load issue types",
description:
result.error || "Unable to fetch issue types for this project",
});
} catch {
toast({
variant: "destructive",
title: "Failed to load issue types",
description: "Unable to fetch issue types for this project",
});
} finally {
setIsFetchingIssueTypes(false);
}
};
async function processBatches(
batches: JiraDispatchTargetBatch[],
settings: JiraDispatchSettings,
) {
const result = await executeJiraDispatchBatches(batches, settings);
const retryBatches = result.retryBatch ? [result.retryBatch] : [];
const retryAction =
retryBatches.length > 0 ? (
<ToastAction
altText="Retry failed Jira dispatches"
onClick={async () => {
toast({
title: "Retry started",
description: "Retrying only the Jira dispatches that failed.",
});
await processBatches(retryBatches, settings);
}}
>
Retry failed
</ToastAction>
) : undefined;
if (result.errors.length > 0 || result.warnings.length > 0) {
if (result.successfulTaskCount > 0) {
toast({
title: "Partial success",
description: `${result.successMessage || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${[
...result.warnings,
...result.errors,
].join(" ")}`,
...(retryAction ? { action: retryAction } : {}),
});
return;
}
toast({
variant: "destructive",
title: "Error",
description: [...result.warnings, ...result.errors].join(" "),
...(retryAction ? { action: retryAction } : {}),
});
return;
}
toast({
title: "Success!",
description: result.successMessage || "Finding sent to Jira successfully",
});
}
const handleSubmit = async (data: SendToJiraFormData) => {
onOpenChange(false);
void processBatches(jiraTargetBatches, {
integrationId: data.integration,
projectKey: data.project,
issueType: data.issueType,
dispatchMode: data.dispatchMode,
}).catch(() => {
toast({
variant: "destructive",
title: "Error",
description:
"The Jira dispatch could not be processed. Check Jira before retrying.",
});
});
};
const issueTypeOptions = issueTypesForProject.map((type) => ({
value: type,
label: type,
}));
const integrationOptions = integrations.map((integration) => ({
value: integration.id,
label: integration.attributes.configuration.domain || integration.id,
}));
const projectOptions = projectEntries.map(([key, name]) => ({
value: key,
label: `${key} - ${name}`,
@@ -248,13 +315,17 @@ export const SendToJiraModal = ({
return (
<Modal
open={isOpen}
open
onOpenChange={onOpenChange}
title="Send Finding to Jira"
description={
findingTitle
? `Create a Jira issue for: "${findingTitle}"`
: "Select integration, project and issue type to create a Jira issue"
description
? description
: shouldShowDispatchChoice
? jiraDispatchChoiceCopy.description
: findingTitle
? `Create a Jira issue for: "${findingTitle}"`
: "Select integration, project and issue type to create a Jira issue"
}
>
<Form {...form}>
@@ -262,7 +333,6 @@ export const SendToJiraModal = ({
onSubmit={form.handleSubmit(handleSubmit)}
className="flex flex-col gap-4"
>
{/* Loading skeleton for project selector */}
{isFetchingIntegrations && (
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-16" />
@@ -270,7 +340,6 @@ export const SendToJiraModal = ({
</div>
)}
{/* Integration Selection */}
{!isFetchingIntegrations && integrations.length > 1 && (
<FormField
control={form.control}
@@ -287,22 +356,20 @@ export const SendToJiraModal = ({
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset dependent fields
field.onChange(values.at(-1) ?? "");
form.setValue("project", "");
form.setValue("issueType", "");
setFetchedIssueTypes({});
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira integration"
searchable={true}
searchable
emptyIndicator="No integrations found."
disabled={isFetchingIntegrations}
hideSelectAll={true}
hideSelectAll
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
closeOnSelect
resetOnDefaultValueChange
/>
<FormMessage className="text-text-error text-xs" />
</div>
@@ -310,7 +377,6 @@ export const SendToJiraModal = ({
/>
)}
{/* Project Selection */}
{!isFetchingIntegrations &&
selectedIntegration &&
projectEntries.length > 0 && (
@@ -329,19 +395,19 @@ export const SendToJiraModal = ({
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset issue type when project changes
const projectKey = values.at(-1) ?? "";
field.onChange(projectKey);
form.setValue("issueType", "");
void loadIssueTypes(selectedIntegration, projectKey);
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
searchable={true}
searchable
emptyIndicator="No projects found."
hideSelectAll={true}
hideSelectAll
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
closeOnSelect
resetOnDefaultValueChange
/>
<FormMessage className="text-text-error text-xs" />
</div>
@@ -349,7 +415,6 @@ export const SendToJiraModal = ({
/>
)}
{/* Issue Type Selection */}
{selectedProject && (
<FormField
control={form.control}
@@ -365,23 +430,22 @@ export const SendToJiraModal = ({
<EnhancedMultiSelect
id="jira-issue-type-select"
options={issueTypeOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
}}
onValueChange={(values) =>
field.onChange(values.at(-1) ?? "")
}
defaultValue={field.value ? [field.value] : []}
placeholder={
isFetchingIssueTypes
? "Loading issue types..."
: "Select an issue type"
}
searchable={true}
searchable
emptyIndicator="No issue types found."
disabled={isFetchingIssueTypes}
hideSelectAll={true}
hideSelectAll
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
closeOnSelect
resetOnDefaultValueChange
/>
<FormMessage className="text-text-error text-xs" />
</div>
@@ -389,7 +453,52 @@ export const SendToJiraModal = ({
/>
)}
{/* No integrations or none connected message */}
{shouldShowDispatchChoice && (
<FormField
control={form.control}
name="dispatchMode"
render={({ field }) => (
<div className="flex flex-col gap-2">
<span className="text-text-neutral-secondary text-xs font-light tracking-tight">
Jira issue creation mode
</span>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
>
<CustomRadio
value={JIRA_DISPATCH_MODE.GROUPED}
ariaLabel="Create one Jira issue"
>
<span className="flex flex-col gap-1">
<span className="text-text-neutral-primary text-sm font-medium">
{jiraDispatchChoiceCopy.groupedTitle}
</span>
<span className="text-text-neutral-secondary text-xs">
{jiraDispatchChoiceCopy.groupedHelp}
</span>
</span>
</CustomRadio>
<CustomRadio
value={JIRA_DISPATCH_MODE.INDIVIDUAL}
ariaLabel="Create separate Jira issues"
>
<span className="flex flex-col gap-1">
<span className="text-text-neutral-primary text-sm font-medium">
Create separate Jira issues
</span>
<span className="text-text-neutral-secondary text-xs">
{jiraDispatchChoiceCopy.individualHelp}
</span>
</span>
</CustomRadio>
</RadioGroup>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
{!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) ? (
<CustomBanner
@@ -421,3 +530,9 @@ export const SendToJiraModal = ({
</Modal>
);
};
export const SendToJiraModal = ({ isOpen, ...props }: SendToJiraModalProps) => {
if (!isOpen) return null;
return <SendToJiraModalContent {...props} />;
};
@@ -5,7 +5,11 @@ import type {
InputHTMLAttributes,
ReactNode,
} from "react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
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", () => ({
@@ -14,8 +18,7 @@ vi.mock("@/components/shadcn/custom/custom-link", () => ({
),
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
vi.mock("@/components/shadcn", () => ({
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
@@ -41,22 +44,6 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: ({
findingId,
isOpen,
}: {
findingId: string;
isOpen: boolean;
}) => (
<div
data-testid="jira-modal"
data-finding-id={findingId}
data-open={isOpen ? "true" : "false"}
/>
),
}));
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
@@ -69,12 +56,14 @@ vi.mock("@/components/shadcn/dropdown", () => ({
label,
onSelect,
disabled,
disabledTooltip,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
disabledTooltip?: string;
}) => (
<button disabled={disabled} onClick={onSelect}>
<button disabled={disabled} onClick={onSelect} title={disabledTooltip}>
{label}
</button>
),
@@ -175,6 +164,11 @@ vi.mock("@/lib/date-utils", () => ({
getFailingForLabel: () => "2d",
}));
vi.mock("@/lib/deployment", () => ({
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
}));
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
vi.mock("./notification-indicator", () => ({
@@ -184,6 +178,7 @@ vi.mock("./notification-indicator", () => ({
},
}));
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import type { FindingResourceRow } from "@/types";
import {
FINDING_TRIAGE_DISABLED_REASON,
@@ -284,6 +279,12 @@ function renderResourceActionsCell({
}
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", () => {
// Given
const columns = getColumnFindingResources({
@@ -296,6 +297,7 @@ describe("column-finding-resources", () => {
// Then
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
expect(columnIds).not.toContain("status");
expect(columnIds).not.toContain("notes");
expect(
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
@@ -473,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();
@@ -506,16 +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",
);
expect(useJiraDispatchStore.getState().activePayload?.selection).toEqual({
kind: "single",
targetId: "real-finding-uuid",
targetType: "finding_id",
});
});
});
@@ -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,
@@ -18,16 +17,15 @@ import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { SeverityBadge } from "@/components/shadcn/table";
import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header";
import {
type FindingStatus,
StatusFindingBadge,
} from "@/components/shadcn/table/status-finding-badge";
import { getFailingForLabel } from "@/lib/date-utils";
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_TARGET } from "@/types/integrations";
import { canMuteFindingResource } from "./finding-resource-selection";
import {
@@ -57,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);
@@ -83,6 +80,19 @@ const ResourceRowActions = ({
if (ids.length > 1) return `Mute ${ids.length}`;
return "Mute";
};
const displayIds = getDisplayIds();
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();
@@ -123,12 +133,6 @@ const ResourceRowActions = ({
onComplete={handleMuteComplete}
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={resource.findingId}
findingTitle={resource.checkId}
/>
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
@@ -159,10 +163,11 @@ const ResourceRowActions = ({
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
<JiraDispatchActionItem
label={buildJiraActionLabel({
findingCount: displayIds.length,
})}
payload={jiraPayload}
/>
</ActionDropdown>
</div>
@@ -243,24 +248,14 @@ export function getColumnFindingResources({
enableSorting: false,
enableHiding: false,
},
// Status
{
id: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
return (
<StatusFindingBadge status={row.original.status as FindingStatus} />
);
},
enableSorting: false,
},
// Resource — name + uid
// Affected failing resource — name + uid
{
id: "resource",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource" />
<DataTableColumnHeader
column={column}
title="Affected failing resource"
/>
),
cell: ({ row }) => (
<div className="max-w-[240px]">
@@ -2,8 +2,21 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
DataTableRowActions,
type FindingRowData,
} from "./data-table-row-actions";
import { FindingsSelectionContext } from "./findings-selection-context";
const { MuteFindingsModalMock } = vi.hoisted(() => ({
MuteFindingsModalMock: vi.fn(() => null),
MuteFindingsModalMock: vi.fn((_props: unknown) => null),
}));
vi.mock("next/navigation", () => ({
@@ -14,14 +27,15 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: MuteFindingsModalMock,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
vi.mock("@/lib/deployment", () => ({
isGroupedJiraDispatchEnabled: () => true,
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
@@ -74,18 +88,6 @@ vi.mock("./finding-note-modal", () => ({
) : null,
}));
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
DataTableRowActions,
type FindingRowData,
} from "./data-table-row-actions";
import { FindingsSelectionContext } from "./findings-selection-context";
function deferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -134,6 +136,7 @@ function makeFindingRow(overrides?: Partial<FindingRowData>) {
describe("DataTableRowActions", () => {
beforeEach(() => {
vi.clearAllMocks();
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("opens the mute modal immediately in preparing state for finding groups", async () => {
@@ -176,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"],
@@ -219,6 +199,7 @@ describe("DataTableRowActions", () => {
});
it("disables the mute action for groups without impacted resources", () => {
// Given / When
render(
<FindingsSelectionContext.Provider
value={{
@@ -247,11 +228,60 @@ describe("DataTableRowActions", () => {
</FindingsSelectionContext.Provider>,
);
// Then
expect(
screen.getByRole("button", { name: "Mute Finding Group" }),
).toBeDisabled();
});
it("opens Jira from the row action for a finding group", 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: 2,
resourcesTotal: 2,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Send 1 Finding Group to Jira" }),
);
// Then
expect(useJiraDispatchStore.getState().activePayload).toEqual({
selection: {
kind: "single",
targetId: "s3_bucket_public_access",
targetType: "check_id",
},
findingTitle: "S3 bucket public access",
selectedResourceCount: 2,
});
});
it("shows Add Triage Note for editable findings without a note", () => {
// Given / When
render(
@@ -5,20 +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 { 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 type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import type { ProviderType } from "@/types/providers";
import { canMuteFindingGroup } from "./finding-group-selection";
@@ -109,7 +111,6 @@ 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<
@@ -166,6 +167,36 @@ export function DataTableRowActions<T extends FindingRowData>({
return isGroup ? "Mute Finding Group" : "Mute Finding";
};
const getJiraTargetIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
return selectedFindingIds;
}
return [muteKey];
};
const jiraTargetIds = getJiraTargetIds();
const jiraTargetType = isGroup
? JIRA_DISPATCH_TARGET.CHECK_ID
: JIRA_DISPATCH_TARGET.FINDING_ID;
const jiraSelection = createJiraTargetSelection(
jiraTargetIds,
jiraTargetType,
);
const selectedJiraResourceCount = isGroup
? (finding.resourcesFail ?? 0)
: undefined;
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),
) => {
@@ -228,15 +259,6 @@ export function DataTableRowActions<T extends FindingRowData>({
return (
<>
{!isGroup && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={findingTitle}
/>
)}
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={handleMuteModalOpenChange}
@@ -274,13 +296,7 @@ export function DataTableRowActions<T extends FindingRowData>({
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
{!isGroup && (
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
)}
<JiraDispatchActionItem label={jiraLabel} payload={jiraPayload} />
</ActionDropdown>
</div>
</>
@@ -1,16 +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");
});
});
@@ -30,9 +30,12 @@ import {
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_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";
@@ -113,6 +116,18 @@ export function FindingsGroupDrillDown({
const impactedCounts = getFindingGroupImpactedCounts(group);
const rows = table.getRowModel().rows;
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
@@ -127,7 +142,7 @@ export function FindingsGroupDrillDown({
>
<div
className={cn(
"minimal-scrollbar border-border-neutral-secondary bg-bg-neutral-secondary rounded-[14px] shadow-sm",
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary",
"flex w-full flex-col overflow-auto border",
)}
>
@@ -231,15 +246,22 @@ export function FindingsGroupDrillDown({
</div>
</div>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
{selectedFindingIds.length > 0 && jiraPayload && (
<FloatingSelectionActions
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
}`}
onBeforeOpen={async () => {
return resolveSelectedFindingIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
isBulkOperation
isBulkOperation={selectedFindingIds.length > 1}
jiraPayload={jiraPayload}
jiraLabel={buildJiraActionLabel({
findingCount: selectedFindingIds.length,
})}
/>
)}
@@ -1,13 +1,35 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { Fragment, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
import { FindingsGroupTable } from "./findings-group-table";
const {
FloatingSelectionActionsMock,
setOnDrillDownMock,
triggerOnDrillDownMock,
} = vi.hoisted(() => {
let onDrillDown: ((checkId: string, group: unknown) => void) | undefined;
return {
FloatingSelectionActionsMock: vi.fn((_props: unknown) => null),
setOnDrillDownMock: vi.fn(
(handler: ((checkId: string, group: unknown) => void) | undefined) => {
onDrillDown = handler;
},
),
triggerOnDrillDownMock: vi.fn((checkId: string, group: unknown) => {
onDrillDown?.(checkId, group);
}),
};
});
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/findings",
}));
@@ -17,6 +39,8 @@ vi.mock("@/components/shadcn/table", () => ({
data,
toolbarRightContent,
getRowAttributes,
onRowSelectionChange,
renderAfterRow,
}: {
data?: Array<{ checkId?: string }>;
toolbarRightContent?: ReactNode;
@@ -24,20 +48,49 @@ vi.mock("@/components/shadcn/table", () => ({
index: number;
original: { checkId?: string };
}) => Record<string, string | undefined>;
onRowSelectionChange?: (
updater: (previous: Record<string, boolean>) => Record<string, boolean>,
) => void;
renderAfterRow?: (row: {
index: number;
original: { checkId?: string };
}) => ReactNode;
}) => (
<div>
<div data-testid="table-toolbar-right">{toolbarRightContent}</div>
<span>10 Total Entries</span>
<table>
<tbody>
{(data ?? []).map((original, index) => (
<tr
key={original.checkId ?? index}
data-testid={`row-${index}`}
{...getRowAttributes?.({ index, original })}
>
<td>{original.checkId}</td>
</tr>
<Fragment key={original.checkId ?? index}>
<tr
data-testid={`row-${index}`}
{...getRowAttributes?.({ index, original })}
>
<td>{original.checkId}</td>
<td>
<button
type="button"
onClick={() =>
onRowSelectionChange?.((previous) => ({
...previous,
[index]: true,
}))
}
>
Select {original.checkId}
</button>
<button
type="button"
onClick={() =>
triggerOnDrillDownMock(original.checkId ?? "", original)
}
>
Expand {original.checkId}
</button>
</td>
</tr>
{renderAfterRow?.({ index, original })}
</Fragment>
))}
</tbody>
</table>
@@ -64,109 +117,242 @@ vi.mock("@/actions/findings/findings-by-resource", () => ({
}));
vi.mock("./column-finding-groups", () => ({
getColumnFindingGroups: () => [],
getColumnFindingGroups: ({
onDrillDown,
}: {
onDrillDown?: (checkId: string, group: unknown) => void;
}) => {
setOnDrillDownMock(onDrillDown);
return [];
},
}));
vi.mock("./inline-resource-container", () => ({
InlineResourceContainer: () => null,
InlineResourceContainer: ({
onResourceSelectionChange,
}: {
onResourceSelectionChange?: (selectedResourceIds: string[]) => void;
}) => (
<tr>
<td>
<button
type="button"
onClick={() => onResourceSelectionChange?.(["finding-1"])}
>
Select finding-1
</button>
<button
type="button"
onClick={() =>
onResourceSelectionChange?.(["finding-1", "finding-2"])
}
>
Select findings 1 and 2
</button>
</td>
</tr>
),
}));
vi.mock("../floating-mute-button", () => ({
FloatingMuteButton: () => null,
vi.mock("../floating-selection-actions", () => ({
FloatingSelectionActions: FloatingSelectionActionsMock,
}));
function makeGroup(checkId: string, resourcesFail = 2) {
return {
checkId,
checkTitle: `Title ${checkId}`,
resourcesFail,
resourcesTotal: Math.max(resourcesFail, 1),
mutedCount: 0,
} as unknown as Parameters<typeof FindingsGroupTable>[0]["data"][number];
}
function getLastFloatingActionsProps(): {
jiraPayload: JiraDispatchModalPayload;
onBeforeOpen: () => Promise<string[]>;
} {
const props = FloatingSelectionActionsMock.mock.calls.at(-1)?.[0];
expect(props).toBeDefined();
return props as unknown as {
jiraPayload: JiraDispatchModalPayload;
onBeforeOpen: () => Promise<string[]>;
};
}
describe("FindingsGroupTable", () => {
describe("toolbar", () => {
it("should render the muted findings checkbox inside the table toolbar", () => {
// Given
render(
<FindingsGroupTable
data={[]}
metadata={{
pagination: {
page: 1,
pages: 1,
count: 10,
},
version: "v1",
}}
resolvedFilters={{ "filter[muted]": "false" }}
hasHistoricalData={false}
/>,
);
beforeEach(() => {
vi.clearAllMocks();
});
// When
const toolbar = screen.getByTestId("table-toolbar-right");
it("renders the muted findings filter in the table toolbar", () => {
// Given / When
render(
<FindingsGroupTable
data={[]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(
screen.getByRole("checkbox", { name: "Include muted findings" }),
).toBeInTheDocument();
expect(toolbar).toHaveTextContent("Include muted findings");
// Then
expect(
screen.getByRole("checkbox", { name: "Include muted findings" }),
).toBeInTheDocument();
});
it("mounts the tour only when finding groups exist", () => {
// Given / When
const { rerender } = render(
<FindingsGroupTable
data={[]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(screen.queryByTestId("onboarding-trigger")).not.toBeInTheDocument();
expect(screen.getByTestId("page-ready")).toBeInTheDocument();
// When
rerender(
<FindingsGroupTable
data={[makeGroup("check-a")]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
});
it("anchors the finding-group tour to the first row only", () => {
// Given / When
render(
<FindingsGroupTable
data={[makeGroup("check-a"), makeGroup("check-b")]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(screen.getByTestId("row-0")).toHaveAttribute(
"data-tour-id",
"explore-findings-group",
);
expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
});
it("opens a drillable group from the expanded deep link", () => {
// Given / When
render(
<FindingsGroupTable
data={[makeGroup("check-a"), makeGroup("check-b")]}
resolvedFilters={{}}
hasHistoricalData={false}
expandedCheckId="check-b"
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Select finding-1" }),
).toBeInTheDocument();
});
it("builds separate Jira batches for selected groups and child findings", async () => {
// Given
const user = userEvent.setup();
render(
<FindingsGroupTable
data={[makeGroup("check-a"), makeGroup("check-b")]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Select check-a" }));
await user.click(screen.getByRole("button", { name: "Expand check-b" }));
await user.click(screen.getByRole("button", { name: "Select finding-1" }));
// Then
expect(getLastFloatingActionsProps().jiraPayload.selection).toEqual({
kind: "batches",
batches: [
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1"],
targetType: "finding_id",
dispatchMode: "individual",
},
],
});
});
describe("explore-findings tour gating", () => {
it("does not mount the tour trigger when there are no finding groups", () => {
// Given an empty table (e.g. a scan is still running)
render(
<FindingsGroupTable
data={[]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
it("keeps resource-only Jira selections scoped to the expanded group", async () => {
// Given
const user = userEvent.setup();
render(
<FindingsGroupTable
data={[makeGroup("check-a")]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then the tour never starts — there is no first-row anchor for the
// "Open a finding group" step to resolve, which would otherwise throw.
expect(
screen.queryByTestId("onboarding-trigger"),
).not.toBeInTheDocument();
// PageReady still signals the navbar that the route's data has loaded.
expect(screen.getByTestId("page-ready")).toBeInTheDocument();
});
// When
await user.click(screen.getByRole("button", { name: "Expand check-a" }));
await user.click(
screen.getByRole("button", { name: "Select findings 1 and 2" }),
);
it("mounts the tour trigger once at least one finding group exists", () => {
// Given a populated table
const data = [{ checkId: "check-a" }] as unknown as Parameters<
typeof FindingsGroupTable
>[0]["data"];
render(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then the explore-findings tour is allowed to start.
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
// Then
expect(getLastFloatingActionsProps().jiraPayload).toMatchObject({
selection: {
kind: "target-list",
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
findingTitle: "Title check-a",
isFindingGroupSelection: true,
selectedResourceCount: 2,
});
});
describe("onboarding anchor", () => {
it("anchors the finding-group tour step to the first row only", () => {
// Given two finding groups (the tour must point at the first, even if there is one)
const data = [
{ checkId: "check-a" },
{ checkId: "check-b" },
] as unknown as Parameters<typeof FindingsGroupTable>[0]["data"];
it("resolves group selections through the visible-resource query before muting", async () => {
// Given
vi.mocked(resolveFindingIdsByVisibleGroupResources).mockResolvedValue([
"finding-a",
"finding-b",
]);
const user = userEvent.setup();
render(
<FindingsGroupTable
data={[makeGroup("check-a")]}
resolvedFilters={{ "filter[severity]": "high" }}
hasHistoricalData={false}
/>,
);
render(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Select check-a" }));
const resolvedIds = await getLastFloatingActionsProps().onBeforeOpen();
// Then driver.js resolves `[data-tour-id="explore-findings-group"]` to the first row.
expect(screen.getByTestId("row-0")).toHaveAttribute(
"data-tour-id",
"explore-findings-group",
);
expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
// Then
expect(resolvedIds).toEqual(["finding-a", "finding-b"]);
expect(resolveFindingIdsByVisibleGroupResources).toHaveBeenCalledWith({
checkId: "check-a",
filters: { "filter[severity]": "high" },
hasDateOrScanFilter: false,
resourceSearch: undefined,
});
});
});
@@ -9,11 +9,17 @@ import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbo
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTable } from "@/components/shadcn/table";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import {
createJiraBatchSelection,
createJiraTargetSelection,
} from "@/lib/jira-dispatch-selection";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
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";
@@ -24,18 +30,42 @@ import {
} from "./inline-resource-container";
const exploreFindingsFlow = getFlowById("explore-findings")!;
const EMPTY_FINDING_GROUPS: FindingGroupRow[] = [];
function buildMuteLabel(groupCount: number, resourceCount: number): string {
const parts: string[] = [];
if (groupCount > 0) {
parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`);
}
if (resourceCount > 0) {
parts.push(
`${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`,
);
}
return `Mute ${parts.join(" and ")}`;
function buildSelectionSummary(
groupCount: number,
findingCount: number,
): string {
return `${buildSelectionEntityLabel(groupCount, findingCount)} selected`;
}
function buildMuteActionLabel(
groupCount: number,
findingCount: number,
): string {
return `Mute ${buildSelectionEntityLabel(groupCount, findingCount)}`;
}
function buildSelectionEntityLabel(
groupCount: number,
findingCount: number,
): string {
const parts = [
buildEntityCountLabel(groupCount, "Group", "Groups"),
buildEntityCountLabel(findingCount, "Finding", "Findings"),
].filter(Boolean);
return parts.join(" and ");
}
function buildEntityCountLabel(
count: number,
singular: string,
plural: string,
): string | null {
if (count === 0) return null;
return `${count} ${count === 1 ? singular : plural}`;
}
interface FindingsGroupTableProps {
@@ -43,6 +73,7 @@ interface FindingsGroupTableProps {
metadata?: MetaDataProps;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
expandedCheckId?: string;
}
export function FindingsGroupTable({
@@ -50,22 +81,67 @@ export function FindingsGroupTable({
metadata,
resolvedFilters,
hasHistoricalData,
expandedCheckId: requestedExpandedCheckId,
}: FindingsGroupTableProps) {
const safeData = data ?? EMPTY_FINDING_GROUPS;
const requestedGroup = requestedExpandedCheckId
? safeData.find((group) => group.checkId === requestedExpandedCheckId)
: undefined;
const initialExpandedCheckId =
requestedGroup && canDrillDownFindingGroup(requestedGroup)
? requestedGroup.checkId
: null;
return (
<FindingsGroupTableContent
key={`${requestedExpandedCheckId ?? "manual"}:${initialExpandedCheckId ?? "collapsed"}`}
data={safeData}
metadata={metadata}
resolvedFilters={resolvedFilters}
hasHistoricalData={hasHistoricalData}
initialExpandedCheckId={initialExpandedCheckId}
/>
);
}
interface FindingsGroupTableContentProps {
data: FindingGroupRow[];
metadata?: MetaDataProps;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
initialExpandedCheckId: string | null;
}
const FindingsGroupTableContent = ({
data,
metadata,
resolvedFilters,
hasHistoricalData,
initialExpandedCheckId,
}: FindingsGroupTableContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
null,
);
const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState<
string | null
>(initialExpandedCheckId);
// Separate input (keystroke) from committed search (Enter) to avoid remounting InlineResourceContainer.
const [resourceSearchInput, setResourceSearchInput] = useState("");
const [resourceSearch, setResourceSearch] = useState("");
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
const inlineRef = useRef<InlineResourceContainerHandle>(null);
const safeData = data ?? [];
const hasResourceSelection = resourceSelection.length > 0;
const safeData = data ?? EMPTY_FINDING_GROUPS;
const expandedGroupCandidate = selectedExpandedCheckId
? safeData.find((group) => group.checkId === selectedExpandedCheckId)
: undefined;
const expandedGroup =
expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate)
? expandedGroupCandidate
: null;
const expandedCheckId = expandedGroup?.checkId ?? null;
const activeResourceSelection = expandedCheckId ? resourceSelection : [];
const hasResourceSelection = activeResourceSelection.length > 0;
const filters = resolvedFilters;
// Exclude expanded group from group-level mutes when it has resource selections.
@@ -82,6 +158,70 @@ export function FindingsGroupTable({
.map((idx) => safeData[parseInt(idx)])
.filter(Boolean);
const selectedGroupTitle =
selectedFindings.length === 1 ? selectedFindings[0]?.checkTitle : undefined;
const hasMixedJiraSelection =
selectedCheckIds.length > 0 && hasResourceSelection;
const jiraGroupSelectionTakesPrecedence = selectedCheckIds.length > 0;
const jiraTargetIds = jiraGroupSelectionTakesPrecedence
? selectedCheckIds
: activeResourceSelection;
const jiraTargetType = jiraGroupSelectionTakesPrecedence
? JIRA_DISPATCH_TARGET.CHECK_ID
: JIRA_DISPATCH_TARGET.FINDING_ID;
const singleSelectedGroup =
selectedCheckIds.length === 1
? selectedFindings.find(
(finding) => finding.checkId === selectedCheckIds[0],
)
: undefined;
const selectedJiraResourceCount = jiraGroupSelectionTakesPrecedence
? singleSelectedGroup
? singleSelectedGroup.resourcesFail
: selectedCheckIds.length
: activeResourceSelection.length;
const jiraTitle = hasMixedJiraSelection
? undefined
: jiraGroupSelectionTakesPrecedence
? selectedGroupTitle
: expandedGroup?.checkTitle;
const jiraSelection = hasMixedJiraSelection
? createJiraBatchSelection([
{
targetIds: selectedCheckIds,
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: activeResourceSelection,
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
...(activeResourceSelection.length > 1
? {}
: { dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL }),
},
])
: createJiraTargetSelection(jiraTargetIds, jiraTargetType);
const jiraDescription = hasMixedJiraSelection
? `Create Jira issues for ${buildSelectionEntityLabel(
selectedCheckIds.length,
activeResourceSelection.length,
)}.`
: undefined;
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({
resourcesFail: g.resourcesFail,
@@ -146,16 +286,14 @@ export function FindingsGroupTable({
handleCollapse();
return;
}
setExpandedCheckId(checkId);
setExpandedGroup(group);
setSelectedExpandedCheckId(checkId);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
};
const handleCollapse = () => {
setExpandedCheckId(null);
setExpandedGroup(null);
setSelectedExpandedCheckId(null);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
@@ -255,29 +393,39 @@ export function FindingsGroupTable({
/>
</div>
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
<FloatingMuteButton
selectedCount={selectedCheckIds.length + resourceSelection.length}
selectedFindingIds={[...selectedCheckIds, ...resourceSelection]}
label={buildMuteLabel(
{(selectedCheckIds.length > 0 || hasResourceSelection) && jiraPayload && (
<FloatingSelectionActions
selectedCount={
selectedCheckIds.length + activeResourceSelection.length
}
selectedFindingIds={[...selectedCheckIds, ...activeResourceSelection]}
label={buildSelectionSummary(
selectedCheckIds.length,
resourceSelection.length,
activeResourceSelection.length,
)}
muteLabel={buildMuteActionLabel(
selectedCheckIds.length,
activeResourceSelection.length,
)}
onBeforeOpen={async () => {
const [groupIds, resourceIds] = await Promise.all([
selectedCheckIds.length > 0
? resolveGroupMuteIds(selectedCheckIds)
: Promise.resolve([]),
Promise.resolve(hasResourceSelection ? resourceSelection : []),
Promise.resolve(
hasResourceSelection ? activeResourceSelection : [],
),
]);
return [...groupIds, ...resourceIds];
}}
onComplete={handleMuteComplete}
isBulkOperation={
selectedCheckIds.length > 0 || resourceSelection.length > 1
selectedCheckIds.length > 0 || activeResourceSelection.length > 1
}
jiraPayload={jiraPayload}
jiraLabel={sendToJiraLabel}
/>
)}
</FindingsSelectionContext.Provider>
);
}
};
@@ -91,11 +91,7 @@ function ResourceSkeletonRow({
<div className="bg-bg-input-primary border-border-input-primary size-5 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
</div>
</TableCell>
{/* Status */}
<TableCell className={cellClassName}>
<Skeleton className="h-6 w-11 rounded-md" />
</TableCell>
{/* Resource: name + uid */}
{/* Affected failing resource: name + uid */}
<TableCell className={cellClassName}>
<div className="space-y-1.5">
<Skeleton className="h-4 w-32 rounded" />
@@ -665,7 +665,9 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => {
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
).toBeInTheDocument();
expect(
within(row as HTMLElement).getByRole("button", { name: "Send to Jira" }),
within(row as HTMLElement).getByRole("button", {
name: "Send 1 Finding to Jira",
}),
).toBeInTheDocument();
});
@@ -702,7 +704,7 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => {
const row = screen.getByText("EC2 Check").closest("tr");
expect(row).not.toBeNull();
const actionsCell = within(row as HTMLElement)
.getByRole("button", { name: "Send to Jira" })
.getByRole("button", { name: "Send 1 Finding to Jira" })
.closest("td");
// Then
@@ -21,11 +21,10 @@ import {
type ResourceDrawerFinding,
updateFindingTriage,
} from "@/actions/findings";
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MarkdownContainer } from "@/components/findings/markdown-container";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { getComplianceIcon } from "@/components/icons";
import { JiraIcon } from "@/components/icons/services/IconServices";
import {
Badge,
Button,
@@ -73,12 +72,15 @@ 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";
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { FindingResourceRow } from "@/types/findings-table";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { Muted } from "../../muted";
import { DeltaIndicator } from "../delta-indicator";
@@ -361,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,
);
@@ -411,6 +412,15 @@ export function ResourceDetailDrawerContent({
// During carousel navigation we only trust row-backed data until the next
// finding payload is fully ready, otherwise stale details flash briefly.
const f = isNavigating ? null : currentFinding;
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;
@@ -545,15 +555,6 @@ export function ResourceDetailDrawerContent({
}}
/>
)}
{f && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={f.id}
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">
@@ -867,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
@@ -1567,8 +1567,11 @@ 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],
JIRA_DISPATCH_TARGET.FINDING_ID,
);
const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`;
@@ -1585,12 +1588,6 @@ function OtherFindingRow({
}}
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={finding.checkTitle}
/>
<TableRow
className="group cursor-pointer"
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
@@ -1659,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,9 +406,12 @@ export const ResourceDetailContent = ({
isLoading={findingsLoading}
/>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
<FloatingSelectionActions
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
}`}
onComplete={handleMuteComplete}
/>
)}
+7 -2
View File
@@ -4,12 +4,17 @@ import { RadioGroupItem } from "@/components/shadcn/radio-group/radio-group";
import { cn } from "@/lib/utils";
interface CustomRadioProps {
ariaLabel?: string;
description?: string;
value?: string;
children?: React.ReactNode;
}
export const CustomRadio = ({ value, children }: CustomRadioProps) => {
export const CustomRadio = ({
ariaLabel,
value,
children,
}: CustomRadioProps) => {
return (
<label
className={cn(
@@ -18,7 +23,7 @@ export const CustomRadio = ({ value, children }: CustomRadioProps) => {
"has-[[data-state=checked]]:border-button-primary",
)}
>
<RadioGroupItem value={value || ""} />
<RadioGroupItem value={value || ""} aria-label={ariaLabel} />
<span>{children}</span>
</label>
);
@@ -5,6 +5,8 @@ import { ComponentProps, ReactNode, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip";
import {
DropdownMenu,
DropdownMenuContent,
@@ -104,6 +106,10 @@ interface ActionDropdownItemProps
description?: string;
/** Whether the item is destructive (danger styling) */
destructive?: boolean;
/** Tooltip shown while the item remains interactive. */
tooltip?: string;
/** Tooltip shown when the item is disabled. */
disabledTooltip?: string;
}
export function ActionDropdownItem({
@@ -112,9 +118,13 @@ export function ActionDropdownItem({
description,
destructive = false,
className,
tooltip,
disabledTooltip,
disabled,
onSelect,
...props
}: ActionDropdownItemProps) {
return (
const item = (
<DropdownMenuItem
className={cn(
"hover:bg-bg-neutral-tertiary flex cursor-pointer items-start gap-2 rounded-md transition-colors",
@@ -122,6 +132,16 @@ export function ActionDropdownItem({
"text-text-error-primary focus:text-text-error-primary hover:bg-destructive/10",
className,
)}
aria-disabled={disabled || undefined}
disabled={disabled && !disabledTooltip}
onSelect={(event) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.(event);
}}
{...props}
>
{icon && (
@@ -149,6 +169,19 @@ export function ActionDropdownItem({
</div>
</DropdownMenuItem>
);
const tooltipContent = tooltip ?? (disabled ? disabledTooltip : undefined);
if (tooltipContent) {
return (
<Tooltip>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
);
}
return item;
}
export function ActionDropdownDangerZone({
@@ -116,6 +116,38 @@ describe("CloudUpgradeModal", () => {
);
});
it("renders the contextual Jira dispatch upgrade", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
// When
render(<CloudUpgradeModal />);
// Then
expect(
await screen.findByRole("dialog", {
name: "Send Findings to Jira at Scale",
}),
).toBeVisible();
expect(
screen.getByRole("link", {
name: "Send Findings to Jira in Prowler Cloud",
}),
).toHaveAttribute(
"href",
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=jira-dispatch",
);
expect(
screen.getByRole("link", { name: "View Plans & Pricing" }),
).toHaveAttribute(
"href",
"https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=jira-dispatch",
);
});
it("closes the active upgrade and returns focus to its trigger", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
@@ -4,16 +4,19 @@ import {
CROSS_PROVIDER_PDF_TASK_KIND,
crossProviderPdfHandler,
} from "@/app/(prowler)/compliance/_lib/cross-provider-pdf";
import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
registerTaskKindHandler,
resumePendingTasks,
} from "@/store/task-watcher/store";
import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations";
// Kind registrations happen at module scope, before any task can settle in
// this tab. Adding a new watched task kind (integration tests, scan exports,
// …) is one line here plus a handler next to the feature that owns it.
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
/**
* Mounted once in the app layout (next to `Toaster`): resumes polling any
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import {
toast as shadcnToast,
Toaster as ShadcnToaster,
} from "@/components/shadcn/toast";
import { toast as uiToast, Toaster as UiToaster } from "@/components/ui/toast";
describe("components/ui/toast", () => {
it("uses the mounted shadcn toast store and provider", () => {
expect(uiToast).toBe(shadcnToast);
expect(UiToaster).toBe(ShadcnToaster);
});
});
+61
View File
@@ -98,6 +98,25 @@ describe("useFilterBatch", () => {
"filter[delta]": ["new"],
});
});
it("should parse filter[check_id] from grouped finding deep links", () => {
// Given - URL produced by the grouped finding resources panel deep link.
setSearchParams({
"filter[check_id]": "teams_external_users_can_join",
expandedCheckId: "teams_external_users_can_join",
});
// When
const { result } = renderHook(() => useFilterBatch());
// Then - expandedCheckId is not a filter chip, but check_id is URL-backed.
expect(result.current.pendingFilters).toEqual({
"filter[check_id]": ["teams_external_users_can_join"],
});
expect(result.current.getFilterValue("filter[check_id]")).toEqual([
"teams_external_users_can_join",
]);
});
});
// ── Excluded keys ──────────────────────────────────────────────────────────
@@ -179,6 +198,30 @@ describe("useFilterBatch", () => {
// Then
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
});
it("should replace exclusive legacy filters when the new grouped filter is edited", () => {
// Given - a legacy deep link with the exact check_id filter applied.
setSearchParams({
"filter[check_id]": "teams_external_users_can_join",
});
const { result } = renderHook(() =>
useFilterBatch({
exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
}),
);
// When - the Finding Group multi-select writes the __in filter.
act(() => {
result.current.setPending("filter[check_id__in]", [
"teams_external_users_cannot_join",
]);
});
// Then - the stale exact filter is removed from pending state.
expect(result.current.pendingFilters).toEqual({
"filter[check_id__in]": ["teams_external_users_cannot_join"],
});
});
});
// ── getFilterValue ─────────────────────────────────────────────────────────
@@ -227,6 +270,24 @@ describe("useFilterBatch", () => {
// Then
expect(values).toEqual(["critical"]);
});
it("should expose legacy exclusive filter values through the replacement key", () => {
// Given - a legacy grouped finding deep link uses filter[check_id].
setSearchParams({
"filter[check_id]": "teams_external_users_can_join",
});
const { result } = renderHook(() =>
useFilterBatch({
exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
}),
);
// When - the new Finding Group control asks for filter[check_id__in].
const values = result.current.getFilterValue("filter[check_id__in]");
// Then - the existing exact value appears selected in the control.
expect(values).toEqual(["teams_external_users_can_join"]);
});
});
// ── hasChanges & changeCount ───────────────────────────────────────────────
+50 -9
View File
@@ -151,6 +151,23 @@ export interface UseFilterBatchOptions {
* (e.g. `{ "filter[muted]": "false" }` on the Findings page).
*/
defaultParams?: Record<string, string>;
/**
* Filter keys that represent the same logical control. Updating one removes
* the others so legacy exact params and new multi-select params cannot be
* applied together.
*/
exclusiveFilterGroups?: string[][];
}
function normalizeFilterKey(key: string): string {
return key.startsWith("filter[") ? key : `filter[${key}]`;
}
function getExclusiveFilterGroup(
filterKey: string,
exclusiveFilterGroups: string[][] | undefined,
): string[] | undefined {
return exclusiveFilterGroups?.find((group) => group.includes(filterKey));
}
/**
@@ -187,15 +204,27 @@ export const useFilterBatch = (
}, [searchParams]);
const setPending = (key: string, values: string[]) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
setPendingFilters((prev) => ({
...prev,
[filterKey]: values,
}));
const filterKey = normalizeFilterKey(key);
const exclusiveGroup = getExclusiveFilterGroup(
filterKey,
options?.exclusiveFilterGroups,
);
setPendingFilters((prev) => {
const next = { ...prev };
exclusiveGroup
?.filter((exclusiveKey) => exclusiveKey !== filterKey)
.forEach((exclusiveKey) => {
delete next[exclusiveKey];
});
next[filterKey] = values;
return next;
});
};
const removePending = (key: string) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const filterKey = normalizeFilterKey(key);
setPendingFilters((prev) => {
const next = { ...prev };
delete next[filterKey];
@@ -265,7 +294,7 @@ export const useFilterBatch = (
};
const removeAppliedAndApply = (key: string, value?: string) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const filterKey = normalizeFilterKey(key);
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
@@ -286,8 +315,20 @@ export const useFilterBatch = (
};
const getFilterValue = (key: string): string[] => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
return pendingFilters[filterKey] ?? [];
const filterKey = normalizeFilterKey(key);
const values = pendingFilters[filterKey];
if (values) return values;
const exclusiveGroup = getExclusiveFilterGroup(
filterKey,
options?.exclusiveFilterGroups,
);
const alternateKey = exclusiveGroup?.find(
(exclusiveKey) =>
exclusiveKey !== filterKey && pendingFilters[exclusiveKey],
);
return alternateKey ? pendingFilters[alternateKey] : [];
};
const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters);
+2
View File
@@ -24,6 +24,7 @@ describe("cloud upgrade content", () => {
"Bring CLI Findings into One Cloud View",
"See Compliance Across Every Provider",
"Coordinate Finding Remediation",
"Send Findings to Jira at Scale",
"Use The Agent Cloud Defender",
"Scale Prowler Without Operating It",
"Configure Every Scan Once",
@@ -71,6 +72,7 @@ describe("cloud upgrade URLs", () => {
"cross-provider-compliance",
],
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"],
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, "jira-dispatch"],
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"],
[CLOUD_UPGRADE_FEATURE.GENERAL, "general"],
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION, "scan-configuration"],
+12
View File
@@ -26,6 +26,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = {
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
"cross-provider-compliance",
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings",
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: "jira-dispatch",
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai",
[CLOUD_UPGRADE_FEATURE.GENERAL]: "general",
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: "scan-configuration",
@@ -98,6 +99,17 @@ export const CLOUD_UPGRADE_CONTENT = {
],
primaryCta: "Triage Findings in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: {
title: "Send Findings to Jira at Scale",
description:
"Create Jira issues from selected findings and finding groups without handling each item separately.",
benefits: [
"Send selected findings or finding groups in one action",
"Choose between grouped and individual Jira issues",
"Track dispatch progress and retry failed findings",
],
primaryCta: "Send Findings to Jira in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: {
title: "Use The Agent Cloud Defender",
description:
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const importFresh = async () => {
vi.resetModules();
return import("./deployment");
};
describe("enterprise feature flags", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("should keep grouped Jira dispatch disabled by default", async () => {
// Given / When
const { isGroupedJiraDispatchEnabled } = await importFresh();
// Then
expect(isGroupedJiraDispatchEnabled()).toBe(false);
});
it("should enable grouped Jira dispatch from the enterprise env flag in cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "cloud");
vi.stubEnv(
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
"true",
);
// When
const { isGroupedJiraDispatchEnabled } = await importFresh();
// Then
expect(isGroupedJiraDispatchEnabled()).toBe(true);
});
it("should keep grouped Jira dispatch disabled outside cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "onpremise");
vi.stubEnv(
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
"true",
);
// When
const { isGroupedJiraDispatchEnabled } = await importFresh();
// Then
expect(isGroupedJiraDispatchEnabled()).toBe(false);
});
});
+55
View File
@@ -0,0 +1,55 @@
export const DEPLOYMENT_MODE = {
CLOUD: "cloud",
ON_PREMISE: "onpremise",
} as const;
export const ENTERPRISE_FEATURE_ENV = {
GROUPED_JIRA_DISPATCH_ENABLED:
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
} as const;
export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud";
export type DeploymentMode =
(typeof DEPLOYMENT_MODE)[keyof typeof DEPLOYMENT_MODE];
type EnterpriseFeatureEnv =
(typeof ENTERPRISE_FEATURE_ENV)[keyof typeof ENTERPRISE_FEATURE_ENV];
const getEnterpriseFeatureValue = (
envName: EnterpriseFeatureEnv,
): string | undefined => {
if (envName === ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED) {
return process.env
.NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED;
}
return undefined;
};
const getBooleanEnv = (
envName: EnterpriseFeatureEnv,
defaultValue: boolean,
): boolean => {
const value = getEnterpriseFeatureValue(envName);
if (value === undefined || value === "") {
return defaultValue;
}
return value === "true";
};
export const getDeploymentMode = (): DeploymentMode | undefined => {
const mode = process.env.NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE;
if (mode === DEPLOYMENT_MODE.CLOUD || mode === DEPLOYMENT_MODE.ON_PREMISE) {
return mode;
}
return undefined;
};
export const isGroupedJiraDispatchEnabled = (): boolean =>
getDeploymentMode() === DEPLOYMENT_MODE.CLOUD &&
getBooleanEnv(ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED, false);
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
import { getFindingGroupFilterOptions } from "./finding-group-filter-options";
function makeResponse(
pageCount: number,
groups: Array<{ id: string; title: string }>,
) {
return {
data: groups.map(({ id, title }) => ({
type: "finding-groups",
id,
attributes: {
check_id: id,
check_title: title,
check_description: null,
severity: "high",
status: "FAIL",
impacted_providers: [],
resources_total: 1,
resources_fail: 1,
pass_count: 0,
fail_count: 1,
muted_count: 0,
new_count: 0,
changed_count: 0,
first_seen_at: null,
last_seen_at: null,
failing_since: null,
},
})),
meta: { pagination: { pages: pageCount } },
};
}
describe("getFindingGroupFilterOptions", () => {
it("loads every page without applying the filter's own selection", async () => {
// Given
const fetchFindingGroups = vi
.fn()
.mockResolvedValueOnce(
makeResponse(2, [{ id: "check-a", title: "Check A" }]),
)
.mockResolvedValueOnce(
makeResponse(2, [
{ id: "check-a", title: "Check A updated" },
{ id: "check-b", title: "Check B" },
]),
);
// When
const options = await getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {
"filter[check_id__in]": "check-a",
"filter[severity__in]": "high",
},
});
// Then
expect(fetchFindingGroups).toHaveBeenNthCalledWith(1, {
filters: { "filter[severity__in]": "high" },
page: 1,
pageSize: 100,
});
expect(fetchFindingGroups).toHaveBeenNthCalledWith(2, {
filters: { "filter[severity__in]": "high" },
page: 2,
pageSize: 100,
});
expect(options).toEqual([
{ checkId: "check-a", checkTitle: "Check A updated" },
{ checkId: "check-b", checkTitle: "Check B" },
]);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
"filter[check_id]",
"filter[check_id__in]",
]);
interface FindingGroupFilterFetcherParams {
page: number;
pageSize: number;
filters: Record<string, string | string[] | undefined>;
}
type FindingGroupFilterFetcher = (
params: FindingGroupFilterFetcherParams,
) => Promise<unknown>;
function excludeFindingGroupOwnFilters(
filters: Record<string, string | string[] | undefined>,
) {
return Object.fromEntries(
Object.entries(filters).filter(
([key]) => !FINDING_GROUP_OWN_FILTER_KEYS.has(key),
),
);
}
function getTotalPages(response: unknown, currentPage: number): number {
if (!response || typeof response !== "object" || !("meta" in response)) {
return currentPage;
}
const meta = response.meta;
if (!meta || typeof meta !== "object" || !("pagination" in meta)) {
return currentPage;
}
const pagination = meta.pagination;
if (
!pagination ||
typeof pagination !== "object" ||
!("pages" in pagination)
) {
return currentPage;
}
return typeof pagination.pages === "number" ? pagination.pages : currentPage;
}
export 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,
});
}
if (page >= getTotalPages(response, page)) break;
page += 1;
}
return Array.from(options.values());
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
createJiraBatchSelection,
createJiraTargetSelection,
} from "@/lib/jira-dispatch-selection";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
buildJiraActionLabel,
getJiraDispatchActionState,
} from "./jira-dispatch-action";
describe("getJiraDispatchActionState", () => {
it("allows one Finding without grouped dispatch", () => {
// Given
const selection = createJiraTargetSelection(
["finding-1"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)!;
// When
const state = getJiraDispatchActionState({ selection }, false);
// Then
expect(state).toEqual({
canChooseGroupedDispatch: false,
defaultDispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
requiresUpgrade: false,
});
});
it("offers grouped choice for multiple Findings in Cloud", () => {
// Given
const selection = createJiraTargetSelection(
["finding-1", "finding-2"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)!;
// When
const state = getJiraDispatchActionState({ selection }, true);
// Then
expect(state).toEqual({
canChooseGroupedDispatch: true,
defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
requiresUpgrade: false,
});
});
it("requires upgrade for multiple Findings outside Cloud", () => {
// Given
const selection = createJiraTargetSelection(
["finding-1", "finding-2"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)!;
// When
const state = getJiraDispatchActionState({ selection }, false);
// Then
expect(state.requiresUpgrade).toBe(true);
expect(state.canChooseGroupedDispatch).toBe(false);
});
it("offers grouped choice for one Finding Group with multiple resources", () => {
// Given
const selection = createJiraTargetSelection(
["check-1"],
JIRA_DISPATCH_TARGET.CHECK_ID,
)!;
// When
const state = getJiraDispatchActionState(
{ selection, selectedResourceCount: 2 },
true,
);
// Then
expect(state).toEqual({
canChooseGroupedDispatch: true,
defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
requiresUpgrade: false,
});
});
it("keeps mixed batches grouped without offering one global choice", () => {
// Given
const selection = createJiraBatchSelection([
{
targetIds: ["check-1"],
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: ["finding-1"],
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
},
])!;
// When
const state = getJiraDispatchActionState({ selection }, true);
// Then
expect(state.defaultDispatchMode).toBe(JIRA_DISPATCH_MODE.GROUPED);
expect(state.canChooseGroupedDispatch).toBe(false);
});
});
describe("buildJiraActionLabel", () => {
it.each([
[{ findingCount: 1 }, "Send 1 Finding to Jira"],
[{ findingCount: 2 }, "Send 2 Findings to Jira"],
[{ findingGroupCount: 1 }, "Send 1 Finding Group to Jira"],
[
{ findingGroupCount: 2, findingCount: 1 },
"Send 2 Finding Groups and 1 Finding to Jira",
],
])("builds consistent Jira action copy", (counts, expected) => {
// Given / When
const label = buildJiraActionLabel(counts);
// Then
expect(label).toBe(expected);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
import {
JIRA_DISPATCH_MODE,
JIRA_DISPATCH_TARGET,
JIRA_TARGET_SELECTION_KIND,
type JiraDispatchMode,
} from "@/types/integrations";
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
export interface JiraDispatchActionState {
defaultDispatchMode: JiraDispatchMode;
canChooseGroupedDispatch: boolean;
requiresUpgrade: boolean;
}
export interface JiraActionLabelCounts {
findingGroupCount?: number;
findingCount?: number;
}
export const getJiraDispatchActionState = (
payload: JiraDispatchModalPayload,
groupedDispatchEnabled: boolean,
): JiraDispatchActionState => {
const batches = getJiraSelectionBatches(payload.selection);
const targetCount = batches.reduce(
(count, batch) => count + batch.targetIds.length,
0,
);
const hasFindingGroupTargets = batches.some(
(batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
);
const requiresGroupedFeature =
hasFindingGroupTargets || batches.length > 1 || targetCount > 1;
const firstBatch = batches[0];
const canChooseGroupedDispatch =
groupedDispatchEnabled &&
payload.selection.kind !== JIRA_TARGET_SELECTION_KIND.BATCHES &&
(firstBatch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID
? firstBatch.targetIds.length > 1
: firstBatch.targetIds.length === 1 &&
(payload.selectedResourceCount ?? 0) > 1);
return {
defaultDispatchMode: requiresGroupedFeature
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL,
canChooseGroupedDispatch,
requiresUpgrade: requiresGroupedFeature && !groupedDispatchEnabled,
};
};
const buildEntityLabel = (
count: number,
singular: string,
plural: string,
): string | null => {
if (count === 0) return null;
return `${count} ${count === 1 ? singular : plural}`;
};
export const buildJiraActionLabel = ({
findingGroupCount = 0,
findingCount = 0,
}: JiraActionLabelCounts): string => {
const entities = [
buildEntityLabel(findingGroupCount, "Finding Group", "Finding Groups"),
buildEntityLabel(findingCount, "Finding", "Findings"),
].filter(Boolean);
return entities.length > 0
? `Send ${entities.join(" and ")} to Jira`
: "Send to Jira";
};
+141
View File
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
JIRA_DISPATCH_MODE,
type JiraDispatchTargetBatch,
} from "@/types/integrations";
import { executeJiraDispatchBatches } from "./jira-dispatch-execution";
const { sendJiraDispatchMock, trackAndPollTaskMock } = vi.hoisted(() => ({
sendJiraDispatchMock: vi.fn(),
trackAndPollTaskMock: vi.fn(),
}));
vi.mock("@/actions/integrations/jira-dispatch", () => ({
sendJiraDispatch: sendJiraDispatchMock,
}));
vi.mock("@/store/task-watcher/store", () => ({
TASK_WATCHER_STATUS: { READY: "ready" },
trackAndPollTask: trackAndPollTaskMock,
}));
const settings = {
integrationId: "jira-1",
projectKey: "SEC",
issueType: "Task",
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
};
describe("executeJiraDispatchBatches", () => {
beforeEach(() => {
vi.clearAllMocks();
sendJiraDispatchMock.mockResolvedValue({
success: true,
taskId: "task-1",
message: "Started",
});
trackAndPollTaskMock.mockResolvedValue({
status: "ready",
result: { created_count: 2, failed_count: 0 },
});
});
it("uses one dispatch path and aggregates successful batches", async () => {
// Given
const batches: JiraDispatchTargetBatch[] = [
{
targetIds: ["check-a"],
targetType: "check_id" as const,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: ["finding-a"],
targetType: "finding_id" as const,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
},
];
// When
const result = await executeJiraDispatchBatches(batches, settings);
// Then
expect(sendJiraDispatchMock).toHaveBeenCalledTimes(2);
expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(2, {
integrationId: "jira-1",
targetIds: ["finding-a"],
filter: "finding_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "individual",
});
expect(result).toMatchObject({
startedTaskCount: 2,
successfulTaskCount: 2,
successfulIssueCount: 4,
successMessage: "4 Jira issues were created or updated successfully.",
errors: [],
warnings: [],
});
});
it("returns only failed finding IDs as an individual retry batch", async () => {
// Given
trackAndPollTaskMock.mockResolvedValue({
status: "ready",
result: {
created_count: 1,
failed_count: 2,
failed_finding_ids: ["finding-b", "finding-b", "finding-c"],
error: "Two issues failed.",
},
});
// When
const result = await executeJiraDispatchBatches(
[
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
],
settings,
);
// Then
expect(result.retryBatch).toEqual({
targetIds: ["finding-b", "finding-c"],
targetType: "finding_id",
dispatchMode: "individual",
});
expect(result.warnings).toEqual([
"Two issues failed. Jira dispatch completed with 3 failed and 1 created/updated issue.",
]);
});
it("does not offer an automatic retry after an unknown launch failure", async () => {
// Given
sendJiraDispatchMock.mockRejectedValue(new Error("Connection closed"));
// When
const result = await executeJiraDispatchBatches(
[
{
targetIds: ["finding-a"],
targetType: "finding_id",
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
},
],
settings,
);
// Then
expect(result.startedTaskCount).toBe(0);
expect(result.retryBatch).toBeUndefined();
expect(result.errors).toEqual([
"The Jira dispatch status is unknown after a connection error. Check Jira before retrying.",
]);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch";
import {
evaluateJiraDispatchTask,
getJiraDispatchSuccessCount,
} from "@/lib/jira-dispatch-result";
import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
import {
TASK_WATCHER_STATUS,
type TaskTrackingResult,
trackAndPollTask,
} from "@/store/task-watcher/store";
import {
JIRA_DISPATCH_MODE,
JIRA_DISPATCH_TARGET,
JIRA_DISPATCH_TASK_KIND,
type JiraDispatchMode,
type JiraDispatchTargetBatch,
type JiraDispatchTaskResult,
} from "@/types/integrations";
export interface JiraDispatchSettings {
integrationId: string;
projectKey: string;
issueType: string;
dispatchMode: JiraDispatchMode;
}
export interface JiraDispatchExecutionResult {
startedTaskCount: number;
successfulTaskCount: number;
successfulIssueCount: number;
successMessage?: string;
warnings: string[];
errors: string[];
retryBatch?: JiraDispatchTargetBatch;
}
interface JiraTrackedOutcome {
success: boolean;
message?: string;
error?: string;
warning?: string;
failedFindingIds?: string[];
successfulCount?: number;
}
export function getJiraRetryBatch(
failedFindingIds: string[] | undefined,
): JiraDispatchTargetBatch | undefined {
const [firstTargetId, ...remainingTargetIds] = Array.from(
new Set(failedFindingIds?.filter(Boolean) ?? []),
);
if (!firstTargetId) return undefined;
return {
targetIds: [firstTargetId, ...remainingTargetIds],
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
};
}
export async function executeJiraDispatchBatches(
batches: JiraDispatchTargetBatch[],
settings: JiraDispatchSettings,
options: { notifyHandler?: boolean } = {},
): Promise<JiraDispatchExecutionResult> {
const startedTasks: Array<{
taskId: string;
dispatchMode: JiraDispatchMode;
}> = [];
const launchErrors: string[] = [];
for (const batch of batches) {
const dispatchMode = batch.dispatchMode ?? settings.dispatchMode;
try {
const result = await sendJiraDispatch({
integrationId: settings.integrationId,
targetIds: batch.targetIds,
filter: batch.targetType,
projectKey: settings.projectKey,
issueType: settings.issueType,
dispatchMode,
});
if (!result.success) {
launchErrors.push(result.error || "Failed to send to Jira");
continue;
}
startedTasks.push({ taskId: result.taskId, dispatchMode });
} catch {
// The request may have reached the server before the RPC failed. An
// automatic retry could create duplicate issues.
launchErrors.push(
"The Jira dispatch status is unknown after a connection error. Check Jira before retrying.",
);
}
}
const trackedOutcomes = await Promise.all(
startedTasks.map(async ({ taskId, dispatchMode }) => {
let trackedTask: TaskTrackingResult<JiraDispatchTaskResult>;
try {
trackedTask = await trackAndPollTask<JiraDispatchTaskResult>({
taskId,
kind: JIRA_DISPATCH_TASK_KIND,
meta: buildJiraDispatchTaskMeta({
integrationId: settings.integrationId,
projectKey: settings.projectKey,
issueType: settings.issueType,
dispatchMode,
}),
notifyHandler: options.notifyHandler ?? false,
});
} catch {
return {
success: false,
error:
"Tracking the Jira dispatch failed unexpectedly. Check Jira before retrying.",
} satisfies JiraTrackedOutcome;
}
if (trackedTask.status !== TASK_WATCHER_STATUS.READY) {
return {
success: false,
error: trackedTask.error || "Failed to track Jira issue creation.",
} satisfies JiraTrackedOutcome;
}
const outcome = evaluateJiraDispatchTask("completed", trackedTask.result);
if (!outcome.success) {
return {
success: false,
error: outcome.error,
failedFindingIds: outcome.failedFindingIds,
} satisfies JiraTrackedOutcome;
}
return {
success: true,
message: outcome.message,
warning: outcome.warning,
failedFindingIds: outcome.failedFindingIds,
successfulCount: getJiraDispatchSuccessCount(trackedTask.result),
} satisfies JiraTrackedOutcome;
}),
);
const successfulOutcomes = trackedOutcomes.filter(
(outcome) => outcome.success,
);
const successfulIssueCount = successfulOutcomes.reduce(
(count, outcome) => count + (outcome.successfulCount ?? 0),
0,
);
const successMessage =
successfulOutcomes.length === 1
? successfulOutcomes[0].message
: successfulOutcomes.length > 1
? `${successfulIssueCount} Jira issues were created or updated successfully.`
: undefined;
return {
startedTaskCount: startedTasks.length,
successfulTaskCount: successfulOutcomes.length,
successfulIssueCount,
successMessage,
warnings: Array.from(
new Set(
trackedOutcomes.flatMap((outcome) =>
outcome.warning ? [outcome.warning] : [],
),
),
),
errors: Array.from(
new Set([
...trackedOutcomes.flatMap((outcome) =>
outcome.error ? [outcome.error] : [],
),
...launchErrors,
]),
),
retryBatch: getJiraRetryBatch(
trackedOutcomes.flatMap((outcome) => outcome.failedFindingIds ?? []),
),
};
}
+141
View File
@@ -0,0 +1,141 @@
import type { JiraDispatchTaskResult } from "@/types/integrations";
import type { TaskState } from "@/types/tasks";
export interface JiraDispatchSuccessOutcome {
success: true;
message: string;
warning?: string;
failedFindingIds?: string[];
}
export interface JiraDispatchFailureOutcome {
success: false;
error: string;
failedFindingIds?: string[];
}
export type JiraDispatchOutcome =
| JiraDispatchSuccessOutcome
| JiraDispatchFailureOutcome;
const getArrayCount = (value: unknown[] | undefined) =>
Array.isArray(value) ? value.length : 0;
const getFailedCount = (result: JiraDispatchTaskResult | undefined) => {
if (!result) return 0;
return Math.max(
result.failed_count ?? 0,
getArrayCount(result.failed_groups),
getArrayCount(result.failed_batches),
getArrayCount(result.failed_finding_ids),
);
};
export const getJiraDispatchSuccessCount = (
result: JiraDispatchTaskResult | undefined,
) => {
if (!result) return 0;
const createdCount = Math.max(
result.created_count ?? 0,
getArrayCount(result.created_issues),
);
const updatedCount = Math.max(
result.updated_count ?? 0,
getArrayCount(result.updated_issues),
);
return Math.max(
result.successful_count ?? 0,
createdCount + updatedCount,
result.issue_key || result.issue_url ? 1 : 0,
);
};
const ensureSentence = (message: string) =>
/[.!?]$/.test(message.trim()) ? message.trim() : `${message.trim()}.`;
const buildFailureMessage = (
result: JiraDispatchTaskResult | undefined,
failedCount: number,
) => {
const successCount = getJiraDispatchSuccessCount(result);
const summary = `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`;
return result?.error ? `${ensureSentence(result.error)} ${summary}` : summary;
};
const buildSuccessMessage = (result: JiraDispatchTaskResult | undefined) => {
const successCount = getJiraDispatchSuccessCount(result);
if (successCount > 1) {
return `${successCount} Jira issues were created or updated successfully.`;
}
return "Finding successfully sent to Jira!";
};
const getFailedFindingIds = (result: JiraDispatchTaskResult | undefined) =>
Array.from(new Set(result?.failed_finding_ids?.filter(Boolean) ?? []));
const withFailedFindingIds = (failedFindingIds: string[]) =>
failedFindingIds.length > 0 ? { failedFindingIds } : {};
export const evaluateJiraDispatchTask = (
state: TaskState,
result: JiraDispatchTaskResult | null | undefined,
): JiraDispatchOutcome => {
const jiraResult = result ?? undefined;
const failedFindingIds = getFailedFindingIds(jiraResult);
if (state === "completed") {
const failedCount = getFailedCount(jiraResult);
if (failedCount > 0) {
const successCount = getJiraDispatchSuccessCount(jiraResult);
if (successCount > 0) {
return {
success: true,
message: buildSuccessMessage(jiraResult),
warning: buildFailureMessage(jiraResult, failedCount),
...withFailedFindingIds(failedFindingIds),
};
}
return {
success: false,
error: buildFailureMessage(jiraResult, failedCount),
...withFailedFindingIds(failedFindingIds),
};
}
if (jiraResult?.success === false || jiraResult?.error) {
return {
success: false,
error: jiraResult.error || "Failed to create Jira issue.",
...withFailedFindingIds(failedFindingIds),
};
}
if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) {
return {
success: false,
error:
"Jira dispatch completed but did not create or update any issues.",
};
}
return {
success: true,
message: buildSuccessMessage(jiraResult),
};
}
if (state === "failed") {
return {
success: false,
error: jiraResult?.error || "Task failed.",
...withFailedFindingIds(failedFindingIds),
};
}
return { success: false, error: `Unknown task state: ${state}` };
};
+84
View File
@@ -0,0 +1,84 @@
import {
JIRA_TARGET_SELECTION_KIND,
type JiraBatchSelection,
type JiraDispatchTarget,
type JiraDispatchTargetBatch,
type JiraSelection,
type NonEmptyStringArray,
} from "@/types/integrations";
export interface JiraDispatchTargetBatchInput {
targetIds: string[];
targetType: JiraDispatchTarget;
dispatchMode?: JiraDispatchTargetBatch["dispatchMode"];
}
export const toNonEmptyStringArray = (
values: string[],
): NonEmptyStringArray | null => {
const [first, ...rest] = values.filter(Boolean);
return first ? [first, ...rest] : null;
};
export const createJiraTargetSelection = (
targetIds: string[],
targetType: JiraDispatchTarget,
): JiraSelection | null => {
const nonEmptyTargetIds = toNonEmptyStringArray(targetIds);
if (!nonEmptyTargetIds) return null;
if (nonEmptyTargetIds.length === 1) {
return {
kind: JIRA_TARGET_SELECTION_KIND.SINGLE,
targetId: nonEmptyTargetIds[0],
targetType,
};
}
return {
kind: JIRA_TARGET_SELECTION_KIND.TARGET_LIST,
targetIds: nonEmptyTargetIds,
targetType,
};
};
export const createJiraBatchSelection = (
batches: JiraDispatchTargetBatchInput[],
): JiraBatchSelection | null => {
const normalizedBatches = batches.flatMap((batch) => {
const targetIds = toNonEmptyStringArray(batch.targetIds);
return targetIds ? [{ ...batch, targetIds }] : [];
});
const [firstBatch, ...remainingBatches] = normalizedBatches;
return firstBatch
? {
kind: JIRA_TARGET_SELECTION_KIND.BATCHES,
batches: [firstBatch, ...remainingBatches],
}
: null;
};
export const getJiraSelectionBatches = (
selection: JiraSelection,
): [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]] => {
if (selection.kind === JIRA_TARGET_SELECTION_KIND.BATCHES) {
return selection.batches;
}
if (selection.kind === JIRA_TARGET_SELECTION_KIND.SINGLE) {
return [
{
targetIds: [selection.targetId],
targetType: selection.targetType,
},
];
}
return [
{
targetIds: selection.targetIds,
targetType: selection.targetType,
},
];
};
+46
View File
@@ -0,0 +1,46 @@
import type { WatchedTask } from "@/store/task-watcher/store";
import {
JIRA_DISPATCH_MODE,
type JiraDispatchMode,
} from "@/types/integrations";
export interface JiraDispatchTaskMeta {
integrationId: string;
projectKey: string;
issueType: string;
dispatchMode: JiraDispatchMode;
}
export const buildJiraDispatchTaskMeta = ({
integrationId,
projectKey,
issueType,
dispatchMode,
}: JiraDispatchTaskMeta): Record<string, string> => ({
integrationId,
projectKey,
issueType,
dispatchMode,
});
export const parseJiraDispatchTaskMeta = (
task: WatchedTask,
): JiraDispatchTaskMeta | null => {
const { integrationId, projectKey, issueType, dispatchMode } = task.meta;
if (
!integrationId ||
!projectKey ||
!issueType ||
(dispatchMode !== JIRA_DISPATCH_MODE.GROUPED &&
dispatchMode !== JIRA_DISPATCH_MODE.INDIVIDUAL)
) {
return null;
}
return {
integrationId,
projectKey,
issueType,
dispatchMode,
};
};
+1
View File
@@ -1,4 +1,5 @@
export * from "./cloud-upgrade/store";
export * from "./jira-dispatch/store";
export * from "./organizations/store";
export * from "./provider-wizard/store";
export * from "./scans/store";
+16
View File
@@ -0,0 +1,16 @@
import { create } from "zustand";
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
interface JiraDispatchStoreState {
activePayload: JiraDispatchModalPayload | null;
openJiraDispatch: (payload: JiraDispatchModalPayload) => void;
closeJiraDispatch: () => void;
}
// Jira dispatch is ephemeral and globally hosted so every action uses one modal.
export const useJiraDispatchStore = create<JiraDispatchStoreState>((set) => ({
activePayload: null,
openJiraDispatch: (activePayload) => set({ activePayload }),
closeJiraDispatch: () => set({ activePayload: null }),
}));
+64
View File
@@ -49,6 +49,37 @@ describe("task watcher store", () => {
expect(onError).not.toHaveBeenCalled();
});
it("returns and persists a completed task result while allowing the caller to own notifications", async () => {
// Given
pollMock.mockResolvedValue({
ok: true,
state: "completed",
result: { created_count: 1, failed_count: 1 },
});
// When
const result = await trackAndPollTask<{
created_count: number;
failed_count: number;
}>({
taskId: "jira-task",
kind: "test-kind",
meta: {},
notifyHandler: false,
});
// Then
expect(result).toEqual({
status: TASK_WATCHER_STATUS.READY,
result: { created_count: 1, failed_count: 1 },
});
expect(useTaskWatcherStore.getState().tasks["jira-task"]?.result).toEqual({
created_count: 1,
failed_count: 1,
});
expect(onReady).not.toHaveBeenCalled();
});
it("replaces settled results of the same kind when tracking new work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
@@ -201,6 +232,39 @@ describe("task watcher store", () => {
expect(onReady).toHaveBeenCalledTimes(1);
});
it("passes a persisted task result to its handler after resuming", async () => {
// Given
const taskResult = {
created_count: 1,
failed_count: 1,
failed_finding_ids: ["finding-2"],
};
pollMock.mockResolvedValue({
ok: true,
state: "completed",
result: taskResult,
});
useTaskWatcherStore.setState({
tasks: {
"resumed-jira-task": {
taskId: "resumed-jira-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
// When
await resumePendingTasks();
// Then
expect(onReady).toHaveBeenCalledWith(
expect.objectContaining({ result: taskResult }),
);
});
it("discards settled tasks before resuming persisted work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
+138 -72
View File
@@ -28,6 +28,9 @@ export interface WatchedTask {
meta: Record<string, string>;
startedAt: number;
error?: string;
/** Serializable task result. Persisted so another tab or a reload can
* finish feature-specific handling without polling the task again. */
result?: unknown;
}
export interface TaskKindHandler {
@@ -35,6 +38,21 @@ export interface TaskKindHandler {
onError: (task: WatchedTask) => void;
}
export interface TaskTrackingResult<R = unknown> {
status: TaskWatcherStatus;
error?: string;
result?: R;
}
export interface TrackAndPollTaskInput {
taskId: string;
kind: string;
meta: Record<string, string>;
/** Let the awaiting caller aggregate notifications. If this tab reloads,
* the persisted task resumes with its registered handler as usual. */
notifyHandler?: boolean;
}
interface TaskWatcherState {
tasks: Record<string, WatchedTask>;
upsertTask: (task: WatchedTask) => void;
@@ -42,6 +60,7 @@ interface TaskWatcherState {
taskId: string,
status: TaskWatcherStatus,
error?: string,
result?: unknown,
) => void;
dismissTask: (taskId: string) => void;
}
@@ -69,12 +88,15 @@ export const useTaskWatcherStore = create<TaskWatcherState>()(
tasks: {},
upsertTask: (task) =>
set((state) => ({ tasks: { ...state.tasks, [task.taskId]: task } })),
resolveTask: (taskId, status, error) =>
resolveTask: (taskId, status, error, result) =>
set((state) => {
const task = state.tasks[taskId];
if (!task) return state;
return {
tasks: { ...state.tasks, [taskId]: { ...task, status, error } },
tasks: {
...state.tasks,
[taskId]: { ...task, status, error, result },
},
};
}),
dismissTask: (taskId) =>
@@ -92,25 +114,35 @@ export const useTaskWatcherStore = create<TaskWatcherState>()(
// In-memory only: poll loops alive in THIS tab. Never persisted, so a reload
// naturally re-enters through resumePendingTasks without double-polling.
const activePolls = new Set<string>();
const activePolls = new Map<string, Promise<TaskTrackingResult<unknown>>>();
const suppressedHandlers = new Set<string>();
const settleTask = (
taskId: string,
status: TaskWatcherStatus,
error?: string,
) => {
result?: unknown,
): TaskTrackingResult => {
const store = useTaskWatcherStore.getState();
const currentTask = store.tasks[taskId];
if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) {
return;
return {
status: currentTask?.status ?? TASK_WATCHER_STATUS.ERROR,
...(currentTask?.error ? { error: currentTask.error } : {}),
...(currentTask?.result !== undefined
? { result: currentTask.result }
: {}),
};
}
store.resolveTask(taskId, status, error);
store.resolveTask(taskId, status, error, result);
const task = useTaskWatcherStore.getState().tasks[taskId];
if (!task) return;
if (!task) {
return { status: TASK_WATCHER_STATUS.ERROR, error: "Task unavailable." };
}
const handler = handlers.get(task.kind);
if (handler) {
if (handler && !suppressedHandlers.has(taskId)) {
if (status === TASK_WATCHER_STATUS.READY) handler.onReady(task);
else handler.onError(task);
}
@@ -120,107 +152,141 @@ const settleTask = (
if (status === TASK_WATCHER_STATUS.ERROR) {
store.dismissTask(taskId);
}
return {
status,
...(error ? { error } : {}),
...(result !== undefined ? { result } : {}),
};
};
const runPollLoop = async (taskId: string): Promise<void> => {
const runPollLoop = async <R>(
taskId: string,
): Promise<TaskTrackingResult<R>> => {
for (let round = 0; round < MAX_POLL_ROUNDS; round++) {
const result = await pollTaskUntilSettled(taskId);
const result = await pollTaskUntilSettled<R>(taskId);
if (result.ok) {
if (result.state === "completed") {
settleTask(taskId, TASK_WATCHER_STATUS.READY);
return settleTask(
taskId,
TASK_WATCHER_STATUS.READY,
undefined,
result.result,
) as TaskTrackingResult<R>;
} else {
settleTask(
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
`Task ended in state "${result.state}".`,
);
result.result,
) as TaskTrackingResult<R>;
}
return;
}
// "Task timeout" just means this server round expired while the task
// is still running — keep polling. Real errors settle immediately.
if (result.error !== "Task timeout") {
settleTask(taskId, TASK_WATCHER_STATUS.ERROR, result.error);
return;
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
result.error,
result.result,
) as TaskTrackingResult<R>;
}
}
settleTask(
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"The task is taking too long. Try again later.",
);
) as TaskTrackingResult<R>;
};
const pollUntilDone = async (taskId: string): Promise<void> => {
if (activePolls.has(taskId)) return;
activePolls.add(taskId);
const pollUntilDone = <R>(taskId: string): Promise<TaskTrackingResult<R>> => {
const existingPoll = activePolls.get(taskId);
if (existingPoll) return existingPoll as Promise<TaskTrackingResult<R>>;
try {
const runIfPending = async () => {
// A different tab may have completed the task while this one waited for
// the cross-tab lock. Refresh persisted state before polling or notifying.
await useTaskWatcherStore.persist.rehydrate();
const task = useTaskWatcherStore.getState().tasks[taskId];
if (task?.status !== TASK_WATCHER_STATUS.PENDING) return;
await runPollLoop(taskId);
};
const pollPromise = (async (): Promise<TaskTrackingResult<R>> => {
try {
const runIfPending = async (): Promise<TaskTrackingResult<R>> => {
// A different tab may have completed the task while this one waited for
// the cross-tab lock. Refresh persisted state before polling or notifying.
await useTaskWatcherStore.persist.rehydrate();
const task = useTaskWatcherStore.getState().tasks[taskId];
if (task?.status !== TASK_WATCHER_STATUS.PENDING) {
return {
status: task?.status ?? TASK_WATCHER_STATUS.ERROR,
...(task?.error ? { error: task.error } : {}),
...(task?.result !== undefined ? { result: task.result as R } : {}),
};
}
return runPollLoop<R>(taskId);
};
if (typeof navigator !== "undefined" && navigator.locks) {
await navigator.locks.request(`task-watcher:${taskId}`, runIfPending);
} else {
await runIfPending();
if (typeof navigator !== "undefined" && navigator.locks) {
return await navigator.locks.request(
`task-watcher:${taskId}`,
runIfPending,
);
}
return await runIfPending();
} catch {
// A thrown poll (e.g. the server-action RPC failing on a network drop)
// must still settle the task, or it stays PENDING in the persisted
// store and blocks the UI until the staleness ceiling.
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"Tracking the task failed unexpectedly. Try again later.",
) as TaskTrackingResult<R>;
} finally {
activePolls.delete(taskId);
}
} catch {
// A thrown poll (e.g. the server-action RPC failing on a network drop)
// must still settle the task, or it stays PENDING in the persisted
// store and blocks the UI until the staleness ceiling.
settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"Tracking the task failed unexpectedly. Try again later.",
);
} finally {
activePolls.delete(taskId);
}
})();
activePolls.set(taskId, pollPromise);
return pollPromise;
};
/** Track a freshly dispatched backend task and poll it to completion. The
* poll loop lives at module scope (fired from the click handler), so it
* survives client-side navigation without any effect subscriptions. */
export const trackAndPollTask = async ({
export const trackAndPollTask = async <R = unknown>({
taskId,
kind,
meta,
}: {
taskId: string;
kind: string;
meta: Record<string, string>;
}): Promise<void> => {
notifyHandler = true,
}: TrackAndPollTaskInput): Promise<TaskTrackingResult<R>> => {
if (!notifyHandler) suppressedHandlers.add(taskId);
const existing = useTaskWatcherStore.getState().tasks[taskId];
if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
return pollUntilDone(taskId);
try {
if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
return await pollUntilDone<R>(taskId);
}
const store = useTaskWatcherStore.getState();
Object.values(store.tasks)
.filter(
(task) =>
task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
)
.forEach((task) => store.dismissTask(task.taskId));
store.upsertTask({
taskId,
kind,
status: TASK_WATCHER_STATUS.PENDING,
meta,
startedAt: Date.now(),
});
return await pollUntilDone<R>(taskId);
} finally {
suppressedHandlers.delete(taskId);
}
const store = useTaskWatcherStore.getState();
Object.values(store.tasks)
.filter(
(task) =>
task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
)
.forEach((task) => store.dismissTask(task.taskId));
store.upsertTask({
taskId,
kind,
status: TASK_WATCHER_STATUS.PENDING,
meta,
startedAt: Date.now(),
});
return pollUntilDone(taskId);
};
/** Resume polling every persisted pending task after a hard reload; tasks
+1
View File
@@ -5,6 +5,7 @@ export const CLOUD_UPGRADE_FEATURE = {
CLI_IMPORT: "cli_import",
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
FINDING_TRIAGE: "finding_triage",
JIRA_DISPATCH: "jira_dispatch",
LIGHTHOUSE_AI: "lighthouse_ai",
GENERAL: "general",
SCAN_CONFIGURATION: "scan_configuration",
+76 -9
View File
@@ -4,6 +4,63 @@ import type { TaskState } from "@/types/tasks";
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",
GROUPED: "grouped",
} as const;
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 const JIRA_TARGET_SELECTION_KIND = {
SINGLE: "single",
TARGET_LIST: "target-list",
BATCHES: "batches",
} as const;
export type JiraTargetSelectionKind =
(typeof JIRA_TARGET_SELECTION_KIND)[keyof typeof JIRA_TARGET_SELECTION_KIND];
export type NonEmptyStringArray = [string, ...string[]];
export interface JiraDispatchTargetBatch {
targetIds: NonEmptyStringArray;
targetType: JiraDispatchTarget;
dispatchMode?: JiraDispatchMode;
}
export interface JiraSingleTargetSelection {
kind: typeof JIRA_TARGET_SELECTION_KIND.SINGLE;
targetId: string;
targetType: JiraDispatchTarget;
}
export interface JiraTargetListSelection {
kind: typeof JIRA_TARGET_SELECTION_KIND.TARGET_LIST;
targetIds: NonEmptyStringArray;
targetType: JiraDispatchTarget;
}
export interface JiraBatchSelection {
kind: typeof JIRA_TARGET_SELECTION_KIND.BATCHES;
batches: [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]];
}
export type JiraSelection =
| JiraSingleTargetSelection
| JiraTargetListSelection
| JiraBatchSelection;
export const JIRA_DISPATCH_TASK_KIND = "jira-dispatch";
export interface IntegrationProps {
type: "integrations";
id: string;
@@ -45,6 +102,7 @@ export interface JiraDispatchRequest {
attributes: {
project_key: string;
issue_type: string;
dispatch_mode?: JiraDispatchMode;
};
};
}
@@ -58,21 +116,30 @@ export interface JiraDispatchResponse {
completed_at: string | null;
name: string;
state: TaskState;
result: {
success?: boolean;
error?: string;
message?: string;
issue_url?: string;
issue_key?: string;
created_count?: number;
failed_count?: number;
} | null;
result: JiraDispatchTaskResult | null;
task_args: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
};
};
}
export interface JiraDispatchTaskResult {
success?: boolean;
error?: string;
message?: string;
successful_count?: number;
created_count?: number;
updated_count?: number;
failed_count?: number;
created_issues?: unknown[];
updated_issues?: unknown[];
failed_groups?: unknown[];
failed_batches?: unknown[];
failed_finding_ids?: string[];
issue_url?: string;
issue_key?: string;
}
// Shared AWS credential fields schema
const awsCredentialFields = {
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]),
+9
View File
@@ -0,0 +1,9 @@
import type { JiraSelection } from "@/types/integrations";
export interface JiraDispatchModalPayload {
selection: JiraSelection;
findingTitle?: string;
selectedResourceCount?: number;
isFindingGroupSelection?: boolean;
description?: string;
}