Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { render, screen } from "@testing-library/react";
import type { SelectionChangedEvent } from "ag-grid-community";
import React from "react";
import DataTableTab, { type DataTableTabProps } from "../index";

// TableComponent is mocked to a prop-capturing stub (forwardRef because the
// shell forwards `tableRef` to it), so the test asserts what the shell composes
// rather than AG-Grid's rendering.
interface MockTableProps {
className?: string;
gridOptions?: Record<string, unknown>;
onSelectionChanged?: (event: SelectionChangedEvent) => void;
}

let mockLatestTableProps: MockTableProps = {};
jest.mock(
"@/components/core/parameterRenderComponent/components/tableComponent",
() => {
const ReactActual = jest.requireActual<typeof React>("react");
return {
__esModule: true,
default: ReactActual.forwardRef((props: MockTableProps, _ref) => {
mockLatestTableProps = props;
return <div data-testid="mock-table" />;
}),
};
},
);

type Row = { id: string; name: string };

function makeProps(
overrides: Partial<DataTableTabProps<Row>> = {},
): DataTableTabProps<Row> {
return {
columnDefs: [{ field: "name" }],
rowData: [{ id: "1", name: "Alpha" }],
isLoading: false,
loadingState: <div data-testid="loading" />,
emptyState: <div data-testid="empty" />,
searchPlaceholder: "Search",
searchInputTestId: "search-input",
quickFilterText: "",
setQuickFilterText: jest.fn(),
toolbarActions: <div data-testid="toolbar" />,
setSelectedRows: jest.fn(),
setQuantitySelected: jest.fn(),
quantitySelected: 0,
isShiftPressed: false,
...overrides,
};
}

function renderTab(overrides: Partial<DataTableTabProps<Row>> = {}) {
return render(<DataTableTab<Row> {...makeProps(overrides)} />);
}

beforeEach(() => {
mockLatestTableProps = {};
jest.useRealTimers();
});

describe("DataTableTab", () => {
it("renders only the loading state while loading (no table, no search)", () => {
renderTab({ isLoading: true });

expect(screen.getByTestId("loading")).toBeTruthy();
expect(screen.queryByTestId("mock-table")).toBeNull();
expect(screen.queryByTestId("search-input")).toBeNull();
});

it("renders only the empty state when there are no rows", () => {
renderTab({ rowData: [] });

expect(screen.getByTestId("empty")).toBeTruthy();
expect(screen.queryByTestId("mock-table")).toBeNull();
});

it("mounts children in the table, loading and empty states", () => {
const child = <div data-testid="child-modal" />;

const { rerender } = render(
<DataTableTab<Row> {...makeProps({ children: child })} />,
);
expect(screen.getByTestId("child-modal")).toBeTruthy();

rerender(
<DataTableTab<Row>
{...makeProps({ isLoading: true, children: child })}
/>,
);
expect(screen.getByTestId("child-modal")).toBeTruthy();

rerender(
<DataTableTab<Row> {...makeProps({ rowData: [], children: child })} />,
);
expect(screen.getByTestId("child-modal")).toBeTruthy();
});

it("merges consumer gridOptions on top of the shared defaults rather than replacing them", () => {
renderTab({ gridOptions: { ensureDomOrder: false, rowBuffer: 5 } });

expect(mockLatestTableProps.gridOptions).toEqual({
stopEditingWhenCellsLoseFocus: true,
ensureDomOrder: false, // consumer override wins
colResizeDefault: "shift",
rowBuffer: 5, // consumer addition preserved
});
});

it("merges tableClassName into the base table classes", () => {
renderTab({ tableClassName: "ag-knowledge-table" });

expect(mockLatestTableProps.className).toContain("ag-no-border");
expect(mockLatestTableProps.className).toContain("ag-knowledge-table");
});

it("wraps the table via renderTableWrapper when provided", () => {
renderTab({
renderTableWrapper: (table) => (
<div data-testid="table-wrapper">{table}</div>
),
});

const wrapper = screen.getByTestId("table-wrapper");
expect(wrapper).toBeTruthy();
expect(wrapper.querySelector('[data-testid="mock-table"]')).toBeTruthy();
});

it("forwards selected rows immediately and defers the zero by 300ms", () => {
jest.useFakeTimers();
const setSelectedRows = jest.fn();
const setQuantitySelected = jest.fn();
renderTab({ setSelectedRows, setQuantitySelected });

const onSelectionChanged = mockLatestTableProps.onSelectionChanged;
expect(onSelectionChanged).toBeDefined();

const rows = [{ id: "1", name: "Alpha" }];
onSelectionChanged?.({
api: { getSelectedRows: () => rows },
} as unknown as SelectionChangedEvent);
expect(setSelectedRows).toHaveBeenLastCalledWith(rows);
expect(setQuantitySelected).toHaveBeenLastCalledWith(1);

setQuantitySelected.mockClear();
onSelectionChanged?.({
api: { getSelectedRows: () => [] },
} as unknown as SelectionChangedEvent);
expect(setSelectedRows).toHaveBeenLastCalledWith([]);
// the zero is not written synchronously…
expect(setQuantitySelected).not.toHaveBeenCalled();
// …only after the 300ms debounce.
jest.advanceTimersByTime(300);
expect(setQuantitySelected).toHaveBeenCalledWith(0);
});
});
179 changes: 179 additions & 0 deletions src/frontend/src/components/core/dataTableTabComponent/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { ColDef, SelectionChangedEvent } from "ag-grid-community";
import type { AgGridReact, AgGridReactProps } from "ag-grid-react";
import type { ElementRef, ReactNode, Ref } from "react";
import TableComponent, {
type TableComponentProps,
} from "@/components/core/parameterRenderComponent/components/tableComponent";
import { Input } from "@/components/ui/input";
import { cn } from "@/utils/utils";

/**
* Shared shell for the Files and Knowledge-Bases table tabs. It owns only the
* genuinely common surface: the search input, the header/toolbar row, the
* loading/empty short-circuit, the shared AG-Grid defaults and the multi-select
* handler.
*
* The `ReactNode` slots (`loadingState`, `emptyState`, `toolbarActions`,
* `children`) and the AG-Grid passthroughs (`editable`, `onCellKeyDown`,
* `onRowClicked`, `getRowId`, `gridOptions`, `tableRef`, `tableClassName`) exist
* to FREEZE each consumer's current DOM during the two-into-one extraction —
* they are not a design goal. A third consumer, or further divergence, should
* narrow this surface (e.g. extract a header component and a `useTableSelection`
* hook) rather than grow it one prop per tab.
*/
export interface DataTableTabProps<TData> {
columnDefs: ColDef[];
/** Row data already sorted by the consumer tab. */
rowData: TData[];
isLoading: boolean;
/** Rendered as-is (no shell wrappers) while `isLoading` is true. */
loadingState: ReactNode;
/** Rendered as-is (no shell wrappers) when `rowData` is empty. */
emptyState: ReactNode;
searchPlaceholder: string;
searchInputTestId: string;
searchInputAriaLabel?: string;
searchInputClassName?: string;
quickFilterText: string;
setQuickFilterText: (text: string) => void;
/** Right side of the header row (primary/delete actions, incl. their own container). */
toolbarActions: ReactNode;
setSelectedRows: (rows: TData[]) => void;
setQuantitySelected: (quantity: number) => void;
quantitySelected: number;
isShiftPressed: boolean;
tableRef?: Ref<ElementRef<typeof AgGridReact>>;
tableClassName?: string;
editable?: TableComponentProps["editable"];
onCellKeyDown?: AgGridReactProps["onCellKeyDown"];
onRowClicked?: AgGridReactProps["onRowClicked"];
getRowId?: AgGridReactProps["getRowId"];
/** Merged on top of the shared grid options. */
gridOptions?: AgGridReactProps["gridOptions"];
/** Wraps the table container (e.g. a file-drop zone). */
renderTableWrapper?: (table: JSX.Element) => ReactNode;
/**
* Feature-specific modals rendered inside the shell root in every state
* (table, loading and empty), so a modal opened from the empty or loading UI
* — e.g. "create the first knowledge base" — actually mounts and can open.
*/
children?: ReactNode;
}

const DataTableTab = <TData,>({
columnDefs,
rowData,
isLoading,
loadingState,
emptyState,
searchPlaceholder,
searchInputTestId,
searchInputAriaLabel,
searchInputClassName,
quickFilterText,
setQuickFilterText,
toolbarActions,
setSelectedRows,
setQuantitySelected,
quantitySelected,
isShiftPressed,
tableRef,
tableClassName,
editable,
onCellKeyDown,
onRowClicked,
getRowId,
gridOptions,
renderTableWrapper,
children,
}: DataTableTabProps<TData>) => {
const handleSelectionChanged = (event: SelectionChangedEvent) => {
const selectedRows = event.api.getSelectedRows();
setSelectedRows(selectedRows);
if (selectedRows.length > 0) {
setQuantitySelected(selectedRows.length);
} else {
setTimeout(() => {
setQuantitySelected(0);
}, 300);
}
};
Comment on lines +90 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Deferred reset can clobber a fresh selection and leaks past unmount.

handleSelectionChanged schedules setQuantitySelected(0) after 300ms but never tracks/clears the timer. If the user re-selects rows within that window, the immediate setQuantitySelected(count) is later overwritten by the stale timeout (resetting the toolbar to 0 while rows remain selected). The timer can also fire after the tab unmounts. Track the handle and clear it on the next selection change and on unmount.

🛡️ Suggested guard
+  const resetTimerRef = useRef<ReturnType<typeof setTimeout>>();
   const handleSelectionChanged = (event: SelectionChangedEvent) => {
     const selectedRows = event.api.getSelectedRows();
     setSelectedRows(selectedRows);
+    if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
     if (selectedRows.length > 0) {
       setQuantitySelected(selectedRows.length);
     } else {
-      setTimeout(() => {
+      resetTimerRef.current = setTimeout(() => {
         setQuantitySelected(0);
       }, 300);
     }
   };

(add useEffect(() => () => clearTimeout(resetTimerRef.current), []) for unmount)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/core/dataTableTabComponent/index.tsx` around
lines 72 - 82, Update handleSelectionChanged to store the deferred reset timer
in a ref, clear any existing timer on every selection change before applying the
new selection count, and clear the timer during component unmount via the
component’s effect lifecycle. Preserve the 300ms delayed reset only when no rows
are selected.


if (isLoading) {
return (
<>
{loadingState}
{children}
</>
);
}

if (rowData.length === 0) {
return (
<>
{emptyState}
{children}
</>
);
}

const table = (
<div className="relative h-full">
<TableComponent
rowHeight={45}
headerHeight={45}
cellSelection={false}
tableOptions={{ hide_options: true }}
suppressRowClickSelection={!isShiftPressed}
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
onRowClicked={onRowClicked}
onCellKeyDown={onCellKeyDown}
editable={editable}
columnDefs={columnDefs}
rowData={rowData}
className={cn(
"ag-no-border group w-full",
tableClassName,
isShiftPressed && quantitySelected > 0 && "no-select-cells",
)}
pagination
ref={tableRef}
quickFilterText={quickFilterText}
getRowId={getRowId}
gridOptions={{
stopEditingWhenCellsLoseFocus: true,
ensureDomOrder: true,
colResizeDefault: "shift",
...gridOptions,
}}
/>
</div>
);

return (
<div className="flex h-full flex-col">
<div className="flex justify-between">
<div className="flex w-full xl:w-5/12">
<Input
icon="Search"
data-testid={searchInputTestId}
type="text"
placeholder={searchPlaceholder}
aria-label={searchInputAriaLabel}
className={cn("w-full", searchInputClassName)}
value={quickFilterText || ""}
onChange={(event) => setQuickFilterText(event.target.value)}
/>
</div>
{toolbarActions}
</div>
<div className="flex h-full flex-col py-4">
{renderTableWrapper ? renderTableWrapper(table) : table}
</div>
{children}
</div>
);
};

export default DataTableTab;
Loading
Loading