mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
fix(ui): align resources filters and resource drawer behavior (#10861)
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
+26
@@ -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", () => ({
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+21
-11
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user