mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5baf3d06b7 | ||
|
|
757b005078 | ||
|
|
33ff2a2a44 | ||
|
|
b0328ab637 | ||
|
|
0729fed780 | ||
|
|
a63f8c3a34 | ||
|
|
b6e1dd02c0 | ||
|
|
f1d26805d8 | ||
|
|
e6f47a9bd4 | ||
|
|
1acf9bf417 | ||
|
|
07d591db64 | ||
|
|
5e09dec669 | ||
|
|
0b62694bcd | ||
|
|
d9ec413216 | ||
|
|
11229b5e73 | ||
|
|
0faeca5586 | ||
|
|
3eebded9e1 | ||
|
|
00bba5af7d | ||
|
|
bb676a3b72 | ||
|
|
5f26463746 | ||
|
|
999a8d1428 | ||
|
|
fea4ce2048 | ||
|
|
f9ac342dae | ||
|
|
3f8e82eba8 |
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -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}
|
||||
+71
-20
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
+4
-2
@@ -665,7 +665,9 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => {
|
||||
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", { name: "Send to Jira" }),
|
||||
within(row as HTMLElement).getByRole("button", {
|
||||
name: "Send 1 Finding to Jira",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -702,7 +704,7 @@ describe("ResourceDetailDrawerContent — triage drawer actions", () => {
|
||||
const row = screen.getByText("EC2 Check").closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
const actionsCell = within(row as HTMLElement)
|
||||
.getByRole("button", { name: "Send to Jira" })
|
||||
.getByRole("button", { name: "Send 1 Finding to Jira" })
|
||||
.closest("td");
|
||||
|
||||
// Then
|
||||
|
||||
+30
-27
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createJiraBatchSelection,
|
||||
createJiraTargetSelection,
|
||||
} from "@/lib/jira-dispatch-selection";
|
||||
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
|
||||
import {
|
||||
buildJiraActionLabel,
|
||||
getJiraDispatchActionState,
|
||||
} from "./jira-dispatch-action";
|
||||
|
||||
describe("getJiraDispatchActionState", () => {
|
||||
it("allows one Finding without grouped dispatch", () => {
|
||||
// Given
|
||||
const selection = createJiraTargetSelection(
|
||||
["finding-1"],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
)!;
|
||||
|
||||
// When
|
||||
const state = getJiraDispatchActionState({ selection }, false);
|
||||
|
||||
// Then
|
||||
expect(state).toEqual({
|
||||
canChooseGroupedDispatch: false,
|
||||
defaultDispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
requiresUpgrade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers grouped choice for multiple Findings in Cloud", () => {
|
||||
// Given
|
||||
const selection = createJiraTargetSelection(
|
||||
["finding-1", "finding-2"],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
)!;
|
||||
|
||||
// When
|
||||
const state = getJiraDispatchActionState({ selection }, true);
|
||||
|
||||
// Then
|
||||
expect(state).toEqual({
|
||||
canChooseGroupedDispatch: true,
|
||||
defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
requiresUpgrade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires upgrade for multiple Findings outside Cloud", () => {
|
||||
// Given
|
||||
const selection = createJiraTargetSelection(
|
||||
["finding-1", "finding-2"],
|
||||
JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
)!;
|
||||
|
||||
// When
|
||||
const state = getJiraDispatchActionState({ selection }, false);
|
||||
|
||||
// Then
|
||||
expect(state.requiresUpgrade).toBe(true);
|
||||
expect(state.canChooseGroupedDispatch).toBe(false);
|
||||
});
|
||||
|
||||
it("offers grouped choice for one Finding Group with multiple resources", () => {
|
||||
// Given
|
||||
const selection = createJiraTargetSelection(
|
||||
["check-1"],
|
||||
JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
)!;
|
||||
|
||||
// When
|
||||
const state = getJiraDispatchActionState(
|
||||
{ selection, selectedResourceCount: 2 },
|
||||
true,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(state).toEqual({
|
||||
canChooseGroupedDispatch: true,
|
||||
defaultDispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
requiresUpgrade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mixed batches grouped without offering one global choice", () => {
|
||||
// Given
|
||||
const selection = createJiraBatchSelection([
|
||||
{
|
||||
targetIds: ["check-1"],
|
||||
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-1"],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
},
|
||||
])!;
|
||||
|
||||
// When
|
||||
const state = getJiraDispatchActionState({ selection }, true);
|
||||
|
||||
// Then
|
||||
expect(state.defaultDispatchMode).toBe(JIRA_DISPATCH_MODE.GROUPED);
|
||||
expect(state.canChooseGroupedDispatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJiraActionLabel", () => {
|
||||
it.each([
|
||||
[{ findingCount: 1 }, "Send 1 Finding to Jira"],
|
||||
[{ findingCount: 2 }, "Send 2 Findings to Jira"],
|
||||
[{ findingGroupCount: 1 }, "Send 1 Finding Group to Jira"],
|
||||
[
|
||||
{ findingGroupCount: 2, findingCount: 1 },
|
||||
"Send 2 Finding Groups and 1 Finding to Jira",
|
||||
],
|
||||
])("builds consistent Jira action copy", (counts, expected) => {
|
||||
// Given / When
|
||||
const label = buildJiraActionLabel(counts);
|
||||
|
||||
// Then
|
||||
expect(label).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
|
||||
import {
|
||||
JIRA_DISPATCH_MODE,
|
||||
JIRA_DISPATCH_TARGET,
|
||||
JIRA_TARGET_SELECTION_KIND,
|
||||
type JiraDispatchMode,
|
||||
} from "@/types/integrations";
|
||||
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
|
||||
export interface JiraDispatchActionState {
|
||||
defaultDispatchMode: JiraDispatchMode;
|
||||
canChooseGroupedDispatch: boolean;
|
||||
requiresUpgrade: boolean;
|
||||
}
|
||||
|
||||
export interface JiraActionLabelCounts {
|
||||
findingGroupCount?: number;
|
||||
findingCount?: number;
|
||||
}
|
||||
|
||||
export const getJiraDispatchActionState = (
|
||||
payload: JiraDispatchModalPayload,
|
||||
groupedDispatchEnabled: boolean,
|
||||
): JiraDispatchActionState => {
|
||||
const batches = getJiraSelectionBatches(payload.selection);
|
||||
const targetCount = batches.reduce(
|
||||
(count, batch) => count + batch.targetIds.length,
|
||||
0,
|
||||
);
|
||||
const hasFindingGroupTargets = batches.some(
|
||||
(batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
|
||||
);
|
||||
const requiresGroupedFeature =
|
||||
hasFindingGroupTargets || batches.length > 1 || targetCount > 1;
|
||||
const firstBatch = batches[0];
|
||||
const canChooseGroupedDispatch =
|
||||
groupedDispatchEnabled &&
|
||||
payload.selection.kind !== JIRA_TARGET_SELECTION_KIND.BATCHES &&
|
||||
(firstBatch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID
|
||||
? firstBatch.targetIds.length > 1
|
||||
: firstBatch.targetIds.length === 1 &&
|
||||
(payload.selectedResourceCount ?? 0) > 1);
|
||||
|
||||
return {
|
||||
defaultDispatchMode: requiresGroupedFeature
|
||||
? JIRA_DISPATCH_MODE.GROUPED
|
||||
: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
canChooseGroupedDispatch,
|
||||
requiresUpgrade: requiresGroupedFeature && !groupedDispatchEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
const buildEntityLabel = (
|
||||
count: number,
|
||||
singular: string,
|
||||
plural: string,
|
||||
): string | null => {
|
||||
if (count === 0) return null;
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
};
|
||||
|
||||
export const buildJiraActionLabel = ({
|
||||
findingGroupCount = 0,
|
||||
findingCount = 0,
|
||||
}: JiraActionLabelCounts): string => {
|
||||
const entities = [
|
||||
buildEntityLabel(findingGroupCount, "Finding Group", "Finding Groups"),
|
||||
buildEntityLabel(findingCount, "Finding", "Findings"),
|
||||
].filter(Boolean);
|
||||
|
||||
return entities.length > 0
|
||||
? `Send ${entities.join(" and ")} to Jira`
|
||||
: "Send to Jira";
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
JIRA_DISPATCH_MODE,
|
||||
type JiraDispatchTargetBatch,
|
||||
} from "@/types/integrations";
|
||||
|
||||
import { executeJiraDispatchBatches } from "./jira-dispatch-execution";
|
||||
|
||||
const { sendJiraDispatchMock, trackAndPollTaskMock } = vi.hoisted(() => ({
|
||||
sendJiraDispatchMock: vi.fn(),
|
||||
trackAndPollTaskMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/integrations/jira-dispatch", () => ({
|
||||
sendJiraDispatch: sendJiraDispatchMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
TASK_WATCHER_STATUS: { READY: "ready" },
|
||||
trackAndPollTask: trackAndPollTaskMock,
|
||||
}));
|
||||
|
||||
const settings = {
|
||||
integrationId: "jira-1",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
};
|
||||
|
||||
describe("executeJiraDispatchBatches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendJiraDispatchMock.mockResolvedValue({
|
||||
success: true,
|
||||
taskId: "task-1",
|
||||
message: "Started",
|
||||
});
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: { created_count: 2, failed_count: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses one dispatch path and aggregates successful batches", async () => {
|
||||
// Given
|
||||
const batches: JiraDispatchTargetBatch[] = [
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: "check_id" as const,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
},
|
||||
{
|
||||
targetIds: ["finding-a"],
|
||||
targetType: "finding_id" as const,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
},
|
||||
];
|
||||
|
||||
// When
|
||||
const result = await executeJiraDispatchBatches(batches, settings);
|
||||
|
||||
// Then
|
||||
expect(sendJiraDispatchMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(2, {
|
||||
integrationId: "jira-1",
|
||||
targetIds: ["finding-a"],
|
||||
filter: "finding_id",
|
||||
projectKey: "SEC",
|
||||
issueType: "Task",
|
||||
dispatchMode: "individual",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
startedTaskCount: 2,
|
||||
successfulTaskCount: 2,
|
||||
successfulIssueCount: 4,
|
||||
successMessage: "4 Jira issues were created or updated successfully.",
|
||||
errors: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns only failed finding IDs as an individual retry batch", async () => {
|
||||
// Given
|
||||
trackAndPollTaskMock.mockResolvedValue({
|
||||
status: "ready",
|
||||
result: {
|
||||
created_count: 1,
|
||||
failed_count: 2,
|
||||
failed_finding_ids: ["finding-b", "finding-b", "finding-c"],
|
||||
error: "Two issues failed.",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await executeJiraDispatchBatches(
|
||||
[
|
||||
{
|
||||
targetIds: ["check-a"],
|
||||
targetType: "check_id",
|
||||
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
|
||||
},
|
||||
],
|
||||
settings,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result.retryBatch).toEqual({
|
||||
targetIds: ["finding-b", "finding-c"],
|
||||
targetType: "finding_id",
|
||||
dispatchMode: "individual",
|
||||
});
|
||||
expect(result.warnings).toEqual([
|
||||
"Two issues failed. Jira dispatch completed with 3 failed and 1 created/updated issue.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not offer an automatic retry after an unknown launch failure", async () => {
|
||||
// Given
|
||||
sendJiraDispatchMock.mockRejectedValue(new Error("Connection closed"));
|
||||
|
||||
// When
|
||||
const result = await executeJiraDispatchBatches(
|
||||
[
|
||||
{
|
||||
targetIds: ["finding-a"],
|
||||
targetType: "finding_id",
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
},
|
||||
],
|
||||
settings,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result.startedTaskCount).toBe(0);
|
||||
expect(result.retryBatch).toBeUndefined();
|
||||
expect(result.errors).toEqual([
|
||||
"The Jira dispatch status is unknown after a connection error. Check Jira before retrying.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch";
|
||||
import {
|
||||
evaluateJiraDispatchTask,
|
||||
getJiraDispatchSuccessCount,
|
||||
} from "@/lib/jira-dispatch-result";
|
||||
import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task";
|
||||
import {
|
||||
TASK_WATCHER_STATUS,
|
||||
type TaskTrackingResult,
|
||||
trackAndPollTask,
|
||||
} from "@/store/task-watcher/store";
|
||||
import {
|
||||
JIRA_DISPATCH_MODE,
|
||||
JIRA_DISPATCH_TARGET,
|
||||
JIRA_DISPATCH_TASK_KIND,
|
||||
type JiraDispatchMode,
|
||||
type JiraDispatchTargetBatch,
|
||||
type JiraDispatchTaskResult,
|
||||
} from "@/types/integrations";
|
||||
|
||||
export interface JiraDispatchSettings {
|
||||
integrationId: string;
|
||||
projectKey: string;
|
||||
issueType: string;
|
||||
dispatchMode: JiraDispatchMode;
|
||||
}
|
||||
|
||||
export interface JiraDispatchExecutionResult {
|
||||
startedTaskCount: number;
|
||||
successfulTaskCount: number;
|
||||
successfulIssueCount: number;
|
||||
successMessage?: string;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
retryBatch?: JiraDispatchTargetBatch;
|
||||
}
|
||||
|
||||
interface JiraTrackedOutcome {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
failedFindingIds?: string[];
|
||||
successfulCount?: number;
|
||||
}
|
||||
|
||||
export function getJiraRetryBatch(
|
||||
failedFindingIds: string[] | undefined,
|
||||
): JiraDispatchTargetBatch | undefined {
|
||||
const [firstTargetId, ...remainingTargetIds] = Array.from(
|
||||
new Set(failedFindingIds?.filter(Boolean) ?? []),
|
||||
);
|
||||
if (!firstTargetId) return undefined;
|
||||
|
||||
return {
|
||||
targetIds: [firstTargetId, ...remainingTargetIds],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeJiraDispatchBatches(
|
||||
batches: JiraDispatchTargetBatch[],
|
||||
settings: JiraDispatchSettings,
|
||||
options: { notifyHandler?: boolean } = {},
|
||||
): Promise<JiraDispatchExecutionResult> {
|
||||
const startedTasks: Array<{
|
||||
taskId: string;
|
||||
dispatchMode: JiraDispatchMode;
|
||||
}> = [];
|
||||
const launchErrors: string[] = [];
|
||||
|
||||
for (const batch of batches) {
|
||||
const dispatchMode = batch.dispatchMode ?? settings.dispatchMode;
|
||||
try {
|
||||
const result = await sendJiraDispatch({
|
||||
integrationId: settings.integrationId,
|
||||
targetIds: batch.targetIds,
|
||||
filter: batch.targetType,
|
||||
projectKey: settings.projectKey,
|
||||
issueType: settings.issueType,
|
||||
dispatchMode,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
launchErrors.push(result.error || "Failed to send to Jira");
|
||||
continue;
|
||||
}
|
||||
|
||||
startedTasks.push({ taskId: result.taskId, dispatchMode });
|
||||
} catch {
|
||||
// The request may have reached the server before the RPC failed. An
|
||||
// automatic retry could create duplicate issues.
|
||||
launchErrors.push(
|
||||
"The Jira dispatch status is unknown after a connection error. Check Jira before retrying.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const trackedOutcomes = await Promise.all(
|
||||
startedTasks.map(async ({ taskId, dispatchMode }) => {
|
||||
let trackedTask: TaskTrackingResult<JiraDispatchTaskResult>;
|
||||
try {
|
||||
trackedTask = await trackAndPollTask<JiraDispatchTaskResult>({
|
||||
taskId,
|
||||
kind: JIRA_DISPATCH_TASK_KIND,
|
||||
meta: buildJiraDispatchTaskMeta({
|
||||
integrationId: settings.integrationId,
|
||||
projectKey: settings.projectKey,
|
||||
issueType: settings.issueType,
|
||||
dispatchMode,
|
||||
}),
|
||||
notifyHandler: options.notifyHandler ?? false,
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Tracking the Jira dispatch failed unexpectedly. Check Jira before retrying.",
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}
|
||||
|
||||
if (trackedTask.status !== TASK_WATCHER_STATUS.READY) {
|
||||
return {
|
||||
success: false,
|
||||
error: trackedTask.error || "Failed to track Jira issue creation.",
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}
|
||||
|
||||
const outcome = evaluateJiraDispatchTask("completed", trackedTask.result);
|
||||
if (!outcome.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: outcome.error,
|
||||
failedFindingIds: outcome.failedFindingIds,
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: outcome.message,
|
||||
warning: outcome.warning,
|
||||
failedFindingIds: outcome.failedFindingIds,
|
||||
successfulCount: getJiraDispatchSuccessCount(trackedTask.result),
|
||||
} satisfies JiraTrackedOutcome;
|
||||
}),
|
||||
);
|
||||
|
||||
const successfulOutcomes = trackedOutcomes.filter(
|
||||
(outcome) => outcome.success,
|
||||
);
|
||||
const successfulIssueCount = successfulOutcomes.reduce(
|
||||
(count, outcome) => count + (outcome.successfulCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const successMessage =
|
||||
successfulOutcomes.length === 1
|
||||
? successfulOutcomes[0].message
|
||||
: successfulOutcomes.length > 1
|
||||
? `${successfulIssueCount} Jira issues were created or updated successfully.`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
startedTaskCount: startedTasks.length,
|
||||
successfulTaskCount: successfulOutcomes.length,
|
||||
successfulIssueCount,
|
||||
successMessage,
|
||||
warnings: Array.from(
|
||||
new Set(
|
||||
trackedOutcomes.flatMap((outcome) =>
|
||||
outcome.warning ? [outcome.warning] : [],
|
||||
),
|
||||
),
|
||||
),
|
||||
errors: Array.from(
|
||||
new Set([
|
||||
...trackedOutcomes.flatMap((outcome) =>
|
||||
outcome.error ? [outcome.error] : [],
|
||||
),
|
||||
...launchErrors,
|
||||
]),
|
||||
),
|
||||
retryBatch: getJiraRetryBatch(
|
||||
trackedOutcomes.flatMap((outcome) => outcome.failedFindingIds ?? []),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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}` };
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from "./cloud-upgrade/store";
|
||||
export * from "./jira-dispatch/store";
|
||||
export * from "./organizations/store";
|
||||
export * from "./provider-wizard/store";
|
||||
export * from "./scans/store";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
|
||||
interface JiraDispatchStoreState {
|
||||
activePayload: JiraDispatchModalPayload | null;
|
||||
openJiraDispatch: (payload: JiraDispatchModalPayload) => void;
|
||||
closeJiraDispatch: () => void;
|
||||
}
|
||||
|
||||
// Jira dispatch is ephemeral and globally hosted so every action uses one modal.
|
||||
export const useJiraDispatchStore = create<JiraDispatchStoreState>((set) => ({
|
||||
activePayload: null,
|
||||
openJiraDispatch: (activePayload) => set({ activePayload }),
|
||||
closeJiraDispatch: () => set({ activePayload: null }),
|
||||
}));
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { JiraSelection } from "@/types/integrations";
|
||||
|
||||
export interface JiraDispatchModalPayload {
|
||||
selection: JiraSelection;
|
||||
findingTitle?: string;
|
||||
selectedResourceCount?: number;
|
||||
isFindingGroupSelection?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user