fix(ui): exclude muted findings and polish filter selectors (#10734)

This commit is contained in:
Alejandro Bailo
2026-04-17 11:07:22 +02:00
committed by GitHub
parent 536e90f2a5
commit f2d35f5885
14 changed files with 178 additions and 8 deletions
+4
View File
@@ -8,6 +8,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization [(#10754)](https://github.com/prowler-cloud/prowler/pull/10754)
### 🐞 Fixed
- Findings and filter UX fixes: exclude muted findings by default in the resource detail drawer and finding group resource views, show category context label (for example `Status: FAIL`) on MultiSelect triggers instead of hiding the placeholder, and add a `wide` width option for filter dropdowns applied to the findings Scan filter to prevent label truncation [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734)
---
## [1.24.0] (Prowler v5.24.0)
@@ -43,6 +43,7 @@ vi.mock("@/actions/finding-groups", () => ({
}));
import {
getLatestFindingsByResourceUid,
resolveFindingIdsByCheckIds,
resolveFindingIdsByVisibleGroupResources,
} from "./findings-by-resource";
@@ -262,3 +263,41 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("getLatestFindingsByResourceUid", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({ data: [] });
});
it("should exclude muted findings by default and always apply severity/time sorting", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
await getLatestFindingsByResourceUid({
resourceUid: "resource-1",
});
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings/latest");
expect(calledUrl.searchParams.get("filter[resource_uid]")).toBe(
"resource-1",
);
expect(calledUrl.searchParams.get("filter[muted]")).toBe("false");
expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at");
});
it("should include muted findings only when explicitly requested", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
await getLatestFindingsByResourceUid({
resourceUid: "resource-1",
includeMuted: true,
});
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.searchParams.get("filter[muted]")).toBe("include");
expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at");
});
});
+3 -1
View File
@@ -250,10 +250,12 @@ export const getLatestFindingsByResourceUid = async ({
resourceUid,
page = 1,
pageSize = 50,
includeMuted = false,
}: {
resourceUid: string;
page?: number;
pageSize?: number;
includeMuted?: boolean;
}) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -262,7 +264,7 @@ export const getLatestFindingsByResourceUid = async ({
);
url.searchParams.append("filter[resource_uid]", resourceUid);
url.searchParams.append("filter[muted]", "include");
url.searchParams.append("filter[muted]", includeMuted ? "include" : "false");
url.searchParams.append("sort", "-severity,-updated_at");
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
@@ -132,6 +132,7 @@ export const FindingsFilters = ({
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, {
@@ -270,6 +270,31 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
]);
});
it("should request muted findings only when explicitly enabled", async () => {
const resources = [makeResource()];
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
adaptFindingsByResourceResponseMock.mockReturnValue([makeDrawerFinding()]);
const { result } = renderHook(() =>
useResourceDetailDrawer({
resources,
checkId: "s3_check",
includeMutedInOtherFindings: true,
}),
);
await act(async () => {
result.current.openDrawer(0);
await Promise.resolve();
});
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({
resourceUid: "arn:aws:s3:::my-bucket",
includeMuted: true,
});
});
it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => {
vi.useFakeTimers();
@@ -47,6 +47,7 @@ interface UseResourceDetailDrawerOptions {
totalResourceCount?: number;
onRequestMoreResources?: () => void;
initialIndex?: number | null;
includeMutedInOtherFindings?: boolean;
}
interface UseResourceDetailDrawerReturn {
@@ -79,6 +80,7 @@ export function useResourceDetailDrawer({
totalResourceCount,
onRequestMoreResources,
initialIndex = null,
includeMutedInOtherFindings = false,
}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn {
const [isOpen, setIsOpen] = useState(initialIndex !== null);
const [isLoading, setIsLoading] = useState(false);
@@ -165,7 +167,10 @@ export function useResourceDetailDrawer({
setIsLoading(true);
try {
const response = await getLatestFindingsByResourceUid({ resourceUid });
const response = await getLatestFindingsByResourceUid({
resourceUid,
includeMuted: includeMutedInOtherFindings,
});
// Discard stale response if a newer request was started
if (controller.signal.aborted) return;
@@ -125,7 +125,10 @@ export const ProvidersFilters = ({
placeholder={`All ${filter.labelCheckboxGroup}`}
/>
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectContent
search={false}
width={filter.width ?? "default"}
>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{filter.values.map((value) => {
@@ -47,6 +47,33 @@ describe("MultiSelect", () => {
expect(
within(screen.getByRole("combobox")).getByText("Production AWS"),
).toBeInTheDocument();
expect(
within(screen.getByRole("combobox")).queryByText("Select accounts"),
).not.toBeInTheDocument();
});
it("keeps the filter label context when a value is selected", () => {
render(
<MultiSelect values={["FAIL"]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="All Status" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="FAIL">FAIL</MultiSelectItem>
<MultiSelectItem value="PASS">PASS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
expect(
within(screen.getByRole("combobox")).getByText("Status"),
).toBeInTheDocument();
expect(
within(screen.getByRole("combobox")).getByText("FAIL"),
).toBeInTheDocument();
expect(
within(screen.getByRole("combobox")).queryByText("All Status"),
).not.toBeInTheDocument();
});
it("filters items without crashing when search is enabled", async () => {
+10 -1
View File
@@ -163,6 +163,10 @@ export function MultiSelectValue({
const shouldWrap =
overflowBehavior === "wrap" ||
(overflowBehavior === "wrap-when-open" && open);
const selectedContextLabel =
placeholder && /^All\s+/i.test(placeholder) && selectedValues.size > 0
? placeholder.replace(/^All\s+/i, "").trim()
: "";
const checkOverflow = useCallback(() => {
if (valueRef.current === null) return;
@@ -222,11 +226,16 @@ export function MultiSelectValue({
className,
)}
>
{placeholder && (
{placeholder && selectedValues.size === 0 && (
<span className="text-bg-button-secondary shrink-0 font-normal">
{placeholder}
</span>
)}
{selectedContextLabel && (
<span className="text-bg-button-secondary shrink-0 font-normal">
{selectedContextLabel}
</span>
)}
{Array.from(selectedValues)
.filter((value) => items.has(value))
.map((value) => (
@@ -62,8 +62,16 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
MultiSelectContent: ({
children,
width,
}: {
children: React.ReactNode;
width?: string;
}) => (
<div data-testid="multiselect-content" data-width={width ?? "default"}>
{children}
</div>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
@@ -114,6 +122,13 @@ const severityFilter: FilterOption = {
values: ["critical", "high"],
};
const scanFilter: FilterOption = {
key: "filter[scan__in]",
labelCheckboxGroup: "Scan ID",
values: ["scan-1"],
width: "wide",
};
describe("DataTableFilterCustom — batch vs instant mode", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -275,4 +290,15 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
});
describe("dropdown width", () => {
it("should propagate the filter width to the dropdown content", () => {
render(<DataTableFilterCustom filters={[scanFilter]} />);
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-width",
"wide",
);
});
});
});
@@ -16,6 +16,7 @@ import {
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { isConnectionStatus, isScanEntity } from "@/lib/helper-filters";
import { cn } from "@/lib/utils";
import {
FilterEntity,
FilterOption,
@@ -29,6 +30,8 @@ export interface DataTableFilterCustomProps {
filters: FilterOption[];
/** Optional element to render at the start of the filters grid */
prependElement?: React.ReactNode;
/** Optional className override for the filters grid layout */
gridClassName?: string;
/** Hide the clear filters button and active badges (useful when parent manages this) */
hideClearButton?: boolean;
/**
@@ -54,6 +57,7 @@ export interface DataTableFilterCustomProps {
export const DataTableFilterCustom = ({
filters,
prependElement,
gridClassName,
hideClearButton = false,
mode = DATA_TABLE_FILTER_MODE.INSTANT,
onBatchChange,
@@ -173,7 +177,12 @@ export const DataTableFilterCustom = ({
};
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<div
className={cn(
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5",
gridClassName,
)}
>
{prependElement}
{sortedFilters().map((filter) => {
const selectedValues = getSelectedValues(filter);
@@ -189,7 +198,10 @@ export const DataTableFilterCustom = ({
placeholder={`All ${filter.labelCheckboxGroup}`}
/>
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectContent
search={false}
width={filter.width ?? "default"}
>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{filter.values.map((value) => {
@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("useFindingGroupResourceState", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "use-finding-group-resource-state.ts");
const source = readFileSync(filePath, "utf8");
it("enables muted findings only for the finding-group resource drawer", () => {
expect(source).toContain("includeMutedInOtherFindings: true");
});
});
@@ -83,6 +83,7 @@ export function useFindingGroupResourceState({
checkId: group.checkId,
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
includeMutedInOtherFindings: true,
});
const handleDrawerMuteComplete = () => {
+1
View File
@@ -15,6 +15,7 @@ export interface FilterOption {
key: string;
labelCheckboxGroup: string;
values: string[];
width?: "default" | "wide";
valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>;
labelFormatter?: (value: string) => string;
index?: number;