Compare commits

...
Author SHA1 Message Date
Alejandro Bailo d09ab9f220 fix(ui): polish shared table pagination and provider spacing (#10862) 2026-04-24 15:15:26 +02:00
Alejandro Bailo bec79820f6 Merge branch 'fix/resources-filters-and-drawer-polish' into fix/findings-group-mute-refresh-and-resources-heading 2026-04-23 16:07:02 +02:00
Alejandro Bailo 0ba8767a05 Merge branch 'master' into fix/resources-filters-and-drawer-polish 2026-04-23 16:05:52 +02:00
Alejandro Bailo 9a2b32268d Merge branch 'fix/resources-filters-and-drawer-polish' into fix/findings-group-mute-refresh-and-resources-heading 2026-04-23 14:25:49 +02:00
Alejandro Bailo 9119598a67 Merge branch 'chore/unify-filter-search-and-batch-patterns' into fix/resources-filters-and-drawer-polish 2026-04-23 12:50:27 +02:00
Alejandro Bailo 5381ceb1f9 Merge branch 'master' into chore/unify-filter-search-and-batch-patterns 2026-04-23 12:49:16 +02:00
alejandrobailo 10820274eb docs(ui): add PR link to findings grouped mute refresh changelog entry 2026-04-22 18:12:49 +02:00
alejandrobailo ebba304a83 docs(ui): add PR link to resources filters changelog entry 2026-04-22 18:12:18 +02:00
alejandrobailo 2411c1851c style(ui): reorder imports in shadcn command component 2026-04-22 18:09:27 +02:00
alejandrobailo c3db09f60e docs(ui): add PR link to unify filter search changelog entry 2026-04-22 18:09:24 +02:00
alejandrobailo c7b288101c fix(ui): refresh grouped findings after mute
- reload grouped findings data without a page refresh
- preserve expanded group context when rows still exist
- label expanded resource rows more clearly
2026-04-22 17:52:51 +02:00
alejandrobailo 611e93e985 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
2026-04-22 17:49:17 +02:00
alejandrobailo aa5b48e03d chore(ui): unify filter search patterns
- enable searchable shared filter dropdowns
- scroll to the first visible match while filtering
- cover provider and table filters with tests
2026-04-22 17:43:51 +02:00
19 changed files with 681 additions and 116 deletions
+6 -1
View File
@@ -14,14 +14,19 @@ 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 [(#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)
- Table pagination controls now keep their arrows visible on hover in light theme, and more UI improvements [(#10862)](https://github.com/prowler-cloud/prowler/pull/10862)
---
## [1.24.4] (Prowler UNRELEASED)
## [1.24.4] (Prowler 5.24.4)
### 🐞 Fixed
- Provider wizard no longer advances to the Launch Scan step when rotating credentials [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851)
- Attack Paths scan selector now lists scans from every provider with working pagination, instead of capping the list at the first ten [(#10864)](https://github.com/prowler-cloud/prowler/pull/10864)
---
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("findings group table", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "findings-group-table.tsx");
const source = readFileSync(filePath, "utf8");
it("refreshes grouped findings locally after mute instead of forcing a router refresh", () => {
expect(source).toContain("refreshFindingGroups");
expect(source).toContain("adaptFindingGroupsResponse");
expect(source).toContain("getLatestFindingGroups");
expect(source).not.toContain("router.refresh()");
});
});
@@ -1,9 +1,14 @@
"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import {
adaptFindingGroupsResponse,
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { DataTable } from "@/components/ui/table";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
@@ -44,8 +49,12 @@ export function FindingsGroupTable({
resolvedFilters,
hasHistoricalData,
}: FindingsGroupTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [tableData, setTableData] = useState<FindingGroupRow[]>(data ?? []);
const [tableMetadata, setTableMetadata] = useState<MetaDataProps | undefined>(
metadata,
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
@@ -61,7 +70,7 @@ export function FindingsGroupTable({
// State resets (selection, drill-down) are handled by the parent via
// key={groupKey} — when data changes, the component remounts with fresh state.
const safeData = data ?? [];
const safeData = tableData ?? [];
const hasResourceSelection = resourceSelection.length > 0;
const filters = resolvedFilters;
@@ -131,12 +140,49 @@ export function FindingsGroupTable({
const resolveMuteIds = async (checkIds: string[]) =>
resolveGroupMuteIds(checkIds);
const handleMuteComplete = () => {
const refreshFindingGroups = async () => {
setIsRefreshing(true);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
const sort = searchParams.get("sort") || undefined;
const fetchFindingGroups = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
try {
const findingGroupsData = await fetchFindingGroups({
page,
...(sort && { sort }),
filters,
pageSize,
});
const nextGroups = adaptFindingGroupsResponse(findingGroupsData);
setTableData(nextGroups);
setTableMetadata(findingGroupsData?.meta);
if (expandedCheckId) {
const refreshedExpandedGroup =
nextGroups.find((group) => group.checkId === expandedCheckId) ?? null;
if (refreshedExpandedGroup) {
setExpandedGroup(refreshedExpandedGroup);
} else {
handleCollapse();
}
}
} finally {
setIsRefreshing(false);
}
};
const handleMuteComplete = async () => {
clearSelection();
setResourceSelection([]);
inlineRef.current?.clearSelection();
await refreshFindingGroups();
inlineRef.current?.refresh();
router.refresh();
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
@@ -203,12 +249,13 @@ export function FindingsGroupTable({
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
metadata={tableMetadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
isLoading={isRefreshing}
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
@@ -13,4 +13,9 @@ describe("inline resource container", () => {
expect(source).toContain("useFindingGroupResourceState");
expect(source).not.toContain("useInfiniteResources");
});
it("renders a resources heading above the expanded group rows", () => {
expect(source).toContain("Resources");
expect(source).toContain("text-xs font-medium");
});
});
@@ -230,6 +230,11 @@ export function InlineResourceContainer({
className="overflow-hidden"
>
<div className="relative">
<div className="px-6 pb-2">
<p className="text-text-neutral-secondary text-xs font-medium">
Resources
</p>
</div>
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
@@ -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,58 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
vi.mock("@/components/providers/add-provider-button", () => ({
AddProviderButton: () => <button type="button">Add provider</button>,
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
MutedFindingsConfigButton: () => (
<button type="button">Muted findings config</button>
),
}));
vi.mock("@/components/providers/providers-filters", () => ({
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
ProvidersAccountsTable: () => <div data-testid="providers-table">Table</div>,
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
}));
import { ProvidersAccountsView } from "./providers-accounts-view";
const filters: FilterOption[] = [];
const providers: ProviderProps[] = [];
const rows: ProvidersTableRow[] = [];
const metadata: MetaDataProps = {
pagination: { page: 1, pages: 1, count: 0, itemsPerPage: [10] },
version: "latest",
};
describe("ProvidersAccountsView", () => {
it("keeps the same vertical spacing between filters and table as other views", () => {
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
"flex",
"flex-col",
"gap-6",
);
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
});
});
@@ -60,23 +60,25 @@ export function ProvidersAccountsView({
return (
<>
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
@@ -0,0 +1,110 @@
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()");
});
});
@@ -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,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 +460,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.
+21 -15
View File
@@ -2,6 +2,11 @@
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
} from "react";
import {
Dialog,
@@ -81,21 +86,22 @@ function CommandInput({
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
const CommandList = forwardRef<
ElementRef<typeof CommandPrimitive.List>,
ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
function CommandEmpty({
...props
+26 -1
View File
@@ -283,13 +283,33 @@ export function MultiSelectContent({
children: ReactNode;
width?: "default" | "wide";
} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) {
const { open } = useMultiSelectContext();
const canSearch = typeof search === "object" ? true : search;
const [searchValue, setSearchValue] = useState("");
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]";
useEffect(() => {
if (open) return;
setSearchValue("");
}, [open]);
useEffect(() => {
if (!canSearch || !searchValue.trim()) return;
const firstVisibleItem = listRef.current?.querySelector<HTMLElement>(
'[data-slot="multiselect-item"]:not([hidden])',
);
firstVisibleItem?.scrollIntoView({
block: "nearest",
});
}, [canSearch, searchValue, children]);
return (
<>
<div className="hidden" aria-hidden="true">
@@ -312,11 +332,16 @@ export function MultiSelectContent({
typeof search === "object" ? search.placeholder : undefined
}
className="text-bg-button-secondary placeholder:text-bg-button-secondary"
value={searchValue}
onValueChange={setSearchValue}
/>
) : (
<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}
@@ -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) {
@@ -0,0 +1,67 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { MetaDataProps } from "@/types";
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/lib", () => ({
getPaginationInfo: () => ({
currentPage: 2,
totalPages: 4,
totalEntries: 40,
itemsPerPageOptions: [10, 20, 50],
}),
}));
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <option value={value}>{children}</option>,
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
SelectValue: () => <span>10</span>,
}));
import { DataTablePagination } from "./data-table-pagination";
const metadata: MetaDataProps = {
pagination: {
page: 2,
pages: 4,
count: 40,
itemsPerPage: [10, 20, 50],
},
version: "latest",
};
describe("DataTablePagination", () => {
it("keeps navigation arrows visible on hover in light theme", () => {
render(<DataTablePagination metadata={metadata} />);
expect(screen.getByLabelText("Go to first page")).toHaveClass(
"hover:text-text-neutral-primary",
);
expect(screen.getByLabelText("Go to first page")).toHaveClass(
"hover:bg-bg-neutral-tertiary",
);
expect(screen.getByLabelText("Go to next page")).toHaveClass(
"hover:text-text-neutral-primary",
);
});
});
@@ -40,7 +40,8 @@ interface DataTablePaginationProps {
const NAV_BUTTON_STYLES = {
base: "flex items-center justify-center rounded-full p-3 transition-colors",
enabled: "text-text-neutral-secondary hover:text-white",
enabled:
"text-text-neutral-secondary hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary",
disabled: "text-text-neutral-tertiary cursor-not-allowed pointer-events-none",
} as const;