diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-mute-button.test.tsx index fe4d22e03f..01eb306ce9 100644 --- a/ui/components/findings/floating-mute-button.test.tsx +++ b/ui/components/findings/floating-mute-button.test.tsx @@ -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() { 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(); }); }); diff --git a/ui/components/findings/floating-mute-button.tsx b/ui/components/findings/floating-mute-button.tsx index 5a675dbf11..696ac6bb78 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-mute-button.tsx @@ -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 = () => ( - - - {PROWLER_CLOUD_ONLY_TOOLTIP} - - +const CloudFeatureBadge = () => ( + {PROWLER_CLOUD_ONLY_TOOLTIP} ); 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([]); @@ -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({ /> } - label={ - - {sendToJiraLabel} - {!canSendToJira && } - + label={sendToJiraLabel} + description={ + !canSendToJira ? : undefined } aria-label={sendToJiraLabel} - disabled={!canSendToJira} - disabledTooltip={jiraDisabledTooltip} onSelect={handleJiraClick} /> diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 99458828d1..ad45307fc8 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -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`} diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 273082a805..fe88817def 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -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 && ( )} @@ -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(); diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 92cd8e408c..76ffdbee1b 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -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)} /> )} diff --git a/ui/components/shadcn/dropdown/action-dropdown.tsx b/ui/components/shadcn/dropdown/action-dropdown.tsx index 4d03d67fc3..a8171b6720 100644 --- a/ui/components/shadcn/dropdown/action-dropdown.tsx +++ b/ui/components/shadcn/dropdown/action-dropdown.tsx @@ -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. */ diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx index 318c03740c..31e6de6e22 100644 --- a/ui/components/shared/cloud-upgrade-modal.test.tsx +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -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(); + + // 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"); diff --git a/ui/lib/cloud-upgrade.test.ts b/ui/lib/cloud-upgrade.test.ts index f16f54413b..233c5dffaf 100644 --- a/ui/lib/cloud-upgrade.test.ts +++ b/ui/lib/cloud-upgrade.test.ts @@ -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"], diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts index a8993cdfbd..b5ae3e55cb 100644 --- a/ui/lib/cloud-upgrade.ts +++ b/ui/lib/cloud-upgrade.ts @@ -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: diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts index 8da89babf8..39c56d269b 100644 --- a/ui/types/cloud-upgrade.ts +++ b/ui/types/cloud-upgrade.ts @@ -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",