mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
fix(ui): exclude muted findings and polish filter selectors (#10734)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+25
@@ -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 () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user