fix(ui): align resources filters and resource drawer behavior (#10861)

This commit is contained in:
Alejandro Bailo
2026-04-24 15:03:47 +02:00
committed by GitHub
parent 06bb382f8e
commit 22a6cc9e73
33 changed files with 1117 additions and 346 deletions
+1
View File
@@ -15,6 +15,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859)
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
- Mutelist improvements: table now supports name/reason search and visual count badges for finding targets [(#10846)](https://github.com/prowler-cloud/prowler/pull/10846)
- Resources now use batch-applied filters, render metadata JSON with syntax highlighting, and more [(#10861)](https://github.com/prowler-cloud/prowler/pull/10861)
- Added knip for dead code detection with `lint:knip` and `lint:knip:fix` scripts [(#10654)](https://github.com/prowler-cloud/prowler/pull/10654)
---
@@ -29,6 +29,32 @@ describe("QueryCodeEditor", () => {
expect(screen.getByText("1")).toBeInTheDocument();
});
it("keeps the accessible editor name when the visible label is hidden", () => {
// Given
render(
<QueryCodeEditor
ariaLabel="Resource metadata"
visibleLabel={null}
language="json"
value="{}"
showCopyButton
onChange={() => {}}
/>,
);
// When
const editor = screen.getByRole("textbox", {
name: /resource metadata/i,
});
// Then
expect(editor).toBeInTheDocument();
expect(screen.queryByText("Resource metadata")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /copy resource metadata/i }),
).toBeInTheDocument();
});
it("propagates content changes and exposes the invalid state in the container", async () => {
// Given
const user = userEvent.setup();
@@ -52,8 +52,6 @@ vi.mock("@/components/shadcn/modal", () => ({
) : null,
}));
const dataTableMock = vi.fn();
vi.mock("@/components/ui/table", () => ({
DataTable: (props: {
columns: Array<{
@@ -61,9 +59,7 @@ vi.mock("@/components/ui/table", () => ({
cell?: (args: { row: { original: unknown } }) => ReactNode;
}>;
data: unknown[];
showSearch?: boolean;
}) => {
dataTableMock(props);
const actionsColumn = props.columns.find(
(column) => column.id === "actions",
);
@@ -186,14 +182,4 @@ describe("MuteRulesTableClient", () => {
).toBeInTheDocument();
expect(within(dialog).getByText("uid-3")).toBeInTheDocument();
});
it("enables search on the DataTable", () => {
dataTableMock.mockClear();
render(<MuteRulesTableClient muteRules={[muteRule]} />);
expect(dataTableMock).toHaveBeenCalled();
const props = dataTableMock.mock.calls[0][0];
expect(props.showSearch).toBe(true);
});
});
@@ -1,39 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/actions/mute-rules", () => ({
getMuteRules: vi.fn(),
}));
vi.mock("./mute-rule-target-previews", () => ({
hydrateMuteRuleTargetPreviews: vi.fn(),
}));
vi.mock("./mute-rules-table-client", () => ({
MuteRulesTableClient: () => null,
}));
import { MuteRulesTableSkeleton } from "./mute-rules-table";
describe("MuteRulesTableSkeleton", () => {
it("renders the table skeleton with the new header, toolbar, rows, and 6 columns", () => {
render(<MuteRulesTableSkeleton />);
const skeleton = screen.getByTestId("mute-rules-table-skeleton");
const intro = screen.getByTestId("mute-rules-table-skeleton-intro");
expect(skeleton).toHaveClass(
"bg-bg-neutral-secondary",
"border-border-neutral-secondary",
"rounded-large",
);
// Intro: title + 1 description line
expect(intro.querySelectorAll("[data-slot='skeleton']").length).toBe(2);
expect(skeleton.querySelector("table")).toBeInTheDocument();
// 6 columns: name + reason + findings + created + enabled + actions
expect(skeleton.querySelectorAll("thead th").length).toBe(6);
expect(skeleton.querySelectorAll("tbody tr").length).toBeGreaterThanOrEqual(
8,
);
});
});
@@ -1,7 +1,5 @@
"use client";
import { Check } from "lucide-react";
import { Button } from "@/components/shadcn";
import { cn } from "@/lib/utils";
@@ -37,21 +35,20 @@ export const ApplyFiltersButton = ({
changeCount > 0 ? `Apply Filters (${changeCount})` : "Apply Filters";
return (
<div className={cn("flex items-center gap-1", className)}>
<div className={cn("flex items-center gap-2", className)}>
<Button
variant="default"
variant="link"
size="sm"
disabled={!hasChanges}
onClick={onApply}
aria-label={label}
>
<Check className="size-4" />
{label}
</Button>
{hasChanges && (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={onDiscard}
aria-label="Undo pending filter changes"
@@ -0,0 +1,69 @@
import type { ReactNode } from "react";
interface BatchFiltersLayoutProps {
controls: ReactNode;
expandedFilters?: ReactNode;
expandedFiltersVisible?: boolean;
appliedSummary?: ReactNode;
appliedActions?: ReactNode;
pendingSummary?: ReactNode;
showAppliedRow?: boolean;
showPendingRow?: boolean;
testIdPrefix: string;
}
export const BatchFiltersLayout = ({
controls,
expandedFilters,
expandedFiltersVisible = true,
appliedSummary,
appliedActions,
pendingSummary,
showAppliedRow = false,
showPendingRow = false,
testIdPrefix,
}: BatchFiltersLayoutProps) => (
<div className="flex flex-col gap-3">
<div
data-testid={`${testIdPrefix}-filter-controls`}
className="flex flex-wrap items-center gap-4"
>
{controls}
</div>
{expandedFilters ? (
<div
data-testid={`${testIdPrefix}-expanded-filters`}
className={expandedFiltersVisible ? undefined : "hidden"}
>
{expandedFilters}
</div>
) : null}
{showAppliedRow ? (
<div
data-testid={`${testIdPrefix}-applied-filter-row`}
className="flex flex-wrap items-start gap-2"
>
<div className="min-w-[220px] flex-1">{appliedSummary}</div>
{appliedActions ? (
<div
data-testid={`${testIdPrefix}-applied-filter-actions`}
className="ml-auto flex flex-wrap items-center gap-2"
>
{appliedActions}
</div>
) : null}
</div>
) : null}
{showPendingRow ? (
<div
data-testid={`${testIdPrefix}-pending-filter-row`}
className="flex flex-wrap items-start gap-2"
>
<div className="min-w-[220px] flex-1">{pendingSummary}</div>
</div>
) : null}
</div>
);
@@ -86,6 +86,7 @@ export const ClearFiltersButton = ({
<Button
aria-label={ariaLabel}
onClick={onClear ?? clearFiltersPreservingExcluded}
size="sm"
variant={variant}
>
<XCircle className="mr-0.5 size-4" />
@@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("filter%5Bmuted%5D=false"),
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({
navigateWithParams: vi.fn(),
}),
}));
vi.mock("@/lib", () => ({
MUTED_FILTER: {
EXCLUDE: "false",
INCLUDE: "include",
},
}));
vi.mock("@/components/icons", () => ({
MutedIcon: ({ className }: { className?: string }) => (
<svg aria-hidden="true" className={className} data-slot="muted-icon" />
),
}));
describe("CustomCheckboxMutedFindings", () => {
it("should show the muted icon before the label text", () => {
// Given
const { container } = render(<CustomCheckboxMutedFindings />);
// When
const checkbox = screen.getByRole("checkbox", {
name: "Include muted findings",
});
const mutedIcon = container.querySelector('[data-slot="muted-icon"]');
const labelText = screen.getByText("Include muted findings");
const wrapperText = checkbox.parentElement?.textContent ?? "";
// Then
expect(checkbox).toBeInTheDocument();
expect(mutedIcon).toBeInTheDocument();
expect(mutedIcon?.compareDocumentPosition(labelText)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(wrapperText).toContain("Include muted findings");
});
});
@@ -2,6 +2,7 @@
import { useSearchParams } from "next/navigation";
import { MutedIcon } from "@/components/icons";
import { Checkbox } from "@/components/shadcn";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { MUTED_FILTER } from "@/lib";
@@ -82,6 +83,7 @@ export const CustomCheckboxMutedFindings = ({
onCheckedChange={handleMutedChange}
aria-label="Include muted findings"
/>
<MutedIcon className="text-bg-data-muted size-3 shrink-0" />
<label
htmlFor="include-muted"
className="cursor-pointer text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@@ -22,6 +22,13 @@ vi.mock("@/components/shadcn", () => ({
{children}
</span>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}));
vi.mock("@/lib/utils", () => ({
+51 -24
View File
@@ -2,7 +2,12 @@
import { X } from "lucide-react";
import { Badge } from "@/components/shadcn";
import {
Badge,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { cn } from "@/lib/utils";
export interface FilterChip {
@@ -12,15 +17,21 @@ export interface FilterChip {
label: string;
/** The individual value within the filter, e.g. "critical" */
value: string;
/** Optional complete value list when the chip groups several selections */
values?: string[];
/** Optional display text for the value (defaults to `value`) */
displayValue?: string;
/** Optional complete display value list for tooltip content */
displayValues?: string[];
}
export interface FilterSummaryStripProps {
/** List of individual chips to render */
chips: FilterChip[];
/** Called when the user clicks the X on a chip */
onRemove: (key: string, value: string) => void;
onRemove?: (key: string, value?: string) => void;
/** Optional content rendered after the last chip in the same wrapping row */
trailingContent?: React.ReactNode;
/** Optional extra class names for the outer wrapper */
className?: string;
}
@@ -36,37 +47,53 @@ export interface FilterSummaryStripProps {
export const FilterSummaryStrip = ({
chips,
onRemove,
trailingContent,
className,
}: FilterSummaryStripProps) => {
if (chips.length === 0) return null;
if (chips.length === 0 && !trailingContent) return null;
return (
<div
className={cn("flex flex-wrap items-center gap-2 py-2", className)}
className={cn("flex flex-wrap items-center gap-2", className)}
role="region"
aria-label="Active filters"
aria-live="polite"
>
{chips.map((chip) => (
<Badge
key={`${chip.key}-${chip.value}`}
variant="tag"
className="flex items-center gap-1 pr-1"
>
<span className="text-text-neutral-primary text-xs">
<span className="font-medium">{chip.label}:</span>{" "}
{chip.displayValue ?? chip.value}
</span>
<button
type="button"
aria-label={`Remove ${chip.label} filter: ${chip.displayValue ?? chip.value}`}
onClick={() => onRemove(chip.key, chip.value)}
className="text-text-neutral-secondary hover:text-text-neutral-primary ml-0.5 rounded-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
>
<X className="size-3" />
</button>
</Badge>
))}
{chips.map((chip) => {
const displayValue = chip.displayValue ?? chip.value;
const displayValues = chip.displayValues ?? [displayValue];
const fullLabel = `${chip.label}: ${displayValues.join(", ")}`;
const removeValue =
chip.values && chip.values.length > 1 ? undefined : chip.value;
return (
<Tooltip key={`${chip.key}-${chip.values?.join("|") ?? chip.value}`}>
<Badge
variant="tag"
className="flex max-w-[280px] min-w-0 items-center gap-1 overflow-hidden pr-1"
>
<TooltipTrigger asChild>
<span className="text-text-neutral-primary min-w-0 flex-1 truncate text-xs">
<span className="font-medium">{chip.label}:</span>{" "}
{displayValue}
</span>
</TooltipTrigger>
{onRemove ? (
<button
type="button"
aria-label={`Remove ${chip.label} filter: ${displayValue}`}
onClick={() => onRemove(chip.key, removeValue)}
className="text-text-neutral-secondary hover:text-text-neutral-primary ml-0.5 shrink-0 rounded-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
>
<X className="size-3" />
</button>
) : null}
</Badge>
<TooltipContent side="top">{fullLabel}</TooltipContent>
</Tooltip>
);
})}
{trailingContent}
</div>
);
};
+1
View File
@@ -1,4 +1,5 @@
export * from "./apply-filters-button";
export * from "./batch-filters-layout";
export * from "./clear-filters-button";
export * from "./custom-checkbox-muted-findings";
export * from "./custom-date-picker";
+114 -87
View File
@@ -6,8 +6,8 @@ import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
import { CustomDatePicker } from "@/components/filters/custom-date-picker";
import { filterFindings } from "@/components/filters/data-filters";
import {
@@ -40,6 +40,13 @@ interface FindingsFiltersProps {
uniqueGroups: string[];
}
const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
Object.entries(filters).filter(([key, values]) => {
if (!values || values.length === 0) return false;
if (key === "filter[muted]") return false;
return true;
}).length;
export const FindingsFilters = ({
providers,
completedScanIds,
@@ -53,11 +60,14 @@ export const FindingsFilters = ({
const [isExpanded, setIsExpanded] = useState(false);
const {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges,
changeCount,
getFilterValue,
@@ -124,29 +134,37 @@ export const FindingsFilters = ({
const hasCustomFilters = customFilters.length > 0;
const filterChips: FilterChip[] = buildFindingsFilterChips(pendingFilters, {
providers,
scans: scanDetails,
});
const appliedFilterChips: FilterChip[] = buildFindingsFilterChips(
appliedFilters,
{
providers,
scans: scanDetails,
},
);
const pendingFilterChips: FilterChip[] = buildFindingsFilterChips(
changedFilters,
{
providers,
scans: scanDetails,
},
);
const appliedCount = countVisibleFilterKeys(appliedFilters);
const showAppliedRow = appliedFilterChips.length > 0;
const showPendingRow = hasChanges;
// Handler for removing a single chip: update the pending filter to remove that value.
// setPending handles both "filter[key]" and "key" formats internally.
const handleChipRemove = (filterKey: string, value: string) => {
const handleChipRemove = (filterKey: string, value?: string) => {
if (value === undefined) {
setPending(filterKey, []);
return;
}
const currentValues = pendingFilters[filterKey] ?? [];
const nextValues = currentValues.filter((v) => v !== value);
setPending(filterKey, nextValues);
};
// Derive pending muted state for the checkbox.
// Note: "filter[muted]" participates in batch mode — applyAll includes it
// when present in pending state, and the defaultParams option ensures
// filter[muted]=false is applied as a fallback when no muted value is pending.
const pendingMutedValue = pendingFilters["filter[muted]"];
const mutedChecked =
pendingMutedValue !== undefined
? pendingMutedValue[0] === "include"
: undefined;
// For the date picker, read from pendingFilters
const pendingDateValues = pendingFilters["filter[inserted_at]"];
const pendingDateValue =
@@ -154,89 +172,98 @@ export const FindingsFilters = ({
? pendingDateValues[0]
: undefined;
return (
<div className="flex flex-col">
{/* First row: Provider selectors + Muted checkbox + More Filters button + Apply/Clear */}
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
const expandedFilters = hasCustomFilters ? (
<ExpandableSection isExpanded={isExpanded} contentClassName="pt-0">
<DataTableFilterCustom
gridClassName="gap-3"
filters={customFilters}
prependElement={
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
/>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
/>
</div>
<CustomCheckboxMutedFindings
onBatchChange={(filterKey, value) => setPending(filterKey, [value])}
checked={mutedChecked}
/>
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
</Button>
)}
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
</ExpandableSection>
) : null;
const appliedSummary = (
<FilterSummaryStrip
chips={appliedFilterChips}
onRemove={removeAppliedAndApply}
trailingContent={
<ClearFiltersButton
showCount
onClear={clearAndApply}
pendingCount={
Object.entries(pendingFilters).filter(([key, values]) => {
if (!values || values.length === 0) return false;
// filter[muted]=false is the silent default — don't count it as active
if (
key === "filter[muted]" &&
values.length === 1 &&
values[0] === "false"
)
return false;
return true;
}).length
}
pendingCount={appliedCount}
/>
}
/>
);
const pendingSummary = (
<FilterSummaryStrip
chips={pendingFilterChips}
onRemove={handleChipRemove}
trailingContent={
<ApplyFiltersButton
hasChanges={hasChanges}
changeCount={changeCount}
onApply={applyAll}
onDiscard={discardAll}
/>
</div>
}
/>
);
{/* Summary strip: shown below filter bar when there are pending changes */}
<FilterSummaryStrip chips={filterChips} onRemove={handleChipRemove} />
{/* Expandable filters section */}
{hasCustomFilters && (
<ExpandableSection isExpanded={isExpanded}>
<DataTableFilterCustom
filters={customFilters}
prependElement={
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
return (
<BatchFiltersLayout
testIdPrefix="findings"
controls={
<>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue(
"filter[provider_type__in]",
)}
/>
</div>
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
</ExpandableSection>
)}
</div>
</Button>
)}
</>
}
expandedFilters={expandedFilters}
expandedFiltersVisible={isExpanded}
appliedSummary={appliedSummary}
pendingSummary={pendingSummary}
showAppliedRow={showAppliedRow}
showPendingRow={showPendingRow}
/>
);
};
@@ -200,7 +200,7 @@ describe("getFindingsFilterDisplayValue", () => {
});
describe("buildFindingsFilterChips", () => {
it("creates one chip per value with normalized labels", () => {
it("creates one chip per filter with normalized labels", () => {
// Given — this is the exact pending state derived from the LinkToFindings URL:
// /findings?sort=...&filter[status__in]=FAIL&filter[delta]=new
const pendingFilters = {
@@ -211,7 +211,7 @@ describe("buildFindingsFilterChips", () => {
// When
const chips = buildFindingsFilterChips(pendingFilters);
// Then — both chips must appear; the delta chip must use "Delta" as label
// Then — both filters must appear; the delta chip must use "Delta" as label
// (not the raw "filter[delta]") and "New" as displayValue (not "NEW" via
// the short-word acronym heuristic in formatLabel).
expect(chips).toEqual([
@@ -239,28 +239,24 @@ describe("buildFindingsFilterChips", () => {
"filter[delta__in]": ["new", "changed"],
});
// Then — both shapes produce the same human labels and display values
// Then — both shapes produce the same human labels and grouped display values
expect(
chipsSingular.map((c) => ({ label: c.label, v: c.displayValue })),
).toEqual([
{ label: "Delta", v: "New" },
{ label: "Delta", v: "Changed" },
]);
).toEqual([{ label: "Delta", v: "+2" }]);
expect(chipsSingular[0].displayValues).toEqual(["New", "Changed"]);
expect(
chipsPlural.map((c) => ({ label: c.label, v: c.displayValue })),
).toEqual([
{ label: "Delta", v: "New" },
{ label: "Delta", v: "Changed" },
]);
).toEqual([{ label: "Delta", v: "+2" }]);
expect(chipsPlural[0].displayValues).toEqual(["New", "Changed"]);
});
it("skips the silent default filter[muted]=false", () => {
it("skips muted filters because the table toolbar owns that control", () => {
const chips = buildFindingsFilterChips({
"filter[muted]": ["false"],
"filter[muted]": ["include"],
"filter[delta]": ["new"],
});
// Only the delta chip — the default muted=false should not surface
// Only the delta chip — muted state is shown by the table checkbox.
expect(chips).toHaveLength(1);
expect(chips[0].key).toBe("filter[delta]");
});
@@ -126,8 +126,7 @@ interface BuildFindingsFilterChipsOptions {
/**
* Builds the chips displayed in the FilterSummaryStrip from a pendingFilters map.
*
* - One chip per individual value (not one per key), so a multi-select filter
* produces multiple chips.
* - One chip per filter key. Multi-select filters are grouped as `Label +N`.
* - Silently skips the default `filter[muted]=false` so it doesn't appear as a
* user-applied filter.
* - Falls back to the raw key as label for unmapped keys, so an unexpected
@@ -141,16 +140,32 @@ export function buildFindingsFilterChips(
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
if (key === "filter[muted]") return;
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
values.forEach((value) => {
if (key === "filter[muted]" && value === "false") return;
chips.push({
key,
label,
value,
displayValue: getFindingsFilterDisplayValue(key, value, options),
});
});
const visibleValues = values;
if (visibleValues.length === 0) return;
const displayValues = visibleValues.map((value) =>
getFindingsFilterDisplayValue(key, value, options),
);
const chip: FilterChip = {
key,
label,
value: visibleValues[0],
displayValue:
displayValues.length > 1
? `+${displayValues.length}`
: displayValues[0],
};
if (visibleValues.length > 1) {
chip.values = visibleValues;
chip.displayValues = displayValues;
}
chips.push(chip);
});
return chips;
@@ -98,8 +98,6 @@ describe("MuteFindingsModal", () => {
expect(
screen.getByText("You are about to mute", { exact: false }),
).toBeInTheDocument();
expect(screen.getByText("Selected findings")).toBeInTheDocument();
expect(screen.getByText("Rule details")).toBeInTheDocument();
expect(screen.getByLabelText("Rule Name")).toBeInTheDocument();
expect(screen.getByLabelText("Reason")).toBeInTheDocument();
expect(
@@ -127,8 +125,6 @@ describe("MuteFindingsModal", () => {
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Preparing..." })).toBeDisabled();
expect(screen.queryByLabelText("Rule Name")).not.toBeInTheDocument();
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
expect(screen.getAllByTestId("skeleton").length).toBeGreaterThanOrEqual(8);
});
it("submits the form, shows the success toast, and closes the modal", async () => {
@@ -0,0 +1,78 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { FindingsGroupTable } from "./findings-group-table";
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/components/ui/table", () => ({
DataTable: ({ toolbarRightContent }: { toolbarRightContent?: ReactNode }) => (
<div>
<div data-testid="table-toolbar-right">{toolbarRightContent}</div>
<span>10 Total Entries</span>
</div>
),
}));
vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({
CustomCheckboxMutedFindings: () => (
<label>
<input type="checkbox" aria-label="Include muted findings" />
Include muted findings
</label>
),
}));
vi.mock("@/actions/findings/findings-by-resource", () => ({
resolveFindingIdsByVisibleGroupResources: vi.fn(),
}));
vi.mock("./column-finding-groups", () => ({
getColumnFindingGroups: () => [],
}));
vi.mock("./inline-resource-container", () => ({
InlineResourceContainer: () => null,
}));
vi.mock("../floating-mute-button", () => ({
FloatingMuteButton: () => null,
}));
describe("FindingsGroupTable", () => {
describe("toolbar", () => {
it("should render the muted findings checkbox inside the table toolbar", () => {
// Given
render(
<FindingsGroupTable
data={[]}
metadata={{
pagination: {
page: 1,
pages: 1,
count: 10,
},
version: "v1",
}}
resolvedFilters={{ "filter[muted]": "false" }}
hasHistoricalData={false}
/>,
);
// When
const toolbar = screen.getByTestId("table-toolbar-right");
// Then
expect(
screen.getByRole("checkbox", { name: "Include muted findings" }),
).toBeInTheDocument();
expect(toolbar).toHaveTextContent("Include muted findings");
});
});
});
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
import { DataTable } from "@/components/ui/table";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import { FindingGroupRow, MetaDataProps } from "@/types";
@@ -220,6 +221,7 @@ export function FindingsGroupTable({
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
: undefined
}
toolbarRightContent={<CustomCheckboxMutedFindings />}
renderAfterRow={renderAfterRow}
/>
@@ -10,11 +10,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/shadcn/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { DOCS_URLS } from "@/lib/external-urls";
import { cn } from "@/lib/utils";
import { FINDING_DELTA, type FindingDelta } from "@/types";
@@ -91,12 +86,17 @@ function DeltaIndicator({
}: {
delta: typeof DeltaValues.NEW | typeof DeltaValues.CHANGED;
}) {
const [open, setOpen] = useState(false);
return (
<Tooltip>
<TooltipTrigger asChild>
<div
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
>
<div
className={cn(
@@ -106,9 +106,15 @@ function DeltaIndicator({
: "bg-system-severity-low",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent>
</button>
</PopoverTrigger>
<PopoverContent
className="border-border-neutral-tertiary bg-bg-neutral-tertiary w-auto rounded-lg px-2 py-1.5 shadow-lg"
sideOffset={4}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1 text-xs">
<span>
{delta === DeltaValues.NEW
@@ -131,8 +137,8 @@ function DeltaIndicator({
</a>
</Button>
</div>
</TooltipContent>
</Tooltip>
</PopoverContent>
</Popover>
);
}
@@ -69,6 +69,7 @@ import {
import { getFailingForLabel } from "@/lib/date-utils";
import { formatDuration } from "@/lib/date-utils";
import { getRegionFlag } from "@/lib/region-flags";
import { cn } from "@/lib/utils";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { FindingResourceRow } from "@/types/findings-table";
@@ -415,6 +416,8 @@ export function ResourceDetailDrawerContent({
const nativeIacConfig = resolveNativeIacConfig(providerType);
const showOverviewCheckMetaContent = showCheckMetaContent;
const showOverviewFindingContent = Boolean(f);
const overviewStatusExtended = f?.statusExtended;
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
const handleOpenCompliance = async (framework: string) => {
if (!complianceScanId || resolvingFramework) {
@@ -794,7 +797,13 @@ export function ResourceDetailDrawerContent({
</Card>
)}
{checkMeta.description && (
<div className="border-default-200 flex flex-col gap-1 border-b pb-4">
<div
className={cn(
"flex flex-col gap-1",
showOverviewStatusExtended &&
"border-default-200 border-b pb-4",
)}
>
<span className="text-text-neutral-secondary text-sm font-semibold">
Description:
</span>
@@ -803,16 +812,17 @@ export function ResourceDetailDrawerContent({
</MarkdownContainer>
</div>
)}
{showOverviewFindingContent && f?.statusExtended && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-sm font-semibold">
Status Extended:
</span>
<p className="text-text-neutral-primary text-sm">
{f.statusExtended}
</p>
</div>
)}
{showOverviewFindingContent &&
showOverviewStatusExtended && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-sm font-semibold">
Status Extended:
</span>
<p className="text-text-neutral-primary text-sm">
{overviewStatusExtended}
</p>
</div>
)}
</Card>
)}
+142 -30
View File
@@ -5,13 +5,26 @@ import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import {
FilterChip,
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { getGroupLabel } from "@/lib/categories";
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
import {
buildResourcesFilterChips,
getResourcesFilterDisplayValue,
} from "./resources-filters.utils";
interface ResourcesFiltersProps {
providers: ProviderProps[];
uniqueRegions: string[];
@@ -20,6 +33,9 @@ interface ResourcesFiltersProps {
uniqueGroups: string[];
}
const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
Object.values(filters).filter((values) => values.length > 0).length;
export const ResourcesFilters = ({
providers,
uniqueRegions,
@@ -28,6 +44,19 @@ export const ResourcesFilters = ({
uniqueGroups,
}: ResourcesFiltersProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch();
// Custom filters for the expandable section
const customFilters = [
@@ -59,38 +88,121 @@ export const ResourcesFilters = ({
];
const hasCustomFilters = customFilters.length > 0;
const appliedFilterChips: FilterChip[] = buildResourcesFilterChips(
appliedFilters,
providers,
);
const pendingFilterChips: FilterChip[] = buildResourcesFilterChips(
changedFilters,
providers,
);
const appliedCount = countVisibleFilterKeys(appliedFilters);
const showAppliedRow = appliedFilterChips.length > 0;
const showPendingRow = hasChanges;
const handleChipRemove = (filterKey: string, value?: string) => {
if (value === undefined) {
setPending(filterKey, []);
return;
}
const currentValues = pendingFilters[filterKey] ?? [];
const nextValues = currentValues.filter((item) => item !== value);
setPending(filterKey, nextValues);
};
const expandedFilters = hasCustomFilters ? (
<ExpandableSection isExpanded={isExpanded} contentClassName="pt-0">
<DataTableFilterCustom
gridClassName="gap-3"
filters={customFilters.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
getResourcesFilterDisplayValue(
`filter[${filter.key}]`,
value,
providers,
),
}))}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
</ExpandableSection>
) : null;
const appliedSummary = (
<FilterSummaryStrip
chips={appliedFilterChips}
onRemove={removeAppliedAndApply}
trailingContent={
<ClearFiltersButton
showCount
onClear={clearAndApply}
pendingCount={appliedCount}
/>
}
/>
);
const pendingSummary = (
<FilterSummaryStrip
chips={pendingFilterChips}
onRemove={handleChipRemove}
trailingContent={
<ApplyFiltersButton
hasChanges={hasChanges}
changeCount={changeCount}
onApply={applyAll}
onDiscard={discardAll}
/>
}
/>
);
return (
<div className="flex flex-col">
{/* First row: Provider selectors + More Filters button + Clear Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector providers={providers} />
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector providers={providers} />
</div>
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
<BatchFiltersLayout
testIdPrefix="resources"
controls={
<>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</Button>
)}
<ClearFiltersButton showCount />
</div>
{/* Expandable filters section */}
{hasCustomFilters && (
<ExpandableSection isExpanded={isExpanded}>
<DataTableFilterCustom filters={customFilters} hideClearButton />
</ExpandableSection>
)}
</div>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue(
"filter[provider_type__in]",
)}
/>
</div>
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
</Button>
)}
</>
}
expandedFilters={expandedFilters}
expandedFiltersVisible={isExpanded}
appliedSummary={appliedSummary}
pendingSummary={pendingSummary}
showAppliedRow={showAppliedRow}
showPendingRow={showPendingRow}
/>
);
};
@@ -0,0 +1,86 @@
import type { FilterChip } from "@/components/filters/filter-summary-strip";
import { formatLabel, getGroupLabel } from "@/lib/categories";
import type { ProviderProps } from "@/types/providers";
import { getProviderDisplayName } from "@/types/providers";
const RESOURCE_FILTER_KEY_LABELS: Record<string, string> = {
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[region__in]": "Region",
"filter[service__in]": "Service",
"filter[type__in]": "Type",
"filter[groups__in]": "Group",
};
function getProviderAccountDisplayValue(
providerId: string,
providers: ProviderProps[],
): string {
const provider = providers.find((item) => item.id === providerId);
if (!provider) {
return providerId;
}
return provider.attributes.alias || provider.attributes.uid || providerId;
}
export function getResourcesFilterDisplayValue(
filterKey: string,
value: string,
providers: ProviderProps[],
): string {
if (!value) return value;
if (filterKey === "filter[provider_type__in]") {
return getProviderDisplayName(value);
}
if (filterKey === "filter[provider_id__in]") {
return getProviderAccountDisplayValue(value, providers);
}
if (filterKey === "filter[groups__in]") {
return getGroupLabel(value);
}
if (filterKey === "filter[type__in]") {
return formatLabel(value, "_");
}
return value;
}
export function buildResourcesFilterChips(
pendingFilters: Record<string, string[]>,
providers: ProviderProps[],
): FilterChip[] {
const chips: FilterChip[] = [];
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key;
const displayValues = values.map((value) =>
getResourcesFilterDisplayValue(key, value, providers),
);
const chip: FilterChip = {
key,
label,
value: values[0],
displayValue:
displayValues.length > 1
? `+${displayValues.length}`
: displayValues[0],
};
if (values.length > 1) {
chip.values = values;
chip.displayValues = displayValues;
}
chips.push(chip);
});
return chips;
}
@@ -1,15 +1,7 @@
"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import {
Check,
Container,
Copy,
CornerDownRight,
ExternalLink,
Link,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { Container, CornerDownRight, ExternalLink, Link } from "lucide-react";
import { useState } from "react";
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
@@ -30,6 +22,10 @@ import {
} from "@/components/shadcn/info-field/info-field";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import {
QUERY_EDITOR_LANGUAGE,
QueryCodeEditor,
} from "@/components/shared/query-code-editor";
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
@@ -115,11 +111,9 @@ export const ResourceDetailContent = ({
);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeTab, setActiveTab] = useState("findings");
const [metadataCopied, setMetadataCopied] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
const router = useRouter();
const resource = resourceDetails;
const resourceId = resource.id;
@@ -155,12 +149,6 @@ export const ResourceDetailContent = ({
navigator.clipboard.writeText(url);
};
const copyMetadata = async (metadata: Record<string, unknown>) => {
await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2));
setMetadataCopied(true);
setTimeout(() => setMetadataCopied(false), 2000);
};
const navigateToFinding = async (findingId: string) => {
setSelectedFindingId(findingId);
await loadFindingDetails(findingId);
@@ -177,7 +165,6 @@ export const ResourceDetailContent = ({
setRowSelection({});
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
router.refresh();
};
const failedFindings = findingsData;
@@ -452,30 +439,17 @@ export const ResourceDetailContent = ({
)}
{hasMetadata && parsedMetadata && (
<Card variant="inner">
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-sm font-semibold">
Metadata:
</span>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary relative w-full rounded-lg border">
<button
type="button"
onClick={() => copyMetadata(parsedMetadata)}
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
aria-label="Copy metadata to clipboard"
>
{metadataCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(parsedMetadata, null, 2)}
</pre>
</div>
</div>
</Card>
<QueryCodeEditor
ariaLabel="Resource metadata"
visibleLabel={null}
language={QUERY_EDITOR_LANGUAGE.JSON}
value={JSON.stringify(parsedMetadata, null, 2)}
copyValue={JSON.stringify(parsedMetadata, null, 2)}
editable={false}
minHeight={220}
showCopyButton
onChange={() => {}}
/>
)}
{!attributes.details?.trim() && !hasMetadata && (
@@ -487,20 +461,13 @@ export const ResourceDetailContent = ({
<TabsContent value="tags" className="flex flex-col gap-4">
{hasTags ? (
<Card variant="inner">
<div className="flex flex-col gap-3">
<span className="text-text-neutral-secondary text-sm font-semibold">
Tags:
</span>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{tagEntries.map(([key, value]) => (
<InfoField key={key} label={key} variant="compact">
{renderValue(value)}
</InfoField>
))}
</div>
</div>
</Card>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{tagEntries.map(([key, value]) => (
<InfoField key={key} label={key} variant="compact">
{renderValue(value)}
</InfoField>
))}
</div>
) : (
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
No tags available for this resource.
+1 -2
View File
@@ -19,8 +19,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
base: "border-border-neutral-secondary bg-bg-neutral-secondary px-[18px] pt-3 pb-4",
inner:
"rounded-[12px] backdrop-blur-[46px] border-border-neutral-tertiary bg-bg-neutral-tertiary",
danger:
"gap-1 rounded-[12px] border-[rgba(67,34,50,0.5)] bg-[rgba(67,34,50,0.2)] dark:border-[rgba(67,34,50,0.7)] dark:bg-[rgba(67,34,50,0.3)]",
danger: "border-border-error bg-bg-fail-secondary gap-1 rounded-[12px]",
},
padding: {
default: "",
+21 -15
View File
@@ -2,6 +2,11 @@
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
} from "react";
import {
Dialog,
@@ -81,21 +86,22 @@ function CommandInput({
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
const CommandList = forwardRef<
ElementRef<typeof CommandPrimitive.List>,
ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
function CommandEmpty({
...props
@@ -1,4 +1,4 @@
import { render, screen, within } 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";
@@ -136,7 +136,48 @@ describe("MultiSelect", () => {
await user.click(screen.getByRole("combobox"));
await user.type(screen.getByPlaceholderText("Search accounts..."), "aws");
expect(scrollIntoViewMock).toHaveBeenCalled();
await waitFor(() => {
expect(scrollIntoViewMock).toHaveBeenCalled();
});
});
it("clears the search input when reopening the popover", async () => {
const user = userEvent.setup();
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
}}
>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
const searchInput = screen.getByPlaceholderText(
"Search accounts...",
) as HTMLInputElement;
await user.type(searchInput, "aws");
expect(searchInput).toHaveValue("aws");
await user.keyboard("{Escape}");
expect(
screen.queryByPlaceholderText("Search accounts..."),
).not.toBeInTheDocument();
await user.click(screen.getByRole("combobox"));
expect(screen.getByPlaceholderText("Search accounts...")).toHaveValue("");
});
it("uses a normalized dropdown width instead of growing with the longest item", async () => {
+21 -2
View File
@@ -284,12 +284,27 @@ export function MultiSelectContent({
width?: "default" | "wide";
} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) {
const canSearch = typeof search === "object" ? true : search;
const listRef = useRef<HTMLDivElement>(null);
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-popover-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
: "w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] max-w-[24rem]";
function handleSearchValueChange(searchValue: string) {
if (!canSearch || !searchValue.trim()) return;
requestAnimationFrame(() => {
const firstVisibleItem = listRef.current?.querySelector<HTMLElement>(
'[data-slot="multiselect-item"]:not([hidden])',
);
firstVisibleItem?.scrollIntoView({
block: "nearest",
});
});
}
return (
<>
<div className="hidden" aria-hidden="true">
@@ -312,11 +327,15 @@ export function MultiSelectContent({
typeof search === "object" ? search.placeholder : undefined
}
className="text-bg-button-secondary placeholder:text-bg-button-secondary"
onValueChange={handleSearchValueChange}
/>
) : (
<button className="sr-only" />
)}
<CommandList className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto p-3">
<CommandList
ref={listRef}
className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto p-3"
>
{canSearch && (
<CommandEmpty className="text-bg-button-secondary py-6 text-center text-sm">
{typeof search === "object" ? search.emptyMessage : undefined}
@@ -357,7 +376,7 @@ export function MultiSelectItem({
keywords={keywords}
data-slot="multiselect-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
isSelected && "bg-slate-100 dark:bg-slate-800/50",
className,
)}
+80 -3
View File
@@ -24,6 +24,7 @@ import { cn } from "@/lib/utils";
export const QUERY_EDITOR_LANGUAGE = {
OPEN_CYPHER: "openCypher",
PLAIN_TEXT: "plainText",
JSON: "json",
SHELL: "shell",
HCL: "hcl",
BICEP: "bicep",
@@ -205,6 +206,74 @@ const HCL_FUNCTIONS = new Set([
"cidrhost",
]);
interface JsonParserState {
inString: boolean;
stringIsProperty: boolean;
escapeNext: boolean;
}
const jsonLanguage = StreamLanguage.define<JsonParserState>({
startState() {
return {
inString: false,
stringIsProperty: false,
escapeNext: false,
};
},
token(stream, state) {
if (state.inString) {
while (!stream.eol()) {
const next = stream.next();
if (state.escapeNext) {
state.escapeNext = false;
continue;
}
if (next === "\\") {
state.escapeNext = true;
continue;
}
if (next === '"') {
state.inString = false;
return state.stringIsProperty ? "propertyName" : "string";
}
}
return state.stringIsProperty ? "propertyName" : "string";
}
if (stream.eatSpace()) {
return null;
}
if (stream.peek() === '"') {
const restOfLine = stream.string.slice(stream.pos);
state.inString = true;
state.escapeNext = false;
state.stringIsProperty = /^\s*"([^"\\]|\\.)*"\s*:/.test(restOfLine);
stream.next();
return state.stringIsProperty ? "propertyName" : "string";
}
if (stream.match(/[{}\[\],:]/)) {
return "punctuation";
}
if (stream.match(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/)) {
return "number";
}
if (stream.match(/\b(?:true|false|null)\b/)) {
return "keyword";
}
stream.next();
return null;
},
});
const BICEP_KEYWORDS = new Set([
"resource",
"module",
@@ -1098,6 +1167,7 @@ function createEditorTheme({
interface QueryCodeEditorProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
ariaLabel: string;
visibleLabel?: string | null;
language?: QueryEditorLanguage;
value: string;
copyValue?: string;
@@ -1115,6 +1185,7 @@ export const QueryCodeEditor = ({
id,
className,
ariaLabel,
visibleLabel = ariaLabel,
language = QUERY_EDITOR_LANGUAGE.OPEN_CYPHER,
value,
copyValue,
@@ -1171,6 +1242,8 @@ export const QueryCodeEditor = ({
extensions.push(shellLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.HCL) {
extensions.push(hclLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.JSON) {
extensions.push(jsonLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.BICEP) {
extensions.push(bicepLanguage, syntaxHighlighting(editorHighlightStyle));
} else if (language === QUERY_EDITOR_LANGUAGE.YAML) {
@@ -1195,9 +1268,13 @@ export const QueryCodeEditor = ({
{...props}
>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
<span className="text-text-neutral-secondary text-xs font-medium">
{ariaLabel}
</span>
{visibleLabel ? (
<span className="text-text-neutral-secondary text-xs font-medium">
{visibleLabel}
</span>
) : (
<span aria-hidden="true" />
)}
<div className="flex items-center gap-2">
{requirementBadge ? (
<Badge
+7 -1
View File
@@ -6,6 +6,7 @@ interface ExpandableSectionProps {
isExpanded: boolean;
children: React.ReactNode;
className?: string;
contentClassName?: string;
}
/**
@@ -16,6 +17,7 @@ export function ExpandableSection({
isExpanded,
children,
className,
contentClassName,
}: ExpandableSectionProps) {
return (
<div
@@ -26,7 +28,11 @@ export function ExpandableSection({
)}
>
<div className="overflow-hidden">
<div className={cn("pt-4", !isExpanded && "invisible")}>{children}</div>
<div
className={cn("pt-4", contentClassName, !isExpanded && "invisible")}
>
{children}
</div>
</div>
</div>
);
+102
View File
@@ -0,0 +1,102 @@
import type { ColumnDef } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { MetaDataProps } from "@/types";
import { DataTable } from "./data-table";
vi.mock("next/navigation", () => ({
usePathname: () => "/findings",
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/contexts", () => ({
useFilterTransitionOptional: () => null,
}));
vi.mock("@/lib", () => ({
cn: (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(" "),
getPaginationInfo: (metadata: MetaDataProps) => ({
currentPage: metadata.pagination.page,
totalPages: metadata.pagination.pages,
totalEntries: metadata.pagination.count,
itemsPerPageOptions: metadata.pagination.itemsPerPage ?? [10, 20, 50],
}),
}));
interface TestRow {
name: string;
}
const columns: ColumnDef<TestRow>[] = [
{
accessorKey: "name",
header: "Name",
},
];
const metadata: MetaDataProps = {
pagination: {
page: 1,
pages: 1,
count: 7,
},
version: "v1",
};
describe("DataTable", () => {
describe("when toolbar right content is provided", () => {
it("should render it before the total entries count", () => {
// Given
render(
<DataTable
columns={columns}
data={[{ name: "Finding A" }]}
metadata={metadata}
toolbarRightContent={<span>Include muted findings</span>}
/>,
);
// When
const toolbarContent = screen.getByText("Include muted findings");
const totalEntries = screen.getByText("7 Total Entries");
const tableContainerText =
toolbarContent.parentElement?.textContent ?? "";
// Then
expect(toolbarContent).toBeInTheDocument();
expect(totalEntries).toBeInTheDocument();
expect(tableContainerText.indexOf("Include muted findings")).toBeLessThan(
tableContainerText.indexOf("7 Total Entries"),
);
});
it("should stack the right content below search on narrow screens", () => {
// Given
render(
<DataTable
columns={columns}
data={[{ name: "Finding A" }]}
metadata={metadata}
showSearch
toolbarRightContent={<span>Include muted findings</span>}
/>,
);
// When
const toolbar = screen.getByTestId("data-table-toolbar");
const rightContent = screen.getByTestId("data-table-toolbar-right");
// Then
expect(toolbar).toHaveClass("flex-col");
expect(toolbar).toHaveClass("md:flex-row");
expect(rightContent).toHaveClass("w-full");
expect(rightContent).toHaveClass("md:w-auto");
});
});
});
+20 -8
View File
@@ -112,6 +112,8 @@ interface DataTableProviderProps<TData, TValue> {
onRowClick?: (row: Row<TData>) => void;
/** Optional header rendered inside the table container, above the toolbar. */
header?: ReactNode;
/** Optional content rendered in the toolbar before the total entries count. */
toolbarRightContent?: ReactNode;
}
export function DataTable<TData, TValue>({
@@ -143,6 +145,7 @@ export function DataTable<TData, TValue>({
searchBadge,
onRowClick,
header,
toolbarRightContent,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -215,7 +218,7 @@ export function DataTable<TData, TValue>({
// Format total entries count
const totalEntries = metadata?.pagination?.count ?? 0;
const formattedTotal = totalEntries.toLocaleString();
const showToolbar = showSearch || metadata;
const showToolbar = showSearch || metadata || toolbarRightContent;
const rows = table.getRowModel().rows;
@@ -241,8 +244,11 @@ export function DataTable<TData, TValue>({
{header && <div className="w-full">{header}</div>}
{/* Table Toolbar */}
{showToolbar && (
<div className="flex items-center justify-between">
<div>
<div
data-testid="data-table-toolbar"
className="flex flex-col items-start gap-3 md:flex-row md:items-center md:justify-between"
>
<div className="w-full md:w-auto">
{showSearch && (
<DataTableSearch
paramPrefix={paramPrefix}
@@ -254,11 +260,17 @@ export function DataTable<TData, TValue>({
/>
)}
</div>
{metadata && (
<span className="text-text-neutral-secondary text-sm">
{formattedTotal} Total Entries
</span>
)}
<div
data-testid="data-table-toolbar-right"
className="flex w-full flex-col items-start gap-2 md:ml-auto md:w-auto md:flex-row md:items-center md:gap-4"
>
{toolbarRightContent}
{metadata && (
<span className="text-text-neutral-secondary text-sm whitespace-nowrap">
{formattedTotal} Total Entries
</span>
)}
</div>
</div>
)}
<Table className={getSubRows ? "table-fixed" : undefined}>
+20
View File
@@ -498,6 +498,26 @@ describe("useFilterBatch", () => {
expect(calledUrl).not.toContain("status");
});
it("should not expose cleared URL filters as pending changes while navigation updates", () => {
// Given
setSearchParams({
"filter[severity__in]": "critical",
"filter[status__in]": "FAIL",
});
const { result } = renderHook(() => useFilterBatch());
// When
act(() => {
result.current.clearAndApply();
});
// Then — the UI should not render the pending-filter row after clearing.
expect(result.current.appliedFilters).toEqual({});
expect(result.current.changedFilters).toEqual({});
expect(result.current.hasChanges).toBe(false);
expect(result.current.changeCount).toBe(0);
});
it("should apply defaultParams when clearing", () => {
// Given
setSearchParams({ "filter[severity__in]": "critical" });
+69 -3
View File
@@ -16,8 +16,12 @@ export interface PendingFilters {
}
export interface UseFilterBatchReturn {
/** Current applied filter values — URL-backed state */
appliedFilters: PendingFilters;
/** Current pending filter values — local state, not yet in URL */
pendingFilters: PendingFilters;
/** Pending filter keys whose selected values differ from the applied URL state */
changedFilters: PendingFilters;
/** Update a single pending filter. Does NOT touch the URL. */
setPending: (key: string, values: string[]) => void;
/** Apply all pending filters to URL in a single router.push */
@@ -30,6 +34,8 @@ export interface UseFilterBatchReturn {
* the resulting URL in one step.
*/
clearAndApply: () => void;
/** Remove one applied URL-backed filter value and immediately navigate */
removeAppliedAndApply: (key: string, value?: string) => void;
/** Remove a single filter key from pending state */
removePending: (key: string) => void;
/** Whether pending state differs from the current URL */
@@ -107,6 +113,37 @@ function countChanges(
return count;
}
function getChangedFilters(
pending: PendingFilters,
applied: PendingFilters,
): PendingFilters {
const pendingKeys = Object.keys(pending).filter((key) => {
const values = pending[key];
return values.length > 0;
});
const appliedKeys = Object.keys(applied).filter((key) => {
const values = applied[key];
return values.length > 0;
});
const allKeys = Array.from(new Set([...pendingKeys, ...appliedKeys]));
return allKeys.reduce<PendingFilters>((changed, key) => {
const pendingValues = pending[key] ?? [];
const appliedValues = applied[key] ?? [];
const sortedPending = [...pendingValues].sort();
const sortedApplied = [...appliedValues].sort();
const isChanged =
sortedPending.length !== sortedApplied.length ||
!sortedPending.every((value, index) => value === sortedApplied[index]);
if (isChanged && pendingValues.length > 0) {
changed[key] = pendingValues;
}
return changed;
}, {});
}
export interface UseFilterBatchOptions {
/**
* Default URL params to apply when applyAll() is called and they are not
@@ -132,6 +169,9 @@ export const useFilterBatch = (
const pathname = usePathname();
const searchParams = useSearchParams();
const [appliedFilters, setAppliedFilters] = useState<PendingFilters>(() =>
deriveAppliedFromUrl(new URLSearchParams(searchParams.toString())),
);
const [pendingFilters, setPendingFilters] = useState<PendingFilters>(() =>
deriveAppliedFromUrl(new URLSearchParams(searchParams.toString())),
);
@@ -142,6 +182,7 @@ export const useFilterBatch = (
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
setAppliedFilters(applied);
setPendingFilters(applied);
}, [searchParams]);
@@ -164,6 +205,7 @@ export const useFilterBatch = (
/** Private helper — builds URLSearchParams from a pending state and pushes. */
const buildAndPush = (nextPending: PendingFilters) => {
setAppliedFilters(nextPending);
const params = new URLSearchParams(searchParams.toString());
// Remove all batch-managed filter params
@@ -222,25 +264,49 @@ export const useFilterBatch = (
buildAndPush({});
};
const removeAppliedAndApply = (key: string, value?: string) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
const nextValues =
value === undefined
? []
: (applied[filterKey] ?? []).filter((item) => item !== value);
const nextApplied = { ...applied };
if (nextValues.length > 0) {
nextApplied[filterKey] = nextValues;
} else {
delete nextApplied[filterKey];
}
setPendingFilters(nextApplied);
buildAndPush(nextApplied);
};
const getFilterValue = (key: string): string[] => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
return pendingFilters[filterKey] ?? [];
};
const appliedFilters = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters);
const changeCount = hasChanges
? countChanges(pendingFilters, appliedFilters)
: 0;
const changedFilters = hasChanges
? getChangedFilters(pendingFilters, appliedFilters)
: {};
return {
appliedFilters,
pendingFilters,
changedFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
removeAppliedAndApply,
removePending,
hasChanges,
changeCount,