Compare commits

...

25 Commits

Author SHA1 Message Date
alejandrobailo
dd6292634c fix(ui): use button for muted tooltip trigger so row click guard detects it 2026-03-31 18:39:19 +02:00
alejandrobailo
dec2c45d1b fix(ui): reorder resource row actions — mute first, then Jira 2026-03-31 18:37:51 +02:00
alejandrobailo
6c68f59ac5 fix(ui): restore muted tooltip link and guard row click against interactive elements 2026-03-31 18:36:28 +02:00
alejandrobailo
b518935ac5 fix(ui): make muted icon a direct link and close dropdown on scroll 2026-03-31 18:19:21 +02:00
alejandrobailo
fcfe712845 refactor(ui): add danger variant to Card and use it for Risk section 2026-03-31 18:11:58 +02:00
alejandrobailo
da9b3656b2 feat(ui): add Send to Jira action to resource row dropdown 2026-03-31 18:06:16 +02:00
alejandrobailo
b017fd7892 fix(ui): remove Muted text label from resource status cell 2026-03-31 17:39:48 +02:00
alejandrobailo
d7927b174a refactor(ui): remove redundant createPortal — shadcn Drawer has its own portal 2026-03-31 17:36:50 +02:00
alejandrobailo
89a566b467 test(ui): fix vacuous assertions and shared state in findings-group-table tests 2026-03-31 17:31:13 +02:00
alejandrobailo
f5f0e9c3a3 fix(ui): restore pageRef reset in refresh to prevent stale page after error 2026-03-31 17:31:08 +02:00
alejandrobailo
688df52591 fix(ui): move stripCodeFences below imports and fix prettier formatting 2026-03-31 17:30:59 +02:00
alejandrobailo
2d2b01134e merge: integrate all findings groups improvements (#10515, #10516, #10517) 2026-03-31 17:15:10 +02:00
Alan Buscaglia
5306bb1133 fix(ui): security and crash fixes for findings grouped view (#10514) 2026-03-31 17:04:56 +02:00
alejandrobailo
113d85803c fix(ui): render categories as badges instead of comma-separated text 2026-03-31 17:00:14 +02:00
alejandrobailo
9dcb0dd1b7 fix(ui): strip markdown code fences and incorrect $ prefix from remediation 2026-03-31 17:00:09 +02:00
alejandrobailo
674de89a80 fix(ui): restore Enter-only search for uncontrolled mode in all tables 2026-03-31 16:45:50 +02:00
alejandrobailo
8e7b310794 test(ui): replace vacuous assertions in findings-group-table tests 2026-03-31 16:44:01 +02:00
alejandrobailo
a8ea78e7ed fix(ui): scope Enter-only search to controlled mode, restore uncontrolled debounce 2026-03-31 16:43:55 +02:00
Alan Buscaglia
917f9252d2 chore: trigger CI re-run 2026-03-30 18:19:15 +02:00
Alan Buscaglia
f43274097b fix(ui): fix Lighthouse AI button contrast — dark text on light gradient
Replace text-foreground (white in dark mode) with text-slate-900 (dark)
on the Lighthouse AI button. The gradient background (green→cyan) is
always light, so dark text ensures readable contrast in both themes.
2026-03-30 18:01:56 +02:00
Alan Buscaglia
c086e3a8d0 fix(ui): always show search input — remove collapse/expand animation
The search input is now always visible in all tables. Removed:
- Collapsed state (icon-only button with hover to expand)
- isExpanded state and shouldStayExpanded logic
- handleMouseEnter/handleMouseLeave/handleIconClick handlers
- Transition animations for expand/collapse

The input starts at w-64 (or w-[28rem] with badge) and stays visible.
Focus/blur still tracked for border styling only.
2026-03-30 17:59:59 +02:00
Alan Buscaglia
bba2f274b8 fix(ui): search commits on Enter only in all tables (uncontrolled mode)
Change DataTableSearch uncontrolled mode from debounce-on-keystroke to
commit-on-Enter for ALL tables (findings, providers, scans, etc.).

- handleChange now only updates the display value (internalValue)
  without triggering URL updates or showing a loading spinner
- onKeyDown Enter handler commits the search: updates URL params
  (prefixed or filter[search]) and resets to page 1
- Removes all debounce logic from the uncontrolled path — typing is
  instant feedback, searching is explicit user intent via Enter
- Controlled mode with onSearchCommit unchanged (already Enter-only)
2026-03-30 17:57:55 +02:00
Alan Buscaglia
d717add78a fix(ui): trigger refresh imperatively on search commit instead of useEffect
Replace useEffect-based refresh on resourceSearch change with
imperative queueMicrotask(() => inlineRef.current?.refresh()) in the
onSearchCommit callback. This follows the project convention of
avoiding useState+useEffect patterns that mirror props.

The hook uses refs (filtersRef.current) which update on render but
don't auto-trigger fetches. queueMicrotask ensures the state update
from setResourceSearch is flushed before refresh reads the new filters.
2026-03-30 17:56:03 +02:00
Alan Buscaglia
b129abe42e fix(ui): suppress misleading spinner in commit-on-Enter search mode
When onSearchCommit is provided, the loading spinner should only appear
after Enter is pressed (actual search commit), not on every keystroke.

- Skip setIsLoading(true) in handleChange when onSearchCommit is present
- Cancel pending debounce timer when Enter fires (prevents stale
  onSearchChange from firing 500ms after commit)
- Sync display state via onSearchChange on Enter to keep input and
  committed state consistent
- Fix pre-existing React.ReactNode namespace usage in data-table.tsx

6 tests passing (1 new test for debounce cancellation on Enter).
2026-03-30 17:34:09 +02:00
Alan Buscaglia
5a85752839 fix(ui): UX improvements for findings grouped view — Pepe's feedback
Address 6 UX items from Pepe's product review:

- Lighthouse AI: change 'View This Finding' to 'Analyze This Finding
  With Lighthouse AI'
- Remediation headings: shorten 'Terraform Command' to 'Terraform' and
  'CloudFormation Command' to 'CloudFormation' for cleaner labels
- Search on Enter: split resourceSearch into input display state and
  committed search state, fire actual search only on Enter key.
  Remove resourceSearch from InlineResourceContainer key to prevent
  full remount on every keystroke
- Muted icon: add 'Muted' text indicator with VolumeX icon in resource
  row status cell for muted findings
- Risk section: wrap in danger-styled container with border-danger and
  bg-bg-fail-secondary background for visual emphasis
- Section headings: upgrade Risk/Description labels from text-xs to
  text-sm font-semibold. Add border separator after Description section

Also fix pre-existing React.ReactNode namespace usage in data-table.tsx
(replaced with named import type).

18 tests across 3 test suites (2 new, 1 extended). All passing.
Judgment Day: 1 round, 0 CRITICAL, warnings documented as follow-ups.

Known follow-ups:
- Spinner fires on keystroke in commit-on-Enter mode (cosmetic)
- Debounce timer not cancelled on Enter (edge case, no crash)
- Remediation card labels still text-xs (inconsistent with overview)
2026-03-30 16:34:07 +02:00
13 changed files with 795 additions and 220 deletions

View File

@@ -0,0 +1,235 @@
/**
* Tests for column-finding-resources.tsx
*
* Fix 4: Muted resource rows should show a visible "Muted" badge/indicator
* in the status cell (not just the tiny 2px NotificationIndicator dot).
*/
import { render, screen } from "@testing-library/react";
import type { InputHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
vi.mock("@/components/shadcn", () => ({
Checkbox: ({
"aria-label": ariaLabel,
...props
}: InputHTMLAttributes<HTMLInputElement> & {
"aria-label"?: string;
size?: string;
}) => <input type="checkbox" aria-label={ariaLabel} {...props} />,
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: () => null,
}));
vi.mock("@/components/shadcn/info-field/info-field", () => ({
InfoField: ({
children,
label,
}: {
children: ReactNode;
label: string;
variant?: string;
}) => (
<div>
<span>{label}</span>
{children}
</div>
),
}));
vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => <div data-testid="spinner" />,
}));
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: () => null,
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { column: unknown; title: string }) => (
<span>{title}</span>
),
SeverityBadge: ({ severity }: { severity: string }) => (
<span data-testid="severity-badge">{severity}</span>
),
}));
vi.mock("@/components/ui/table/status-finding-badge", () => ({
StatusFindingBadge: ({ status }: { status: string }) => (
<span data-testid="status-badge">{status}</span>
),
}));
vi.mock("@/components/ui/table/data-table-column-header", () => ({
DataTableColumnHeader: ({ title }: { column: unknown; title: string }) => (
<span>{title}</span>
),
}));
vi.mock("@/lib/date-utils", () => ({
getFailingForLabel: vi.fn(() => "2 days"),
}));
vi.mock("@/lib", () => ({
cn: (...args: (string | undefined | false | null)[]) =>
args.filter(Boolean).join(" "),
}));
vi.mock("./findings-selection-context", () => ({
FindingsSelectionContext: {
Provider: ({ children }: { children: ReactNode; value: unknown }) => (
<>{children}</>
),
},
default: {
Provider: ({ children }: { children: ReactNode; value: unknown }) => (
<>{children}</>
),
},
}));
vi.mock("./notification-indicator", () => ({
NotificationIndicator: ({
isMuted,
}: {
isMuted?: boolean;
mutedReason?: string;
}) => (
<div
data-testid="notification-indicator"
data-is-muted={isMuted ? "true" : "false"}
/>
),
}));
vi.mock("lucide-react", () => ({
Container: () => <svg data-testid="container-icon" />,
CornerDownRight: () => <svg data-testid="corner-icon" />,
VolumeOff: () => <svg data-testid="volume-off-icon" />,
VolumeX: () => <svg data-testid="volume-x-icon" />,
}));
vi.mock("@tanstack/react-table", () => ({}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import type { FindingResourceRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeResource(
overrides?: Partial<FindingResourceRow>,
): FindingResourceRow {
return {
id: "resource-1",
rowType: "resource" as const,
findingId: "finding-1",
checkId: "s3_check",
providerType: "aws",
providerAlias: "prod",
providerUid: "123456789",
resourceName: "my-bucket",
resourceGroup: "default",
resourceUid: "arn:aws:s3:::my-bucket",
service: "s3",
region: "us-east-1",
severity: "high",
status: "FAIL",
isMuted: false,
mutedReason: undefined,
firstSeenAt: "2024-01-01T00:00:00Z",
lastSeenAt: "2024-01-02T00:00:00Z",
...overrides,
};
}
function renderStatusCell(resource: FindingResourceRow) {
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const statusColumn = columns.find(
(col) => "id" in col && col.id === "status",
);
if (!statusColumn?.cell) throw new Error("status column not found");
const CellComponent = statusColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
const { container } = render(
<div>{CellComponent({ row: { original: resource } })}</div>,
);
return container;
}
// ---------------------------------------------------------------------------
// Fix 4: Muted resource rows must show a visible "Muted" indicator
// ---------------------------------------------------------------------------
describe("column-finding-resources — status cell rendering", () => {
it("should show 'FAIL' status badge for non-muted resources", () => {
// Given
const activeResource = makeResource({ isMuted: false, status: "FAIL" });
// When
renderStatusCell(activeResource);
// Then
expect(screen.getByTestId("status-badge")).toBeInTheDocument();
expect(screen.getByTestId("status-badge")).toHaveTextContent("FAIL");
});
it("should convert MUTED status to FAIL for display", () => {
// Given — muted resources show FAIL badge (MUTED → FAIL conversion)
const mutedResource = makeResource({ isMuted: true, status: "MUTED" });
// When
renderStatusCell(mutedResource);
// Then
expect(screen.getByTestId("status-badge")).toBeInTheDocument();
expect(screen.getByTestId("status-badge")).toHaveTextContent("FAIL");
});
});

View File

@@ -5,6 +5,8 @@ import { Container, CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
import { useContext, useState } from "react";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { Checkbox } from "@/components/shadcn";
import {
ActionDropdown,
@@ -29,6 +31,7 @@ import { NotificationIndicator } from "./notification-indicator";
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const resource = row.original;
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
@@ -86,6 +89,12 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
onComplete={handleMuteComplete}
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={resource.findingId}
findingTitle={resource.checkId}
/>
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
@@ -105,6 +114,11 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
disabled={resource.isMuted || isResolving}
onSelect={handleMuteClick}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
</ActionDropdown>
</div>
</>

View File

@@ -0,0 +1,185 @@
/**
* Tests for findings-group-table.tsx
*
* Fix 3: Search should only trigger on Enter, not on every keystroke.
* resourceSearch must NOT be part of InlineResourceContainer's key.
*/
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Hoist mocks
// ---------------------------------------------------------------------------
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/actions/findings/findings-by-resource", () => ({
resolveFindingIds: vi.fn().mockResolvedValue([]),
resolveFindingIdsByVisibleGroupResources: vi.fn().mockResolvedValue([]),
}));
// Track InlineResourceContainer renders & props
const inlineRenders: Array<{ resourceSearch: string }> = [];
vi.mock("./inline-resource-container", () => ({
InlineResourceContainer: ({
resourceSearch,
}: {
resourceSearch: string;
group: unknown;
columnCount: number;
onResourceSelectionChange: (ids: string[]) => void;
ref?: unknown;
}) => {
inlineRenders.push({ resourceSearch });
return (
<div
data-testid="inline-resource-container"
data-resource-search={resourceSearch}
/>
);
},
}));
vi.mock("./column-finding-groups", () => ({
getColumnFindingGroups: vi.fn().mockReturnValue([]),
}));
vi.mock("./findings-selection-context", () => ({
FindingsSelectionContext: {
Provider: ({ children }: { children: ReactNode; value: unknown }) => (
<>{children}</>
),
},
}));
vi.mock("../floating-mute-button", () => ({
FloatingMuteButton: () => null,
}));
vi.mock("@/lib", () => ({
hasDateOrScanFilter: vi.fn().mockReturnValue(false),
cn: (...args: (string | undefined | false | null)[]) =>
args.filter(Boolean).join(" "),
}));
// ---------------------------------------------------------------------------
// DataTable mock that exposes onSearchCommit (Enter behavior)
// ---------------------------------------------------------------------------
let capturedOnSearchChange: ((value: string) => void) | undefined;
let capturedOnSearchCommit: ((value: string) => void) | undefined;
vi.mock("@/components/ui/table", () => ({
DataTable: ({
onSearchChange,
onSearchCommit,
controlledSearch,
renderAfterRow,
data,
}: {
onSearchChange?: (value: string) => void;
onSearchCommit?: (value: string) => void;
controlledSearch?: string;
renderAfterRow?: (row: { original: unknown }) => ReactNode;
children?: ReactNode;
columns?: unknown[];
data?: unknown[];
metadata?: unknown;
enableRowSelection?: boolean;
rowSelection?: unknown;
onRowSelectionChange?: unknown;
getRowCanSelect?: unknown;
showSearch?: boolean;
searchPlaceholder?: string;
searchBadge?: unknown;
}) => {
capturedOnSearchChange = onSearchChange;
capturedOnSearchCommit = onSearchCommit;
return (
<div data-testid="data-table">
<input
data-testid="search-input"
value={controlledSearch ?? ""}
onChange={(e) => onSearchChange?.(e.target.value)}
placeholder="Search resources..."
/>
{/* Render inline container for first row (simulates expanded drill-down) */}
{data && data.length > 0 && renderAfterRow?.({ original: data[0] })}
</div>
);
},
}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import type { FindingGroupRow } from "@/types";
import { FindingsGroupTable } from "./findings-group-table";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const mockGroup: FindingGroupRow = {
id: "group-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 Bucket Public Access Check",
resourcesTotal: 5,
resourcesFail: 3,
newCount: 0,
changedCount: 0,
mutedCount: 0,
severity: "high",
status: "FAIL",
providers: ["aws"],
updatedAt: "2024-01-01T00:00:00Z",
};
// ---------------------------------------------------------------------------
// Fix 3: Search fires only on Enter (onSearchCommit), not on every keystroke
// ---------------------------------------------------------------------------
describe("FindingsGroupTable — Enter-only search for resource drill-down", () => {
beforeEach(() => {
capturedOnSearchChange = undefined;
capturedOnSearchCommit = undefined;
inlineRenders.length = 0;
});
it("should render successfully with drill-down data", () => {
render(<FindingsGroupTable data={[mockGroup]} />);
expect(screen.getByTestId("data-table")).toBeInTheDocument();
});
it("should not pass onSearchCommit when no group is expanded", () => {
// Given — no drill-down active
render(<FindingsGroupTable data={[mockGroup]} />);
// Then — onSearchCommit must be undefined (no active drill-down)
expect(capturedOnSearchCommit).toBeUndefined();
// onSearchChange must also be undefined
expect(capturedOnSearchChange).toBeUndefined();
});
it("should not render InlineResourceContainer when no group is expanded", () => {
// Given — no drill-down active
render(<FindingsGroupTable data={[mockGroup]} />);
// Then — no inline containers rendered
const inlineContainers = screen.queryAllByTestId(
"inline-resource-container",
);
expect(inlineContainers).toHaveLength(0);
});
});

View File

@@ -49,6 +49,9 @@ export function FindingsGroupTable({
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
null,
);
// Separate display state (updates on keystroke) from committed search (updates on Enter only).
// This prevents InlineResourceContainer from remounting on every keystroke.
const [resourceSearchInput, setResourceSearchInput] = useState("");
const [resourceSearch, setResourceSearch] = useState("");
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
const inlineRef = useRef<InlineResourceContainerHandle>(null);
@@ -140,6 +143,7 @@ export function FindingsGroupTable({
}
setExpandedCheckId(checkId);
setExpandedGroup(group);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
};
@@ -147,6 +151,7 @@ export function FindingsGroupTable({
const handleCollapse = () => {
setExpandedCheckId(null);
setExpandedGroup(null);
setResourceSearchInput("");
setResourceSearch("");
setResourceSelection([]);
};
@@ -197,8 +202,9 @@ export function FindingsGroupTable({
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
controlledSearch={expandedCheckId ? resourceSearch : undefined}
onSearchChange={expandedCheckId ? setResourceSearch : undefined}
controlledSearch={expandedCheckId ? resourceSearchInput : undefined}
onSearchChange={expandedCheckId ? setResourceSearchInput : undefined}
onSearchCommit={expandedCheckId ? setResourceSearch : undefined}
searchBadge={
expandedGroup
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }

View File

@@ -12,29 +12,12 @@ import type {
ReactNode,
TdHTMLAttributes,
} from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// Hoist createPortal spy so it is available when the vi.mock factory runs.
// vi.hoisted() runs before all imports, making the spy available in the factory.
const { createPortalSpy } = vi.hoisted(() => ({
createPortalSpy: vi.fn(),
}));
vi.mock("react-dom", async (importOriginal) => {
const original = await importOriginal<typeof import("react-dom")>();
// Delegate to the real createPortal so other tests keep working,
// but allow spy assertions on call count and timing.
createPortalSpy.mockImplementation(original.createPortal);
return {
...original,
createPortal: createPortalSpy,
};
});
// Mock next/navigation before component import
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
@@ -182,19 +165,12 @@ const mockGroup: FindingGroupRow = {
};
// ---------------------------------------------------------------------------
// Fix 2: SSR crash — portal only mounted after client-side mount
// Fix 2: Drawer renders without manual createPortal (shadcn Drawer has its own portal)
// ---------------------------------------------------------------------------
describe("InlineResourceContainer — Fix 2: SSR portal guard", () => {
beforeEach(() => {
createPortalSpy.mockClear();
});
it("should render without crash when document.body exists (JSDOM)", async () => {
// Given — JSDOM has document.body; this verifies the happy path
describe("InlineResourceContainer — Drawer rendering", () => {
it("should render without crash", async () => {
let renderError: Error | null = null;
// When
try {
await act(async () => {
render(
@@ -213,13 +189,10 @@ describe("InlineResourceContainer — Fix 2: SSR portal guard", () => {
} catch (e) {
renderError = e as Error;
}
// Then — component must not throw
expect(renderError).toBeNull();
});
it("should render the portal content (ResourceDetailDrawer) after mount", async () => {
// Given
it("should render the ResourceDetailDrawer", async () => {
await act(async () => {
render(
<table>
@@ -234,50 +207,8 @@ describe("InlineResourceContainer — Fix 2: SSR portal guard", () => {
</table>,
);
});
// Then — drawer appears in the document via portal after mount
expect(screen.getByTestId("resource-detail-drawer")).toBeInTheDocument();
});
it("should NOT call createPortal synchronously on initial render — only after useEffect fires (isMounted guard)", () => {
// Given — createPortalSpy is reset by beforeEach, so call count starts at 0.
// Before the fix: createPortal runs on the initial synchronous render → crash in SSR.
// After the fix: createPortal is guarded by isMounted (set via useEffect).
// useEffect fires AFTER commit, so createPortal must NOT be called
// during the synchronous render phase.
// When — render inside synchronous act() and capture spy count INSIDE the callback.
// In React 19, act() DOES flush effects — but the callback runs BEFORE effects drain.
// So: the callback body executes first (synchronous render only), THEN act() flushes
// pending effects after the callback returns.
// Capturing spy state inside the callback captures the pre-effect state.
let portalCallsAfterSyncRender = 0;
act(() => {
render(
<table>
<tbody>
<InlineResourceContainer
group={mockGroup}
resourceSearch=""
columnCount={10}
onResourceSelectionChange={vi.fn()}
/>
</tbody>
</table>,
);
// Capture call count here — inside the callback = after synchronous render,
// but BEFORE act() drains pending effects (effects flush after this returns).
portalCallsAfterSyncRender = createPortalSpy.mock.calls.length;
});
// After the callback returns, act() flushes pending effects (isMounted = true → re-render)
// Then — createPortal must NOT have been called during the synchronous render phase
// (isMounted starts as false, createPortal is inside {isMounted && createPortal(...)})
expect(portalCallsAfterSyncRender).toBe(0);
// After act() completes, effects have flushed → isMounted = true → re-render → createPortal called
expect(createPortalSpy).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------

View File

@@ -10,8 +10,7 @@ import {
import { AnimatePresence, motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useImperativeHandle, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useImperativeHandle, useRef, useState } from "react";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
@@ -133,12 +132,6 @@ export function InlineResourceContainer({
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Scroll hint: shows "scroll for more" when content overflows
const {
containerRef: scrollHintContainerRef,
@@ -328,7 +321,18 @@ export function InlineResourceContainer({
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => drawer.openDrawer(row.index)}
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
const target = e.target as HTMLElement;
if (
target.closest(
"a, button, input, [role=menuitem]",
)
)
return;
drawer.openDrawer(row.index);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
@@ -395,26 +399,22 @@ export function InlineResourceContainer({
</td>
</tr>
{isMounted &&
createPortal(
<ResourceDetailDrawer
open={drawer.isOpen}
onOpenChange={(open) => {
if (!open) drawer.closeDrawer();
}}
isLoading={drawer.isLoading}
isNavigating={drawer.isNavigating}
checkMeta={drawer.checkMeta}
currentIndex={drawer.currentIndex}
totalResources={drawer.totalResources}
currentFinding={drawer.currentFinding}
otherFindings={drawer.otherFindings}
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleDrawerMuteComplete}
/>,
document.body,
)}
<ResourceDetailDrawer
open={drawer.isOpen}
onOpenChange={(open) => {
if (!open) drawer.closeDrawer();
}}
isLoading={drawer.isLoading}
isNavigating={drawer.isNavigating}
checkMeta={drawer.checkMeta}
currentIndex={drawer.currentIndex}
totalResources={drawer.totalResources}
currentFinding={drawer.currentFinding}
otherFindings={drawer.otherFindings}
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleDrawerMuteComplete}
/>
</FindingsSelectionContext.Provider>
);
}

View File

@@ -34,20 +34,23 @@ export const NotificationIndicator = ({
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
<button
type="button"
className="flex w-4 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
onClick={(e) => e.stopPropagation()}
>
<MutedIcon className="text-bg-data-muted size-2" />
</div>
</button>
</TooltipTrigger>
<TooltipContent onClick={(e) => e.stopPropagation()}>
<TooltipContent
onClick={(e) => e.stopPropagation()}
onPointerDownCapture={(e) => e.stopPropagation()}
>
<Link
href="/mutelist"
onClick={(e) => e.stopPropagation()}
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
>
{/* TODO: always show rule name once the API returns muted_reason in finding-group-resources */}
{mutedReason ? (
<>
<span className="text-text-neutral-primary">Mute rule:</span>

View File

@@ -80,7 +80,11 @@ vi.mock("@/components/shadcn", () => {
});
vi.mock("@/components/shadcn/card/card", () => ({
Card: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Card: ({ children, variant }: { children: ReactNode; variant?: string }) => (
<div data-slot="card" data-variant={variant}>
{children}
</div>
),
}));
vi.mock("@/components/shadcn/dropdown", () => ({
@@ -264,6 +268,193 @@ const mockFinding: ResourceDrawerFinding = {
scan: null,
};
// ---------------------------------------------------------------------------
// Fix 1: Lighthouse AI button text change
// ---------------------------------------------------------------------------
describe("ResourceDetailDrawerContent — Fix 1: Lighthouse AI button text", () => {
it("should say 'Analyze this finding with Lighthouse AI' instead of 'View This Finding'", () => {
// Given
const { container } = render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When — look for the lighthouse link
const allText = container.textContent ?? "";
// Then — correct text must be present, old text must be absent
expect(allText.toLowerCase()).toContain("analyze this finding");
expect(allText.toLowerCase()).not.toContain("view this finding");
});
});
// ---------------------------------------------------------------------------
// Fix 2: Remediation heading labels — remove "Command" suffix
// ---------------------------------------------------------------------------
describe("ResourceDetailDrawerContent — Fix 2: Remediation heading labels", () => {
const checkMetaWithCommands: CheckMeta = {
...mockCheckMeta,
remediation: {
recommendation: { text: "Fix it", url: "https://example.com" },
code: {
cli: "aws s3 ...",
terraform: "resource aws_s3_bucket {}",
nativeiac: "AWSTemplateFormatVersion: ...",
other: "",
},
},
};
it("should render 'Terraform' heading without 'Command' suffix", () => {
// Given
const { container } = render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={checkMetaWithCommands}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const allText = container.textContent ?? "";
// Then — "Terraform" present, "Terraform Command" absent
expect(allText).toContain("Terraform");
expect(allText).not.toContain("Terraform Command");
});
it("should render 'CloudFormation' heading without 'Command' suffix", () => {
// Given
const { container } = render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={checkMetaWithCommands}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const allText = container.textContent ?? "";
// Then — "CloudFormation" present, "CloudFormation Command" absent
expect(allText).toContain("CloudFormation");
expect(allText).not.toContain("CloudFormation Command");
});
it("should still render 'CLI Command' label for CLI section", () => {
// Given
const { container } = render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={checkMetaWithCommands}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const allText = container.textContent ?? "";
// Then — CLI Command label must remain
expect(allText).toContain("CLI Command");
});
});
// ---------------------------------------------------------------------------
// Fix 5 & 6: Risk section has danger styling, sections have separators and bigger headings
// ---------------------------------------------------------------------------
describe("ResourceDetailDrawerContent — Fix 5 & 6: Risk section styling", () => {
it("should wrap the Risk section in a Card component (data-slot='card')", () => {
// Given
const { container } = render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When — find a Card with variant="danger" that contains the Risk label
const dangerCards = Array.from(
container.querySelectorAll('[data-variant="danger"]'),
);
const riskCard = dangerCards.find((el) =>
el.textContent?.includes("Risk:"),
);
// Then — Risk section must be wrapped in a Card variant="danger"
expect(riskCard).toBeDefined();
});
it("should use larger heading size for section labels (text-sm → text-base or larger)", () => {
// Given
const { container } = render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When — look for section heading span with "Risk:"
const headingSpans = Array.from(container.querySelectorAll("span")).filter(
(el) => el.textContent?.trim() === "Risk:",
);
// Then — heading must not be tiny text-xs; should be text-sm or larger with font-semibold/font-medium
expect(headingSpans.length).toBeGreaterThan(0);
const riskHeading = headingSpans[0];
expect(riskHeading.className).not.toContain("text-xs");
});
});
// ---------------------------------------------------------------------------
// Fix 4: Dark mode — no hardcoded color classes
// ---------------------------------------------------------------------------

View File

@@ -69,6 +69,14 @@ import { NotificationIndicator } from "../notification-indicator";
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
import type { CheckMeta } from "./use-resource-detail-drawer";
/** Strip markdown code fences (```lang ... ```) so CodeSnippet shows clean code. */
function stripCodeFences(code: string): string {
return code
.replace(/^```\w*\n?/, "")
.replace(/\n?```\s*$/, "")
.trim();
}
interface ResourceDetailDrawerContentProps {
isLoading: boolean;
isNavigating: boolean;
@@ -382,16 +390,16 @@ export function ResourceDetailDrawerContent({
{(checkMeta.risk || checkMeta.description || f?.statusExtended) && (
<Card variant="inner">
{checkMeta.risk && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
<Card variant="danger">
<span className="text-text-neutral-secondary text-sm font-semibold">
Risk:
</span>
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
</div>
</Card>
)}
{checkMeta.description && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
<div className="border-default-200 flex flex-col gap-1 border-b pb-4">
<span className="text-text-neutral-secondary text-sm font-semibold">
Description:
</span>
<MarkdownContainer>
@@ -401,7 +409,7 @@ export function ResourceDetailDrawerContent({
)}
{f?.statusExtended && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
<span className="text-text-neutral-secondary text-sm font-semibold">
Status Extended:
</span>
<p className="text-text-neutral-primary text-sm">
@@ -448,7 +456,7 @@ export function ResourceDetailDrawerContent({
CLI Command:
</span>
<CodeSnippet
value={`$ ${checkMeta.remediation.code.cli}`}
value={`$ ${stripCodeFences(checkMeta.remediation.code.cli)}`}
multiline
transparent
className="max-w-full text-sm"
@@ -459,10 +467,12 @@ export function ResourceDetailDrawerContent({
{checkMeta.remediation.code.terraform && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
Terraform Command:
Terraform:
</span>
<CodeSnippet
value={`$ ${checkMeta.remediation.code.terraform}`}
value={stripCodeFences(
checkMeta.remediation.code.terraform,
)}
multiline
transparent
className="max-w-full text-sm"
@@ -473,10 +483,12 @@ export function ResourceDetailDrawerContent({
{checkMeta.remediation.code.nativeiac && (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-secondary text-xs">
CloudFormation Command:
CloudFormation:
</span>
<CodeSnippet
value={`$ ${checkMeta.remediation.code.nativeiac}`}
value={stripCodeFences(
checkMeta.remediation.code.nativeiac,
)}
multiline
transparent
className="max-w-full text-sm"
@@ -526,9 +538,17 @@ export function ResourceDetailDrawerContent({
<span className="text-text-neutral-secondary text-xs">
Categories:
</span>
<p className="text-text-neutral-primary text-sm">
{checkMeta.categories.join(", ")}
</p>
<div className="flex flex-wrap items-center gap-2">
{checkMeta.categories.map((category) => (
<Badge
key={category}
variant="outline"
className="text-xs capitalize"
>
{category}
</Badge>
))}
</div>
</div>
</Card>
)}
@@ -684,13 +704,13 @@ export function ResourceDetailDrawerContent({
{/* Lighthouse AI button */}
<a
href={`/lighthouse?${new URLSearchParams({ prompt: `Analyze this security finding and provide remediation guidance:\n\n- **Finding**: ${checkMeta.checkTitle}\n- **Check ID**: ${checkMeta.checkId}\n- **Severity**: ${f?.severity ?? "unknown"}\n- **Status**: ${f?.status ?? "unknown"}${f?.statusExtended ? `\n- **Detail**: ${f.statusExtended}` : ""}${checkMeta.risk ? `\n- **Risk**: ${checkMeta.risk}` : ""}` }).toString()}`}
className="text-foreground flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold transition-opacity hover:opacity-90"
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-900 transition-opacity hover:opacity-90"
style={{
background: "var(--gradient-lighthouse)",
}}
>
<CircleArrowRight className="size-5" />
View This Finding With Lighthouse AI
Analyze This Finding With Lighthouse AI
</a>
</div>
);

View File

@@ -19,6 +19,8 @@ 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-border-error-primary bg-bg-fail-secondary",
},
padding: {
default: "",
@@ -34,6 +36,11 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
padding: "default",
className: "px-4 py-3", // md padding by default for inner
},
{
variant: "danger",
padding: "default",
className: "px-4 py-3", // md padding by default for danger
},
],
defaultVariants: {
variant: "default",

View File

@@ -1,7 +1,7 @@
"use client";
import { EllipsisVertical } from "lucide-react";
import { ComponentProps, ReactNode } from "react";
import { ComponentProps, ReactNode, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
@@ -43,8 +43,18 @@ export function ActionDropdown({
ariaLabel = "Open actions menu",
children,
}: ActionDropdownProps) {
const [open, setOpen] = useState(false);
// Close dropdown when any ancestor scrolls (capture phase catches all scroll events)
useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [open]);
return (
<DropdownMenu modal={false}>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
{trigger ?? (
<button

View File

@@ -27,6 +27,13 @@ interface DataTableSearchProps {
*/
controlledValue?: string;
onSearchChange?: (value: string) => void;
/**
* Called when the user commits a search (pressing Enter).
* When provided, the search is only "committed" on Enter, while
* onSearchChange still fires on every keystroke for responsive display.
* Use this to avoid remounting child components on every keystroke.
*/
onSearchCommit?: (value: string) => void;
placeholder?: string;
/** Badge shown inside the search input (e.g., active drill-down group title) */
badge?: { label: string; onDismiss: () => void };
@@ -36,6 +43,7 @@ export const DataTableSearch = ({
paramPrefix = "",
controlledValue,
onSearchChange,
onSearchCommit,
placeholder = "Search...",
badge,
}: DataTableSearchProps) => {
@@ -47,7 +55,6 @@ export const DataTableSearch = ({
// In controlled mode, track display value separately for immediate feedback
const [displayValue, setDisplayValue] = useState(controlledValue ?? "");
const [isLoading, setIsLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const id = useId();
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -72,31 +79,30 @@ export const DataTableSearch = ({
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
// Keep expanded if there's a value or input is focused or badge is present
const shouldStayExpanded = value.length > 0 || isFocused || hasBadge;
// Sync with URL on mount (only for uncontrolled mode)
useEffect(() => {
if (isControlled) return;
const searchFromUrl = searchParams.get(searchParam) || "";
setInternalValue(searchFromUrl);
// If there's a search value, start expanded
if (searchFromUrl) {
setIsExpanded(true);
}
}, [searchParams, searchParam, isControlled]);
// Handle input change with debounce
const handleChange = (newValue: string) => {
// For controlled mode, update display immediately, debounce the callback
if (isControlled) {
// Update display value immediately for responsive typing
setDisplayValue(newValue);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
if (onSearchCommit) {
// Enter-to-commit mode: sync parent immediately (no debounce, no loading).
// The actual search commit happens on Enter via onSearchCommit.
onSearchChange(newValue);
return;
}
// Standard controlled mode: debounce the callback
setIsLoading(true);
debounceTimeoutRef.current = setTimeout(() => {
onSearchChange(newValue);
@@ -105,39 +111,9 @@ export const DataTableSearch = ({
return;
}
// Uncontrolled mode: only update display value on keystroke.
// The actual URL update happens on Enter (see onKeyDown handler).
setInternalValue(newValue);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// If using prefix, handle URL updates directly instead of useUrlFilters
if (paramPrefix) {
setIsLoading(true);
debounceTimeoutRef.current = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (newValue) {
params.set(searchParam, newValue);
} else {
params.delete(searchParam);
}
params.set(pageParam, "1"); // Reset to first page
router.push(`${pathname}?${params.toString()}`, { scroll: false });
setIsLoading(false);
}, SEARCH_DEBOUNCE_MS);
} else {
// Original behavior for non-prefixed search
if (newValue) {
setIsLoading(true);
debounceTimeoutRef.current = setTimeout(() => {
updateFilter("search", newValue);
setIsLoading(false);
}, SEARCH_DEBOUNCE_MS);
} else {
setIsLoading(false);
updateFilter("search", null);
}
}
};
// Cleanup timeout on unmount
@@ -149,67 +125,22 @@ export const DataTableSearch = ({
};
}, []);
const handleMouseEnter = () => {
setIsExpanded(true);
};
const handleMouseLeave = () => {
if (!shouldStayExpanded) {
setIsExpanded(false);
}
};
const handleFocus = () => {
setIsFocused(true);
setIsExpanded(true);
};
const handleBlur = () => {
setIsFocused(false);
if (!value && !hasBadge) {
setIsExpanded(false);
}
};
const handleIconClick = () => {
setIsExpanded(true);
// Focus input after expansion animation starts
setTimeout(() => {
inputRef.current?.focus();
}, 50);
};
const effectiveExpanded = isExpanded || hasBadge;
return (
<div
className={cn(
"relative flex items-center transition-all duration-300 ease-in-out",
effectiveExpanded ? (hasBadge ? "w-[28rem]" : "w-64") : "w-10",
"relative flex items-center",
hasBadge ? "w-[28rem]" : "w-64",
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Collapsed state - just icon button */}
<button
type="button"
onClick={handleIconClick}
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary absolute left-0 flex size-10 items-center justify-center rounded-md border transition-opacity duration-200",
effectiveExpanded ? "pointer-events-none opacity-0" : "opacity-100",
)}
aria-label="Open search"
>
<SearchIcon className="text-text-neutral-tertiary size-4" />
</button>
{/* Expanded state - full input with optional badge */}
<div
className={cn(
"relative w-full transition-opacity duration-200",
effectiveExpanded ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<div className="relative w-full">
<div
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
@@ -252,6 +183,40 @@ export const DataTableSearch = ({
placeholder={placeholder}
value={value}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
// Cancel any pending debounce — Enter commits immediately
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
debounceTimeoutRef.current = null;
}
setIsLoading(false);
// Controlled mode with explicit commit callback
if (isControlled && onSearchCommit) {
onSearchCommit(value);
return;
}
// Uncontrolled mode: immediate URL update (shortcut for debounce)
if (!isControlled) {
if (paramPrefix) {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(searchParam, value);
} else {
params.delete(searchParam);
}
params.set(pageParam, "1");
router.push(`${pathname}?${params.toString()}`, {
scroll: false,
});
} else {
updateFilter("search", value || null);
}
}
}}
onFocus={handleFocus}
onBlur={handleBlur}
className="h-9 min-w-0 flex-1 border-0 bg-transparent pr-9 shadow-none hover:bg-transparent focus:border-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"

View File

@@ -16,6 +16,7 @@ import {
useReactTable,
} from "@tanstack/react-table";
import { AnimatePresence } from "framer-motion";
import type { ReactNode } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
@@ -90,6 +91,11 @@ interface DataTableProviderProps<TData, TValue> {
*/
controlledSearch?: string;
onSearchChange?: (value: string) => void;
/**
* Called when the user commits a search by pressing Enter.
* Use this alongside onSearchChange to implement "search on Enter" behavior.
*/
onSearchCommit?: (value: string) => void;
controlledPage?: number;
controlledPageSize?: number;
onPageChange?: (page: number) => void;
@@ -99,7 +105,7 @@ interface DataTableProviderProps<TData, TValue> {
/** Custom placeholder text for the search input */
searchPlaceholder?: string;
/** Render additional content after each row (e.g., inline expansion) */
renderAfterRow?: (row: Row<TData>) => React.ReactNode;
renderAfterRow?: (row: Row<TData>) => ReactNode;
/** Badge shown inside the search input (e.g., active drill-down group) */
searchBadge?: { label: string; onDismiss: () => void };
}
@@ -122,6 +128,7 @@ export function DataTable<TData, TValue>({
paramPrefix = "",
controlledSearch,
onSearchChange,
onSearchCommit,
controlledPage,
controlledPageSize,
onPageChange,
@@ -222,6 +229,7 @@ export function DataTable<TData, TValue>({
paramPrefix={paramPrefix}
controlledValue={controlledSearch}
onSearchChange={onSearchChange}
onSearchCommit={onSearchCommit}
placeholder={searchPlaceholder}
badge={searchBadge}
/>