fix(ui): improve Cloud Jira upgrade action

This commit is contained in:
alejandrobailo
2026-07-21 23:27:26 +02:00
parent b6e1dd02c0
commit a63f8c3a34
10 changed files with 96 additions and 68 deletions
@@ -1,7 +1,10 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
// ---------------------------------------------------------------------------
// Hoist mocks to avoid deep dependency chains
// ---------------------------------------------------------------------------
@@ -42,6 +45,7 @@ function deferredPromise<T>() {
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
@@ -261,7 +265,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
).toHaveTextContent("Send 1 Group and 1 Finding to Jira");
});
it("should show disabled Cloud-only Jira action in the chooser", async () => {
it("should show the Cloud Jira upgrade below the action label", async () => {
// Given
const onSendToJira = vi.fn();
const user = userEvent.setup();
@@ -282,25 +286,21 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// Then
expect(jiraAction).toBeVisible();
expect(jiraAction).toHaveAttribute("aria-disabled", "true");
const cloudBadgeLink = screen.getByRole("link", {
name: "Available only in Prowler Cloud",
});
expect(cloudBadgeLink).toHaveTextContent("Available only in Prowler Cloud");
expect(cloudBadgeLink).toHaveAttribute(
"href",
"https://prowler.com/pricing",
);
expect(jiraAction).toContainElement(cloudBadgeLink);
expect(onSendToJira).not.toHaveBeenCalled();
expect(jiraAction).not.toHaveAttribute("aria-disabled");
// When
await user.hover(jiraAction);
// Then
const tooltipTexts = await screen.findAllByText(
const jiraLabel = within(jiraAction).getByText("Send to Jira");
const cloudBadge = within(jiraAction).getByText(
"Available only in Prowler Cloud",
);
expect(tooltipTexts[0]).toBeVisible();
expect(jiraLabel.nextElementSibling).toContainElement(cloudBadge);
// When
await user.click(cloudBadge);
// Then
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
);
expect(onSendToJira).not.toHaveBeenCalled();
});
});
+14 -21
View File
@@ -13,6 +13,8 @@ import {
} from "@/components/shadcn/dropdown/action-dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { PROWLER_CLOUD_ONLY_TOOLTIP } from "@/lib/deployment";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { MuteFindingsModal } from "./mute-findings-modal";
@@ -36,20 +38,10 @@ interface FloatingMuteButtonProps {
showSendToJira?: boolean;
/** Custom Jira action label. Defaults to "Send to Jira". */
sendToJiraLabel?: string;
/** Tooltip shown when the Jira action is visible but disabled. */
jiraDisabledTooltip?: string;
}
const CloudFeatureBadgeLink = () => (
<Badge variant="cloud" asChild>
<a
href="https://prowler.com/pricing"
target="_blank"
rel="noopener noreferrer"
>
{PROWLER_CLOUD_ONLY_TOOLTIP}
</a>
</Badge>
const CloudFeatureBadge = () => (
<Badge variant="cloud">{PROWLER_CLOUD_ONLY_TOOLTIP}</Badge>
);
export function FloatingMuteButton({
@@ -64,7 +56,6 @@ export function FloatingMuteButton({
canSendToJira = false,
showSendToJira = canSendToJira,
sendToJiraLabel = "Send to Jira",
jiraDisabledTooltip = PROWLER_CLOUD_ONLY_TOOLTIP,
}: FloatingMuteButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
@@ -73,6 +64,9 @@ export function FloatingMuteButton({
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const handleModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
@@ -117,7 +111,10 @@ export function FloatingMuteButton({
};
const handleJiraClick = () => {
if (!canSendToJira) return;
if (!canSendToJira) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
}
onSendToJira?.();
};
@@ -170,15 +167,11 @@ export function FloatingMuteButton({
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={
<span className="flex items-center gap-2">
{sendToJiraLabel}
{!canSendToJira && <CloudFeatureBadgeLink />}
</span>
label={sendToJiraLabel}
description={
!canSendToJira ? <CloudFeatureBadge /> : undefined
}
aria-label={sendToJiraLabel}
disabled={!canSendToJira}
disabledTooltip={jiraDisabledTooltip}
onSelect={handleJiraClick}
/>
</ActionDropdown>
@@ -26,10 +26,7 @@ import {
import { SeverityBadge, StatusFindingBadge } from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
import {
getFilteredFindingGroupDelta,
getFindingGroupImpactedCounts,
@@ -259,7 +256,6 @@ export function FindingsGroupDrillDown({
isBulkOperation={selectedFindingIds.length > 1}
showSendToJira
canSendToJira={canSendSelectedFindingsToJira}
jiraDisabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP}
sendToJiraLabel={`Send ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
} to Jira`}
@@ -179,7 +179,6 @@ vi.mock("../floating-mute-button", () => ({
onSendToJira,
canSendToJira,
showSendToJira,
jiraDisabledTooltip,
}: {
label?: string;
muteLabel?: string;
@@ -188,7 +187,6 @@ vi.mock("../floating-mute-button", () => ({
onSendToJira?: () => void;
canSendToJira?: boolean;
showSendToJira?: boolean;
jiraDisabledTooltip?: string;
}) => {
const [isChooserOpen, setIsChooserOpen] = useState(false);
@@ -205,11 +203,11 @@ vi.mock("../floating-mute-button", () => ({
{showSendToJira && (
<button
type="button"
disabled={!canSendToJira}
title={!canSendToJira ? jiraDisabledTooltip : undefined}
onClick={onSendToJira}
aria-label={sendToJiraLabel ?? "Send to Jira"}
onClick={() => canSendToJira && onSendToJira?.()}
>
{sendToJiraLabel ?? "Send to Jira"}
{!canSendToJira && <span>Available only in Prowler Cloud</span>}
</button>
)}
</div>
@@ -915,7 +913,7 @@ describe("FindingsGroupTable", () => {
});
});
it("should disable selected multi-finding Jira dispatch when grouped dispatch is disabled", async () => {
it("should show selected multi-finding Jira upgrade when grouped dispatch is disabled", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
const user = userEvent.setup();
@@ -950,18 +948,17 @@ describe("FindingsGroupTable", () => {
await user.click(jiraButton);
// Then
expect(jiraButton).toBeDisabled();
expect(jiraButton).toHaveAttribute(
"title",
"Available only in Prowler Cloud",
);
expect(jiraButton).not.toBeDisabled();
expect(
within(jiraButton).getByText("Available only in Prowler Cloud"),
).toBeVisible();
expect(SendToJiraModalMock).not.toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true }),
undefined,
);
});
it("should render disabled Cloud-only bulk Jira button when grouped Jira dispatch is disabled", async () => {
it("should render Cloud-only bulk Jira upgrade when grouped Jira dispatch is disabled", async () => {
// Given
const user = userEvent.setup();
const data = [
@@ -991,11 +988,10 @@ describe("FindingsGroupTable", () => {
name: "Send 1 Group to Jira",
});
expect(jiraButton).toBeVisible();
expect(jiraButton).toBeDisabled();
expect(jiraButton).toHaveAttribute(
"title",
"Available only in Prowler Cloud",
);
expect(jiraButton).not.toBeDisabled();
expect(
within(jiraButton).getByText("Available only in Prowler Cloud"),
).toBeVisible();
expect(
screen.getByRole("button", { name: "Mute 1 Group" }),
).toBeInTheDocument();
@@ -9,10 +9,7 @@ import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbo
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTable } from "@/components/shadcn/table";
import {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import {
createJiraBatchSelection,
@@ -443,7 +440,6 @@ const FindingsGroupTableContent = ({
showSendToJira={hasJiraTargets}
canSendToJira={canSendToJira}
sendToJiraLabel={sendToJiraLabel}
jiraDisabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP}
onSendToJira={() => setIsJiraModalOpen(true)}
/>
)}
@@ -102,7 +102,7 @@ interface ActionDropdownItemProps
/** Main label text */
label: ReactNode;
/** Optional description text below the label */
description?: string;
description?: ReactNode;
/** Whether the item is destructive (danger styling) */
destructive?: boolean;
/** Tooltip shown when the item is disabled. */
@@ -85,6 +85,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("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+2
View File
@@ -24,6 +24,7 @@ describe("cloud upgrade content", () => {
"Bring CLI Findings into One Cloud View",
"See Compliance Across Every Provider",
"Coordinate Finding Remediation",
"Send Findings to Jira at Scale",
"Use The Agent Cloud Defender",
"Scale Prowler Without Operating It",
"Configure Every Scan Once",
@@ -71,6 +72,7 @@ describe("cloud upgrade URLs", () => {
"cross-provider-compliance",
],
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"],
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, "jira-dispatch"],
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"],
[CLOUD_UPGRADE_FEATURE.GENERAL, "general"],
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION, "scan-configuration"],
+12
View File
@@ -26,6 +26,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = {
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
"cross-provider-compliance",
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings",
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: "jira-dispatch",
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai",
[CLOUD_UPGRADE_FEATURE.GENERAL]: "general",
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: "scan-configuration",
@@ -98,6 +99,17 @@ export const CLOUD_UPGRADE_CONTENT = {
],
primaryCta: "Triage Findings in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: {
title: "Send Findings to Jira at Scale",
description:
"Create Jira issues from selected findings and finding groups without handling each item separately.",
benefits: [
"Send selected findings or finding groups in one action",
"Choose between grouped and individual Jira issues",
"Track dispatch progress and retry failed findings",
],
primaryCta: "Send Findings to Jira in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: {
title: "Use The Agent Cloud Defender",
description:
+1
View File
@@ -5,6 +5,7 @@ export const CLOUD_UPGRADE_FEATURE = {
CLI_IMPORT: "cli_import",
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
FINDING_TRIAGE: "finding_triage",
JIRA_DISPATCH: "jira_dispatch",
LIGHTHOUSE_AI: "lighthouse_ai",
GENERAL: "general",
SCAN_CONFIGURATION: "scan_configuration",