feat(mutelisting): add mute button inside finding detailed view (#10303)

This commit is contained in:
Pedro Martín
2026-03-13 11:45:10 +01:00
committed by GitHub
parent ebc792e578
commit 3672d19c6a
4 changed files with 375 additions and 29 deletions

View File

@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.20.0] (Prowler v5.20.0)
### 🚀 Added
- Mute button in the finding detailed view, allowing users to mute findings directly without going back to the table [(#10303)](https://github.com/prowler-cloud/prowler/pull/10303)
### 🔄 Changed
- Attack Paths: Improved error handling for server errors (5xx) and network failures with user-friendly messages instead of raw internal errors and layout changes [(#10249)](https://github.com/prowler-cloud/prowler/pull/10249)

View File

@@ -0,0 +1,7 @@
export default function WorkflowLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,295 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FindingProps } from "@/types";
import { FindingDetail } from "./finding-detail";
// Mock next/navigation
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: mockRefresh }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
}));
// Mock @/components/shadcn to avoid next-auth import chain
vi.mock("@/components/shadcn", () => {
const Slot = ({ children }: { children: React.ReactNode }) => <>{children}</>;
return {
Button: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: string;
size?: string;
}) => <button {...props}>{children}</button>,
Drawer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DrawerClose: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerDescription: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerHeader: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerTitle: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DrawerTrigger: Slot,
InfoField: ({
children,
label,
}: {
children: React.ReactNode;
label: string;
variant?: string;
}) => (
<div>
<span>{label}</span>
{children}
</div>
),
Tabs: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TabsContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TabsList: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipTrigger: Slot,
};
});
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
CodeSnippet: ({ value }: { value: string }) => <span>{value}</span>,
}));
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
}));
vi.mock("@/components/ui/entities", () => ({
EntityInfo: () => <div data-testid="entity-info" />,
}));
vi.mock("@/components/ui/entities/date-with-time", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
}));
vi.mock("@/components/ui/table/severity-badge", () => ({
SeverityBadge: ({ severity }: { severity: string }) => (
<span>{severity}</span>
),
}));
vi.mock("@/components/ui/table/status-finding-badge", () => ({
FindingStatus: {},
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("@/lib/iac-utils", () => ({
buildGitFileUrl: () => null,
extractLineRangeFromUid: () => null,
}));
vi.mock("@/lib/utils", () => ({
cn: (...args: string[]) => args.filter(Boolean).join(" "),
}));
// Mock child components that are not under test
vi.mock("../mute-findings-modal", () => ({
MuteFindingsModal: ({
isOpen,
findingIds,
}: {
isOpen: boolean;
findingIds: string[];
}) =>
isOpen ? (
<div data-testid="mute-modal">Muting {findingIds.length} finding(s)</div>
) : null,
}));
vi.mock("../muted", () => ({
Muted: ({ isMuted }: { isMuted: boolean }) =>
isMuted ? <span data-testid="muted-badge">Muted</span> : null,
}));
vi.mock("./delta-indicator", () => ({
DeltaIndicator: () => null,
}));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: string }) => <span>{children}</span>,
}));
const baseFinding: FindingProps = {
type: "findings",
id: "finding-123",
attributes: {
uid: "uid-123",
delta: null,
status: "FAIL",
status_extended: "S3 bucket is publicly accessible",
severity: "high",
check_id: "s3_bucket_public_access",
muted: false,
check_metadata: {
risk: "Public access risk",
notes: "",
checkid: "s3_bucket_public_access",
provider: "aws",
severity: "high",
checktype: [],
dependson: [],
relatedto: [],
categories: ["security"],
checktitle: "S3 Bucket Public Access Check",
compliance: null,
relatedurl: "",
description: "Checks if S3 buckets are publicly accessible",
remediation: {
code: { cli: "", other: "", nativeiac: "", terraform: "" },
recommendation: { url: "", text: "" },
},
servicename: "s3",
checkaliases: [],
resourcetype: "AwsS3Bucket",
subservicename: "",
resourceidtemplate: "",
},
raw_result: null,
inserted_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
first_seen_at: "2024-01-01T00:00:00Z",
},
relationships: {
resources: { data: [{ type: "resources", id: "res-1" }] },
scan: {
data: { type: "scans", id: "scan-1" },
attributes: {
name: "Daily Scan",
trigger: "scheduled",
state: "completed",
unique_resource_count: 50,
progress: 100,
scanner_args: { checks_to_execute: [] },
duration: 120,
started_at: "2024-01-01T00:00:00Z",
inserted_at: "2024-01-01T00:00:00Z",
completed_at: "2024-01-01T00:02:00Z",
scheduled_at: null,
next_scan_at: "2024-01-02T00:00:00Z",
},
},
resource: {
data: [{ type: "resources", id: "res-1" }],
id: "res-1",
attributes: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
region: "us-east-1",
service: "s3",
tags: {},
type: "AwsS3Bucket",
inserted_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
details: null,
partition: "aws",
},
relationships: {
provider: { data: { type: "providers", id: "prov-1" } },
findings: {
meta: { count: 1 },
data: [{ type: "findings", id: "finding-123" }],
},
},
links: { self: "/resources/res-1" },
},
provider: {
data: { type: "providers", id: "prov-1" },
attributes: {
provider: "aws",
uid: "123456789012",
alias: "my-account",
connection: {
connected: true,
last_checked_at: "2024-01-01T00:00:00Z",
},
inserted_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
relationships: {
secret: { data: { type: "provider-secrets", id: "secret-1" } },
},
links: { self: "/providers/prov-1" },
},
},
links: { self: "/findings/finding-123" },
};
describe("FindingDetail", () => {
it("shows the Mute button for non-muted findings", () => {
render(<FindingDetail findingDetails={baseFinding} />);
expect(screen.getByRole("button", { name: /mute/i })).toBeInTheDocument();
});
it("hides the Mute button for muted findings", () => {
const mutedFinding: FindingProps = {
...baseFinding,
attributes: { ...baseFinding.attributes, muted: true },
};
render(<FindingDetail findingDetails={mutedFinding} />);
expect(screen.queryByRole("button", { name: /mute/i })).toBeNull();
});
it("opens the mute modal when clicking the Mute button", async () => {
const user = userEvent.setup();
render(<FindingDetail findingDetails={baseFinding} />);
expect(screen.queryByTestId("mute-modal")).toBeNull();
await user.click(screen.getByRole("button", { name: /mute/i }));
expect(screen.getByTestId("mute-modal")).toBeInTheDocument();
});
it("does not render the mute modal for muted findings", () => {
const mutedFinding: FindingProps = {
...baseFinding,
attributes: { ...baseFinding.attributes, muted: true },
};
render(<FindingDetail findingDetails={mutedFinding} />);
expect(screen.queryByTestId("mute-modal")).toBeNull();
});
it("shows the muted badge for muted findings", () => {
const mutedFinding: FindingProps = {
...baseFinding,
attributes: { ...baseFinding.attributes, muted: true },
};
render(<FindingDetail findingDetails={mutedFinding} />);
expect(screen.getByTestId("muted-badge")).toBeInTheDocument();
});
});

View File

@@ -1,11 +1,12 @@
"use client";
import { ExternalLink, Link, X } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { ExternalLink, Link, VolumeX, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { type ReactNode, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
@@ -35,6 +36,7 @@ import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
import { cn } from "@/lib/utils";
import { FindingProps, ProviderType } from "@/types";
import { MuteFindingsModal } from "../mute-findings-modal";
import { Muted } from "../muted";
import { DeltaIndicator } from "./delta-indicator";
@@ -85,8 +87,10 @@ export const FindingDetail = ({
const resource = finding.relationships?.resource?.attributes;
const scan = finding.relationships?.scan?.attributes;
const providerDetails = finding.relationships?.provider?.attributes;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const copyFindingUrl = () => {
const params = new URLSearchParams(searchParams.toString());
@@ -106,6 +110,21 @@ export const FindingDetail = ({
)
: null;
const handleMuteComplete = () => {
setIsMuteModalOpen(false);
onOpenChange?.(false);
router.refresh();
};
const muteModal = !attributes.muted && (
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={[findingDetails.id]}
onComplete={handleMuteComplete}
/>
);
const content = (
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
{/* Header */}
@@ -154,11 +173,24 @@ export const FindingDetail = ({
{/* Tabs */}
<Tabs defaultValue="general" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="resources">Resources</TabsTrigger>
<TabsTrigger value="scans">Scans</TabsTrigger>
</TabsList>
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="resources">Resources</TabsTrigger>
<TabsTrigger value="scans">Scans</TabsTrigger>
</TabsList>
{!attributes.muted && (
<Button
variant="outline"
size="sm"
onClick={() => setIsMuteModalOpen(true)}
>
<VolumeX className="size-4" />
Mute
</Button>
)}
</div>
{/* General Tab */}
<TabsContent value="general" className="flex flex-col gap-4">
@@ -444,29 +476,37 @@ export const FindingDetail = ({
// If no trigger, render content directly (inline mode)
if (!trigger) {
return content;
return (
<>
{muteModal}
{content}
</>
);
}
// With trigger, wrap in Drawer
// With trigger, wrap in Drawer — modal rendered outside to avoid nested overlay issues
return (
<Drawer
direction="right"
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none">
<DrawerHeader className="sr-only">
<DrawerTitle>Finding Details</DrawerTitle>
<DrawerDescription>View the finding details</DrawerDescription>
</DrawerHeader>
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DrawerClose>
{content}
</DrawerContent>
</Drawer>
<>
{muteModal}
<Drawer
direction="right"
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none">
<DrawerHeader className="sr-only">
<DrawerTitle>Finding Details</DrawerTitle>
<DrawerDescription>View the finding details</DrawerDescription>
</DrawerHeader>
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DrawerClose>
{content}
</DrawerContent>
</Drawer>
</>
);
};