fix(ui): align resources filters and drawer behavior

- move resources filters to the shared batch apply flow
- render resource metadata as highlighted JSON code
- keep drawer finding helper links interactive
This commit is contained in:
alejandrobailo committed 2026-04-22 17:49:17 +02:00
1 parent aa5b48e03d
commit 611e93e985
9 files changed
+405 -69

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters
- Resources now use batch-applied filters, render metadata JSON with syntax highlighting, simplify metadata/tags tabs, and keep finding-row Learn more links interactive inside the drawer
### ❌ Removed
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("notification indicator", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "notification-indicator.tsx");
const source = readFileSync(filePath, "utf8");
it("uses a popover for delta learn-more content so the link stays interactive", () => {
expect(source).toContain("<Popover");
expect(source).toContain("Learn more");
expect(source).not.toContain("<Tooltip>");
});
});
@@ -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";
@@ -67,12 +62,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(
@@ -82,9 +82,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
@@ -107,8 +113,8 @@ function DeltaIndicator({
</a>
</Button>
</div>
</TooltipContent>
</Tooltip>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
const mockSetPending = vi.fn();
const mockApplyAll = vi.fn();
const mockDiscardAll = vi.fn();
const mockClearAndApply = vi.fn();
const mockGetFilterValue = vi.fn().mockReturnValue([]);
vi.mock("@/hooks/use-filter-batch", () => ({
useFilterBatch: () => ({
pendingFilters: {
"filter[region__in]": ["eu-west-1"],
},
setPending: mockSetPending,
applyAll: mockApplyAll,
discardAll: mockDiscardAll,
clearAndApply: mockClearAndApply,
hasChanges: true,
changeCount: 1,
getFilterValue: mockGetFilterValue,
}),
}));
vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
ProviderTypeSelector: () => <div>Provider type selector</div>,
}));
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
AccountsSelector: () => <div>Accounts selector</div>,
}));
vi.mock("@/components/filters/apply-filters-button", () => ({
ApplyFiltersButton: ({
hasChanges,
changeCount,
}: {
hasChanges: boolean;
changeCount: number;
}) => (
<div data-testid="apply-filters-button">
{String(hasChanges)}:{changeCount}
</div>
),
}));
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: ({
pendingCount,
}: {
pendingCount?: number;
}) => <div data-testid="clear-filters-button">{pendingCount ?? 0}</div>,
}));
vi.mock("@/components/filters/filter-summary-strip", () => ({
FilterSummaryStrip: ({
chips,
}: {
chips: Array<{ displayValue?: string; value: string }>;
}) => (
<div data-testid="filter-summary-strip">
{chips.map((chip) => chip.displayValue ?? chip.value).join(",")}
</div>
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
}));
vi.mock("@/components/ui/expandable-section", () => ({
ExpandableSection: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/components/ui/table", () => ({
DataTableFilterCustom: ({
mode,
}: {
mode?: string;
}) => <div data-testid="data-table-filter-custom">{mode}</div>,
}));
import { ResourcesFilters } from "./resources-filters";
describe("ResourcesFilters", () => {
it("uses batch mode controls and renders pending summary chips", () => {
render(
<ResourcesFilters
providers={[]}
uniqueRegions={["eu-west-1"]}
uniqueServices={["ec2"]}
uniqueResourceTypes={["aws_instance"]}
uniqueGroups={["engineering_team"]}
/>,
);
expect(screen.getByTestId("data-table-filter-custom")).toHaveTextContent(
DATA_TABLE_FILTER_MODE.BATCH,
);
expect(screen.getByTestId("apply-filters-button")).toHaveTextContent(
"true:1",
);
expect(screen.getByTestId("clear-filters-button")).toHaveTextContent("1");
expect(screen.getByTestId("filter-summary-strip")).toHaveTextContent(
"eu-west-1",
);
});
});
+75 -5
View File
@@ -5,13 +5,25 @@ 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 {
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[];
@@ -28,6 +40,16 @@ export const ResourcesFilters = ({
uniqueGroups,
}: ResourcesFiltersProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const {
pendingFilters,
setPending,
applyAll,
discardAll,
clearAndApply,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch();
// Custom filters for the expandable section
const customFilters = [
@@ -59,16 +81,35 @@ export const ResourcesFilters = ({
];
const hasCustomFilters = customFilters.length > 0;
const filterChips: FilterChip[] = buildResourcesFilterChips(
pendingFilters,
providers,
);
const handleChipRemove = (filterKey: string, value: string) => {
const currentValues = pendingFilters[filterKey] ?? [];
const nextValues = currentValues.filter((item) => item !== value);
setPending(filterKey, nextValues);
};
return (
<div className="flex flex-col">
{/* First row: Provider selectors + More Filters button + Clear Filters */}
{/* First row: Provider selectors + 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>
{hasCustomFilters && (
<Button
@@ -82,13 +123,42 @@ export const ResourcesFilters = ({
/>
</Button>
)}
<ClearFiltersButton showCount />
<ClearFiltersButton
showCount
onClear={clearAndApply}
pendingCount={
Object.values(pendingFilters).filter((values) => values.length > 0)
.length
}
/>
<ApplyFiltersButton
hasChanges={hasChanges}
changeCount={changeCount}
onApply={applyAll}
onDiscard={discardAll}
/>
</div>
<FilterSummaryStrip chips={filterChips} onRemove={handleChipRemove} />
{/* Expandable filters section */}
{hasCustomFilters && (
<ExpandableSection isExpanded={isExpanded}>
<DataTableFilterCustom filters={customFilters} hideClearButton />
<DataTableFilterCustom
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>
)}
</div>
@@ -0,0 +1,75 @@
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;
values.forEach((value) => {
chips.push({
key,
label,
value,
displayValue: getResourcesFilterDisplayValue(key, value, providers),
});
});
});
return chips;
}
@@ -26,4 +26,15 @@ describe("resource detail content", () => {
expect(source).not.toContain("useEffect");
expect(source).not.toContain("useEffect(");
});
it("renders resource metadata with the shared code editor JSON highlighting instead of a plain pre block", () => {
expect(source).toContain("QueryCodeEditor");
expect(source).toContain("QUERY_EDITOR_LANGUAGE.JSON");
expect(source).not.toContain("<pre");
});
it("refreshes findings locally after mute without forcing a router refresh", () => {
expect(source).toContain("setFindingsReloadNonce");
expect(source).not.toContain("router.refresh()");
});
});
@@ -2,14 +2,11 @@
import { Row, RowSelectionState } from "@tanstack/react-table";
import {
Check,
Container,
Copy,
CornerDownRight,
ExternalLink,
Link,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
@@ -29,6 +26,10 @@ import {
InfoTooltip,
} from "@/components/shadcn/info-field/info-field";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
QUERY_EDITOR_LANGUAGE,
QueryCodeEditor,
} from "@/components/shared/query-code-editor";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
@@ -115,11 +116,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 +154,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 +170,6 @@ export const ResourceDetailContent = ({
setRowSelection({});
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
router.refresh();
};
const failedFindings = findingsData;
@@ -452,30 +444,16 @@ 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"
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 +465,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.
@@ -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",
@@ -1171,6 +1240,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) {