diff --git a/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts b/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts index cbdfa3cc5d..fd489b5972 100644 --- a/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts +++ b/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts @@ -33,8 +33,11 @@ export const useCellVisibility = () => { return undefined } + // `.avt-body` is the table package's stable hook. The antd selectors stay as a fallback + // for the raw call sites this hook is also used from. const root = scrollContainer ?? + element.closest(".avt-body") ?? element.closest(".ant-table-body") ?? element.closest(".ant-table-body-inner") ?? null diff --git a/web/oss/src/components/pages/agents/AgentsTableSection.tsx b/web/oss/src/components/pages/agents/AgentsTableSection.tsx index 75beecc12e..deaa678d76 100644 --- a/web/oss/src/components/pages/agents/AgentsTableSection.tsx +++ b/web/oss/src/components/pages/agents/AgentsTableSection.tsx @@ -82,7 +82,7 @@ export default function AgentsTableSection({ return ( - className="grow min-h-0 [&_.ant-table-cell]:!align-middle [&_.ant-table-container]:!border-b" + className="grow min-h-0 [&_.avt-cell]:!align-middle [&_.avt-container]:!border-b" tableScope={tableScope} columns={columns} rowKey={(record) => record.key} diff --git a/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx b/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx index 047672f754..1c4f2039f8 100644 --- a/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx +++ b/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx @@ -297,7 +297,7 @@ const ObservabilityTable = () => { enableExport={false} useSettingsDropdown={false} store={store} - className="flex-1 min-h-0 [&_.ant-table-thead_tr:nth-child(2)]:hidden" + className="flex-1 min-h-0 [&_.avt-thead_tr:nth-child(2)]:hidden" rowSelection={{ selectedRowKeys, type: "checkbox", diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx index d9243fd38b..6122d39164 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx @@ -138,7 +138,7 @@ const SessionsTable: React.FC = () => { resizableColumns enableExport={false} useSettingsDropdown={false} - className="flex-1 min-h-0 [&_.ant-table-tbody_.ant-table-cell]:align-top" + className="flex-1 min-h-0 [&_.avt-row_.avt-cell]:align-top" tableProps={{ bordered: true, loading: isLoading && sessionIds.length === 0, diff --git a/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx b/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx index 74f501615a..a0a73011ee 100644 --- a/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx +++ b/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx @@ -190,7 +190,7 @@ export const PromptsTableSection = ({ return ( - className="grow min-h-0 [&_.ant-table-cell]:!align-middle [&_.ant-table-container]:!border-b" + className="grow min-h-0 [&_.avt-cell]:!align-middle [&_.avt-container]:!border-b" tableScope={tableScope} columns={columns} rowKey={(record) => record.key} diff --git a/web/oss/src/styles/globals.css b/web/oss/src/styles/globals.css index 0d243c498b..103d710618 100644 --- a/web/oss/src/styles/globals.css +++ b/web/oss/src/styles/globals.css @@ -74,7 +74,9 @@ body { transition: opacity 0.3s ease; } -.ant-table-row:hover .hover-button-wrapper { +/* .avt-row is the table package's stable hook; the antd selector stays for raw
users. */ +.ant-table-row:hover .hover-button-wrapper, +.avt-row:hover .hover-button-wrapper { opacity: 1; } diff --git a/web/packages/agenta-entity-ui/tests/unit/tableClassHooks.test.ts b/web/packages/agenta-entity-ui/tests/unit/tableClassHooks.test.ts new file mode 100644 index 0000000000..1e41e2c0ca --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/tableClassHooks.test.ts @@ -0,0 +1,80 @@ +import {AVT, stampTableDom, toAntdColumns} from "@agenta/ui/table" +import {describe, expect, it} from "vitest" + +/** + * The table's stable class hooks. App code targets `avt-*` so a selector does not depend on + * antd's DOM, which the render-leaf swap will replace. If these break, consumer styling + * silently stops applying, so the contract is pinned here rather than left to a browser pass. + */ + +interface FakeNode { + classes: Set + classList: {add: (c: string) => void} +} + +const node = (): FakeNode => { + const classes = new Set() + return {classes, classList: {add: (c: string) => classes.add(c)}} +} + +/** Minimal stand-in for the mounted table: querySelector over a fixed selector map. */ +const container = (found: Record) => + ({ + querySelector: (selector: string) => found[selector] ?? null, + }) as unknown as HTMLElement + +describe("stampTableDom", () => { + it("stamps the structural hooks onto antd's nodes", () => { + const nodes = { + ".ant-table-container": node(), + ".ant-table-body": node(), + ".ant-table-thead": node(), + } + stampTableDom(container(nodes)) + + expect([...nodes[".ant-table-container"].classes]).toEqual([AVT.container]) + expect([...nodes[".ant-table-body"].classes]).toEqual([AVT.body]) + expect([...nodes[".ant-table-thead"].classes]).toEqual([AVT.header]) + }) + + it("skips nodes that are not present rather than throwing", () => { + expect(() => stampTableDom(container({}))).not.toThrow() + expect(() => stampTableDom(null)).not.toThrow() + }) +}) + +describe("toAntdColumns cell hooks", () => { + interface Row { + id: string + } + + it("adds the cell hooks to a plain column", () => { + const [column] = toAntdColumns([{key: "id", title: "ID"}]) + + expect(column.onCell?.({id: "a"}, 0)).toEqual({className: AVT.cell}) + expect(column.onHeaderCell?.(column, 0)).toEqual({className: AVT.headerCell}) + }) + + it("keeps a column's own cell props and appends the hook", () => { + const [column] = toAntdColumns([ + { + key: "id", + onCell: () => ({className: "mine", colSpan: 2}), + }, + ]) + + expect(column.onCell?.({id: "a"}, 0)).toEqual({ + className: `mine ${AVT.cell}`, + colSpan: 2, + }) + }) + + it("reaches columns nested in a group", () => { + const [group] = toAntdColumns([ + {key: "g", title: "Group", children: [{key: "id", title: "ID"}]}, + ]) + const child = (group as {children: (typeof group)[]}).children[0] + + expect(child.onCell?.({id: "a"}, 0)).toEqual({className: AVT.cell}) + }) +}) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts index 2351e17fcc..b4af47609d 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts @@ -1,6 +1,8 @@ import type {ColumnsType as AntdColumnsType} from "antd/es/table" -import type {ColumnDefs} from "./columnDef" +import type {ColumnCellProps, ColumnDefs} from "./columnDef" +import {isColumnGroupDef} from "./columnDef" +import {AVT} from "./tableDom" /** * The one place the table's own column model meets antd's. @@ -11,8 +13,30 @@ import type {ColumnDefs} from "./columnDef" * rather than inferred. Keep the assertion here; do not import antd column types elsewhere in * this directory. */ + +const withClass = (props: ColumnCellProps | undefined, className: string): ColumnCellProps => ({ + ...props, + className: props?.className ? `${props.className} ${className}` : className, +}) + +/** + * Stamps the stable cell hooks. Cells are recycled by virtualization, so they cannot be + * stamped from a mount effect the way the structural nodes are. + */ +const withCellHooks = (columns: ColumnDefs): ColumnDefs => + columns.map((column) => { + const next = { + ...column, + onCell: (record: RecordType, index?: number) => + withClass(column.onCell?.(record, index), AVT.cell), + onHeaderCell: (col: ColumnDefs[number], index?: number) => + withClass(column.onHeaderCell?.(col, index), AVT.headerCell), + } + return isColumnGroupDef(column) ? {...next, children: withCellHooks(column.children)} : next + }) as ColumnDefs + export const toAntdColumns = (columns: ColumnDefs) => - columns as unknown as AntdColumnsType + withCellHooks(columns) as unknown as AntdColumnsType /** Columns arriving from an antd-typed call site, on their way into the table. */ export const fromAntdColumns = (columns: AntdColumnsType) => diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx index c607b24852..3fa7ae06f2 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx @@ -40,6 +40,7 @@ import useTableRowSelection from "../hooks/useTableRowSelection" import {useTypeChipColumns} from "../hooks/useTypeChipColumns" import {useTypeChipFeature} from "../hooks/useTypeChipFeature" import ColumnVisibilityProvider from "../providers/ColumnVisibilityProvider" +import {ANTD_SELECTOR, AVT, stampTableDom} from "../tableDom" import type {InfiniteVirtualTableProps} from "../types" import { buildColumnDescendantMap, @@ -190,9 +191,7 @@ const InfiniteVirtualTableInnerBase = ({ return } const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), + container.querySelectorAll(ANTD_SELECTOR.headerCellWithKey), ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) if (!headerCells.length) { columnDomRefs.current = new Map() @@ -329,7 +328,7 @@ const InfiniteVirtualTableInnerBase = ({ const tables = container.querySelectorAll(".ant-table table") tables.forEach((table) => { const selectionCol = table.querySelector( - "colgroup col.ant-table-selection-col", + ANTD_SELECTOR.selectionCol, ) if (selectionCol) { selectionCol.style.width = widthPx @@ -339,7 +338,7 @@ const InfiniteVirtualTableInnerBase = ({ }) const headerCells = container.querySelectorAll( - ".ant-table-thead th.ant-table-selection-column", + ANTD_SELECTOR.headerSelectionCell, ) headerCells.forEach((cell) => { cell.style.width = widthPx @@ -366,7 +365,7 @@ const InfiniteVirtualTableInnerBase = ({ return } const headerEl = - container.querySelector(".ant-table-thead") ?? + container.querySelector(ANTD_SELECTOR.header) ?? container.querySelector("table thead") if (!headerEl) { setTableHeaderHeight(null) @@ -411,7 +410,7 @@ const InfiniteVirtualTableInnerBase = ({ const headerHeight = (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) + : (containerRef.current?.querySelector(ANTD_SELECTOR.header) as HTMLElement | null) ?.offsetHeight) ?? null const computedY = Math.max((scrollY ?? 0) - (headerHeight ?? 0), 0) @@ -692,6 +691,20 @@ const InfiniteVirtualTableInnerBase = ({ const columnVisibilityVersion = version const tableComponentRef = tableRef as unknown as Ref + // Stable class hooks for app code, so a consumer's selector does not depend on antd's DOM. + // The structural nodes exist for the table's lifetime; rows and cells get theirs from + // rowClassName and the column adapter, because virtualization recycles them. + useEffect(() => { + stampTableDom(containerRef.current) + }, [dataSource]) + + const rowClassName = useMemo["rowClassName"]>(() => { + const inherited = tablePropsWithShortcuts.rowClassName + if (!inherited) return AVT.row + if (typeof inherited !== "function") return cn(inherited, AVT.row) + return (record, index, indent) => cn(inherited(record, index, indent), AVT.row) + }, [tablePropsWithShortcuts.rowClassName]) + useEffect(() => { const key = resolvedScopeId if (!key) return undefined @@ -731,6 +744,7 @@ const InfiniteVirtualTableInnerBase = ({
({ rowSelection={tableRowSelection} expandable={tableExpandable} {...tablePropsWithShortcuts} + rowClassName={rowClassName} scroll={{ x: scrollConfig.x, y: scrollConfig.y, diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts index 45de13d2df..aa02352e45 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts @@ -1,6 +1,7 @@ import {useLayoutEffect, useRef} from "react" import type {ColumnDefs} from "../columnDef" +import {ANTD_SELECTOR} from "../tableDom" interface ColumnDomRefs { cols: HTMLTableColElement[] @@ -24,9 +25,7 @@ const useColumnDomRefs = ( } const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), + container.querySelectorAll(ANTD_SELECTOR.headerCellWithKey), ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) if (!headerCells.length) { diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts index 2bc84e02ad..5fc7606edc 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts @@ -2,6 +2,7 @@ import {useMemo, useRef, type RefObject} from "react" import type {TableProps} from "antd/es/table" +import {ANTD_SELECTOR} from "../tableDom" import {shallowEqual} from "../utils/columnUtils" interface UseScrollConfigOptions { @@ -50,7 +51,7 @@ const useScrollConfig = ({ const headerHeight = (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) + : (containerRef.current?.querySelector(ANTD_SELECTOR.header) as HTMLElement | null) ?.offsetHeight) ?? null const computedY = Math.max((containerHeight ?? 0) - (headerHeight ?? 0), 0) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts index 0a82f638a0..0ae9ebba66 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts @@ -1,5 +1,7 @@ import {useEffect, useRef, useState} from "react" +import {ANTD_SELECTOR} from "../tableDom" + interface ScrollContainerResult { scrollContainer: HTMLDivElement | null visibilityRoot: HTMLDivElement | null @@ -33,7 +35,7 @@ const useScrollContainer = ( return } - const tableBody = containerElement.querySelector(".ant-table-body") ?? null + const tableBody = containerElement.querySelector(ANTD_SELECTOR.body) ?? null const isScrollable = (element: HTMLDivElement | null) => { if (!element) return false @@ -52,7 +54,7 @@ const useScrollContainer = ( } const headerContainer = - containerElement.querySelector(".ant-table-container") ?? + containerElement.querySelector(ANTD_SELECTOR.container) ?? containerElement if (headerContainer !== lastVisibilityRootRef.current) { diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts index 9e16e47254..e03838e771 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts @@ -3,6 +3,7 @@ import {useLayoutEffect, useState, type RefObject} from "react" import type {TableProps} from "antd/es/table" import type {ColumnDefs} from "../columnDef" +import {ANTD_SELECTOR} from "../tableDom" interface UseTableHeaderHeightOptions { containerRef: RefObject @@ -29,7 +30,7 @@ const useTableHeaderHeight = ({ return } const headerEl = - container.querySelector(".ant-table-thead") ?? + container.querySelector(ANTD_SELECTOR.header) ?? container.querySelector("table thead") if (!headerEl) { setTableHeaderHeight(null) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx index f7525b95ec..4cba009b7d 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx @@ -17,6 +17,7 @@ import type { TableDeleteConfig, TableExportConfig, } from "../features/InfiniteVirtualTableFeatureShell" +import {ANTD_SELECTOR} from "../tableDom" import type { InfiniteTableRowBase, InfiniteVirtualTableProps, @@ -31,7 +32,7 @@ const dummySearchAtom = atom("") const INTERACTIVE_SELECTOR = "button, a, input, textarea, select, [role='button'], [role='menuitem'], [role='checkbox'], " + ".ant-btn, .ant-checkbox, .ant-checkbox-input, .ant-checkbox-inner, .ant-checkbox-wrapper, " + - ".ant-select, .ant-dropdown-trigger, .ant-table-selection-column, .ag-table-actions-cell" + ANTD_SELECTOR.interactiveCell /** * Returns true when the click originated from an interactive element (button, link, diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts index 7cdc4a2345..5b245f0148 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts @@ -137,6 +137,7 @@ export type { ColumnSorterConfig, } from "./columnDef" export {toAntdColumns, fromAntdColumns} from "./antdColumns" +export {AVT, ANTD_SELECTOR, stampTableDom, type AvtClass} from "./tableDom" export type {VisibilityRegistrationHandler} from "./components/ColumnVisibilityHeader" // Shared hooks for cell renderers diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/tableDom.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/tableDom.ts new file mode 100644 index 0000000000..ff2c2a4a83 --- /dev/null +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/tableDom.ts @@ -0,0 +1,55 @@ +/** + * The table's DOM contract, in one file. + * + * `AVT` names are stamped onto the rendered table by the package and are the only hooks app + * code should target. They survive the render-leaf swap; `.ant-table-*` will not. + * + * `ANTD_SELECTOR` is where each hook lives in antd's DOM today. It is the implementation + * detail the swap replaces, so keep every `.ant-table-*` string in this file and nowhere else + * in the directory. + */ + +export const AVT = { + root: "avt-table", + container: "avt-container", + body: "avt-body", + header: "avt-thead", + row: "avt-row", + cell: "avt-cell", + headerCell: "avt-head-cell", +} as const + +export type AvtClass = (typeof AVT)[keyof typeof AVT] + +export const ANTD_SELECTOR = { + container: ".ant-table-container", + body: ".ant-table-body", + bodyInner: ".ant-table-body-inner", + header: ".ant-table-thead", + headerCellWithKey: ".ant-table-thead th[data-column-key]", + headerSelectionCell: ".ant-table-thead th.ant-table-selection-column", + selectionCol: "colgroup col.ant-table-selection-col", + /** Cells that own their click, so row-click shortcuts skip them. */ + interactiveCell: + ".ant-select, .ant-dropdown-trigger, .ant-table-selection-column, .ag-table-actions-cell", +} as const + +/** Structural nodes rendered once per table, so a mount-time stamp is enough. */ +const STAMPED: [selector: string, className: string][] = [ + [ANTD_SELECTOR.container, AVT.container], + [ANTD_SELECTOR.body, AVT.body], + [ANTD_SELECTOR.header, AVT.header], +] + +/** + * Adds the stable class hooks to a mounted table. + * + * Rows and cells are not stamped here — virtualization recycles them, so they get their class + * through `rowClassName` and the column adapter instead. + */ +export const stampTableDom = (container: HTMLElement | null): void => { + if (!container) return + for (const [selector, className] of STAMPED) { + container.querySelector(selector)?.classList.add(className) + } +}