fix(ui): address grouped Jira review feedback

This commit is contained in:
Hugo P.Brito
2026-07-16 22:16:47 +01:00
parent 0b62694bcd
commit 5e09dec669
16 changed files with 303 additions and 31 deletions
+41 -2
View File
@@ -100,7 +100,27 @@ describe("sendJiraDispatch", () => {
// Then
expect(result).toEqual({
success: false,
error: "Jira dispatch completed with 1 failed and 2 created issues.",
error:
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
it("should include updated issues in partial failure summaries", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: { created_count: 0, updated_count: 2, failed_count: 1 },
});
// When
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error:
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
@@ -118,7 +138,8 @@ describe("sendJiraDispatch", () => {
// Then
expect(result).toEqual({
success: false,
error: "Jira dispatch completed with 1 failed and 1 created issue.",
error:
"Jira dispatch completed with 1 failed and 1 created/updated issue.",
});
});
@@ -186,4 +207,22 @@ describe("sendJiraDispatch", () => {
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.",
});
});
});
+4 -2
View File
@@ -63,7 +63,9 @@ const buildJiraDispatchFailureMessage = (
if (result?.error) return result.error;
const createdCount = result?.created_count ?? 0;
return `Jira dispatch completed with ${failedCount} failed and ${createdCount} created issue${createdCount === 1 ? "" : "s"}.`;
const updatedCount = result?.updated_count ?? 0;
const successCount = createdCount + updatedCount;
return `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`;
};
export const getJiraIssueTypes = async (
@@ -254,7 +256,7 @@ export const pollJiraDispatchTask = async (
};
}
if (jiraResult && getJiraDispatchSuccessCount(jiraResult) === 0) {
if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) {
return {
success: false,
error:
@@ -260,7 +260,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
).toBeVisible();
expect(
screen.getByRole("button", {
name: "Send to Jira",
name: "Send 1 Group and 1 Finding to Jira",
}),
).toHaveTextContent("Send 1 Group and 1 Finding to Jira");
});
@@ -14,6 +14,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import { MuteFindingsModal } from "./mute-findings-modal";
@@ -171,12 +172,11 @@ export function FloatingMuteButton({
<Button
type="button"
variant="outline"
className={
canSendToJira
? "w-full justify-start"
: "w-full justify-start pr-56"
}
aria-label="Send to Jira"
className={cn(
"w-full justify-start",
!canSendToJira && "pr-56",
)}
aria-label={sendToJiraLabel}
disabled={!canSendToJira}
onClick={handleJiraClick}
>
@@ -38,4 +38,21 @@ describe("buildJiraDispatchChoiceCopy", () => {
"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: "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.",
});
});
});
@@ -1,6 +1,7 @@
interface JiraDispatchChoiceCopyParams {
selectedCount: number;
isSelectedFindingGroupFlow: boolean;
selectionKind?: "findings" | "resources";
}
interface JiraDispatchChoiceCopy {
@@ -13,6 +14,7 @@ interface JiraDispatchChoiceCopy {
export const buildJiraDispatchChoiceCopy = ({
selectedCount,
isSelectedFindingGroupFlow,
selectionKind = "resources",
}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
if (isSelectedFindingGroupFlow) {
return {
@@ -26,6 +28,17 @@ export const buildJiraDispatchChoiceCopy = ({
};
}
if (selectionKind === "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:
@@ -129,12 +129,41 @@ describe("SendToJiraModal", () => {
"Create one Jira issue for all selected Findings in this Finding Group",
),
).toBeInTheDocument();
expect(
screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
).toBeInTheDocument();
expect(screen.getByText("Create separate Jira issues")).toBeInTheDocument();
expect(sendFindingToJiraMock).not.toHaveBeenCalled();
expect(sendJiraDispatchMock).not.toHaveBeenCalled();
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
});
it("uses neutral Findings copy for ordinary multi-Finding selections", async () => {
render(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
findingId="finding-1"
findingTitle="Finding 1"
targetIds={["finding-1", "finding-2"]}
targetType="finding_id"
defaultDispatchMode="grouped"
canChooseGroupedDispatch
/>,
);
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
expect(
screen.getByText("Create one Jira issue for all selected Findings"),
).toBeInTheDocument();
expect(
screen.queryByText(
"Create one Jira issue for all selected Findings in this Finding Group",
),
).not.toBeInTheDocument();
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
});
it("submits mixed Group and Finding batches with the correct dispatch filters and modes", async () => {
// Given
const user = userEvent.setup();
@@ -520,4 +549,64 @@ describe("SendToJiraModal", () => {
expect.objectContaining({ title: "Success!" }),
);
});
it("polls started tasks when a later mixed dispatch batch fails to launch", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock
.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
})
.mockResolvedValueOnce({
success: false,
error: "Failed to launch Finding batch.",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
]}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
expect(pollJiraDispatchTaskMock).toHaveBeenCalledWith("group-task"),
);
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Error",
description:
"Some Jira dispatches started, but Failed to launch Finding batch.",
}),
);
});
});
+21 -7
View File
@@ -56,6 +56,7 @@ interface SendToJiraModalProps {
targetBatches?: JiraDispatchTargetBatch[];
defaultDispatchMode?: JiraDispatchMode;
canChooseGroupedDispatch?: boolean;
isFindingGroupSelection?: boolean;
selectedResourceCount?: number;
description?: string;
}
@@ -99,6 +100,7 @@ export const SendToJiraModal = ({
targetBatches,
defaultDispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
canChooseGroupedDispatch = false,
isFindingGroupSelection = false,
selectedResourceCount,
description,
}: SendToJiraModalProps) => {
@@ -142,14 +144,14 @@ export const SendToJiraModal = ({
(multiFindingTargetCount > 1 || jiraSelectedResourceCount > 1);
const isSelectedFindingGroupFlow =
shouldShowDispatchChoice &&
(targetType === JIRA_DISPATCH_TARGET.FINDING_ID ||
multiFindingTargetCount > 1);
(targetType === JIRA_DISPATCH_TARGET.CHECK_ID || isFindingGroupSelection);
const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({
selectedCount:
multiFindingTargetCount > 1
? multiFindingTargetCount
: jiraSelectedResourceCount,
isSelectedFindingGroupFlow,
selectionKind: multiFindingTargetCount > 1 ? "findings" : "resources",
});
const selectedIntegration = form.watch("integration");
@@ -217,6 +219,7 @@ export const SendToJiraModal = ({
void (async () => {
try {
const taskIds: string[] = [];
const launchErrors: string[] = [];
for (const batch of jiraTargetBatches) {
const batchDispatchMode = batch.dispatchMode ?? data.dispatchMode;
@@ -240,12 +243,17 @@ export const SendToJiraModal = ({
});
if (!result.success) {
throw new Error(result.error || "Failed to send to Jira");
launchErrors.push(result.error || "Failed to send to Jira");
continue;
}
taskIds.push(result.taskId);
}
if (taskIds.length === 0 && launchErrors.length > 0) {
throw new Error(launchErrors.join(" "));
}
// Poll for task completion and notify once
const taskResults = await Promise.all(
taskIds.map((taskId) => pollJiraDispatchTaskUntilDone(taskId)),
@@ -258,6 +266,12 @@ export const SendToJiraModal = ({
throw new Error(failedTask.error || "Failed to create Jira issue");
}
if (launchErrors.length > 0) {
throw new Error(
`Some Jira dispatches started, but ${launchErrors.join(" ")}`,
);
}
toast({
title: "Success!",
description:
@@ -375,10 +389,10 @@ export const SendToJiraModal = ({
onOpenChange={onOpenChange}
title="Send Finding to Jira"
description={
shouldShowDispatchChoice
? jiraDispatchChoiceCopy.description
: description
? description
description
? description
: shouldShowDispatchChoice
? jiraDispatchChoiceCopy.description
: findingTitle
? `Create a Jira issue for: "${findingTitle}"`
: "Select integration, project and issue type to create a Jira issue"
@@ -496,6 +496,44 @@ describe("DataTableRowActions", () => {
);
});
it("keeps single finding Jira dispatch enabled when other rows are selected outside cloud", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-2", "finding-3"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
<DataTableRowActions row={makeFindingRow()} />
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Send 1 Finding to Jira" }),
);
// Then
expect(
screen.getByRole("button", { name: "Send 1 Finding to Jira" }),
).toBeEnabled();
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
expect.objectContaining({
isOpen: true,
targetIds: ["finding-1"],
targetType: "finding_id",
defaultDispatchMode: "individual",
canChooseGroupedDispatch: false,
}),
undefined,
);
});
it("shows Add Triage Note for editable findings without a note", () => {
// Given / When
render(
@@ -153,8 +153,6 @@ export function DataTableRowActions<T extends FindingRowData>({
const isCurrentSelected = selectedFindingIds.includes(muteKey);
const hasMultipleSelected = selectedFindingIds.length > 1;
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
const isJiraActionDisabled =
(isGroup || hasMultipleSelected) && !groupedJiraDispatchEnabled;
const getDisplayIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
@@ -188,6 +186,8 @@ export function DataTableRowActions<T extends FindingRowData>({
};
const jiraTargetIds = getJiraTargetIds();
const isJiraActionDisabled =
(isGroup || jiraTargetIds.length > 1) && !groupedJiraDispatchEnabled;
const selectedJiraResourceCount = isGroup
? (finding.resourcesFail ?? 0)
: undefined;
@@ -251,7 +251,7 @@ export function FindingsGroupDrillDown({
return resolveSelectedFindingIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
isBulkOperation
isBulkOperation={selectedFindingIds.length > 1}
showSendToJira
canSendToJira={canSendSelectedFindingsToJira}
jiraDisabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP}
@@ -407,6 +407,38 @@ describe("FindingsGroupTable", () => {
screen.queryByRole("button", { name: "Select finding-1" }),
).not.toBeInTheDocument();
});
it("clears the expanded group when expandedCheckId is removed", () => {
// Given
const data = [
{ checkId: "check-a", resourcesTotal: 1 },
] as unknown as Parameters<typeof FindingsGroupTable>[0]["data"];
const { rerender } = render(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
expandedCheckId="check-a"
/>,
);
expect(
screen.getByRole("button", { name: "Select finding-1" }),
).toBeInTheDocument();
// When
rerender(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then
expect(
screen.queryByRole("button", { name: "Select finding-1" }),
).not.toBeInTheDocument();
});
});
describe("bulk Jira action", () => {
@@ -101,6 +101,9 @@ export function FindingsGroupTable({
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const inlineRef = useRef<InlineResourceContainerHandle>(null);
const previousRequestedExpandedCheckIdRef = useRef<string | undefined>(
undefined,
);
const safeData = data ?? [];
const hasResourceSelection = resourceSelection.length > 0;
@@ -108,7 +111,21 @@ export function FindingsGroupTable({
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
useEffect(() => {
if (!requestedExpandedCheckId) return;
const previousRequestedExpandedCheckId =
previousRequestedExpandedCheckIdRef.current;
previousRequestedExpandedCheckIdRef.current = requestedExpandedCheckId;
if (!requestedExpandedCheckId) {
if (previousRequestedExpandedCheckId) {
setExpandedCheckId(null);
setExpandedGroup(null);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
}
return;
}
const requestedGroup = safeData.find(
(group) => group.checkId === requestedExpandedCheckId,
@@ -430,6 +447,9 @@ export function FindingsGroupTable({
targetType={jiraTargetType}
targetBatches={jiraBatches}
defaultDispatchMode={jiraDispatchMode}
isFindingGroupSelection={
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
description={jiraDescription}
@@ -116,6 +116,8 @@ export function ActionDropdownItem({
destructive = false,
className,
disabledTooltip,
disabled,
onSelect,
...props
}: ActionDropdownItemProps) {
const item = (
@@ -126,6 +128,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 && (
@@ -154,12 +166,10 @@ export function ActionDropdownItem({
</DropdownMenuItem>
);
if (props.disabled && disabledTooltip) {
if (disabled && disabledTooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">{item}</span>
</TooltipTrigger>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent>{disabledTooltip}</TooltipContent>
</Tooltip>
);
+2 -2
View File
@@ -68,7 +68,7 @@ describe("enterprise feature flags", () => {
it("should enable grouped Jira dispatch from the enterprise env flag in cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "cloud");
vi.stubEnv(
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
"true",
@@ -83,7 +83,7 @@ describe("enterprise feature flags", () => {
it("should keep grouped Jira dispatch disabled outside cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "onpremise");
vi.stubEnv(
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
"true",
+1 -3
View File
@@ -1,5 +1,3 @@
import { isCloud } from "./shared/env";
export const DEPLOYMENT_MODE = {
CLOUD: "cloud",
ON_PREMISE: "onpremise",
@@ -67,5 +65,5 @@ export const isPostHogEnabled = (): boolean =>
getBooleanEnv(ENTERPRISE_FEATURE_ENV.POSTHOG_ENABLED, true);
export const isGroupedJiraDispatchEnabled = (): boolean =>
isCloud() &&
getDeploymentMode() === DEPLOYMENT_MODE.CLOUD &&
getBooleanEnv(ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED, false);