fix(ui): refine grouped Jira upgrade prompts

This commit is contained in:
alejandrobailo
2026-07-22 00:13:50 +02:00
parent a63f8c3a34
commit 0729fed780
6 changed files with 69 additions and 33 deletions
@@ -265,7 +265,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
).toHaveTextContent("Send 1 Group and 1 Finding to Jira");
});
it("should show the Cloud Jira upgrade below the action label", async () => {
it("should show the Cloud Jira tooltip and open the upgrade modal", async () => {
// Given
const onSendToJira = vi.fn();
const user = userEvent.setup();
@@ -287,15 +287,20 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
// Then
expect(jiraAction).toBeVisible();
expect(jiraAction).not.toHaveAttribute("aria-disabled");
const jiraLabel = within(jiraAction).getByText("Send to Jira");
const cloudBadge = within(jiraAction).getByText(
"Available only in Prowler Cloud",
);
expect(jiraLabel.nextElementSibling).toContainElement(cloudBadge);
expect(
within(jiraAction).queryByText("Available only in Prowler Cloud"),
).not.toBeInTheDocument();
// When
await user.click(cloudBadge);
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(
@@ -6,7 +6,6 @@ import { createPortal } from "react-dom";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { Button } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
import {
ActionDropdown,
ActionDropdownItem,
@@ -40,10 +39,6 @@ interface FloatingMuteButtonProps {
sendToJiraLabel?: string;
}
const CloudFeatureBadge = () => (
<Badge variant="cloud">{PROWLER_CLOUD_ONLY_TOOLTIP}</Badge>
);
export function FloatingMuteButton({
selectedCount,
selectedFindingIds,
@@ -168,8 +163,8 @@ export function FloatingMuteButton({
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={sendToJiraLabel}
description={
!canSendToJira ? <CloudFeatureBadge /> : undefined
tooltip={
!canSendToJira ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
}
aria-label={sendToJiraLabel}
onSelect={handleJiraClick}
@@ -2,6 +2,9 @@ import { render, screen, waitFor } 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";
const { MuteFindingsModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted(
() => ({
MuteFindingsModalMock: vi.fn(() => null),
@@ -43,13 +46,19 @@ vi.mock("@/components/shadcn/dropdown", () => ({
onSelect,
disabled,
disabledTooltip,
tooltip,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
disabledTooltip?: string;
tooltip?: string;
}) => (
<button onClick={onSelect} disabled={disabled} title={disabledTooltip}>
<button
onClick={onSelect}
disabled={disabled}
title={tooltip ?? disabledTooltip}
>
{label}
</button>
),
@@ -149,6 +158,7 @@ describe("DataTableRowActions", () => {
beforeEach(() => {
vi.clearAllMocks();
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("opens the mute modal immediately in preparing state for finding groups", async () => {
@@ -319,7 +329,7 @@ describe("DataTableRowActions", () => {
);
});
it("shows disabled Cloud-only Jira action for finding groups outside cloud", async () => {
it("shows the Cloud tooltip and opens the upgrade modal for groups outside cloud", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
const user = userEvent.setup();
@@ -359,11 +369,14 @@ describe("DataTableRowActions", () => {
// Then
expect(jiraButton).toBeVisible();
expect(jiraButton).toBeDisabled();
expect(jiraButton).toBeEnabled();
expect(jiraButton).toHaveAttribute(
"title",
"Available only in Prowler Cloud",
);
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
);
expect(SendToJiraModalMock).not.toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true }),
undefined,
@@ -20,6 +20,8 @@ import {
import { isFindingGroupMuted } from "@/lib/findings-groups";
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
import { getOptionalText } from "@/lib/utils";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
@@ -121,6 +123,9 @@ export function DataTableRowActions<T extends FindingRowData>({
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
const resolvedFindingContext = findingContext ?? {
@@ -196,7 +201,7 @@ export function DataTableRowActions<T extends FindingRowData>({
jiraTargetIds,
jiraTargetType,
);
const isJiraActionDisabled =
const requiresJiraUpgrade =
(isGroup || jiraTargetIds.length > 1) && !groupedJiraDispatchEnabled;
const selectedJiraResourceCount = isGroup
? (finding.resourcesFail ?? 0)
@@ -273,7 +278,10 @@ export function DataTableRowActions<T extends FindingRowData>({
};
const handleJiraClick = () => {
if (isJiraActionDisabled) return;
if (requiresJiraUpgrade) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
}
setIsJiraModalOpen(true);
};
@@ -332,8 +340,9 @@ export function DataTableRowActions<T extends FindingRowData>({
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={getJiraLabel()}
disabled={isJiraActionDisabled}
disabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP}
tooltip={
requiresJiraUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
}
onSelect={handleJiraClick}
/>
</ActionDropdown>
@@ -170,7 +170,6 @@ vi.mock("./inline-resource-container", () => ({
}));
vi.mock("../floating-mute-button", () => ({
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
FloatingMuteButton: ({
label,
muteLabel,
@@ -204,10 +203,12 @@ vi.mock("../floating-mute-button", () => ({
<button
type="button"
aria-label={sendToJiraLabel ?? "Send to Jira"}
title={
canSendToJira ? undefined : "Available only in Prowler Cloud"
}
onClick={() => canSendToJira && onSendToJira?.()}
>
{sendToJiraLabel ?? "Send to Jira"}
{!canSendToJira && <span>Available only in Prowler Cloud</span>}
</button>
)}
</div>
@@ -913,7 +914,7 @@ describe("FindingsGroupTable", () => {
});
});
it("should show selected multi-finding Jira upgrade when grouped dispatch is disabled", async () => {
it("should show selected multi-finding Jira tooltip when grouped dispatch is disabled", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
const user = userEvent.setup();
@@ -949,16 +950,20 @@ describe("FindingsGroupTable", () => {
// Then
expect(jiraButton).not.toBeDisabled();
expect(jiraButton).toHaveAttribute(
"title",
"Available only in Prowler Cloud",
);
expect(
within(jiraButton).getByText("Available only in Prowler Cloud"),
).toBeVisible();
within(jiraButton).queryByText("Available only in Prowler Cloud"),
).not.toBeInTheDocument();
expect(SendToJiraModalMock).not.toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true }),
undefined,
);
});
it("should render Cloud-only bulk Jira upgrade when grouped Jira dispatch is disabled", async () => {
it("should render Cloud-only bulk Jira tooltip when grouped Jira dispatch is disabled", async () => {
// Given
const user = userEvent.setup();
const data = [
@@ -989,9 +994,13 @@ describe("FindingsGroupTable", () => {
});
expect(jiraButton).toBeVisible();
expect(jiraButton).not.toBeDisabled();
expect(jiraButton).toHaveAttribute(
"title",
"Available only in Prowler Cloud",
);
expect(
within(jiraButton).getByText("Available only in Prowler Cloud"),
).toBeVisible();
within(jiraButton).queryByText("Available only in Prowler Cloud"),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Mute 1 Group" }),
).toBeInTheDocument();
@@ -102,9 +102,11 @@ interface ActionDropdownItemProps
/** Main label text */
label: ReactNode;
/** Optional description text below the label */
description?: ReactNode;
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;
}
@@ -115,6 +117,7 @@ export function ActionDropdownItem({
description,
destructive = false,
className,
tooltip,
disabledTooltip,
disabled,
onSelect,
@@ -166,11 +169,13 @@ export function ActionDropdownItem({
</DropdownMenuItem>
);
if (disabled && disabledTooltip) {
const tooltipContent = tooltip ?? (disabled ? disabledTooltip : undefined);
if (tooltipContent) {
return (
<Tooltip>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent>{disabledTooltip}</TooltipContent>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
);
}