diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 304d934af1e..38a74d7dde6 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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) diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts index 4aff44a2cfb..3a045d2e752 100644 --- a/ui/actions/findings/findings-by-resource.test.ts +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -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"); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 5a848fff4b8..ee6d952cf01 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -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()); diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 6316e49203b..61008d311a2 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -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, { diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index 797676562fc..b3729fd2cc8 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -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(); diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index 687d9ac7397..3affd7d38a5 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -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; diff --git a/ui/components/providers/providers-filters.tsx b/ui/components/providers/providers-filters.tsx index c5eebad74e3..d50c90f2912 100644 --- a/ui/components/providers/providers-filters.tsx +++ b/ui/components/providers/providers-filters.tsx @@ -125,7 +125,10 @@ export const ProvidersFilters = ({ placeholder={`All ${filter.labelCheckboxGroup}`} /> - + Select All {filter.values.map((value) => { diff --git a/ui/components/shadcn/select/multiselect.test.tsx b/ui/components/shadcn/select/multiselect.test.tsx index e87804ab4f1..d5e497e8cee 100644 --- a/ui/components/shadcn/select/multiselect.test.tsx +++ b/ui/components/shadcn/select/multiselect.test.tsx @@ -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( + {}}> + + + + + FAIL + PASS + + , + ); + + 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 () => { diff --git a/ui/components/shadcn/select/multiselect.tsx b/ui/components/shadcn/select/multiselect.tsx index 1658a3c20e8..e7dca260db8 100644 --- a/ui/components/shadcn/select/multiselect.tsx +++ b/ui/components/shadcn/select/multiselect.tsx @@ -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 && ( {placeholder} )} + {selectedContextLabel && ( + + {selectedContextLabel} + + )} {Array.from(selectedValues) .filter((value) => items.has(value)) .map((value) => ( diff --git a/ui/components/ui/table/data-table-filter-custom-batch.test.tsx b/ui/components/ui/table/data-table-filter-custom-batch.test.tsx index f108c9d70b3..a8a629d24c9 100644 --- a/ui/components/ui/table/data-table-filter-custom-batch.test.tsx +++ b/ui/components/ui/table/data-table-filter-custom-batch.test.tsx @@ -62,8 +62,16 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} ), - MultiSelectContent: ({ children }: { children: React.ReactNode }) => ( - <>{children} + MultiSelectContent: ({ + children, + width, + }: { + children: React.ReactNode; + width?: string; + }) => ( +
+ {children} +
), MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => ( @@ -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(); + + expect(screen.getByTestId("multiselect-content")).toHaveAttribute( + "data-width", + "wide", + ); + }); + }); }); diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index af302592dae..da95dd7aff2 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -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 ( -
+
{prependElement} {sortedFilters().map((filter) => { const selectedValues = getSelectedValues(filter); @@ -189,7 +198,10 @@ export const DataTableFilterCustom = ({ placeholder={`All ${filter.labelCheckboxGroup}`} /> - + Select All {filter.values.map((value) => { diff --git a/ui/hooks/use-finding-group-resource-state.test.ts b/ui/hooks/use-finding-group-resource-state.test.ts new file mode 100644 index 00000000000..789ea592c24 --- /dev/null +++ b/ui/hooks/use-finding-group-resource-state.test.ts @@ -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"); + }); +}); diff --git a/ui/hooks/use-finding-group-resource-state.ts b/ui/hooks/use-finding-group-resource-state.ts index 6374a5319bc..f313bb77b06 100644 --- a/ui/hooks/use-finding-group-resource-state.ts +++ b/ui/hooks/use-finding-group-resource-state.ts @@ -83,6 +83,7 @@ export function useFindingGroupResourceState({ checkId: group.checkId, totalResourceCount: totalCount ?? group.resourcesTotal, onRequestMoreResources: loadMore, + includeMutedInOtherFindings: true, }); const handleDrawerMuteComplete = () => { diff --git a/ui/types/filters.ts b/ui/types/filters.ts index 40e1c7b7a03..ccaf7f2c53f 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -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;