Compare commits

...

7 Commits

Author SHA1 Message Date
Alan Buscaglia
cca5c52a15 docs(skills): add guardrails for review-driven UI patterns
- Add batch/instant API contract and derived-state guardrails to prowler-ui

- Add coupled optional props rule to TypeScript skill

- Add pre-re-review thread hygiene checklist to prowler-pr
2026-03-20 12:53:42 +01:00
Alan Buscaglia
8604215b99 fix(ui): finalize reviewer feedback for findings batch filters
- Enforce batch/instant props with discriminated unions in filter components

- Add strict FilterParam typing for findings filter labels

- Simplify apply button label logic and clarify batch hook comments
2026-03-20 12:53:33 +01:00
Alan Buscaglia
1116228fc2 fix(ui): polish findings filter chips and pending count
- Hide default muted=false from summary chips

- Exclude muted=false from pending clear-filters count

- Keep inserted_at chip value as ISO date string
2026-03-20 12:08:20 +01:00
Alan Buscaglia
176c11b15b refactor(ui): align findings filter labels with shared formatters
- Replace local provider and status label maps with shared utilities
- Improve filter value formatting with category/group/provider helpers
- Restore contextual account selector messaging without narrowing options
- Add aria-live polite announcements to the filter summary strip
- Clarify applyAll comment for non-batch URL params
2026-03-20 10:57:52 +01:00
Alan Buscaglia
957439030c refactor(ui): address PR review findings for batch filters
- Remove dead props from FindingsFiltersProps (providerIds, providerDetails, completedScans)
- Make muted filter fully participate in batch flow (remove from EXCLUDED_FROM_BATCH)
- Remove eslint-disable and use searchParams as proper useEffect dependency
- Hide ClearFiltersButton when both pending and URL filter counts are zero
- Simplify handleChipRemove by removing redundant key normalization
- Make useFilterBatch reusable with defaultParams option (remove hardcoded FINDINGS_PATH)
- Simplify CustomDatePicker by deriving date from valueProp in batch mode
- Extract STATUS_DISPLAY as top-level const alongside PROVIDER_TYPE_DISPLAY
- Use const object pattern for DataTableFilterMode type (DATA_TABLE_FILTER_MODE)
2026-03-19 12:40:41 +01:00
Alan Buscaglia
c3c48b1eff style(ui): fix prettier formatting in batch filters 2026-03-19 12:29:13 +01:00
Alan Buscaglia
c09d82a1f4 feat(ui): add batch apply pattern to Findings filters
- Add useFilterBatch hook for two-state filter management (pending vs applied)
- Add FilterSummaryStrip component showing pending selections as removable chips
- Add ApplyFiltersButton with change detection and discard action
- Add batch mode to DataTableFilterCustom with backward-compatible mode prop
- Wire all Findings filters (including provider/account) through batch mode
- Remove cross-filter dependency logic between provider type and accounts
- Remove scan option narrowing from useRelatedFilters in Findings view
- Add clearAll function for complete pending state reset
- Add 47 unit tests covering hook, components, and batch integration
2026-03-19 12:23:18 +01:00
22 changed files with 2480 additions and 115 deletions

View File

@@ -132,6 +132,18 @@ Follow conventional commits:
4. ✅ Branch is up to date with main
5. ✅ Commits are clean and descriptive
## Before Re-Requesting Review (REQUIRED)
Resolve or respond to **every** open inline review thread before re-requesting review:
1. **Agreed + fixed**: Commit the change. Reply with the commit hash so the reviewer can verify quickly:
> Fixed in `abc1234`.
2. **Agreed but deferred**: Explain why it's out of scope for this PR and where it's tracked.
3. **Disagreed**: Reply with clear technical reasoning. Do not leave threads silently open.
4. **Re-request review** only after all threads are in a clean state — either resolved or explicitly responded to.
> **Rule of thumb**: A reviewer should never have to wonder "did they see my comment?" when they re-open the PR.
## Resources
- **Documentation**: See [references/](references/) for links to local developer guide

View File

@@ -186,6 +186,109 @@ cd ui && pnpm run build
cd ui && pnpm start
```
## Batch vs Instant Component API (REQUIRED)
When a component supports both **batch** (deferred, submit-based) and **instant** (immediate callback) behavior, model the coupling with a discriminated union — never as independent optionals. Coupled props must be all-or-nothing.
```typescript
// ❌ NEVER: Independent optionals — allows invalid half-states
interface FilterProps {
onBatchApply?: (values: string[]) => void;
onInstantChange?: (value: string) => void;
isBatchMode?: boolean;
}
// ✅ ALWAYS: Discriminated union — one valid shape per mode
type BatchProps = {
mode: "batch";
onApply: (values: string[]) => void;
onCancel: () => void;
};
type InstantProps = {
mode: "instant";
onChange: (value: string) => void;
// onApply/onCancel are forbidden here via structural exclusion
onApply?: never;
onCancel?: never;
};
type FilterProps = BatchProps | InstantProps;
```
This makes invalid prop combinations a compile error, not a runtime surprise.
## Reuse Shared Display Utilities First (REQUIRED)
Before adding **local** display maps (labels, provider names, status strings, category formatters), search `ui/types/*` and `ui/lib/*` for existing helpers.
```typescript
// ✅ CHECK THESE FIRST before creating a new map:
// ui/lib/utils.ts → general formatters
// ui/types/providers.ts → provider display names, icons
// ui/types/findings.ts → severity/status display maps
// ui/types/compliance.ts → category/group formatters
// ❌ NEVER add a local map that already exists:
const SEVERITY_LABELS: Record<string, string> = {
critical: "Critical",
high: "High",
// ...duplicating an existing shared map
};
// ✅ Import and reuse instead:
import { severityLabel } from "@/types/findings";
```
If a helper doesn't exist and will be used in 2+ places, add it to `ui/lib/` or `ui/types/` and reuse it. Keep local only if used in exactly one place.
## Derived State Rule (REQUIRED)
Avoid `useState` + `useEffect` patterns that mirror props or searchParams — they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth.
```typescript
// ❌ NEVER: Mirror props into state via effect
const [localFilter, setLocalFilter] = useState(filter);
useEffect(() => { setLocalFilter(filter); }, [filter]);
// ✅ ALWAYS: Derive directly
const localFilter = filter; // or compute inline
```
If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment:
```typescript
// Local state needed: user edits are buffered until "Apply" is clicked
const [pending, setPending] = useState(initialValues);
```
## Strict Key Typing for Label Maps (REQUIRED)
Avoid `Record<string, string>` when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time.
```typescript
// ❌ Loose — typos compile silently
const STATUS_LABELS: Record<string, string> = {
actve: "Active", // typo, no error
};
// ✅ Tight — union key
type Status = "active" | "inactive" | "pending";
const STATUS_LABELS: Record<Status, string> = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
// actve: "Active" ← compile error
};
// ✅ Also fine — const satisfies
const STATUS_LABELS = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
} as const satisfies Record<Status, string>;
```
## QA Checklist Before Commit
- [ ] `pnpm run typecheck` passes
@@ -199,6 +302,15 @@ cd ui && pnpm start
- [ ] Accessibility: keyboard navigation, ARIA labels
- [ ] Mobile responsive (if applicable)
## Pre-Re-Review Checklist (Review Thread Hygiene)
Before requesting re-review from a reviewer:
- [ ] Every unresolved inline thread has been either fixed or explicitly answered with a rationale
- [ ] If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply
- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
- [ ] Re-request review only after all threads are in a clean state
## Migrations Reference
| From | To | Key Changes |

View File

@@ -102,6 +102,38 @@ function isUser(value: unknown): value is User {
}
```
## Coupled Optional Props (REQUIRED)
Do not model semantically coupled props as independent optionals — this allows invalid half-states that compile but break at runtime. Use discriminated unions with `never` to make invalid combinations impossible.
```typescript
// ❌ BEFORE: Independent optionals — half-states allowed
interface PaginationProps {
onPageChange?: (page: number) => void;
pageSize?: number;
currentPage?: number;
}
// ✅ AFTER: Discriminated union — shape is all-or-nothing
type ControlledPagination = {
controlled: true;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
};
type UncontrolledPagination = {
controlled: false;
currentPage?: never;
pageSize?: never;
onPageChange?: never;
};
type PaginationProps = ControlledPagination | UncontrolledPagination;
```
**Key rule:** If two or more props are only meaningful together, they belong to the same discriminated union branch. Mixing them as independent optionals shifts correctness responsibility from the type system to runtime guards.
## Import Types
```typescript

View File

@@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Findings filters now use a batch-apply pattern with an Apply Filters button, filter summary strip, and independent filter options instead of triggering API calls on every selection
- Google Workspace provider support [(#10333)](https://github.com/prowler-cloud/prowler/pull/10333)
- Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167)
- Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317)

View File

@@ -27,7 +27,11 @@ import {
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderProps, ProviderType } from "@/types/providers";
import {
getProviderDisplayName,
type ProviderProps,
type ProviderType,
} from "@/types/providers";
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
@@ -46,60 +50,73 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
openstack: <OpenStackProviderBadge width={18} height={18} />,
};
interface AccountsSelectorProps {
/** Common props shared by both batch and instant modes. */
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
/**
* Currently selected provider types (from the pending ProviderTypeSelector state).
* Used only for contextual description/empty-state messaging — does NOT narrow
* the list of available accounts, which remains independent of provider selection.
*/
selectedProviderTypes?: string[];
}
export function AccountsSelector({ providers }: AccountsSelectorProps) {
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
interface AccountsSelectorBatchProps extends AccountsSelectorBaseProps {
/**
* Called instead of navigating immediately.
* Use this on pages that batch filter changes (e.g. Findings).
*
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_id__in"
* @param values - The selected values array
*/
onBatchChange: (filterKey: string, values: string[]) => void;
/**
* Pending selected values controlled by the parent.
* Reflects pending state before Apply is clicked.
*/
selectedValues: string[];
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface AccountsSelectorInstantProps extends AccountsSelectorBaseProps {
onBatchChange?: never;
selectedValues?: never;
}
type AccountsSelectorProps =
| AccountsSelectorBatchProps
| AccountsSelectorInstantProps;
export function AccountsSelector({
providers,
onBatchChange,
selectedValues,
selectedProviderTypes,
}: AccountsSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
const selectedTypes = searchParams.get("filter[provider_type__in]") || "";
const selectedTypesList = selectedTypes
? selectedTypes.split(",").filter(Boolean)
: [];
const selectedIds = current ? current.split(",").filter(Boolean) : [];
const visibleProviders = providers
// .filter((p) => p.attributes.connection?.connected)
.filter((p) =>
selectedTypesList.length > 0
? selectedTypesList.includes(p.attributes.provider)
: true,
);
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
const visibleProviders = providers;
// .filter((p) => p.attributes.connection?.connected)
const handleMultiValueChange = (ids: string[]) => {
if (onBatchChange) {
onBatchChange("provider_id__in", ids);
return;
}
navigateWithParams((params) => {
params.delete(filterKey);
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}
// Auto-deselect provider types that no longer have any selected accounts
if (selectedTypesList.length > 0) {
// Get provider types of currently selected accounts
const selectedProviders = providers.filter((p) => ids.includes(p.id));
const selectedProviderTypes = new Set(
selectedProviders.map((p) => p.attributes.provider),
);
// Keep only provider types that still have selected accounts
const remainingProviderTypes = selectedTypesList.filter((type) =>
selectedProviderTypes.has(type as ProviderType),
);
// Update provider_type__in filter
if (remainingProviderTypes.length > 0) {
params.set(
"filter[provider_type__in]",
remainingProviderTypes.join(","),
);
} else {
params.delete("filter[provider_type__in]");
}
}
});
};
@@ -115,9 +132,12 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
);
};
// Build a contextual description based on currently selected provider types.
// This is purely for user guidance (aria label + empty state) and does NOT
// narrow the list of available accounts — all providers remain selectable.
const filterDescription =
selectedTypesList.length > 0
? `Showing accounts for ${selectedTypesList.join(", ")} providers`
selectedProviderTypes && selectedProviderTypes.length > 0
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "All connected cloud provider accounts";
return (
@@ -176,8 +196,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
{selectedTypesList.length > 0
? "No accounts available for selected providers"
{selectedProviderTypes && selectedProviderTypes.length > 0
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "No connected accounts available"}
</div>
)}

View File

@@ -152,22 +152,60 @@ const PROVIDER_DATA: Record<
},
};
type ProviderTypeSelectorProps = {
/** Common props shared by both batch and instant modes. */
interface ProviderTypeSelectorBaseProps {
providers: ProviderProps[];
};
}
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
interface ProviderTypeSelectorBatchProps extends ProviderTypeSelectorBaseProps {
/**
* Called instead of navigating immediately.
* Use this on pages that batch filter changes (e.g. Findings).
*
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_type__in"
* @param values - The selected values array
*/
onBatchChange: (filterKey: string, values: string[]) => void;
/**
* Pending selected values controlled by the parent.
* Reflects pending state before Apply is clicked.
*/
selectedValues: string[];
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface ProviderTypeSelectorInstantProps
extends ProviderTypeSelectorBaseProps {
onBatchChange?: never;
selectedValues?: never;
}
type ProviderTypeSelectorProps =
| ProviderTypeSelectorBatchProps
| ProviderTypeSelectorInstantProps;
export const ProviderTypeSelector = ({
providers,
onBatchChange,
selectedValues,
}: ProviderTypeSelectorProps) => {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
const selectedTypes = currentProviders
const urlSelectedTypes = currentProviders
? currentProviders.split(",").filter(Boolean)
: [];
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedTypes = onBatchChange ? selectedValues : urlSelectedTypes;
const handleMultiValueChange = (values: string[]) => {
if (onBatchChange) {
onBatchChange("provider_type__in", values);
return;
}
navigateWithParams((params) => {
// Update provider_type__in
if (values.length > 0) {
@@ -175,10 +213,6 @@ export const ProviderTypeSelector = ({
} else {
params.delete("filter[provider_type__in]");
}
// Clear account selection when changing provider types
// User should manually select accounts if they want to filter by specific accounts
params.delete("filter[provider_id__in]");
});
};

View File

@@ -24,10 +24,6 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import {
createProviderDetailsMappingById,
extractProviderIds,
} from "@/lib/provider-helpers";
import { ScanEntity, ScanProps } from "@/types";
import { FindingProps, SearchParamsProps } from "@/types/components";
@@ -124,12 +120,6 @@ export default async function Findings({
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
// Extract provider IDs and details using helper functions
const providerIds = providersData ? extractProviderIds(providersData) : [];
const providerDetails = providersData
? createProviderDetailsMappingById(providerIds, providersData)
: [];
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -151,9 +141,6 @@ export default async function Findings({
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerIds={providerIds}
providerDetails={providerDetails}
completedScans={completedScans || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}

View File

@@ -0,0 +1,265 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
// Mock lucide-react to avoid SVG rendering issues in jsdom
vi.mock("lucide-react", () => ({
Check: () => <svg data-testid="check-icon" />,
X: () => <svg data-testid="x-icon" />,
}));
// Mock @/components/shadcn to avoid next-auth import chain
vi.mock("@/components/shadcn", () => ({
Button: ({
children,
disabled,
onClick,
"aria-label": ariaLabel,
variant,
size,
}: {
children?: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
"aria-label"?: string;
variant?: string;
size?: string;
}) => (
<button
disabled={disabled}
onClick={onClick}
aria-label={ariaLabel}
data-variant={variant}
data-size={size}
>
{children}
</button>
),
}));
vi.mock("@/lib/utils", () => ({
cn: (...classes: (string | undefined | false)[]) =>
classes.filter(Boolean).join(" "),
}));
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
// ── Future E2E coverage ────────────────────────────────────────────────────
// TODO (E2E): Full apply-filters button flow should be covered in Playwright tests:
// - Button appears disabled when no filters have been staged
// - Button shows correct count after staging multiple filters
// - Clicking Apply pushes all pending filters to the URL in one navigation event
// - Clicking Discard resets pending state to current URL state (staged filters disappear)
// ──────────────────────────────────────────────────────────────────────────
describe("ApplyFiltersButton", () => {
// ── No changes ───────────────────────────────────────────────────────────
describe("when hasChanges is false", () => {
it("should render the Apply Filters button as disabled", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters",
});
expect(applyButton).toBeDisabled();
});
it("should NOT render the discard (X) button when there are no changes", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.queryByRole("button", {
name: /discard/i,
}),
).not.toBeInTheDocument();
});
it("should show 'Apply Filters' label without count", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
).toBeInTheDocument();
});
});
// ── Has changes ──────────────────────────────────────────────────────────
describe("when hasChanges is true", () => {
it("should render the Apply Filters button as enabled", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={2}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters (2)",
});
expect(applyButton).not.toBeDisabled();
});
it("should show the change count in the button label", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={3}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Apply Filters (3)" }),
).toBeInTheDocument();
});
it("should show 'Apply Filters' (without count) when changeCount is 0 but hasChanges is true", () => {
// Given — hasChanges=true but changeCount=0 (edge case)
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
).toBeInTheDocument();
});
it("should render the discard (X) button", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={1}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: /discard pending filter changes/i }),
).toBeInTheDocument();
});
});
// ── onApply interaction ──────────────────────────────────────────────────
describe("onApply", () => {
it("should call onApply when the Apply Filters button is clicked", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
const onDiscard = vi.fn();
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={1}
onApply={onApply}
onDiscard={onDiscard}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: "Apply Filters (1)" }),
);
// Then
expect(onApply).toHaveBeenCalledTimes(1);
expect(onDiscard).not.toHaveBeenCalled();
});
it("should NOT call onApply when the button is disabled", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={onApply}
onDiscard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Apply Filters" }));
// Then — disabled button should not fire
expect(onApply).not.toHaveBeenCalled();
});
});
// ── onDiscard interaction ────────────────────────────────────────────────
describe("onDiscard", () => {
it("should call onDiscard when the Discard button is clicked", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
const onDiscard = vi.fn();
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={2}
onApply={onApply}
onDiscard={onDiscard}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /discard pending filter changes/i }),
);
// Then
expect(onDiscard).toHaveBeenCalledTimes(1);
expect(onApply).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,64 @@
"use client";
import { Check, X } from "lucide-react";
import { Button } from "@/components/shadcn";
import { cn } from "@/lib/utils";
export interface ApplyFiltersButtonProps {
/** Whether there are pending changes that differ from the applied (URL) state */
hasChanges: boolean;
/** Number of filter keys that have pending changes */
changeCount: number;
/** Called when the user clicks "Apply Filters" */
onApply: () => void;
/** Called when the user clicks the discard (X) action */
onDiscard: () => void;
/** Optional extra class names for the outer wrapper */
className?: string;
}
/**
* Displays an "Apply Filters" button with an optional discard action.
*
* - Shows the count of pending changes when `hasChanges` is true.
* - The apply button is disabled (and visually muted) when there are no changes.
* - The discard (X) button only appears when there are pending changes.
* - Uses Prowler's shadcn `Button` component.
*/
export const ApplyFiltersButton = ({
hasChanges,
changeCount,
onApply,
onDiscard,
className,
}: ApplyFiltersButtonProps) => {
const label =
changeCount > 0 ? `Apply Filters (${changeCount})` : "Apply Filters";
return (
<div className={cn("flex items-center gap-1", className)}>
<Button
variant="default"
size="sm"
disabled={!hasChanges}
onClick={onApply}
aria-label={label}
>
<Check className="size-4" />
{label}
</Button>
{hasChanges && (
<Button
variant="ghost"
size="icon-sm"
onClick={onDiscard}
aria-label="Discard pending filter changes"
>
<X className="size-4" />
</Button>
)}
</div>
);
};

View File

@@ -17,6 +17,19 @@ export interface ClearFiltersButtonProps {
showCount?: boolean;
/** Use link style (text only, no button background) */
variant?: "link" | "default";
/**
* Optional callback for batch mode. When provided, this is called INSTEAD
* of pushing URL params directly. Useful for clearing pending filter state
* without immediately navigating.
*/
onClear?: () => void;
/**
* In batch mode, the number of pending filter keys that have non-empty values.
* When provided alongside `onClear`, overrides the URL-based count shown by
* `showCount`. This ensures the displayed count reflects the pending state
* (not the last-applied URL state) while the user is editing filters.
*/
pendingCount?: number;
}
export const ClearFiltersButton = ({
@@ -24,6 +37,8 @@ export const ClearFiltersButton = ({
ariaLabel = "Reset",
showCount = false,
variant = "link",
onClear,
pendingCount,
}: ClearFiltersButtonProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -51,17 +66,27 @@ export const ClearFiltersButton = ({
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [router, searchParams, pathname]);
// Only show button if there are filters other than the excluded ones
if (filterCount === 0) {
// In batch mode: use pendingCount if provided; otherwise fall back to URL count.
// In instant mode: always use URL count.
const displayCount =
onClear && pendingCount !== undefined ? pendingCount : filterCount;
// In instant mode: hide when no URL filters exist
if (!onClear && filterCount === 0) {
return null;
}
const displayText = showCount ? `Clear Filters (${filterCount})` : text;
// In batch mode: hide when there are no pending or URL filters to clear
if (onClear && displayCount === 0) {
return null;
}
const displayText = showCount ? `Clear Filters (${displayCount})` : text;
return (
<Button
aria-label={ariaLabel}
onClick={clearFiltersPreservingExcluded}
onClick={onClear ?? clearFiltersPreservingExcluded}
variant={variant}
>
<XCircle className="mr-0.5 size-4" />

View File

@@ -11,7 +11,35 @@ const MUTED_FILTER_VALUES = {
INCLUDE: "include",
} as const;
export const CustomCheckboxMutedFindings = () => {
/** Batch mode: caller controls both the checked state and the notification callback (all-or-nothing). */
interface CustomCheckboxMutedFindingsBatchProps {
/**
* Called instead of navigating directly.
* Receives the filter key ("muted") and the string value ("include" or "false").
*/
onBatchChange: (filterKey: string, value: string) => void;
/**
* Controlled checked state from the parent (pending state).
* `true` = include muted, `false` = exclude muted.
* `undefined` defers to URL state while pending state is not yet set.
*/
checked: boolean | undefined;
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface CustomCheckboxMutedFindingsInstantProps {
onBatchChange?: never;
checked?: never;
}
type CustomCheckboxMutedFindingsProps =
| CustomCheckboxMutedFindingsBatchProps
| CustomCheckboxMutedFindingsInstantProps;
export const CustomCheckboxMutedFindings = ({
onBatchChange,
checked: checkedProp,
}: CustomCheckboxMutedFindingsProps = {}) => {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
@@ -21,11 +49,25 @@ export const CustomCheckboxMutedFindings = () => {
// URL states:
// - filter[muted]=false → Exclude muted (checkbox UNCHECKED)
// - filter[muted]=include → Include muted (checkbox CHECKED)
const includeMuted = mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
// When a controlled `checked` prop is provided (batch mode), use it; otherwise fall back to URL.
const includeMuted =
checkedProp !== undefined
? checkedProp
: mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
const handleMutedChange = (checked: boolean | "indeterminate") => {
const isChecked = checked === true;
if (onBatchChange) {
// Batch mode: notify caller instead of navigating
onBatchChange(
"muted",
isChecked ? MUTED_FILTER_VALUES.INCLUDE : MUTED_FILTER_VALUES.EXCLUDE,
);
return;
}
// Instant mode (default): navigate immediately
navigateWithParams((params) => {
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)

View File

@@ -14,22 +14,79 @@ import {
import { useUrlFilters } from "@/hooks/use-url-filters";
import { cn } from "@/lib/utils";
export const CustomDatePicker = () => {
/** Batch mode: caller controls both the pending date value and the notification callback (all-or-nothing). */
interface CustomDatePickerBatchProps {
/**
* Called instead of updating the URL directly.
* Receives the filter key ("inserted_at") and the formatted date string (YYYY-MM-DD).
*/
onBatchChange: (filterKey: string, value: string) => void;
/**
* Controlled date value from the parent (pending state).
* Expected format: YYYY-MM-DD (or any value parseable by `new Date()`).
*/
value: string | undefined;
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface CustomDatePickerInstantProps {
onBatchChange?: never;
value?: never;
}
type CustomDatePickerProps =
| CustomDatePickerBatchProps
| CustomDatePickerInstantProps;
const parseDate = (raw: string | null | undefined): Date | undefined => {
if (!raw) return undefined;
try {
// Use T00:00:00 suffix to avoid timezone offset shifting the date
return new Date(raw + "T00:00:00");
} catch {
return undefined;
}
};
export const CustomDatePicker = ({
onBatchChange,
value: valueProp,
}: CustomDatePickerProps = {}) => {
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const [open, setOpen] = useState(false);
const [date, setDate] = useState<Date | undefined>(() => {
const dateParam = searchParams.get("filter[inserted_at]");
if (!dateParam) return undefined;
try {
return new Date(dateParam);
} catch {
return undefined;
// In instant mode, we need local state to track the selected date so the
// calendar stays in sync when URL params change externally (e.g. Clear Filters).
// In batch mode, `valueProp` is the source of truth — derive date directly.
const [localDate, setLocalDate] = useState<Date | undefined>(() =>
parseDate(searchParams.get("filter[inserted_at]")),
);
// In batch mode: derive the displayed date from the controlled prop.
// In instant mode: keep local state in sync with URL changes.
useEffect(() => {
if (valueProp === undefined) {
// Instant mode: sync from URL (e.g., when Clear Filters is clicked)
setLocalDate(parseDate(searchParams.get("filter[inserted_at]")));
}
});
// Batch mode: date is derived from valueProp directly — no state update needed
}, [valueProp, searchParams]);
// In batch mode, derive date from controlled prop directly to avoid stale state
const date = valueProp !== undefined ? parseDate(valueProp) : localDate;
const applyDateFilter = (selectedDate: Date | undefined) => {
if (onBatchChange) {
// Batch mode: notify caller instead of updating URL
onBatchChange(
"inserted_at",
selectedDate ? format(selectedDate, "yyyy-MM-dd") : "",
);
return;
}
// Instant mode (default): push to URL immediately
if (selectedDate) {
// Format as YYYY-MM-DD for the API
updateFilter("inserted_at", format(selectedDate, "yyyy-MM-dd"));
@@ -38,22 +95,11 @@ export const CustomDatePicker = () => {
}
};
// Sync local state with URL params (e.g., when Clear Filters is clicked)
useEffect(() => {
const dateParam = searchParams.get("filter[inserted_at]");
if (!dateParam) {
setDate(undefined);
} else {
try {
setDate(new Date(dateParam));
} catch {
setDate(undefined);
}
}
}, [searchParams]);
const handleDateSelect = (newDate: Date | undefined) => {
setDate(newDate);
if (valueProp === undefined) {
// Instant mode: update local state
setLocalDate(newDate);
}
applyDateFilter(newDate);
setOpen(false);
};

View File

@@ -0,0 +1,272 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
// Mock lucide-react to avoid SVG rendering issues in jsdom
vi.mock("lucide-react", () => ({
X: () => <svg data-testid="x-icon" />,
}));
// Mock @/components/shadcn to avoid next-auth import chain
vi.mock("@/components/shadcn", () => ({
Badge: ({
children,
className,
variant,
}: {
children: React.ReactNode;
className?: string;
variant?: string;
}) => (
<span data-testid="badge" data-variant={variant} className={className}>
{children}
</span>
),
}));
vi.mock("@/lib/utils", () => ({
cn: (...classes: (string | undefined | false)[]) =>
classes.filter(Boolean).join(" "),
}));
import {
FilterChip,
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
// ── Future E2E coverage ────────────────────────────────────────────────────
// TODO (E2E): Full filter strip flow should be covered in Playwright tests:
// - Filter chips appear after staging selections in the findings page
// - Removing a chip via the X button un-stages that filter value
// - "Clear all" removes all staged filter chips at once
// - Chips disappear after applying filters (pending state resets to URL state)
// ──────────────────────────────────────────────────────────────────────────
const mockChips: FilterChip[] = [
{ key: "filter[severity__in]", label: "Severity", value: "critical" },
{ key: "filter[severity__in]", label: "Severity", value: "high" },
{ key: "filter[status__in]", label: "Status", value: "FAIL" },
];
describe("FilterSummaryStrip", () => {
// ── Empty state ──────────────────────────────────────────────────────────
describe("when chips array is empty", () => {
it("should not render anything", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
const { container } = render(
<FilterSummaryStrip
chips={[]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(container.firstChild).toBeNull();
});
});
// ── Chip rendering ───────────────────────────────────────────────────────
describe("when chips are provided", () => {
it("should render a chip for each filter value", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then — 3 chips should be visible (2 severity + 1 status)
expect(screen.getAllByTestId("badge")).toHaveLength(3);
});
it("should display the label and value text for each chip", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={[
{
key: "filter[severity__in]",
label: "Severity",
value: "critical",
},
]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(screen.getByText("Severity:")).toBeInTheDocument();
expect(screen.getByText("critical")).toBeInTheDocument();
});
it("should display displayValue when provided instead of value", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={[
{
key: "filter[status__in]",
label: "Status",
value: "FAIL",
displayValue: "Failed",
},
]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then — displayValue takes precedence
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.queryByText("FAIL")).not.toBeInTheDocument();
});
it("should render a 'Clear all' button", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Clear all" }),
).toBeInTheDocument();
});
it("should render an aria-label region for accessibility", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(
screen.getByRole("region", { name: "Active filters" }),
).toBeInTheDocument();
});
});
// ── onRemove interaction ─────────────────────────────────────────────────
describe("onRemove", () => {
it("should call onRemove with correct filterKey and value when X is clicked", async () => {
// Given
const user = userEvent.setup();
const onRemove = vi.fn();
const onClearAll = vi.fn();
render(
<FilterSummaryStrip
chips={[
{
key: "filter[severity__in]",
label: "Severity",
value: "critical",
},
]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// When
const removeButton = screen.getByRole("button", {
name: /Remove Severity filter: critical/i,
});
await user.click(removeButton);
// Then
expect(onRemove).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledWith("filter[severity__in]", "critical");
});
it("should call onRemove with the correct chip when there are multiple chips", async () => {
// Given
const user = userEvent.setup();
const onRemove = vi.fn();
const onClearAll = vi.fn();
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// When — click the X button for "high" severity
const removeHighButton = screen.getByRole("button", {
name: /Remove Severity filter: high/i,
});
await user.click(removeHighButton);
// Then
expect(onRemove).toHaveBeenCalledWith("filter[severity__in]", "high");
expect(onRemove).toHaveBeenCalledTimes(1);
});
});
// ── onClearAll interaction ───────────────────────────────────────────────
describe("onClearAll", () => {
it("should call onClearAll when 'Clear all' is clicked", async () => {
// Given
const user = userEvent.setup();
const onRemove = vi.fn();
const onClearAll = vi.fn();
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Clear all" }));
// Then
expect(onClearAll).toHaveBeenCalledTimes(1);
expect(onRemove).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,84 @@
"use client";
import { X } from "lucide-react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
export interface FilterChip {
/** The filter parameter key, e.g. "filter[severity__in]" */
key: string;
/** Human-readable label, e.g. "Severity" */
label: string;
/** The individual value within the filter, e.g. "critical" */
value: string;
/** Optional display text for the value (defaults to `value`) */
displayValue?: 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;
/** Called when the user clicks "Clear all" */
onClearAll: () => void;
/** Optional extra class names for the outer wrapper */
className?: string;
}
/**
* Renders a horizontal strip of removable filter chips summarising
* the current pending filter state.
*
* - Hidden when `chips` is empty.
* - Each chip carries its own X button to remove that single value.
* - A "Clear all" link removes everything at once.
* - Reusable: no Findings-specific logic, driven entirely by props.
*/
export const FilterSummaryStrip = ({
chips,
onRemove,
onClearAll,
className,
}: FilterSummaryStripProps) => {
if (chips.length === 0) return null;
return (
<div
className={cn("flex flex-wrap items-center gap-2 py-2", className)}
role="region"
aria-label="Active filters"
aria-live="polite"
>
{chips.map((chip) => (
<Badge
key={`${chip.key}-${chip.value}`}
variant="outline"
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>
))}
<button
type="button"
onClick={onClearAll}
className="text-text-neutral-secondary hover:text-text-neutral-primary text-xs underline-offset-2 hover:underline focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-none"
>
Clear all
</button>
</div>
);
};

View File

@@ -1,6 +1,8 @@
export * from "./apply-filters-button";
export * from "./clear-filters-button";
export * from "./custom-checkbox-muted-findings";
export * from "./custom-date-picker";
export * from "./custom-provider-inputs";
export * from "./data-filters";
export * from "./filter-controls";
export * from "./filter-summary-strip";

View File

@@ -5,24 +5,28 @@ 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 { 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 {
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 { useRelatedFilters } from "@/hooks";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
import { ProviderProps } from "@/types/providers";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, FINDING_STATUS_DISPLAY_NAMES, ScanEntity } from "@/types";
import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
interface FindingsFiltersProps {
/** Provider data for ProviderTypeSelector and AccountsSelector */
providers: ProviderProps[];
providerIds: string[];
providerDetails: { [id: string]: FilterEntity }[];
completedScans: ScanProps[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
uniqueRegions: string[];
@@ -32,10 +36,73 @@ interface FindingsFiltersProps {
uniqueGroups: string[];
}
/**
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
* Used to render chips in the FilterSummaryStrip.
* Typed as Record<FilterParam, string> so TypeScript enforces exhaustiveness — any
* addition to FilterParam will cause a compile error here if the label is missing.
*/
const FILTER_KEY_LABELS: Record<FilterParam, string> = {
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[severity__in]": "Severity",
"filter[status__in]": "Status",
"filter[delta__in]": "Delta",
"filter[region__in]": "Region",
"filter[service__in]": "Service",
"filter[resource_type__in]": "Resource Type",
"filter[category__in]": "Category",
"filter[resource_groups__in]": "Resource Group",
"filter[scan__in]": "Scan ID",
"filter[inserted_at]": "Date",
"filter[muted]": "Muted",
};
/**
* Formats a raw filter value into a human-readable display string.
* - Provider types: uses shared getProviderDisplayName utility
* - Severities: uses shared SEVERITY_DISPLAY_NAMES (e.g. "critical" → "Critical")
* - Status: uses shared FINDING_STATUS_DISPLAY_NAMES (e.g. "FAIL" → "Fail")
* - Categories: uses getCategoryLabel (handles IAM, EC2, IMDSv1, etc.)
* - Resource groups: uses getGroupLabel (underscore-delimited)
* - Date (filter[inserted_at]): returns the ISO date string as-is (YYYY-MM-DD)
* - Other values: uses formatLabel as a generic fallback (avoids naive capitalisation)
*/
const formatFilterValue = (filterKey: string, value: string): string => {
if (!value) return value;
if (filterKey === "filter[provider_type__in]") {
return getProviderDisplayName(value);
}
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[status__in]") {
return (
FINDING_STATUS_DISPLAY_NAMES[
value as keyof typeof FINDING_STATUS_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[category__in]") {
return getCategoryLabel(value);
}
if (filterKey === "filter[resource_groups__in]") {
return getGroupLabel(value);
}
// Date filter: preserve ISO date string (YYYY-MM-DD) — do not run through formatLabel
if (filterKey === "filter[inserted_at]") {
return value;
}
// Generic fallback: handles hyphen/underscore-delimited IDs with smart capitalisation
return formatLabel(value);
};
export const FindingsFilters = ({
providers,
providerIds,
providerDetails,
completedScanIds,
scanDetails,
uniqueRegions,
@@ -46,12 +113,17 @@ export const FindingsFilters = ({
}: FindingsFiltersProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const { availableScans } = useRelatedFilters({
providerIds,
providerDetails,
completedScanIds,
scanDetails,
enableScanRelation: true,
const {
pendingFilters,
setPending,
applyAll,
discardAll,
clearAll,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
});
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
@@ -92,7 +164,7 @@ export const FindingsFilters = ({
{
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: availableScans,
values: completedScanIds,
valueLabelMapping: scanDetails,
index: 7,
},
@@ -100,17 +172,72 @@ export const FindingsFilters = ({
const hasCustomFilters = customFilters.length > 0;
// Build FilterChip[] from pendingFilters — one chip per individual value, not per key.
// Skip filter[muted]="false" — it is the silent default and should not appear as a chip.
const filterChips: FilterChip[] = [];
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
values.forEach((value) => {
// Do not show a chip for the default muted=false state
if (key === "filter[muted]" && value === "false") return;
filterChips.push({
key,
label,
value,
displayValue: formatFilterValue(key, value),
});
});
});
// 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 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 =
pendingDateValues && pendingDateValues.length > 0
? pendingDateValues[0]
: undefined;
return (
<div className="flex flex-col">
{/* First row: Provider selectors + Muted checkbox + More Filters button + Clear Filters */}
{/* 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} />
<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} />
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
/>
</div>
<CustomCheckboxMutedFindings />
<CustomCheckboxMutedFindings
onBatchChange={(filterKey, value) => setPending(filterKey, [value])}
checked={mutedChecked}
/>
{hasCustomFilters && (
<Button
variant="outline"
@@ -123,16 +250,55 @@ export const FindingsFilters = ({
/>
</Button>
)}
<ClearFiltersButton showCount />
<ClearFiltersButton
showCount
onClear={clearAll}
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
}
/>
<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}
onClearAll={clearAll}
/>
{/* Expandable filters section */}
{hasCustomFilters && (
<ExpandableSection isExpanded={isExpanded}>
<DataTableFilterCustom
filters={customFilters}
prependElement={<CustomDatePicker />}
prependElement={
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
/>
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
</ExpandableSection>
)}

View File

@@ -0,0 +1,278 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FilterOption } from "@/types/filters";
// ── next/navigation mock ────────────────────────────────────────────────────
const mockPush = vi.fn();
const mockUpdateFilter = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
}));
// ── useUrlFilters mock — tracks whether updateFilter is called ───────────────
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({ updateFilter: mockUpdateFilter }),
}));
// ── context (optional dependency used by useUrlFilters) ────────────────────
vi.mock("@/contexts", () => ({
useFilterTransitionOptional: () => null,
}));
// ── MultiSelect mock — renders a simple <select> backed by onValuesChange ──
// This lets us trigger filter changes without needing the full Popover UI.
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({
children,
values,
onValuesChange,
}: {
children: React.ReactNode;
values?: string[];
onValuesChange?: (values: string[]) => void;
}) => (
<div data-testid="multiselect" data-values={JSON.stringify(values ?? [])}>
{children}
{/* expose a select to drive value changes in tests */}
<select
data-testid="multiselect-trigger"
multiple
defaultValue={values ?? []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions).map(
(o) => o.value,
);
onValuesChange?.(selected);
}}
>
<option value="critical">critical</option>
<option value="high">high</option>
<option value="FAIL">FAIL</option>
</select>
</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
MultiSelectSeparator: () => <hr />,
MultiSelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <option value={value}>{children}</option>,
}));
// ── ClearFiltersButton stub ─────────────────────────────────────────────────
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <button type="button">Clear</button>,
}));
// ── Other component stubs ───────────────────────────────────────────────────
vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info",
() => ({
ComplianceScanInfo: () => null,
}),
);
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: () => null,
}));
vi.mock("@/lib/helper-filters", () => ({
isScanEntity: () => false,
isConnectionStatus: () => false,
}));
import { DataTableFilterCustom } from "./data-table-filter-custom";
// ── Future E2E coverage ────────────────────────────────────────────────────
// TODO (E2E): Integration tests for DataTableFilterCustom in batch mode:
// - In batch mode, selecting filters does NOT navigate the browser immediately
// - Multiple filter selections accumulate in pending state
// - Pressing Apply sends a single router.push with all staged filters
// - Pressing Discard reverts staged selections to match the current URL
// ──────────────────────────────────────────────────────────────────────────
const severityFilter: FilterOption = {
key: "filter[severity__in]",
labelCheckboxGroup: "Severity",
values: ["critical", "high"],
};
describe("DataTableFilterCustom — batch vs instant mode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── Default / instant mode ───────────────────────────────────────────────
describe("instant mode (default)", () => {
it("should call updateFilter (URL update) when a selection changes", async () => {
// Given
const user = userEvent.setup();
render(<DataTableFilterCustom filters={[severityFilter]} />);
// When — simulate a value change on the mock select
const select = screen.getByTestId("multiselect-trigger");
await user.selectOptions(select, ["critical"]);
// Then — instant mode pushes to URL via updateFilter
expect(mockUpdateFilter).toHaveBeenCalledTimes(1);
expect(mockUpdateFilter).toHaveBeenCalledWith(
"filter[severity__in]",
expect.any(Array),
);
});
it("should NOT call onBatchChange in instant mode", async () => {
// Given
const user = userEvent.setup();
const onBatchChange = vi.fn();
render(
<DataTableFilterCustom
filters={[severityFilter]}
onBatchChange={onBatchChange}
// no mode prop → defaults to "instant"
/>,
);
// When
const select = screen.getByTestId("multiselect-trigger");
await user.selectOptions(select, ["critical"]);
// Then
expect(onBatchChange).not.toHaveBeenCalled();
});
it("should render without mode prop (backward compatibility)", () => {
// Given / When
render(<DataTableFilterCustom filters={[severityFilter]} />);
// Then — renders without crashing
expect(screen.getByText("Severity")).toBeInTheDocument();
});
});
// ── Batch mode ───────────────────────────────────────────────────────────
describe("batch mode", () => {
it("should call onBatchChange instead of updateFilter when selection changes", async () => {
// Given
const user = userEvent.setup();
const onBatchChange = vi.fn();
const getFilterValue = vi.fn().mockReturnValue([]);
render(
<DataTableFilterCustom
filters={[severityFilter]}
mode="batch"
onBatchChange={onBatchChange}
getFilterValue={getFilterValue}
/>,
);
// When
const select = screen.getByTestId("multiselect-trigger");
await user.selectOptions(select, ["critical"]);
// Then — batch mode notifies caller instead of URL
expect(onBatchChange).toHaveBeenCalledTimes(1);
expect(onBatchChange).toHaveBeenCalledWith(
"filter[severity__in]",
expect.any(Array),
);
expect(mockUpdateFilter).not.toHaveBeenCalled();
});
it("should read selected values from getFilterValue in batch mode", () => {
// Given — batch mode with pre-seeded pending state
const onBatchChange = vi.fn();
const getFilterValue = vi
.fn()
.mockImplementation((key: string) =>
key === "filter[severity__in]" ? ["critical"] : [],
);
render(
<DataTableFilterCustom
filters={[severityFilter]}
mode="batch"
onBatchChange={onBatchChange}
getFilterValue={getFilterValue}
/>,
);
// Then — the mock multiselect receives the pending values
const multiselect = screen.getByTestId("multiselect");
expect(multiselect).toHaveAttribute(
"data-values",
JSON.stringify(["critical"]),
);
// getFilterValue must have been called for the filter key
expect(getFilterValue).toHaveBeenCalledWith("filter[severity__in]");
});
it("should pass empty array to MultiSelect when getFilterValue returns empty", () => {
// Given
const getFilterValue = vi.fn().mockReturnValue([]);
render(
<DataTableFilterCustom
filters={[severityFilter]}
mode="batch"
onBatchChange={vi.fn()}
getFilterValue={getFilterValue}
/>,
);
// Then — multiselect gets empty values
const multiselect = screen.getByTestId("multiselect");
expect(multiselect).toHaveAttribute("data-values", JSON.stringify([]));
});
});
// ── hideClearButton ──────────────────────────────────────────────────────
describe("hideClearButton prop", () => {
it("should hide the ClearFiltersButton when hideClearButton is true", () => {
// Given / When
render(
<DataTableFilterCustom
filters={[severityFilter]}
hideClearButton={true}
/>,
);
// Then
expect(
screen.queryByRole("button", { name: "Clear" }),
).not.toBeInTheDocument();
});
it("should show the ClearFiltersButton by default", () => {
// Given / When
render(<DataTableFilterCustom filters={[severityFilter]} />);
// Then
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
});
});

View File

@@ -22,6 +22,7 @@ import {
ProviderEntity,
ScanEntity,
} from "@/types";
import { DATA_TABLE_FILTER_MODE, DataTableFilterMode } from "@/types/filters";
import { ProviderConnectionStatus } from "@/types/providers";
export interface DataTableFilterCustomProps {
@@ -30,12 +31,33 @@ export interface DataTableFilterCustomProps {
prependElement?: React.ReactNode;
/** Hide the clear filters button and active badges (useful when parent manages this) */
hideClearButton?: boolean;
/**
* Controls when filter selections are pushed to the URL.
* - "instant" (default): each selection immediately updates the URL (legacy behavior, backward-compatible).
* - "batch": selections accumulate in pending state; caller manages when to push URL.
*/
mode?: DataTableFilterMode;
/**
* Called in "batch" mode when a filter value changes.
* The key is the raw filter key (e.g. "filter[severity__in]" or "severity__in").
* Only invoked when mode === "batch".
*/
onBatchChange?: (filterKey: string, values: string[]) => void;
/**
* Returns the current selected values for a filter in "batch" mode.
* Replaces reading from URL searchParams when mode === "batch".
* Only used when mode === "batch".
*/
getFilterValue?: (filterKey: string) => string[];
}
export const DataTableFilterCustom = ({
filters,
prependElement,
hideClearButton = false,
mode = DATA_TABLE_FILTER_MODE.INSTANT,
onBatchChange,
getFilterValue,
}: DataTableFilterCustomProps) => {
const { updateFilter } = useUrlFilters();
const searchParams = useSearchParams();
@@ -109,6 +131,13 @@ export const DataTableFilterCustom = ({
};
const pushDropdownFilter = (filter: FilterOption, values: string[]) => {
if (mode === DATA_TABLE_FILTER_MODE.BATCH && onBatchChange) {
// In batch mode, notify the caller instead of updating the URL
onBatchChange(filter.key, values);
return;
}
// Instant mode (default): push to URL immediately
// If this filter defaults to "all selected" and the user selected all items,
// clear the URL param to represent "no specific filter" (i.e., all).
const allSelected =
@@ -123,6 +152,12 @@ export const DataTableFilterCustom = ({
};
const getSelectedValues = (filter: FilterOption): string[] => {
if (mode === DATA_TABLE_FILTER_MODE.BATCH && getFilterValue) {
// In batch mode, read from pending state provided by the caller
return getFilterValue(filter.key);
}
// Instant mode (default): read from URL searchParams
const filterKey = filter.key.startsWith("filter[")
? filter.key
: `filter[${filter.key}]`;

View File

@@ -0,0 +1,588 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// --- Mock next/navigation ---
const mockPush = vi.fn();
let mockSearchParamsValue = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => "/findings",
useSearchParams: () => mockSearchParamsValue,
}));
import { useFilterBatch } from "./use-filter-batch";
/**
* Helper to re-assign the mocked searchParams and re-import the hook.
* Because useSearchParams() is called inside the hook on every render,
* we just update the module-level variable and force a re-render.
*/
function setSearchParams(params: Record<string, string>) {
mockSearchParamsValue = new URLSearchParams(params);
}
describe("useFilterBatch", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParamsValue = new URLSearchParams();
});
// ── Initial state ──────────────────────────────────────────────────────────
describe("initial state", () => {
it("should have empty pending filters when there are no URL params", () => {
// Given
setSearchParams({});
// When
const { result } = renderHook(() => useFilterBatch());
// Then
expect(result.current.pendingFilters).toEqual({});
expect(result.current.hasChanges).toBe(false);
expect(result.current.changeCount).toBe(0);
});
it("should initialize pending filters from URL search params on mount", () => {
// Given
setSearchParams({
"filter[severity__in]": "critical,high",
"filter[status__in]": "FAIL",
});
// When
const { result } = renderHook(() => useFilterBatch());
// Then
expect(result.current.pendingFilters).toEqual({
"filter[severity__in]": ["critical", "high"],
"filter[status__in]": ["FAIL"],
});
expect(result.current.hasChanges).toBe(false);
});
});
// ── Excluded keys ──────────────────────────────────────────────────────────
describe("excluded keys", () => {
it("should exclude filter[search] from batch operations", () => {
// Given — search is excluded from batch; muted now participates in batch
setSearchParams({
"filter[search]": "some-search-term",
"filter[muted]": "false",
"filter[severity__in]": "critical",
});
// When
const { result } = renderHook(() => useFilterBatch());
// Then — severity and muted are in pendingFilters; search is excluded
expect(result.current.pendingFilters).toEqual({
"filter[muted]": ["false"],
"filter[severity__in]": ["critical"],
});
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
// muted is now part of batch (not excluded)
expect(result.current.pendingFilters["filter[muted]"]).toEqual(["false"]);
});
});
// ── setPending ─────────────────────────────────────────────────────────────
describe("setPending", () => {
it("should update pending state for a given key", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// Then
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
"high",
]);
});
it("should auto-prefix key with filter[] when not already prefixed", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When
act(() => {
result.current.setPending("severity__in", ["critical"]);
});
// Then — key is stored with filter[] prefix
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
]);
});
it("should keep the key but with empty array when values is empty", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition: set a value first
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
// When — clear the filter by passing empty array
act(() => {
result.current.setPending("filter[severity__in]", []);
});
// Then
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
});
});
// ── getFilterValue ─────────────────────────────────────────────────────────
describe("getFilterValue", () => {
it("should return pending values for a key that has been set", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// When
const values = result.current.getFilterValue("filter[severity__in]");
// Then
expect(values).toEqual(["critical", "high"]);
});
it("should return an empty array for a key that has not been set", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When
const values = result.current.getFilterValue("filter[unknown_key]");
// Then
expect(values).toEqual([]);
});
it("should auto-prefix key when calling getFilterValue without filter[]", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
// When — key without prefix
const values = result.current.getFilterValue("severity__in");
// Then
expect(values).toEqual(["critical"]);
});
});
// ── hasChanges & changeCount ───────────────────────────────────────────────
describe("hasChanges", () => {
it("should be false when pending matches the URL state", () => {
// Given
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// Then — initial state = URL state, so no changes
expect(result.current.hasChanges).toBe(false);
});
it("should be true when pending differs from the URL state", () => {
// Given
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// When — change pending
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// Then
expect(result.current.hasChanges).toBe(true);
});
});
describe("changeCount", () => {
it("should be 0 when pending matches URL", () => {
// Given
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// Then
expect(result.current.changeCount).toBe(0);
});
it("should count the number of changed filter keys", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When — add two different pending filters
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// Then — 2 keys differ from URL (which has neither)
expect(result.current.changeCount).toBe(2);
});
it("should decrease changeCount when a pending filter is reset to match URL", () => {
// Given — URL has severity=critical, pending adds status=FAIL
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[status__in]", ["FAIL"]);
});
expect(result.current.changeCount).toBe(1);
// When — reset status back to empty (matching URL which has no status)
act(() => {
result.current.setPending("filter[status__in]", []);
});
// Then
expect(result.current.changeCount).toBe(0);
});
});
// ── applyAll ───────────────────────────────────────────────────────────────
describe("applyAll", () => {
it("should call router.push with all pending filters serialized as URL params", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// When
act(() => {
result.current.applyAll();
});
// Then
expect(mockPush).toHaveBeenCalledTimes(1);
const calledUrl: string = mockPush.mock.calls[0][0];
expect(calledUrl).toContain("filter%5Bseverity__in%5D=critical%2Chigh");
});
it("should reset page number when a page param exists in the URL", () => {
// Given — simulate a URL that already has page=3
mockSearchParamsValue = new URLSearchParams({
"filter[severity__in]": "critical",
page: "3",
});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// When
act(() => {
result.current.applyAll();
});
// Then — page should be reset to 1
const calledUrl: string = mockPush.mock.calls[0][0];
expect(calledUrl).toContain("page=1");
});
it("should preserve excluded params (filter[search], filter[muted]) in the URL", () => {
// Given
mockSearchParamsValue = new URLSearchParams({
"filter[search]": "my-search",
"filter[muted]": "false",
});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
// When
act(() => {
result.current.applyAll();
});
// Then — search and muted should still be present
const calledUrl: string = mockPush.mock.calls[0][0];
expect(calledUrl).toContain("filter%5Bsearch%5D=my-search");
expect(calledUrl).toContain("filter%5Bmuted%5D=false");
});
});
// ── clearAll ───────────────────────────────────────────────────────────────
describe("clearAll", () => {
it("should clear all pending filters including provider and account keys", () => {
// Given — user has pending provider, account, severity, and status filters
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[provider_type__in]", [
"aws",
"azure",
]);
result.current.setPending("filter[provider_id__in]", [
"provider-uuid-1",
]);
result.current.setPending("filter[severity__in]", ["critical"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// Pre-condition — all filters are pending
expect(
result.current.pendingFilters["filter[provider_type__in]"],
).toEqual(["aws", "azure"]);
expect(result.current.pendingFilters["filter[provider_id__in]"]).toEqual([
"provider-uuid-1",
]);
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
]);
// When
act(() => {
result.current.clearAll();
});
// Then — pending state must be TRULY EMPTY (no keys at all, not even with empty arrays)
expect(result.current.pendingFilters).toEqual({});
// getFilterValue normalises missing keys to [] so all selectors show "all selected"
expect(
result.current.getFilterValue("filter[provider_type__in]"),
).toEqual([]);
expect(result.current.getFilterValue("filter[provider_id__in]")).toEqual(
[],
);
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
expect(result.current.getFilterValue("filter[status__in]")).toEqual([]);
});
it("should also clear provider/account keys that came from the URL (applied state)", () => {
// Given — URL has provider and account filters applied
setSearchParams({
"filter[provider_type__in]": "aws",
"filter[provider_id__in]": "provider-uuid-1",
"filter[severity__in]": "critical",
});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition — filters are loaded from URL into pending
expect(
result.current.pendingFilters["filter[provider_type__in]"],
).toEqual(["aws"]);
expect(result.current.pendingFilters["filter[provider_id__in]"]).toEqual([
"provider-uuid-1",
]);
// When
act(() => {
result.current.clearAll();
});
// Then — pending state must be truly empty (no keys, not { key: [] })
expect(result.current.pendingFilters).toEqual({});
// provider and account must be cleared even though they came from the URL
expect(
result.current.getFilterValue("filter[provider_type__in]"),
).toEqual([]);
expect(result.current.getFilterValue("filter[provider_id__in]")).toEqual(
[],
);
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
});
it("should mark hasChanges as true after clear when URL still has applied filters", () => {
// Given — URL has filters applied
setSearchParams({
"filter[provider_type__in]": "aws",
"filter[severity__in]": "critical",
});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition — no pending changes (matches URL)
expect(result.current.hasChanges).toBe(false);
// When — clear all
act(() => {
result.current.clearAll();
});
// Then — hasChanges must be true (pending is empty, URL still has filters)
expect(result.current.hasChanges).toBe(true);
});
it("should NOT clear excluded keys (filter[search]) but DOES clear filter[muted]", () => {
// Given — URL has search (excluded) plus muted and severity (both in batch)
setSearchParams({
"filter[search]": "my-search",
"filter[muted]": "false",
"filter[severity__in]": "critical",
});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition — muted and severity are in pendingFilters; search is excluded
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
expect(result.current.pendingFilters["filter[muted]"]).toEqual(["false"]);
// When
act(() => {
result.current.clearAll();
});
// Then — severity and muted are cleared; search remains excluded (undefined in pending)
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
// muted is a batch key, so it gets cleared by clearAll
expect(result.current.pendingFilters["filter[muted]"]).toBeUndefined();
});
it("should clear applied URL filters even if they were explicitly removed from pendingFilters", () => {
// This covers the edge case where pendingFilters diverged from URL state
// (e.g., URL has provider filter but the key was removed from pending via removePending)
setSearchParams({
"filter[provider_type__in]": "gcp",
"filter[severity__in]": "high",
});
const { result } = renderHook(() => useFilterBatch());
// Remove the provider key from pending (diverge from URL state)
act(() => {
result.current.removePending("filter[provider_type__in]");
});
// Pre-condition — provider is gone from pending but still in URL
expect(
result.current.pendingFilters["filter[provider_type__in]"],
).toBeUndefined();
// When — clearAll should clear BOTH pending keys AND applied URL keys
act(() => {
result.current.clearAll();
});
// Then — severity is cleared
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
// provider_type__in was in the URL (applied state), so clearAll must handle it
expect(
result.current.getFilterValue("filter[provider_type__in]"),
).toEqual([]);
});
});
// ── discardAll ─────────────────────────────────────────────────────────────
describe("discardAll", () => {
it("should reset pending to match the current URL state", () => {
// Given — URL has severity=critical
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// Add a pending change
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
expect(result.current.hasChanges).toBe(true);
// When
act(() => {
result.current.discardAll();
});
// Then — pending should match URL again
expect(result.current.pendingFilters).toEqual({
"filter[severity__in]": ["critical"],
});
expect(result.current.hasChanges).toBe(false);
});
});
// ── URL sync (back/forward) ────────────────────────────────────────────────
describe("URL sync", () => {
it("should re-sync pending state when searchParams change (e.g., browser back/forward)", () => {
// Given — initial empty URL
setSearchParams({});
const { result, rerender } = renderHook(() => useFilterBatch());
// Add a pending change
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
]);
// When — simulate browser back by changing searchParams externally
act(() => {
mockSearchParamsValue = new URLSearchParams({
"filter[severity__in]": "high",
});
});
rerender();
// Then — pending should re-sync from new URL
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"high",
]);
});
});
// ── removePending ──────────────────────────────────────────────────────────
describe("removePending", () => {
it("should remove a single filter key from pending state", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// When
act(() => {
result.current.removePending("filter[severity__in]");
});
// Then
expect(
result.current.pendingFilters["filter[severity__in]"],
).toBeUndefined();
expect(result.current.pendingFilters["filter[status__in]"]).toEqual([
"FAIL",
]);
});
});
});

View File

@@ -0,0 +1,257 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
// Filters that are managed by the batch hook (excludes system defaults)
const EXCLUDED_FROM_BATCH = ["filter[search]"];
/**
* Snapshot of pending (un-applied) filter state.
* Keys are raw filter param names, e.g. "filter[severity__in]".
* Values are arrays of selected option strings.
*/
export interface PendingFilters {
[filterKey: string]: string[];
}
export interface UseFilterBatchReturn {
/** Current pending filter values — local state, not yet in URL */
pendingFilters: 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 */
applyAll: () => void;
/** Discard all pending changes, reset pending to the current URL state */
discardAll: () => void;
/**
* Clear all pending filters to an empty state (no filters selected).
* Unlike `discardAll`, this does NOT reset to the URL state — it sets
* pending to `{}` (truly empty). The user must click Apply to push
* the empty state to the URL.
* Includes provider/account keys and all batch-managed filter keys.
*/
clearAll: () => void;
/** Remove a single filter key from pending state */
removePending: (key: string) => void;
/** Whether pending state differs from the current URL */
hasChanges: boolean;
/** Number of filter keys that differ from the URL */
changeCount: number;
/** Get current value for a filter (pending if set, else from URL) */
getFilterValue: (key: string) => string[];
}
/**
* Derives the applied (URL-backed) filter state from `searchParams`.
* Returns only the filter keys that are not excluded from batch management.
*/
function deriveAppliedFromUrl(searchParams: URLSearchParams): PendingFilters {
const applied: PendingFilters = {};
Array.from(searchParams.entries()).forEach(([key, value]) => {
if (!key.startsWith("filter[")) return;
if (EXCLUDED_FROM_BATCH.includes(key)) return;
if (!value) return;
applied[key] = value.split(",").filter(Boolean);
});
return applied;
}
/**
* Compares two PendingFilters objects for shallow equality.
* Two states are equal when they contain the same keys and the same sorted values.
*/
function areFiltersEqual(a: PendingFilters, b: PendingFilters): boolean {
const keysA = Object.keys(a).filter((k) => a[k].length > 0);
const keysB = Object.keys(b).filter((k) => b[k].length > 0);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => {
if (!b[key]) return false;
const sortedA = [...a[key]].sort();
const sortedB = [...b[key]].sort();
if (sortedA.length !== sortedB.length) return false;
return sortedA.every((v, i) => v === sortedB[i]);
});
}
/**
* Counts the number of filter keys that differ between pending and applied.
*/
function countChanges(
pending: PendingFilters,
applied: PendingFilters,
): number {
const pendingKeys = Object.keys(pending).filter((k) => pending[k].length > 0);
const appliedKeys = Object.keys(applied).filter((k) => applied[k].length > 0);
// Merge all unique keys without Set iteration
const allKeys = Array.from(new Set([...pendingKeys, ...appliedKeys]));
let count = 0;
allKeys.forEach((key) => {
const p = pending[key] ?? [];
const a = applied[key] ?? [];
const sortedP = [...p].sort();
const sortedA = [...a].sort();
if (
sortedP.length !== sortedA.length ||
!sortedP.every((v, i) => v === sortedA[i])
) {
count++;
}
});
return count;
}
export interface UseFilterBatchOptions {
/**
* Default URL params to apply when applyAll() is called and they are not
* already present in the params. Useful for page-level filter defaults
* (e.g. `{ "filter[muted]": "false" }` on the Findings page).
*/
defaultParams?: Record<string, string>;
}
/**
* Manages a two-state (pending → applied) filter model for the Findings view.
*
* - Pending state lives only in this hook (React `useState`).
* - Applied state is owned by the URL (`searchParams`).
* - `applyAll()` performs a single `router.push()` with the full pending state.
* - `discardAll()` resets pending to match the current URL.
* - Browser back/forward automatically re-syncs pending state from the new URL.
*/
export const useFilterBatch = (
options?: UseFilterBatchOptions,
): UseFilterBatchReturn => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pendingFilters, setPendingFilters] = useState<PendingFilters>(() =>
deriveAppliedFromUrl(new URLSearchParams(searchParams.toString())),
);
// Sync pending state whenever the URL changes (back/forward nav or external update).
// `searchParams` from useSearchParams() is stable between renders in Next.js App Router.
useEffect(() => {
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
setPendingFilters(applied);
}, [searchParams]);
const setPending = (key: string, values: string[]) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
setPendingFilters((prev) => ({
...prev,
[filterKey]: values,
}));
};
const removePending = (key: string) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
setPendingFilters((prev) => {
const next = { ...prev };
delete next[filterKey];
return next;
});
};
const applyAll = () => {
// Start from the current URL params to preserve non-batch params.
// Only filter[search] is excluded from batch management and preserved from the URL as-is.
const params = new URLSearchParams(searchParams.toString());
// Remove all existing batch-managed filter params
Array.from(params.keys()).forEach((key) => {
if (key.startsWith("filter[") && !EXCLUDED_FROM_BATCH.includes(key)) {
params.delete(key);
}
});
// Write the pending state
Object.entries(pendingFilters).forEach(([key, values]) => {
const nonEmpty = values.filter(Boolean);
if (nonEmpty.length > 0) {
params.set(key, nonEmpty.join(","));
}
});
// Apply caller-supplied defaults for any params not already set
if (options?.defaultParams) {
Object.entries(options.defaultParams).forEach(([key, value]) => {
if (!params.has(key)) {
params.set(key, value);
}
});
}
// Reset pagination
if (params.has("page")) {
params.set("page", "1");
}
const queryString = params.toString();
const targetUrl = queryString ? `${pathname}?${queryString}` : pathname;
router.push(targetUrl, { scroll: false });
};
const discardAll = () => {
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
setPendingFilters(applied);
};
/**
* Clears ALL pending batch filters to an empty state (no filters selected).
*
* Unlike `discardAll`, this resets pending to `{}` — not to the current URL
* state. This covers both:
* - Keys that are already in `pendingFilters` (pending-only or URL-loaded)
* - Keys that are in the applied (URL) state but were removed from pending
* via `removePending` (edge case: diverged state)
*
* The user must click Apply to push the empty state to the URL.
* `applyAll()` removes all batch-managed URL params first, so even keys
* absent from `pendingFilters` will be removed from the URL on apply.
*/
const clearAll = () => {
// Return a truly empty object — no filters pending at all.
// `getFilterValue` normalises missing keys to [] so selectors will show
// their "all selected" / placeholder state immediately.
setPendingFilters({});
};
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;
return {
pendingFilters,
setPending,
applyAll,
discardAll,
clearAll,
removePending,
hasChanges,
changeCount,
getFilterValue,
};
};

View File

@@ -95,6 +95,16 @@ export const FINDING_STATUS = {
export type FindingStatus =
(typeof FINDING_STATUS)[keyof typeof FINDING_STATUS];
/**
* Maps raw finding status values to human-readable display strings.
* Follows the same pattern as SEVERITY_DISPLAY_NAMES in types/severities.ts.
*/
export const FINDING_STATUS_DISPLAY_NAMES: Record<FindingStatus, string> = {
PASS: "Pass",
FAIL: "Fail",
MANUAL: "Manual",
};
export const SEVERITY = {
INFORMATIONAL: "informational",
LOW: "low",

View File

@@ -42,3 +42,36 @@ export enum FilterType {
CATEGORY = "category__in",
RESOURCE_GROUPS = "resource_groups__in",
}
/**
* Controls the filter dispatch behavior of DataTableFilterCustom.
* - "instant": every selection immediately updates the URL (legacy/default behavior)
* - "batch": selections accumulate in pending state; URL only updates on explicit apply
*/
export const DATA_TABLE_FILTER_MODE = {
INSTANT: "instant",
BATCH: "batch",
} as const;
export type DataTableFilterMode =
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
/**
* Exhaustive union of all URL filter param keys used in Findings filters.
* Use this instead of `string` to ensure FILTER_KEY_LABELS and other
* param-keyed records stay in sync with the actual filter surface.
*/
export type FilterParam =
| "filter[provider_type__in]"
| "filter[provider_id__in]"
| "filter[severity__in]"
| "filter[status__in]"
| "filter[delta__in]"
| "filter[region__in]"
| "filter[service__in]"
| "filter[resource_type__in]"
| "filter[category__in]"
| "filter[resource_groups__in]"
| "filter[scan__in]"
| "filter[inserted_at]"
| "filter[muted]";