From 0133543e5d144406a2d22bdef85756abd15f5cde Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 23 Jul 2026 18:47:58 +0200 Subject: [PATCH 1/2] refactor(ui): unify extended column type, clear dual-copy imports Prep for retiring the duplicate InfiniteVirtualTable in oss/src/components (55 files, a strict subset of the 67-file @agenta/ui copy). The two copies had grown parallel abstractions for the same concept: the package's ExtendedColumn/ExtendedColumnProps and an ExtendedColumnType added to the OSS copy. Their bases were already equivalent (ColumnsType[number] IS ColumnType | ColumnGroupType), so the package type just gained the three props it lacked (defaultHidden, maxWidth, exportEnabled) and the OSS type is now an alias of it. Three files imported BOTH copies at once (TestcasesTableShell, getObservabilityColumns, getSessionColumns); each needed only the column type from the OSS side, so they now take it from the package. No file imports both copies anymore. --- .../InfiniteVirtualTable/columns/types.ts | 22 ++++--------------- .../components/TestcasesTableShell.tsx | 4 ++-- .../assets/getObservabilityColumns.tsx | 5 ++--- .../assets/getSessionColumns.tsx | 6 ++--- .../src/InfiniteVirtualTable/types.ts | 6 +++++ 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/types.ts b/web/oss/src/components/InfiniteVirtualTable/columns/types.ts index e25840aa51..f31f6da2d1 100644 --- a/web/oss/src/components/InfiniteVirtualTable/columns/types.ts +++ b/web/oss/src/components/InfiniteVirtualTable/columns/types.ts @@ -1,27 +1,13 @@ import type {Key, ReactNode} from "react" +import type {ExtendedColumn} from "@agenta/ui/table" import type {ColumnsType, ColumnType} from "antd/es/table" /** - * antd column extended with the custom props the InfiniteVirtualTable layer - * consumes at runtime (column visibility menu, smart resizing, export). + * Alias of the canonical extended column in @agenta/ui/table. This local copy + * of InfiniteVirtualTable is being retired; consumers should move to the package. */ -export type ExtendedColumnType = ColumnsType[number] & { - key?: Key - children?: ExtendedColumnType[] - /** Custom node shown for this column in the visibility menu */ - columnVisibilityTitle?: ReactNode - /** Label shown for this column in the visibility menu */ - columnVisibilityLabel?: string - /** Lock column from being hidden via the visibility menu */ - columnVisibilityLocked?: boolean - /** Hide the column by default (until toggled visible) */ - defaultHidden?: boolean - /** Max width constraint consumed by smart resizable columns */ - maxWidth?: number - /** Include the column in table export */ - exportEnabled?: boolean -} +export type ExtendedColumnType = ExtendedColumn export interface TableColumnCell { render: (row: Row, rowIndex: number) => ReactNode diff --git a/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx b/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx index 0c8866ed7f..efc83edc5c 100644 --- a/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx +++ b/web/oss/src/components/TestcasesTableNew/components/TestcasesTableShell.tsx @@ -8,6 +8,7 @@ import { type TableScopeConfig, type TypeChipConfig, useTypeChipFeature, + type ExtendedColumn, } from "@agenta/ui/table" import {TypeChip, type ChipVariant} from "@agenta/ui/type-chip" import {PlusOutlined} from "@ant-design/icons" @@ -18,7 +19,6 @@ import clsx from "clsx" import {useAtomValue} from "jotai" import {getDefaultStore} from "jotai/vanilla" -import type {ExtendedColumnType} from "@/oss/components/InfiniteVirtualTable" import {extractTestcaseUserData, testcaseEntityAtomFamily} from "@/oss/state/entities/testcase" import type {Column} from "@/oss/state/entities/testcase/columnState" import {objectColumnSubKeysAtom} from "@/oss/state/entities/testcase/columnState" @@ -671,7 +671,7 @@ export function TestcasesTableShell(props: TestcasesTableShellProps) { } // Custom actions column with Add Column button in header - const actionsColumn: ExtendedColumnType[] = [ + const actionsColumn: ExtendedColumn[] = [ { title: (
diff --git a/web/oss/src/components/pages/observability/assets/getObservabilityColumns.tsx b/web/oss/src/components/pages/observability/assets/getObservabilityColumns.tsx index e8cb6e65b3..28febfde5e 100644 --- a/web/oss/src/components/pages/observability/assets/getObservabilityColumns.tsx +++ b/web/oss/src/components/pages/observability/assets/getObservabilityColumns.tsx @@ -2,11 +2,10 @@ import type {Key} from "react" import {LastInputMessageCell, SmartCellContent} from "@agenta/ui/cell-renderers" import {CopyTooltip as TooltipWithCopyAction} from "@agenta/ui/copy-tooltip" -import {ColumnVisibilityMenuTrigger} from "@agenta/ui/table" +import {ColumnVisibilityMenuTrigger, type ExtendedColumn} from "@agenta/ui/table" import {Tag} from "antd" import {ColumnsType} from "antd/es/table" -import type {ExtendedColumnType} from "@/oss/components/InfiniteVirtualTable" import {sanitizeDataWithBlobUrls} from "@/oss/lib/helpers/utils" import {TraceSpanNode} from "@/oss/services/tracing/types" import { @@ -33,7 +32,7 @@ interface ObservabilityColumnsProps { export type TraceRow = TraceSpanNode & {key: Key; [key: string]: unknown} // antd column extended with props consumed by the InfiniteVirtualTable layer. -type ObservabilityColumn = ExtendedColumnType +type ObservabilityColumn = ExtendedColumn const collectDefaultHiddenColumnKeys = (columns: ColumnsType): string[] => { const hiddenKeys = new Set() diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/assets/getSessionColumns.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/assets/getSessionColumns.tsx index 492f3ff674..1ee4aecd26 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/assets/getSessionColumns.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/assets/getSessionColumns.tsx @@ -1,8 +1,6 @@ import {Key} from "react" -import {ColumnVisibilityMenuTrigger} from "@agenta/ui/table" - -import type {ExtendedColumnType} from "@/oss/components/InfiniteVirtualTable" +import {ColumnVisibilityMenuTrigger, type ExtendedColumn} from "@agenta/ui/table" import { DurationCell, @@ -27,7 +25,7 @@ export interface SessionRow { } // antd column extended with props consumed by the InfiniteVirtualTable layer. -type SessionColumn = ExtendedColumnType +type SessionColumn = ExtendedColumn export const getSessionColumns = (): SessionColumn[] => [ { diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts index 8eec7f177c..9ffe2e7f85 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/types.ts @@ -26,6 +26,12 @@ export interface ExtendedColumnProps { columnVisibilityLabel?: string /** If true, column cannot be hidden */ columnVisibilityLocked?: boolean + /** Hide the column by default (until toggled visible) */ + defaultHidden?: boolean + /** Max width constraint consumed by smart resizable columns */ + maxWidth?: number + /** Include the column in table export */ + exportEnabled?: boolean /** Nested children columns */ children?: ExtendedColumn[] } From 1b3be940ddd57f69bc012a6c71c8099984765313 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 23 Jul 2026 20:42:18 +0200 Subject: [PATCH 2/2] refactor(ui): delete duplicate InfiniteVirtualTable, migrate to @agenta/ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library existed twice: 55 files in oss/src/components/InfiniteVirtualTable (a strict subset) and the canonical 67-file copy in @agenta/ui. Fixes to either had to be applied to both — this session alone hit that with the rowSelection.fixed widening and the ExtendedColumnType consolidation. - Migrated all 33 app consumers from @/oss/components/InfiniteVirtualTable to @agenta/ui/table (the barrel already re-exported every symbol they used). - Deleted the 55-file OSS copy. - Package exports ColumnVisibilityNodeMeta / ColumnVisibilityNodeMetaResolver (needed by a migrated consumer). Preserved the export permission gate. The retired OSS shell computed exportEnabled = enableExport && canExportData internally; the package shell has no permission check and defaults enableExport=true. The two consumers that relied on the implicit gate (EvalRunDetails/Table.tsx, EvaluationRunsTablePOC/.../EvaluationRunsTable/index.tsx) now pass enableExport={canExportData} explicitly. tsc could not have caught this — both sides are boolean. TestsetsTable needed two behavior-preserving adjustments where the package types are wider: String(record.key) at two Set/string[] lookups (keys are UUIDs, so a no-op) and 'as never' on the invariant datasetStore generic (mirrors DeploymentsTable). --- .../src/components/EvalRunDetails/Table.tsx | 19 +- .../EvalTestcaseDrawerAdapter/index.tsx | 6 +- .../components/FocusDrawerHeader.tsx | 3 +- .../components/FocusDrawerSidePanel.tsx | 2 +- .../ColumnVisibilityPopoverContent.tsx | 10 +- .../ScenarioNavigator.tsx | 3 +- .../views/SingleScenarioViewerPOC/index.tsx | 3 +- .../evaluationPreviewTableStore.ts | 9 +- .../EvalRunDetails/hooks/useCellVisibility.ts | 2 +- .../hooks/usePreviewColumns.tsx | 9 +- .../utils/buildPreviewColumns.tsx | 4 +- .../atoms/fetchAutoEvaluationRuns.ts | 2 +- .../atoms/tableStore.ts | 5 +- .../components/EvaluationRunsTable/index.tsx | 18 +- .../components/EvaluationRunsTable/types.ts | 2 +- .../ColumnVisibilityPopoverContent.tsx | 11 +- .../filters/EvaluationRunsHeaderFilters.tsx | 2 +- .../hooks/useEvaluationRunsColumns/index.tsx | 14 +- .../hooks/useEvaluationRunsColumns/utils.tsx | 3 +- .../hooks/useEvaluatorHeaderReference.ts | 2 +- .../EvaluationRunsTablePOC/types.ts | 4 +- .../InfiniteVirtualTable.tsx | 70 -- .../atoms/columnHiddenKeys.ts | 116 --- .../atoms/columnVisibility.ts | 268 ------- .../atoms/columnWidths.ts | 25 - .../InfiniteVirtualTable/columns/cells.tsx | 210 ------ .../columns/createStandardColumns.tsx | 348 --------- .../columns/createTableColumns.ts | 158 ----- .../InfiniteVirtualTable/columns/types.ts | 55 -- .../components/ColumnVisibilityHeader.tsx | 48 -- .../components/ColumnVisibilityTrigger.tsx | 124 ---- .../components/InfiniteVirtualTableInner.tsx | 661 ----------------- .../components/TableDescription.tsx | 49 -- .../components/TableShell.tsx | 117 ---- .../ColumnVisibilityMenuTrigger.tsx | 73 -- .../ColumnVisibilityPopoverContent.tsx | 320 --------- .../TableSettingsDropdown.tsx | 161 ----- .../filters/FiltersPopoverTrigger.tsx | 81 --- .../context/ColumnVisibilityContext.ts | 59 -- .../context/ColumnVisibilityFlagContext.tsx | 45 -- .../VirtualTableScrollContainerContext.ts | 7 - .../createInfiniteDatasetStore.ts | 266 ------- .../createInfiniteTableStore.ts | 370 ---------- .../InfiniteVirtualTableFeatureShell.tsx | 616 ---------------- .../InfiniteVirtualTable/features/index.ts | 12 - .../useInfiniteTableFeaturePagination.ts | 23 - .../helpers/createSimpleTableStore.ts | 191 ----- .../helpers/createTableRowHelpers.ts | 105 --- .../InfiniteVirtualTable/helpers/index.ts | 15 - .../hooks/useColumnDomRefs.ts | 80 --- .../hooks/useColumnVisibility.ts | 283 -------- .../hooks/useColumnVisibilityControls.ts | 93 --- .../hooks/useContainerResize.ts | 76 -- .../hooks/useContainerSize.ts | 58 -- .../hooks/useEditableTable.ts | 454 ------------ .../hooks/useExpandableRows.tsx | 284 -------- .../hooks/useHeaderViewportVisibility.ts | 435 ------------ .../hooks/useInfiniteScroll.ts | 54 -- .../hooks/useInfiniteTablePagination.ts | 144 ---- .../hooks/useResizableColumns.ts | 221 ------ .../hooks/useRowHeight.tsx | 187 ----- .../hooks/useScopedColumnVisibility.tsx | 28 - .../hooks/useScrollConfig.ts | 108 --- .../hooks/useScrollContainer.ts | 67 -- .../hooks/useSmartResizableColumns.ts | 406 ----------- .../hooks/useTableActions.tsx | 173 ----- .../hooks/useTableExport.ts | 349 --------- .../hooks/useTableHeaderHeight.ts | 52 -- .../hooks/useTableKeyboardShortcuts.ts | 663 ------------------ .../hooks/useTableManager.tsx | 500 ------------- .../hooks/useTableRowSelection.ts | 56 -- .../components/InfiniteVirtualTable/index.ts | 102 --- .../providers/ColumnVisibilityProvider.tsx | 53 -- .../InfiniteVirtualTableStoreProvider.tsx | 38 - .../components/InfiniteVirtualTable/types.ts | 310 -------- .../InfiniteVirtualTable/utils/columnUtils.ts | 101 --- .../TestsetPreviewPanelWrapper.tsx | 2 +- .../components/PreviewSection.tsx | 2 +- .../components/TestcaseHeader.tsx | 2 +- .../components/TestcasesTableNew/index.tsx | 2 +- .../TestcasesTableNew/state/rowHeight.ts | 2 +- .../TestsetsTable/TestsetsTable.tsx | 9 +- .../assets/createTestsetsColumns.tsx | 6 +- .../TestsetsTable/atoms/fetchTestsets.ts | 3 +- .../components/TestsetsHeaderFilters.tsx | 2 +- .../shared/createPaginatedEntityStore.ts | 17 +- .../state/entities/testcase/paginatedStore.ts | 8 +- .../state/entities/testset/paginatedStore.ts | 6 +- .../src/InfiniteVirtualTable/index.ts | 4 + 89 files changed, 93 insertions(+), 10073 deletions(-) delete mode 100644 web/oss/src/components/InfiniteVirtualTable/InfiniteVirtualTable.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/atoms/columnHiddenKeys.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/atoms/columnVisibility.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/atoms/columnWidths.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/columns/cells.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/columns/createTableColumns.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/columns/types.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityTrigger.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/TableDescription.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/TableShell.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/TableSettingsDropdown.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/components/filters/FiltersPopoverTrigger.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityContext.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityFlagContext.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/context/VirtualTableScrollContainerContext.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/createInfiniteDatasetStore.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/createInfiniteTableStore.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/features/InfiniteVirtualTableFeatureShell.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/features/index.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/features/useInfiniteTableFeaturePagination.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/helpers/createSimpleTableStore.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/helpers/createTableRowHelpers.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/helpers/index.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useColumnDomRefs.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibility.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibilityControls.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useContainerResize.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useContainerSize.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useEditableTable.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useExpandableRows.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useHeaderViewportVisibility.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteScroll.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteTablePagination.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useResizableColumns.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useRowHeight.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useScopedColumnVisibility.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useScrollConfig.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useScrollContainer.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useTableActions.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useTableExport.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useTableKeyboardShortcuts.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/hooks/useTableRowSelection.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/index.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/providers/ColumnVisibilityProvider.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/providers/InfiniteVirtualTableStoreProvider.tsx delete mode 100644 web/oss/src/components/InfiniteVirtualTable/types.ts delete mode 100644 web/oss/src/components/InfiniteVirtualTable/utils/columnUtils.ts diff --git a/web/oss/src/components/EvalRunDetails/Table.tsx b/web/oss/src/components/EvalRunDetails/Table.tsx index c88a66c16d..831739847b 100644 --- a/web/oss/src/components/EvalRunDetails/Table.tsx +++ b/web/oss/src/components/EvalRunDetails/Table.tsx @@ -2,22 +2,21 @@ import {useCallback, useEffect, useMemo, useRef} from "react" import type {RunSchema} from "@agenta/entities/evaluationRun/etl" import {message} from "@agenta/ui/app-message" -import clsx from "clsx" -import {useAtomValue, useSetAtom, useStore} from "jotai" - -import VirtualizedScenarioTableAnnotateDrawer from "@/oss/components/EvalRunDetails/components/AnnotateDrawer/VirtualizedScenarioTableAnnotateDrawer" import { + EXPORT_RESOLVE_SKIP, InfiniteVirtualTableFeatureShell, type ColumnVisibilityMenuRendererContext, type ColumnVisibilityState, + type TableExportColumnContext, type TableFeaturePagination, type TableScopeConfig, useInfiniteTablePagination, -} from "@/oss/components/InfiniteVirtualTable" -import { - EXPORT_RESOLVE_SKIP, - type TableExportColumnContext, -} from "@/oss/components/InfiniteVirtualTable/hooks/useTableExport" +} from "@agenta/ui/table" +import clsx from "clsx" +import {useAtomValue, useSetAtom, useStore} from "jotai" + +import VirtualizedScenarioTableAnnotateDrawer from "@/oss/components/EvalRunDetails/components/AnnotateDrawer/VirtualizedScenarioTableAnnotateDrawer" +import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" import useComparisonPaginations from "../EvalRunDetails2/hooks/useComparisonPaginations" import useComparisonSchemas from "../EvalRunDetails2/hooks/useComparisonSchemas" @@ -73,6 +72,7 @@ const EvalRunDetailsTable = ({ const runDisplayName = useAtomValue(runDisplayNameAtom) const rowHeightMenuItems = useRowHeightMenuItems() const store = useStore() + const {canExportData} = useProjectPermissions() const basePagination = useInfiniteTablePagination({ store: evaluationPreviewTableStore, @@ -1073,6 +1073,7 @@ const EvalRunDetailsTable = ({ /> )} pagination={paginationForShell} + enableExport={canExportData} exportOptions={exportOptions} tableProps={{ rowClassName: (record) => diff --git a/web/oss/src/components/EvalRunDetails/components/EvalTestcaseDrawerAdapter/index.tsx b/web/oss/src/components/EvalRunDetails/components/EvalTestcaseDrawerAdapter/index.tsx index 461de8e997..3c67145f6e 100644 --- a/web/oss/src/components/EvalRunDetails/components/EvalTestcaseDrawerAdapter/index.tsx +++ b/web/oss/src/components/EvalRunDetails/components/EvalTestcaseDrawerAdapter/index.tsx @@ -6,13 +6,9 @@ import { useTestcaseDrawerNavigation, type TestcaseDrawerContentRenderProps, } from "@agenta/entity-ui/testcase" +import {useInfiniteTablePagination, type InfiniteTableStore} from "@agenta/ui/table" import {useAtomValue, useSetAtom} from "jotai" -import { - useInfiniteTablePagination, - type InfiniteTableStore, -} from "@/oss/components/InfiniteVirtualTable" - import {scenarioStepsQueryFamily} from "../../atoms/scenarioSteps" import { scenarioTestcaseEntityAtomFamily, diff --git a/web/oss/src/components/EvalRunDetails/components/FocusDrawerHeader.tsx b/web/oss/src/components/EvalRunDetails/components/FocusDrawerHeader.tsx index 42de27f28d..344e832141 100644 --- a/web/oss/src/components/EvalRunDetails/components/FocusDrawerHeader.tsx +++ b/web/oss/src/components/EvalRunDetails/components/FocusDrawerHeader.tsx @@ -1,12 +1,11 @@ import {memo, useCallback, useEffect, useMemo} from "react" import {CopyTooltip as TooltipWithCopyAction} from "@agenta/ui/copy-tooltip" +import {useInfiniteTablePagination} from "@agenta/ui/table" import {CaretDownIcon, CaretUpIcon} from "@phosphor-icons/react" import {Button, Select, SelectProps, Tag, Typography} from "antd" import {useAtomValue} from "jotai" -import {useInfiniteTablePagination} from "@/oss/components/InfiniteVirtualTable" - import {evaluationPreviewTableStore} from "../evaluationPreviewTableStore" import {previewEvalTypeAtom} from "../state/evalType" import {focusScenarioAtom} from "../state/focusDrawerAtom" diff --git a/web/oss/src/components/EvalRunDetails/components/FocusDrawerSidePanel.tsx b/web/oss/src/components/EvalRunDetails/components/FocusDrawerSidePanel.tsx index 868a4b2d5e..f931d35fbb 100644 --- a/web/oss/src/components/EvalRunDetails/components/FocusDrawerSidePanel.tsx +++ b/web/oss/src/components/EvalRunDetails/components/FocusDrawerSidePanel.tsx @@ -1,12 +1,12 @@ import {memo, useCallback, useMemo, useState} from "react" import type {ReactNode} from "react" +import {useInfiniteTablePagination} from "@agenta/ui/table" import {TreeStructure, Download, Sparkle, Speedometer} from "@phosphor-icons/react" import {Skeleton} from "antd" import {useAtomValue} from "jotai" import CustomTreeComponent from "@/oss/components/CustomUIs/CustomTreeComponent" -import {useInfiniteTablePagination} from "@/oss/components/InfiniteVirtualTable" import {evaluationPreviewTableStore} from "../evaluationPreviewTableStore" import usePreviewTableData from "../hooks/usePreviewTableData" diff --git a/web/oss/src/components/EvalRunDetails/components/columnVisibility/ColumnVisibilityPopoverContent.tsx b/web/oss/src/components/EvalRunDetails/components/columnVisibility/ColumnVisibilityPopoverContent.tsx index 45537f4ffd..fa874154cb 100644 --- a/web/oss/src/components/EvalRunDetails/components/columnVisibility/ColumnVisibilityPopoverContent.tsx +++ b/web/oss/src/components/EvalRunDetails/components/columnVisibility/ColumnVisibilityPopoverContent.tsx @@ -1,11 +1,13 @@ import {useMemo, useCallback, useEffect, useRef} from "react" +import { + ColumnVisibilityPopoverContentBase, + type ColumnTreeNode, + type ColumnVisibilityNodeMeta, + type ColumnVisibilityState, +} from "@agenta/ui/table" import {Typography} from "antd" -import type {ColumnTreeNode, ColumnVisibilityState} from "@/oss/components/InfiniteVirtualTable" -import ColumnVisibilityPopoverContentBase, { - type ColumnVisibilityNodeMeta, -} from "@/oss/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent" import {humanizeMetricPath} from "@/oss/lib/evaluations/utils/metrics" import { diff --git a/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioNavigator.tsx b/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioNavigator.tsx index 989c56265a..bc779be92f 100644 --- a/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioNavigator.tsx +++ b/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioNavigator.tsx @@ -1,10 +1,9 @@ import {memo, useCallback, useEffect, useMemo} from "react" +import {useInfiniteTablePagination} from "@agenta/ui/table" import {LeftOutlined, RightOutlined} from "@ant-design/icons" import {Button, Select, SelectProps, Tag, Typography} from "antd" -import {useInfiniteTablePagination} from "@/oss/components/InfiniteVirtualTable" - import {evaluationPreviewTableStore} from "../../../evaluationPreviewTableStore" interface ScenarioNavigatorProps { diff --git a/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/index.tsx b/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/index.tsx index dbdb51616e..930204f58b 100644 --- a/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/index.tsx +++ b/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/index.tsx @@ -1,12 +1,11 @@ import {memo, useCallback, useEffect, useMemo, useRef} from "react" +import {useInfiniteTablePagination} from "@agenta/ui/table" import {Card, Tag, Typography} from "antd" import {useAtom, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {useRouter} from "next/router" -import {useInfiniteTablePagination} from "@/oss/components/InfiniteVirtualTable" - import {scenarioAnnotationsQueryAtomFamily} from "../../../atoms/annotations" import {runningInvocationsAtom, triggerRunInvocationAtom} from "../../../atoms/runInvocationAction" import {scenarioStepsQueryFamily} from "../../../atoms/scenarioSteps" diff --git a/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts b/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts index ad0c8b5f74..6135699552 100644 --- a/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts +++ b/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts @@ -1,13 +1,12 @@ import type {Key} from "react" -import {atom, useAtom} from "jotai" -import {atomFamily} from "jotai/utils" - import { createInfiniteTableStore, useInfiniteTablePagination, -} from "@/oss/components/InfiniteVirtualTable" -import type {InfiniteDatasetStore} from "@/oss/components/InfiniteVirtualTable/createInfiniteDatasetStore" + type InfiniteDatasetStore, +} from "@agenta/ui/table" +import {atom, useAtom} from "jotai" +import {atomFamily} from "jotai/utils" import {effectiveProjectIdAtom} from "./atoms/run" import type {WindowingState, EvaluationScenarioRow} from "./atoms/table" diff --git a/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts b/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts index 1949906009..cbdfa3cc5d 100644 --- a/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts +++ b/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts @@ -1,6 +1,6 @@ import {useCallback, useEffect, useState} from "react" -import {useVirtualTableScrollContainer} from "@/oss/components/InfiniteVirtualTable" +import {useVirtualTableScrollContainer} from "@agenta/ui/table" // Fixed buffer values - no need for dynamic calculation per cell // These provide generous lookahead for smooth scrolling diff --git a/web/oss/src/components/EvalRunDetails/hooks/usePreviewColumns.tsx b/web/oss/src/components/EvalRunDetails/hooks/usePreviewColumns.tsx index 196bbfbe73..2fee417450 100644 --- a/web/oss/src/components/EvalRunDetails/hooks/usePreviewColumns.tsx +++ b/web/oss/src/components/EvalRunDetails/hooks/usePreviewColumns.tsx @@ -1,12 +1,13 @@ import {useEffect, useMemo, useCallback, useRef} from "react" import type {ReactNode} from "react" +import { + ColumnVisibilityMenuTrigger, + type ColumnTreeNode, + type ColumnVisibilityNodeMeta, +} from "@agenta/ui/table" import {Typography} from "antd" -import type {ColumnTreeNode} from "@/oss/components/InfiniteVirtualTable" -import ColumnVisibilityMenuTrigger, { - type ColumnVisibilityNodeMeta, -} from "@/oss/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger" import {humanizeMetricPath} from "@/oss/lib/evaluations/utils/metrics" import { diff --git a/web/oss/src/components/EvalRunDetails/utils/buildPreviewColumns.tsx b/web/oss/src/components/EvalRunDetails/utils/buildPreviewColumns.tsx index 1f7fb3d915..e836e3e72b 100644 --- a/web/oss/src/components/EvalRunDetails/utils/buildPreviewColumns.tsx +++ b/web/oss/src/components/EvalRunDetails/utils/buildPreviewColumns.tsx @@ -1,12 +1,10 @@ import React from "react" +import {ColumnVisibilityHeader, type ExtendedColumn as ExtendedColumnType} from "@agenta/ui/table" import {Tooltip} from "antd" import type {ColumnsType, ColumnType} from "antd/es/table" import clsx from "clsx" -import {ColumnVisibilityHeader} from "@/oss/components/InfiniteVirtualTable" -import type {ExtendedColumnType} from "@/oss/components/InfiniteVirtualTable" - import type { EvaluationTableColumn, EvaluationTableColumnGroup, diff --git a/web/oss/src/components/EvaluationRunsTablePOC/atoms/fetchAutoEvaluationRuns.ts b/web/oss/src/components/EvaluationRunsTablePOC/atoms/fetchAutoEvaluationRuns.ts index 5d4d35b843..9b03016255 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/atoms/fetchAutoEvaluationRuns.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/atoms/fetchAutoEvaluationRuns.ts @@ -1,6 +1,6 @@ import {hasResolvableSubject, isSubjectRun} from "@agenta/entities/evaluationRun/etl" +import type {WindowingState} from "@agenta/ui/table" -import type {WindowingState} from "@/oss/components/InfiniteVirtualTable/types" import {deriveEvaluationKind} from "@/oss/lib/evaluations/utils/evaluationKind" import type {QueryWindowingPayload} from "../../../services/onlineEvaluations/api" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts b/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts index 803a19f0f4..f71dac8842 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts @@ -1,11 +1,10 @@ +import {createInfiniteDatasetStore} from "@agenta/ui/table" +import type {WindowingState} from "@agenta/ui/table" import {atom} from "jotai" import type {PrimitiveAtom} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithStorage} from "jotai/vanilla/utils" -import {createInfiniteDatasetStore} from "@/oss/components/InfiniteVirtualTable" -import type {WindowingState} from "@/oss/components/InfiniteVirtualTable/types" - import type { EvaluationRunApiRow, EvaluationRunTableRow, diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx index 6568d9e6ff..003e317660 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx +++ b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/index.tsx @@ -1,6 +1,14 @@ import type {Key, ReactNode} from "react" import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import { + InfiniteVirtualTableFeatureShell, + useTableExport, + EXPORT_RESOLVE_SKIP, + type TableFeaturePagination, + type TableScopeConfig, + type TableExportColumnContext, +} from "@agenta/ui/table" import {useQueryClient} from "@tanstack/react-query" import {Grid} from "antd" import type {TableProps} from "antd/es/table" @@ -12,15 +20,6 @@ import {useRouter} from "next/router" import type {DeleteEvaluationKind} from "@/oss/components/DeleteEvaluationModal/types" import {activePreviewProjectIdAtom} from "@/oss/components/EvalRunDetails/atoms/run" import {clearAllMetricStatsCaches} from "@/oss/components/EvalRunDetails/atoms/runMetrics" -import { - InfiniteVirtualTableFeatureShell, - type TableFeaturePagination, - type TableScopeConfig, -} from "@/oss/components/InfiniteVirtualTable" -import useTableExport, { - EXPORT_RESOLVE_SKIP, - type TableExportColumnContext, -} from "@/oss/components/InfiniteVirtualTable/hooks/useTableExport" import EmptyStateAllEvaluations from "@/oss/components/pages/evaluations/allEvaluations/EmptyStateAllEvaluations" import EmptyStateEvaluation from "@/oss/components/pages/evaluations/autoEvaluation/EmptyStateEvaluation" import EmptyStateHumanEvaluation from "@/oss/components/pages/evaluations/humanEvaluation/EmptyStateHumanEvaluation" @@ -772,6 +771,7 @@ const EvaluationRunsTableActive = ({ primaryActions={createButton} deleteAction={deleteAction} exportAction={exportAction} + enableExport={canExportData} autoHeight={autoHeight} rowHeight={ROW_HEIGHT} fallbackControlsHeight={fallbackControlsHeight} diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/types.ts b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/types.ts index 069364da3e..9838f44bd2 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/types.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTable/types.ts @@ -1,4 +1,4 @@ -import type {TableTabsConfig} from "@/oss/components/InfiniteVirtualTable" +import type {TableTabsConfig} from "@agenta/ui/table" import {type EvaluationRunKind} from "../../types" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/columnVisibility/ColumnVisibilityPopoverContent.tsx b/web/oss/src/components/EvaluationRunsTablePOC/components/columnVisibility/ColumnVisibilityPopoverContent.tsx index 488fbdacaf..15dc1e8e28 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/columnVisibility/ColumnVisibilityPopoverContent.tsx +++ b/web/oss/src/components/EvaluationRunsTablePOC/components/columnVisibility/ColumnVisibilityPopoverContent.tsx @@ -1,15 +1,14 @@ import {useCallback, useMemo} from "react" -import {Typography} from "antd" -import {LOW_PRIORITY, useAtomValueWithSchedule} from "jotai-scheduler" - import { + ColumnVisibilityPopoverContentBase, type ColumnVisibilityState, type ColumnTreeNode, -} from "@/oss/components/InfiniteVirtualTable" -import ColumnVisibilityPopoverContentBase, { type ColumnVisibilityNodeMeta, -} from "@/oss/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent" +} from "@agenta/ui/table" +import {Typography} from "antd" +import {LOW_PRIORITY, useAtomValueWithSchedule} from "jotai-scheduler" + import { getEvaluatorMetricBlueprintAtom, type EvaluatorMetricGroupBlueprint, diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/filters/EvaluationRunsHeaderFilters.tsx b/web/oss/src/components/EvaluationRunsTablePOC/components/filters/EvaluationRunsHeaderFilters.tsx index 742ddc663e..57fc6dc29a 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/filters/EvaluationRunsHeaderFilters.tsx +++ b/web/oss/src/components/EvaluationRunsTablePOC/components/filters/EvaluationRunsHeaderFilters.tsx @@ -1,10 +1,10 @@ import {MouseEvent, useMemo, useState, useCallback} from "react" +import {FiltersPopoverTrigger} from "@agenta/ui/table" import {Input, Tag, Tooltip, Typography, type PopoverProps} from "antd" import clsx from "clsx" import {atom, useAtom, useAtomValue, useSetAtom} from "jotai" -import {FiltersPopoverTrigger} from "@/oss/components/InfiniteVirtualTable" import { getReferenceToneColors, type ReferenceTone, diff --git a/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/index.tsx b/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/index.tsx index 76a86389bb..7a34620a39 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/index.tsx +++ b/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/index.tsx @@ -1,5 +1,12 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import { + ColumnVisibilityMenuTrigger, + createColumnVisibilityAwareCell, + createComponentCell, + createTableColumns, + type TableColumnConfig, +} from "@agenta/ui/table" import type {ColumnsType} from "antd/es/table" import {useAtomValue, useSetAtom} from "jotai" @@ -7,13 +14,6 @@ import { INVOCATION_METRIC_KEYS, INVOCATION_METRIC_LABELS, } from "@/oss/components/EvalRunDetails/components/views/OverviewView/constants" -import { - ColumnVisibilityMenuTrigger, - createColumnVisibilityAwareCell, - createComponentCell, - createTableColumns, -} from "@/oss/components/InfiniteVirtualTable" -import type {TableColumnConfig} from "@/oss/components/InfiniteVirtualTable/columns/types" import {getEvaluatorMetricBlueprintAtom} from "@/oss/components/References/atoms/metricBlueprint" import {PreviewCreatedByCell} from "@/oss/components/References/cells/CreatedByCells" import {humanizeEvaluatorName, humanizeMetricPath} from "@/oss/lib/evaluations/utils/metrics" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/utils.tsx b/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/utils.tsx index 65bb8af07e..d51c4bd07e 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/utils.tsx +++ b/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluationRunsColumns/utils.tsx @@ -1,6 +1,7 @@ import type {ReactNode} from "react" -import {ColumnVisibilityHeader} from "@/oss/components/InfiniteVirtualTable" +import {ColumnVisibilityHeader} from "@agenta/ui/table" + import {deriveEvaluationKind} from "@/oss/lib/evaluations/utils/evaluationKind" import {humanizeMetricPath} from "@/oss/lib/evaluations/utils/metrics" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluatorHeaderReference.ts b/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluatorHeaderReference.ts index eab5163de7..83893fa6e6 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluatorHeaderReference.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/hooks/useEvaluatorHeaderReference.ts @@ -1,9 +1,9 @@ import {useMemo} from "react" +import {getColumnViewportVisibilityAtom} from "@agenta/ui/table" import {atom} from "jotai" import {LOW_PRIORITY, useAtomValueWithSchedule} from "jotai-scheduler" -import {getColumnViewportVisibilityAtom} from "@/oss/components/InfiniteVirtualTable/atoms/columnVisibility" import {evaluatorReferenceAtomFamily} from "@/oss/components/References/atoms/entityReferences" import type {EvaluatorReference} from "@/oss/components/References/atoms/entityReferences" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/types.ts b/web/oss/src/components/EvaluationRunsTablePOC/types.ts index 119b9d0300..48ee63d8ae 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/types.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/types.ts @@ -1,7 +1,7 @@ import type {EvaluationRun} from "@agenta/entities/evaluationRun" +import type {InfiniteTableRowBase} from "@agenta/ui/table" +import type {WindowingState} from "@agenta/ui/table" -import type {InfiniteTableRowBase} from "@/oss/components/InfiniteVirtualTable/types" -import type {WindowingState} from "@/oss/components/InfiniteVirtualTable/types" import type {SnakeToCamelCaseKeys} from "@/oss/lib/Types" // The original `@/oss/state/evaluations/legacyAtoms` module no longer exists, and `legacy` diff --git a/web/oss/src/components/InfiniteVirtualTable/InfiniteVirtualTable.tsx b/web/oss/src/components/InfiniteVirtualTable/InfiniteVirtualTable.tsx deleted file mode 100644 index 74b9317082..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/InfiniteVirtualTable.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import {useEffect, useRef} from "react" - -import {useQueryClient} from "@tanstack/react-query" -import {Provider} from "jotai" -import {createStore} from "jotai/vanilla" -import type {Store} from "jotai/vanilla/store" - -import InfiniteVirtualTableInner from "./components/InfiniteVirtualTableInner" -import {useColumnVisibilityControls as useColumnVisibilityControlsFromContext} from "./context/ColumnVisibilityContext" -import {useVirtualTableScrollContainer} from "./context/VirtualTableScrollContainerContext" -import { - InfiniteVirtualTableStoreHydrator, - InfiniteVirtualTableStoreProvider, -} from "./providers/InfiniteVirtualTableStoreProvider" -import type { - ColumnVisibilityConfig, - ColumnVisibilityState, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, - ResizableColumnsConfig, -} from "./types" - -export {useVirtualTableScrollContainer} - -export const useColumnVisibilityControls = () => - useColumnVisibilityControlsFromContext() - -function InfiniteVirtualTable( - props: InfiniteVirtualTableProps, -) { - const {useIsolatedStore = false, store, ...rest} = props - const queryClient = useQueryClient() - const managedStoreRef = useRef(store ?? null) - - useEffect(() => { - if (store) { - managedStoreRef.current = store - } - }, [store]) - - if (!store && useIsolatedStore && !managedStoreRef.current) { - managedStoreRef.current = createStore() - } - - const activeStore = managedStoreRef.current - const content = - - if (!activeStore) { - return content - } - - return ( - - - {content} - - - ) -} - -export {InfiniteVirtualTableStoreProvider} - -export default InfiniteVirtualTable - -export type { - InfiniteVirtualTableRowSelection, - ResizableColumnsConfig, - ColumnVisibilityConfig, - ColumnVisibilityState, -} diff --git a/web/oss/src/components/InfiniteVirtualTable/atoms/columnHiddenKeys.ts b/web/oss/src/components/InfiniteVirtualTable/atoms/columnHiddenKeys.ts deleted file mode 100644 index 8751d59db0..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/atoms/columnHiddenKeys.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type {Key} from "react" - -import {atom, type PrimitiveAtom} from "jotai" -import {atomFamily} from "jotai/utils" -import {atomWithStorage} from "jotai/utils" - -type HiddenKeysAtom = PrimitiveAtom - -interface HiddenKeysParams { - storageKey: string | null - defaults: Key[] - signature: string - version: number -} - -const METADATA_SUFFIX = "__meta" - -interface HiddenKeysMeta { - version: number - updatedAt: number -} - -const arraysEqual = (a: Key[], b: Key[]) => { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) return false - } - return true -} - -const hiddenKeysAtomFamily = atomFamily( - ({storageKey, defaults, version}: HiddenKeysParams): HiddenKeysAtom => { - if (!storageKey) { - return atom(defaults) - } - if (typeof window === "undefined") { - return atom(defaults) - } - - const metaStorageKey = `${storageKey}${METADATA_SUFFIX}` - const metaAtom = atomWithStorage( - metaStorageKey, - {version, updatedAt: Date.now()}, - { - getItem: (key, initialValue) => { - try { - const raw = window.localStorage.getItem(key) - if (!raw) return initialValue - const parsed = JSON.parse(raw) - if (typeof parsed?.version === "number") { - return parsed as HiddenKeysMeta - } - } catch { - // ignore - } - return initialValue - }, - setItem: (key, newValue) => { - try { - window.localStorage.setItem(key, JSON.stringify(newValue)) - } catch { - // ignore - } - }, - removeItem: (key) => { - try { - window.localStorage.removeItem(key) - } catch { - // ignore - } - }, - }, - ) - - if (!storageKey) { - return atom(defaults) - } - if (typeof window === "undefined") { - return atom(defaults) - } - const storageAtom = atomWithStorage(storageKey, defaults) - - return atom( - (get) => { - // Atom reads must be pure: on version mismatch serve defaults and let the next write reconcile storage. - const meta = get(metaAtom) - if (meta.version !== version) { - return defaults - } - return get(storageAtom) - }, - (get, set, next: Key[] | ((prev: Key[]) => Key[])) => { - const meta = get(metaAtom) - const current = meta.version !== version ? defaults : get(storageAtom) - const resolved = typeof next === "function" ? next(current) : next - set(storageAtom, resolved) - set(metaAtom, {version, updatedAt: Date.now()}) - }, - ) as HiddenKeysAtom - }, - (a, b) => - (a.storageKey ?? null) === (b.storageKey ?? null) && - a.version === b.version && - (a.signature === b.signature || arraysEqual(a.defaults, b.defaults)), -) - -export const getColumnHiddenKeysAtom = ( - storageKey?: string, - defaultHiddenKeys: Key[] = [], -): HiddenKeysAtom => - hiddenKeysAtomFamily({ - storageKey: storageKey ?? null, - defaults: defaultHiddenKeys, - signature: defaultHiddenKeys.join("|"), - version: defaultHiddenKeys.length, - }) diff --git a/web/oss/src/components/InfiniteVirtualTable/atoms/columnVisibility.ts b/web/oss/src/components/InfiniteVirtualTable/atoms/columnVisibility.ts deleted file mode 100644 index 074385124b..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/atoms/columnVisibility.ts +++ /dev/null @@ -1,268 +0,0 @@ -import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" -import {atomWithImmer} from "jotai-immer" - -import type {ColumnViewportVisibilityEvent} from "../types" - -const DEFAULT_SCOPE = "__default__" -const resolveScopeKey = (scopeId: string | null) => scopeId ?? DEFAULT_SCOPE - -type ColumnVisibilityState = Map> -type ColumnVisibilityUserState = Record> - -const createScopeMap = () => new Map() -const EMPTY_SCOPE_MAP = createScopeMap() - -const columnVisibilityStateAtom = atomWithImmer(new Map()) -const defaultVisibilityAtom = atom(false) - -// const visibilityDebugEnabled = process.env.NEXT_PUBLIC_EVAL_RUN_DEBUG === "true" - -// const logStateTable = ( -// scopeId: string | null, -// previous: Record, -// next: Record, -// ) => { -// if (!visibilityDebugEnabled || typeof window === "undefined") return -// // const timestamp = new Date().toISOString() -// // const scopeLabel = scopeId ? `scope:${scopeId}` : "scope:none" -// const keys = Array.from(new Set([...Object.keys(previous), ...Object.keys(next)])).sort() -// const rows = keys -// .map((column) => { -// const prev = previous[column] ?? false -// const nextValue = next[column] ?? false -// if (prev === nextValue) { -// return null -// } -// return { -// column, -// prev, -// next: nextValue, -// } -// }) -// .filter((row): row is {column: string; prev: boolean; next: boolean} => row !== null) -// if (!rows.length) { -// return -// } -// // try { -// // console.groupCollapsed("[infiniteTable][columnVisibility]", `${timestamp} ${scopeLabel}`) -// // console.table(rows) -// // console.groupEnd() -// // } catch (error) { -// // console.debug("[infiniteTable][columnVisibility] log failed", error) -// // } -// } - -type ColumnViewportVisibilityPayload = - | ColumnViewportVisibilityEvent - | ColumnViewportVisibilityEvent[] - -export const setColumnViewportVisibilityAtom = atom( - null, - (get, set, payload: ColumnViewportVisibilityPayload) => { - const updates = Array.isArray(payload) ? payload : [payload] - if (!updates.length) { - return - } - - set(columnVisibilityStateAtom, (draft) => { - updates.forEach((update) => { - const scopeKey = resolveScopeKey(update.scopeId) - let scopeMap = draft.get(scopeKey) - if (!scopeMap) { - scopeMap = new Map() - draft.set(scopeKey, scopeMap) - } - const previousValue = scopeMap.get(update.columnKey) ?? false - if (previousValue === update.visible) { - return - } - scopeMap.set(update.columnKey, update.visible) - }) - }) - }, -) - -/** - * Delete column visibility state from the atom - * Use when columns are removed from DOM to prevent stale visibility state - */ -export const deleteColumnViewportVisibilityAtom = atom( - null, - ( - get, - set, - payload: - | {scopeId: string | null; columnKey: string} - | {scopeId: string | null; columnKey: string}[], - ) => { - const deletions = Array.isArray(payload) ? payload : [payload] - if (!deletions.length) { - return - } - - set(columnVisibilityStateAtom, (draft) => { - deletions.forEach((deletion) => { - const scopeKey = resolveScopeKey(deletion.scopeId) - const scopeMap = draft.get(scopeKey) - if (scopeMap) { - scopeMap.delete(deletion.columnKey) - } - }) - }) - }, -) - -const viewportStateAtomFamily = atomFamily( - (scopeId: string | null) => - atom( - (get) => - get(columnVisibilityStateAtom).get(resolveScopeKey(scopeId)) ?? EMPTY_SCOPE_MAP, - ), - (a, b) => resolveScopeKey(a) === resolveScopeKey(b), -) - -const columnViewportVisibilityAtomFamily = atomFamily( - ({scopeId, columnKey}: {scopeId: string | null; columnKey: string}) => - selectAtom( - viewportStateAtomFamily(scopeId), - // Always default to true (visible) for columns not yet tracked - // This ensures: - // 1. Cells render immediately on scope change (e.g., revision switch) - // 2. Newly expanded column groups show content immediately - // 3. IntersectionObserver will set to false if outside viewport - (state) => state.get(columnKey) ?? true, - (a, b) => a === b, - ), - (a, b) => - resolveScopeKey(a.scopeId) === resolveScopeKey(b.scopeId) && a.columnKey === b.columnKey, -) - -export const getColumnViewportVisibilityAtom = ( - scopeId: string | null, - columnKey: string | undefined, -) => { - if (!scopeId || !columnKey) { - return defaultVisibilityAtom - } - return columnViewportVisibilityAtomFamily({scopeId, columnKey}) -} - -const userVisibilityStateAtom = atomWithImmer({}) - -const userStateAtomFamily = atomFamily( - (scopeId: string | null) => - atom((get) => get(userVisibilityStateAtom)[resolveScopeKey(scopeId)] ?? {}), - (a, b) => resolveScopeKey(a) === resolveScopeKey(b), -) - -export const setColumnUserVisibilityAtom = atom( - null, - ( - get, - set, - update: { - scopeId: string | null - columnKey: string - visible: boolean - }, - ) => { - const scopeKey = resolveScopeKey(update.scopeId) - const prevState = get(userVisibilityStateAtom) - const prevScopeEntries = prevState[scopeKey] ?? {} - const previousValue = prevScopeEntries[update.columnKey] ?? false - if (previousValue === update.visible) { - return - } - - set(userVisibilityStateAtom, (draft) => { - if (!draft[scopeKey]) { - draft[scopeKey] = {} - } - draft[scopeKey][update.columnKey] = update.visible - }) - }, -) - -const columnUserVisibilityAtomFamily = atomFamily( - ({scopeId, columnKey}: {scopeId: string | null; columnKey: string}) => - selectAtom( - userStateAtomFamily(scopeId), - (state) => { - const scopedValue = state[columnKey] - return scopedValue === undefined ? true : scopedValue - }, - (a, b) => a === b, - ), - (a, b) => - resolveScopeKey(a.scopeId) === resolveScopeKey(b.scopeId) && a.columnKey === b.columnKey, -) - -export const getColumnUserVisibilityAtom = ( - scopeId: string | null, - columnKey: string | undefined, -) => { - if (!scopeId || !columnKey) { - return defaultVisibilityAtom - } - return columnUserVisibilityAtomFamily({scopeId, columnKey}) -} - -export const getColumnEffectiveVisibilityAtom = ( - scopeId: string | null, - columnKey: string | undefined, -) => { - if (!scopeId || !columnKey) { - return defaultVisibilityAtom - } - const userAtom = getColumnUserVisibilityAtom(scopeId, columnKey) - const viewportAtom = getColumnViewportVisibilityAtom(scopeId, columnKey) - return atom((get) => get(userAtom) && get(viewportAtom)) -} - -// const scopeVisibilityMapAtomFamily = atomFamily((scopeId: string | null) => -// selectAtom( -// atom((get) => { -// const viewportState = get(viewportStateAtomFamily(scopeId)) -// const userState = get(userStateAtomFamily(scopeId)) -// const keys = new Set([...Object.keys(viewportState), ...Object.keys(userState)]) -// const next: Record = {} -// keys.forEach((key) => { -// const viewportVisible = viewportState[key] -// const userVisible = userState[key] -// next[key] = -// (userVisible === undefined ? true : userVisible) && -// (viewportVisible === undefined ? false : viewportVisible) -// }) -// return next -// }), -// (a, b) => deepEqual(resolveScopeKey(a), resolveScopeKey(b)), -// ), -// ) - -// export const getScopeVisibilityMapAtom = (scopeId: string | null) => - -export const scopedColumnVisibilityAtomFamily = atomFamily( - ({scopeId, columnKey}: {scopeId: string | null; columnKey: string}) => - columnViewportVisibilityAtomFamily({scopeId, columnKey}), - (a, b) => - resolveScopeKey(a.scopeId) === resolveScopeKey(b.scopeId) && a.columnKey === b.columnKey, -) - -// export const getScopedColumnVisibilityAtom = (scopeId: string | null, columnKey?: string) => { -// if (!columnKey) { -// return defaultVisibilityAtom -// } -// return selectAtom( -// scopeVisibilityMapAtomFamily(scopeId), -// (state) => { -// const explicit = state[columnKey] -// console.log("scopeVisibilityMapAtomFamily", state) -// if (typeof explicit === "boolean") { -// return explicit -// } -// return true -// }, -// (a, b) => a === b, -// ) -// } diff --git a/web/oss/src/components/InfiniteVirtualTable/atoms/columnWidths.ts b/web/oss/src/components/InfiniteVirtualTable/atoms/columnWidths.ts deleted file mode 100644 index a89c3f76b6..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/atoms/columnWidths.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {atom, type PrimitiveAtom} from "jotai" - -type ColumnWidthAtom = PrimitiveAtom> - -const DEFAULT_SCOPE = "__default__" -const scopeKey = (scopeId: string | null | undefined) => scopeId ?? DEFAULT_SCOPE - -const atomCache = new Map() - -const createColumnWidthsAtom = (scopeId: string | null | undefined) => { - const key = scopeKey(scopeId) - const cached = atomCache.get(key) - if (cached) { - return cached - } - - // Use simple atom without storage - widths are session-only and reset on navigation - const safeAtom: ColumnWidthAtom = atom>({}) - - atomCache.set(key, safeAtom) - return safeAtom -} - -export const getColumnWidthsAtom = (scopeId: string | null | undefined) => - createColumnWidthsAtom(scopeId) diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/cells.tsx b/web/oss/src/components/InfiniteVirtualTable/columns/cells.tsx deleted file mode 100644 index fb63d87c89..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/cells.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import {useEffect, memo, useRef, useState, type ReactNode} from "react" - -import clsx from "clsx" - -import {useColumnVisibilityFlag} from "../context/ColumnVisibilityFlagContext" - -import type {TableColumnCell} from "./types" - -export const createTextCell = (opts: { - getValue: (row: Row) => ReactNode - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: opts.getValue, - align: opts.align, - className: clsx("ivt-cell ivt-cell--text", opts.className), -}) - -export const createComponentCell = (opts: { - render: (row: Row, index: number) => ReactNode - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: opts.render, - align: opts.align, - className: clsx(opts.className), -}) - -export const createStatusCell = (opts?: { - formatter?: (status: ReactNode, row: Row) => ReactNode - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: (row) => { - const value = row.status ?? null - return opts?.formatter ? opts.formatter(value, row) : value - }, - align: opts?.align ?? "left", - className: clsx("ivt-cell ivt-cell--status", opts?.className), -}) - -export const createActionsCell = (opts: { - render: (row: Row) => ReactNode - className?: string -}): TableColumnCell => ({ - render: (row) => opts.render(row), - className: clsx("ivt-cell ivt-cell--actions", opts.className), - align: "center", -}) - -const VisibilityObserverCell = ({ - row, - index, - render, - onVisible, - rootMargin, - once, - placeholder, -}: { - row: Row - index: number - render: (row: Row, index: number, isVisible: boolean) => ReactNode - onVisible?: (row: Row, index: number) => void - rootMargin?: string - once?: boolean - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) -}) => { - const ref = useRef(null) - const hasTriggeredRef = useRef(false) - const [isVisible, setIsVisible] = useState(!onVisible) - - useEffect(() => { - if (!onVisible) return - const element = ref.current - if (!element) return - let unsubscribed = false - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0] - if (entry?.isIntersecting) { - setIsVisible(true) - if (once && hasTriggeredRef.current) return - onVisible(row, index) - if (once) { - hasTriggeredRef.current = true - observer.disconnect() - unsubscribed = true - } - } else if (!once) { - setIsVisible(false) - } - }, - {rootMargin}, - ) - observer.observe(element) - return () => { - if (!unsubscribed) { - observer.disconnect() - } - } - }, [index, onVisible, once, rootMargin, row]) - - const content = - !isVisible && placeholder - ? typeof placeholder === "function" - ? placeholder(row, index) - : placeholder - : render(row, index, isVisible) - - return ( -
- {content} -
- ) -} - -export const createViewportAwareCell = (opts: { - render: (row: Row, index: number, isVisible: boolean) => ReactNode - onVisible?: (row: Row, index: number) => void - rootMargin?: string - align?: "left" | "right" | "center" - className?: string - once?: boolean - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) -}): TableColumnCell => ({ - render: (row, index) => ( - - row={row} - index={index} - render={opts.render} - onVisible={opts.onVisible} - rootMargin={opts.rootMargin} - once={opts.once} - placeholder={opts.placeholder} - /> - ), - align: opts.align, - className: clsx("ivt-cell ivt-cell--viewport-wrapper", opts.className), -}) - -const ColumnVisibilityAwareCellBase = ({ - row, - index, - columnKey, - render, - placeholder, - keepMounted = false, -}: { - row: Row - index: number - columnKey?: string - render: (row: Row, index: number, isVisible: boolean) => ReactNode - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) - keepMounted?: boolean -}) => { - const isVisible = useColumnVisibilityFlag(columnKey) - if (!keepMounted && !isVisible) { - if (placeholder) { - return ( -
- {typeof placeholder === "function" ? placeholder(row, index) : placeholder} -
- ) - } - return null - } - const content = render(row, index, isVisible) - - if (!content && !placeholder) { - if (!keepMounted) { - return null - } - return ( -
- ) - } - - return ( -
- {content ?? (typeof placeholder === "function" ? placeholder(row, index) : placeholder)} -
- ) -} - -// memo() erases the generic call signature; cast restores it. -const ColumnVisibilityAwareCell = memo( - ColumnVisibilityAwareCellBase, -) as typeof ColumnVisibilityAwareCellBase - -export const createColumnVisibilityAwareCell = (opts: { - columnKey?: string - render: (row: Row, index: number, isVisible: boolean) => ReactNode - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) - keepMounted?: boolean - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: (row, index) => ( - - row={row} - index={index} - columnKey={opts.columnKey} - render={opts.render} - placeholder={opts.placeholder} - keepMounted={opts.keepMounted} - /> - ), - align: opts.align, - className: clsx("ivt-cell ivt-cell--column-visibility-wrapper", opts.className), -}) diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx b/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx deleted file mode 100644 index c910ad908e..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import type {ReactNode} from "react" - -import {MoreOutlined} from "@ant-design/icons" -import {Copy, DownloadSimple} from "@phosphor-icons/react" -import {Button, Dropdown, Tooltip} from "antd" -import type {ColumnsType, ColumnType} from "antd/es/table" - -import {UserReference} from "@/oss/components/References" -import {copyToClipboard} from "@/oss/lib/helpers/copyToClipboard" - -import ColumnVisibilityMenuTrigger from "../components/columnVisibility/ColumnVisibilityMenuTrigger" -import type {InfiniteTableRowBase} from "../types" - -import type {ExtendedColumnType} from "./types" - -export interface TextColumnDef { - type: "text" - key: string - title: string - width?: number - render?: (value: any, record: any) => ReactNode - /** Pin column to left or right */ - fixed?: "left" | "right" - /** Lock column from being hidden in visibility menu (defaults to true if fixed is set) */ - columnVisibilityLocked?: boolean -} - -export interface DateColumnDef { - type: "date" - key: string - title: string - width?: number - /** Custom date formatter (default: formatDate from helpers) */ - format?: (date: string) => string -} - -export interface UserColumnDef { - type: "user" - /** The key in the record that contains the user ID */ - key: string - title: string - width?: number - /** Custom user ID extractor (default: uses record[key]) */ - getUserId?: (record: T) => string | null | undefined -} - -export interface ActionItem { - key: string - label: string - icon?: ReactNode - danger?: boolean - onClick: (record: T, event?: any) => void - /** Hide this action conditionally */ - hidden?: (record: T) => boolean -} - -export interface ActionDivider { - type: "divider" - hidden?: (record: T) => boolean -} - -export interface ActionsColumnDef { - type: "actions" - items: (ActionItem | ActionDivider)[] - width?: number - /** Maximum width for the actions column */ - maxWidth?: number - /** Show copy ID action (default: true) */ - showCopyId?: boolean - /** Custom ID extractor for copy action */ - getRecordId?: (record: T) => string - /** Show copy slug action (default: false — requires getSlug to yield a value) */ - showCopySlug?: boolean - /** Slug extractor for copy-slug action */ - getSlug?: (record: T) => string | null | undefined - /** Export row callback */ - onExportRow?: (record: T) => void - /** Whether export is currently in progress */ - isExporting?: boolean -} - -export type StandardColumnDef = - | TextColumnDef - | DateColumnDef - | UserColumnDef - | ActionsColumnDef - -/** - * Create standard table columns from simplified definitions. - * Reduces boilerplate for common column types. - * - * @example - * ```tsx - * const columns = createStandardColumns([ - * { type: "text", key: "name", title: "Name", width: 300 }, - * { type: "date", key: "updated_at", title: "Date Modified" }, - * { type: "date", key: "created_at", title: "Date Created" }, - * { - * type: "actions", - * items: [ - * { key: "view", label: "View details", icon: , onClick: handleView }, - * { key: "clone", label: "Clone", icon: , onClick: handleClone }, - * { type: "divider" }, - * { key: "rename", label: "Rename", icon: , onClick: handleRename }, - * { key: "delete", label: "Delete", icon: , danger: true, onClick: handleDelete }, - * ], - * }, - * ]) - * ``` - */ -export function createStandardColumns( - defs: StandardColumnDef[], -): ColumnsType { - return defs.map((def) => { - switch (def.type) { - case "text": - return createTextColumn(def) - case "date": - return createDateColumn(def) - case "user": - return createUserColumn(def) - case "actions": - return createActionsColumn(def) - default: - throw new Error(`Unknown column type: ${(def as any).type}`) - } - }) -} - -function createTextColumn(def: TextColumnDef): ExtendedColumnType { - return { - title: def.title, - dataIndex: def.key, - key: def.key, - width: def.width, - fixed: def.fixed, - render: def.render, - // Lock column from being toggled in visibility menu (explicit or derived from fixed) - columnVisibilityLocked: def.columnVisibilityLocked ?? Boolean(def.fixed), - onHeaderCell: () => ({ - style: {minWidth: def.width || 220}, - }), - } -} - -const formatDateCell = (value?: string | null) => { - if (!value) return "—" - try { - return new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - }).format(new Date(value)) - } catch { - return value - } -} - -function createDateColumn(def: DateColumnDef): ColumnType { - return { - title: def.title, - dataIndex: def.key, - key: def.key, - width: def.width || 200, - render: (date: string) => { - const formatted = !date ? "—" : def.format ? def.format(date) : formatDateCell(date) - return
{formatted}
- }, - onHeaderCell: () => ({ - style: {minWidth: def.width || 180}, - }), - } -} - -function createActionsColumn( - def: ActionsColumnDef, -): ExtendedColumnType { - const { - items, - width = 56, // TODO: try 61px here - maxWidth, - showCopyId = true, - getRecordId, - showCopySlug = false, - getSlug, - onExportRow, - isExporting, - } = def - - const defaultGetId = (record: T): string => { - if (getRecordId) return getRecordId(record) - const id = (record as any).id || (record as any)._id || (record as any).key - if (typeof id === "string") return id - return "" - } - - return { - title: , - key: "actions", - width, - ...(maxWidth ? {maxWidth} : {}), - fixed: "right", - align: "center", - // Lock actions column from being toggled in visibility menu - columnVisibilityLocked: true, - onCell: () => ({className: "ag-table-actions-cell"}), - render: (_, record) => { - if (record.__isSkeleton) return null - - // Build menu items from config - const menuItems: any[] = [] - - items.forEach((item) => { - if ("type" in item && item.type === "divider") { - const dividerItem = item as ActionDivider - // Skip if hidden - if (dividerItem.hidden?.(record)) { - return - } - menuItems.push({type: "divider"}) - return - } - - const actionItem = item as ActionItem - - // Skip if hidden - if (actionItem.hidden?.(record)) { - return - } - - menuItems.push({ - key: actionItem.key, - label: actionItem.label, - icon: actionItem.icon, - danger: actionItem.danger, - onClick: (e: any) => { - e.domEvent.stopPropagation() - actionItem.onClick(record, e) - }, - }) - }) - - // Add export row if enabled - if (onExportRow) { - menuItems.push({ - key: "export-row", - label: "Export row", - icon: , - disabled: isExporting, - onClick: (e: any) => { - e.domEvent.stopPropagation() - if (!isExporting) { - onExportRow(record) - } - }, - }) - } - - // Add copy ID if enabled - if (showCopyId) { - const recordId = defaultGetId(record) - if (recordId) { - if ( - menuItems.length > 0 && - menuItems[menuItems.length - 1].type !== "divider" - ) { - menuItems.push({type: "divider"}) - } - menuItems.push({ - key: "copy-id", - label: "Copy ID", - icon: , - onClick: (e: any) => { - e.domEvent.stopPropagation() - copyToClipboard(recordId) - }, - }) - } - } - - // Add copy slug if enabled - if (showCopySlug && getSlug) { - const slug = getSlug(record) - if (slug) { - menuItems.push({ - key: "copy-slug", - label: "Copy Slug", - icon: , - onClick: (e: any) => { - e.domEvent.stopPropagation() - copyToClipboard(slug) - }, - }) - } - } - - return ( -
e.stopPropagation()} - > - - -
- ) - }, - } -} - -function createUserColumn(def: UserColumnDef): ColumnType { - const {key, title, width = 180, getUserId} = def - - return { - title, - dataIndex: key, - key, - width, - render: (value: string | null | undefined, record: T) => { - if (record.__isSkeleton) return null - const userId = getUserId ? getUserId(record) : value - return ( -
- -
- ) - }, - onHeaderCell: () => ({ - style: {minWidth: width}, - }), - } -} - -// Export individual column creators for custom use -export {createTextColumn, createDateColumn, createUserColumn, createActionsColumn} diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/createTableColumns.ts b/web/oss/src/components/InfiniteVirtualTable/columns/createTableColumns.ts deleted file mode 100644 index 5cfb17d902..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/createTableColumns.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type {MouseEvent, ReactNode} from "react" - -import type {ColumnsType} from "antd/es/table" -import clsx from "clsx" - -import type {TableColumnConfig, TableColumnGroup, TableColumnCell} from "./types" - -type ColumnWithChildren = ColumnsType[number] & { - children?: ColumnsType -} - -type OnHeaderCell = ColumnsType[number]["onHeaderCell"] -type OnHeaderCellArgs = Parameters>> -type OnHeaderCellResult = ReturnType>> - -const normalizeGroups = ( - groups: TableColumnGroup[], -): TableColumnConfig[] => - groups.flatMap((group) => { - if (Array.isArray(group)) { - return group - } - return [group] - }) - -const resolveTitle = ( - config: TableColumnConfig, - depth: number, -): ReactNode => { - if (typeof config.title === "function") { - return config.title({column: config, depth}) - } - return config.title -} - -const applyCellRenderer = ( - column: ColumnsType[number], - cell?: TableColumnCell, -) => { - if (!cell) return - column.render = (_value, record: Row, index) => cell.render(record, index) - column.align = cell.align ?? column.align - column.className = clsx(column.className, cell.className) -} - -const buildColumn = ( - config: TableColumnConfig, - depth = 0, -): ColumnsType[number] => { - const column: ColumnWithChildren = { - key: config.key, - title: resolveTitle(config, depth), - width: config.width, - fixed: config.fixed, - align: config.align, - ellipsis: config.ellipsis, - className: clsx(config.className), - shouldCellUpdate: config.shouldCellUpdate, - } - - applyCellRenderer(column, config.cell) - - if (config.children?.length) { - column.children = config.children.map((child) => buildColumn(child, depth + 1)) - } - - if (config.minWidth || config.flex) { - const prev = config.columnProps?.onHeaderCell - column.onHeaderCell = (...args: OnHeaderCellArgs): OnHeaderCellResult => { - const baseStyle: React.CSSProperties = { - minWidth: config.minWidth, - flex: config.flex, - } - const prevResult = typeof prev === "function" ? prev(...args) : undefined - return { - ...(prevResult ?? {}), - style: {...baseStyle, ...(prevResult?.style ?? {})}, - } - } - } - - if (config.columnProps) { - const {className, render, ...rest} = config.columnProps - column.className = clsx(column.className, className) - Object.assign(column, rest) - if (!column.render && render) { - column.render = render - } - } - - if (config.visibilityKey) { - ;(column as any)["data-column-visibility-key"] = config.visibilityKey - } - - if (config.visibilityLabel) { - ;(column as any).columnVisibilityLabel = config.visibilityLabel - } - - if (config.visibilityLocked) { - ;(column as any).columnVisibilityLocked = true - } - - if (config.visibilityTitle) { - ;(column as any).columnVisibilityTitle = config.visibilityTitle - } - - if (config.defaultHidden) { - ;(column as any).defaultHidden = true - } - - if (config.exportLabel) { - ;(column as any).exportLabel = config.exportLabel - } - - if (config.exportEnabled === false) { - ;(column as any).exportEnabled = false - } - - if (config.exportDataIndex) { - ;(column as any).exportDataIndex = config.exportDataIndex - } - - if (config.exportValue) { - ;(column as any).exportValue = config.exportValue - } - - if (config.exportFormatter) { - ;(column as any).exportFormatter = config.exportFormatter - } - - if (config.exportMetadata !== undefined) { - ;(column as any).exportMetadata = config.exportMetadata - } - - // Auto-stop click propagation in action columns so clicks on empty cell area - // don't bubble to the row navigation handler. - if (config.key === "actions") { - const prevOnCell = column.onCell as ((record: Row, index?: number) => any) | undefined - column.onCell = (record: Row, index?: number) => { - const base = prevOnCell ? prevOnCell(record, index) : {} - const prevClick = (base as any)?.onClick - return { - ...base, - className: clsx((base as any)?.className, "ag-table-actions-cell"), - onClick: (e: MouseEvent) => { - e.stopPropagation() - prevClick?.(e) - }, - } - } - } - - return column -} - -export const createTableColumns = ( - groups: TableColumnGroup[], -): ColumnsType => normalizeGroups(groups).map((config) => buildColumn(config)) diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/types.ts b/web/oss/src/components/InfiniteVirtualTable/columns/types.ts deleted file mode 100644 index f31f6da2d1..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type {Key, ReactNode} from "react" - -import type {ExtendedColumn} from "@agenta/ui/table" -import type {ColumnsType, ColumnType} from "antd/es/table" - -/** - * Alias of the canonical extended column in @agenta/ui/table. This local copy - * of InfiniteVirtualTable is being retired; consumers should move to the package. - */ -export type ExtendedColumnType = ExtendedColumn - -export interface TableColumnCell { - render: (row: Row, rowIndex: number) => ReactNode - align?: "left" | "right" | "center" - className?: string -} - -export interface TableColumnConfig { - key: Key - title?: ReactNode | ((context: {column: TableColumnConfig; depth: number}) => ReactNode) - width?: number - minWidth?: number - flex?: number - align?: "left" | "right" | "center" - fixed?: "left" | "right" - ellipsis?: boolean - className?: string - defaultHidden?: boolean - visibilityKey?: string - visibilityLabel?: string - visibilityLocked?: boolean - visibilityTitle?: ReactNode - cell?: TableColumnCell - children?: TableColumnConfig[] - columnProps?: Partial[number]> - shouldCellUpdate?: ColumnsType[number]["shouldCellUpdate"] - exportLabel?: string - exportEnabled?: boolean - // Only ColumnType carries dataIndex (group columns don't), so index off ColumnType. - exportDataIndex?: ColumnType["dataIndex"] - exportValue?: (row: Row, column?: ColumnsType[number], columnIndex?: number) => unknown - exportFormatter?: ( - value: unknown, - row: Row, - column?: ColumnsType[number], - columnIndex?: number, - ) => string | undefined - exportMetadata?: unknown -} - -export type TableColumnGroup = TableColumnConfig | TableColumnConfig[] - -export type TableColumnsBuilder = ( - config: TableColumnGroup[], -) => ColumnsType diff --git a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx b/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx deleted file mode 100644 index 6bb9d61c6a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {memo, forwardRef, useCallback, type MutableRefObject, type ReactNode} from "react" - -import {useColumnVisibilityContext} from "../context/ColumnVisibilityContext" - -export type VisibilityRegistrationHandler = (columnKey: string, node: HTMLElement | null) => void - -interface ColumnVisibilityHeaderProps { - columnKey: string - columnVisibilityLabel?: string - children: ReactNode -} - -const ColumnVisibilityHeader = forwardRef( - ({columnKey, children}, ref) => { - const {registerHeader} = useColumnVisibilityContext() - - const mergedRef = useCallback( - (node: HTMLSpanElement | null) => { - const thNode = node?.closest("th") - const target = (thNode as HTMLElement | null) ?? (node as HTMLElement | null) - if (thNode) { - thNode.dataset.columnKey = columnKey - } - - if (registerHeader) { - registerHeader(columnKey, target) - } - if (typeof ref === "function") { - ref(node) - } else if (ref && typeof ref === "object") { - ;(ref as MutableRefObject).current = node - } - }, - [columnKey, ref, registerHeader], - ) - - return ( - - {children} - - ) - }, -) - -export default memo(ColumnVisibilityHeader) diff --git a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityTrigger.tsx b/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityTrigger.tsx deleted file mode 100644 index 9d4ec9eee8..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityTrigger.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type {MouseEvent, ReactNode} from "react" -import {useMemo, useState} from "react" - -import {GearSix} from "@phosphor-icons/react" -import {Button, Checkbox, Divider, Popover, Space, Tooltip} from "antd" - -import type {ColumnVisibilityState} from "../types" - -type ColumnVisibilityControls = ColumnVisibilityState - -interface ColumnVisibilityTriggerProps { - controls: ColumnVisibilityControls - variant?: "button" | "icon" - label?: string - renderContent?: (controls: ColumnVisibilityControls, close: () => void) => ReactNode -} - -const DefaultVisibilityContent = ({ - controls, - onClose, -}: { - controls: ColumnVisibilityControls - onClose: () => void -}) => { - const nodes = useMemo(() => controls.columnTree, [controls.columnTree]) - - const renderNodes = (tree: typeof nodes, depth = 0): ReactNode => - tree.map((node) => { - const label = node.titleNode ?? node.label ?? node.key - const childNodes = node.children?.length ? renderNodes(node.children, depth + 1) : null - const isGroup = Boolean(node.children?.length) - return ( -
- - isGroup - ? controls.toggleTree(node.key) - : controls.toggleColumn(node.key) - } - style={{marginLeft: depth ? depth * 12 : 0}} - > - {label} - - {childNodes} -
- ) - }) - - return ( - -
Toggle columns
-
{renderNodes(nodes)}
- -
- - -
-
- ) -} - -const ColumnVisibilityTrigger = ({ - controls, - variant = "button", - label = "Columns", - renderContent, -}: ColumnVisibilityTriggerProps) => { - const [open, setOpen] = useState(false) - const {leafKeys, isHidden} = controls - - const visibleLeafCount = useMemo( - () => leafKeys.filter((key) => !isHidden(key)).length, - [leafKeys, isHidden], - ) - - const stopPropagation = (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - } - - const triggerNode = - variant === "icon" ? ( - - - ) - - const content = renderContent ? ( - renderContent(controls, () => setOpen(false)) - ) : ( - setOpen(false)} /> - ) - - return ( - setOpen(value)} - content={content} - > - {triggerNode} - - ) -} - -export default ColumnVisibilityTrigger diff --git a/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx b/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx deleted file mode 100644 index 9547976d0b..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx +++ /dev/null @@ -1,661 +0,0 @@ -import { - memo, - useCallback, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react" - -import {Table} from "antd" -import type {TableProps} from "antd/es/table" -import clsx from "clsx" -import {useSetAtom} from "jotai" - -import { - deleteColumnViewportVisibilityAtom, - setColumnUserVisibilityAtom, - setColumnViewportVisibilityAtom, -} from "../atoms/columnVisibility" -import {type VisibilityRegistrationHandler} from "../components/ColumnVisibilityHeader" -import {ColumnVisibilityFlagProvider} from "../context/ColumnVisibilityFlagContext" -import VirtualTableScrollContainerContext from "../context/VirtualTableScrollContainerContext" -import useColumnVisibility from "../hooks/useColumnVisibility" -import useColumnVisibilityControlsBuilder from "../hooks/useColumnVisibilityControls" -import useContainerResize from "../hooks/useContainerResize" -import useExpandableRows from "../hooks/useExpandableRows" -import useHeaderViewportVisibility from "../hooks/useHeaderViewportVisibility" -import useInfiniteScroll from "../hooks/useInfiniteScroll" -import useScrollContainer from "../hooks/useScrollContainer" -import useSmartResizableColumns from "../hooks/useSmartResizableColumns" -import useTableKeyboardShortcuts from "../hooks/useTableKeyboardShortcuts" -import {shouldIgnoreRowClick} from "../hooks/useTableManager" -import useTableRowSelection from "../hooks/useTableRowSelection" -import ColumnVisibilityProvider from "../providers/ColumnVisibilityProvider" -import type {InfiniteVirtualTableProps} from "../types" -import { - buildColumnDescendantMap, - collectFixedColumnKeys, - mergeHandlers, - shallowEqual, -} from "../utils/columnUtils" - -const scopeUsageCounts = new Map() - -type InfiniteVirtualTableInnerProps = Omit< - InfiniteVirtualTableProps, - "useIsolatedStore" | "store" -> - -const InfiniteVirtualTableInnerBase = ({ - columns, - dataSource, - loadMore, - rowKey, - active = true, - scrollThreshold = 300, - containerClassName, - tableClassName, - tableProps, - rowSelection, - resizableColumns, - columnVisibility, - onColumnToggle, - scopeId = null, - beforeTable, - bodyHeight = null, - onHeaderHeightChange, - keyboardShortcuts, - expandable, - tableRef, - disableInteractiveClickGuard = false, -}: InfiniteVirtualTableInnerProps) => { - const generatedScopeId = useId() - const resolvedScopeId = useMemo( - () => scopeId ?? `ivt-${generatedScopeId}`, - [generatedScopeId, scopeId], - ) - const containerRef = useRef(null) - const visibilityRootRef = useRef(null) - const columnDomRefs = useRef< - Map - >(new Map()) - const containerSize = useContainerResize(containerRef) - const [tableHeaderHeight, setTableHeaderHeight] = useState(null) - const lastScrollConfigRef = useRef | null>(null) - const visibilityStorageKey = columnVisibility?.storageKey - const visibilityDefaultHiddenKeys = columnVisibility?.defaultHiddenKeys - const normalizedDefaultHiddenKeys = useMemo( - () => visibilityDefaultHiddenKeys?.map((key) => String(key)), - [visibilityDefaultHiddenKeys], - ) - const handleVisibilityStateChange = columnVisibility?.onStateChange - const handleVisibilityContextChange = columnVisibility?.onContextChange - const handleViewportVisibilityChange = columnVisibility?.onViewportVisibilityChange - const baseTrackingEnabled = - columnVisibility?.viewportTrackingEnabled === undefined - ? true - : columnVisibility.viewportTrackingEnabled - - useEffect(() => { - if (!onHeaderHeightChange) return - onHeaderHeightChange(tableHeaderHeight) - }, [onHeaderHeightChange, tableHeaderHeight]) - - // Use extracted hook for infinite scroll handling - const handleScroll = useInfiniteScroll({loadMore, scrollThreshold}) - - const scrollX = containerSize.width - const scrollY = containerSize.height - - const resizable = typeof resizableColumns === "object" ? resizableColumns : undefined - const resizableEnabled = Boolean(resizableColumns) - - const columnVisibilityResult = useColumnVisibility(columns, { - storageKey: visibilityStorageKey, - defaultHiddenKeys: normalizedDefaultHiddenKeys, - }) - const {visibleColumns, version} = columnVisibilityResult - const columnVisibilityControls = - useColumnVisibilityControlsBuilder(columnVisibilityResult) - const lastReportedVersionRef = useRef(null) - - // Calculate selection column width before using resizable columns hook - const selectionColumnWidth = rowSelection ? (rowSelection.columnWidth ?? 48) : 0 - - const { - columns: resizableProcessedColumns, - headerComponents: resizableHeaderComponents, - getTotalWidth, - isResizing, - } = useSmartResizableColumns({ - columns: visibleColumns, - enabled: resizableEnabled, - minWidth: resizable?.minWidth, - scopeId: resolvedScopeId, - containerWidth: scrollX > 0 ? scrollX : 1200, // fallback to 1200 if no width yet - selectionColumnWidth, - }) - const visibilityTrackingEnabled = baseTrackingEnabled && active - - const stickyColumnKeys = useMemo( - () => collectFixedColumnKeys(resizableProcessedColumns), - [resizableProcessedColumns], - ) - - const finalColumns = resizableProcessedColumns - const columnDescendantMap = useMemo( - () => buildColumnDescendantMap(resizableProcessedColumns), - [resizableProcessedColumns], - ) - const internalViewportVisibilityHandler = useSetAtom(setColumnViewportVisibilityAtom) - const internalViewportVisibilityDeleteHandler = useSetAtom(deleteColumnViewportVisibilityAtom) - const internalUserVisibilityHandler = useSetAtom(setColumnUserVisibilityAtom) - const viewportVisibilityHandler = - handleViewportVisibilityChange ?? internalViewportVisibilityHandler - const _userVisibilityHandler = onColumnToggle ?? internalUserVisibilityHandler - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - columnDomRefs.current = new Map() - return - } - const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), - ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) - if (!headerCells.length) { - columnDomRefs.current = new Map() - return - } - - const keyToIndices = new Map() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - const index = cell.cellIndex - if (index < 0) return - if (!keyToIndices.has(key)) { - keyToIndices.set(key, []) - } - keyToIndices.get(key)!.push(index) - }) - - const registry = new Map< - string, - {cols: HTMLTableColElement[]; headers: HTMLTableCellElement[]} - >() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.headers.push(cell) - }) - - const tables = container.querySelectorAll(".ant-table table") - tables.forEach((table) => { - const cols = table.querySelectorAll("colgroup col") - keyToIndices.forEach((indices, key) => { - indices.forEach((idx) => { - const col = cols[idx] - if (!col) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.cols.push(col) - }) - }) - }) - - columnDomRefs.current = registry - }, [resizableProcessedColumns]) - - const registerHeaderForVisibility = useHeaderViewportVisibility({ - scopeId: resolvedScopeId, - containerRef: visibilityRootRef, - onVisibilityChange: viewportVisibilityHandler, - onColumnUnregister: internalViewportVisibilityDeleteHandler, - enabled: visibilityTrackingEnabled, - suspendUpdates: isResizing, - viewportMargin: columnVisibility?.viewportMargin, - exitDebounceMs: columnVisibility?.viewportExitDebounceMs, - excludeKeys: stickyColumnKeys, - descendantColumnMap: columnDescendantMap, - }) - - const visibilityHandlersRef = useRef(new Map void>()) - - useEffect(() => { - visibilityHandlersRef.current.clear() - }, [registerHeaderForVisibility]) - - const registerHeaderNode = useCallback( - (columnKey: string, node: HTMLElement | null) => { - if (!registerHeaderForVisibility) return - const cache = visibilityHandlersRef.current - let handler = cache.get(columnKey) - if (!handler) { - handler = registerHeaderForVisibility(columnKey) - cache.set(columnKey, handler) - } - handler(node) - }, - [registerHeaderForVisibility], - ) - - const visibilityRegistration = registerHeaderForVisibility ? registerHeaderNode : null - const lastNotifiedContextRef = useRef<{ - version: number - register: VisibilityRegistrationHandler | null - } | null>(null) - - useEffect(() => { - if (handleVisibilityStateChange && columnVisibilityControls) { - if (lastReportedVersionRef.current !== version) { - lastReportedVersionRef.current = version - handleVisibilityStateChange(columnVisibilityControls) - } - } - if (handleVisibilityContextChange && columnVisibilityControls) { - const previous = lastNotifiedContextRef.current - const nextRegister = visibilityRegistration ?? null - const shouldNotify = - !previous || previous.version !== version || previous.register !== nextRegister - if (shouldNotify) { - lastNotifiedContextRef.current = { - version, - register: nextRegister, - } - handleVisibilityContextChange({ - controls: columnVisibilityControls, - registerHeader: nextRegister, - version, - }) - } - } - }, [ - columnVisibilityControls, - handleVisibilityContextChange, - handleVisibilityStateChange, - visibilityRegistration, - version, - ]) - - // Ensure the Ant Design selection column (checkbox column) keeps the configured - // width, even when using resizable columns and fixed headers. AntD renders the - // selection column via col.ant-table-selection-col and th.ant-table-selection-column, - // which are not part of our normal column tree, so we adjust them directly. - useLayoutEffect(() => { - if (!rowSelection) return - if (!selectionColumnWidth || !Number.isFinite(selectionColumnWidth)) return - - const container = containerRef.current - if (!container) return - - const widthPx = `${selectionColumnWidth}px` - - const tables = container.querySelectorAll(".ant-table table") - tables.forEach((table) => { - const selectionCol = table.querySelector( - "colgroup col.ant-table-selection-col", - ) - if (selectionCol) { - selectionCol.style.width = widthPx - selectionCol.style.minWidth = widthPx - selectionCol.style.maxWidth = widthPx - } - }) - - const headerCells = container.querySelectorAll( - ".ant-table-thead th.ant-table-selection-column", - ) - headerCells.forEach((cell) => { - cell.style.width = widthPx - cell.style.minWidth = widthPx - cell.style.maxWidth = widthPx - }) - }, [rowSelection, selectionColumnWidth, resizableProcessedColumns]) - - const computedTotalWidth = useMemo( - () => getTotalWidth(finalColumns), - [finalColumns, getTotalWidth], - ) - const computedScrollX = computedTotalWidth + selectionColumnWidth - - const resolvedTableProps = useMemo>( - () => tableProps ?? ({} as TableProps), - [tableProps], - ) - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - setTableHeaderHeight(null) - return - } - const headerEl = - container.querySelector(".ant-table-thead") ?? - container.querySelector("table thead") - if (!headerEl) { - setTableHeaderHeight(null) - return - } - let frameId: number | null = null - const updateHeight = () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - frameId = requestAnimationFrame(() => { - frameId = null - const nextHeight = headerEl.getBoundingClientRect().height - setTableHeaderHeight((prev) => { - if (prev === nextHeight) return prev - return Number.isFinite(nextHeight) ? nextHeight : prev - }) - }) - } - const observer = new ResizeObserver(() => updateHeight()) - observer.observe(headerEl) - updateHeight() - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - observer.disconnect() - } - }, []) - - const scrollConfig = useMemo(() => { - if (typeof bodyHeight === "number" && Number.isFinite(bodyHeight)) { - const resolvedScroll = resolvedTableProps.scroll - const resolvedX = - resolvedScroll && typeof resolvedScroll.x !== "undefined" - ? resolvedScroll.x - : scrollX > 0 - ? scrollX - : undefined - return {x: resolvedX, y: bodyHeight} - } - const headerHeight = - (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) - ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) - ?.offsetHeight) ?? null - - const computedY = Math.max((scrollY ?? 0) - (headerHeight ?? 0), 0) - const resolvedScroll = resolvedTableProps.scroll - const requestedY = - resolvedScroll && typeof resolvedScroll.y === "number" ? resolvedScroll.y : undefined - const fallbackY = requestedY ?? computedY - let resolvedY = - typeof fallbackY === "number" && Number.isFinite(fallbackY) ? fallbackY : undefined - const resolvedX = (() => { - const rawX = resolvedScroll?.x - if (typeof rawX === "number" || typeof rawX === "string") { - return rawX - } - const computed = - Number.isFinite(computedScrollX) && computedScrollX > 0 ? computedScrollX : 0 - const container = scrollX > 0 ? scrollX : 0 - - // Always use the larger of computed or container width - // The sum constraint is enforced in computeSmartWidths, - // so computed should always >= container - const maxWidth = Math.max(computed, container) - return maxWidth > 0 ? maxWidth : undefined - })() - - if (resolvedY === undefined || resolvedY <= 0) { - const measured = scrollY ?? 0 - resolvedY = measured > 0 ? Math.max(measured - (headerHeight ?? 0), 0) : 360 - } - - if (resolvedY <= 0) { - resolvedY = 360 - } - - const { - x: _ignoredX, - y: _ignoredY, - ...restScroll - } = (resolvedScroll ?? {}) as Record - const nextConfig = { - ...restScroll, - x: resolvedX, - y: resolvedY, - } - const previous = lastScrollConfigRef.current - if (shallowEqual(previous, nextConfig)) { - return previous! - } - lastScrollConfigRef.current = nextConfig - return nextConfig - }, [ - bodyHeight, - scrollX, - scrollY, - resolvedTableProps.scroll, - shallowEqual, - computedScrollX, - tableHeaderHeight, - ]) - - // Sync .ant-table-header scroll position with .ant-table-body on every horizontal scroll. - // - // AntD's virtual Table syncs header/body scroll internally, but it can lose sync after - // column visibility changes, resizes, or scroll-config updates that trigger a re-render. - // We attach our own passive scroll listener as a safety net: when it fires, the header is - // already correct (AntD's handler ran first), so this is a no-op in the happy path. - // When AntD's sync breaks, our listener corrects the header on the very next scroll tick. - useEffect(() => { - const container = containerRef.current - if (!container) return - - const body = container.querySelector(".ant-table-body") - const header = container.querySelector(".ant-table-header") - if (!body || !header) return - - const sync = () => { - if (header.scrollLeft !== body.scrollLeft) { - header.scrollLeft = body.scrollLeft - } - } - - body.addEventListener("scroll", sync, {passive: true}) - // Correct any drift that happened during the re-render that triggered this effect - sync() - - return () => { - body.removeEventListener("scroll", sync) - } - }, [finalColumns, scrollConfig.x]) - - // Memoize dependencies object to prevent unnecessary useEffect runs in useScrollContainer - // Without memoization, a new object is created every render, causing infinite loops during scroll - const scrollContainerDeps = useMemo( - () => ({ - scrollX: scrollConfig.x, - scrollY: scrollConfig.y, - className: resolvedTableProps.className, - }), - [scrollConfig.x, scrollConfig.y, resolvedTableProps.className], - ) - - const {scrollContainer, visibilityRoot} = useScrollContainer(containerRef, scrollContainerDeps) - - // Sync visibilityRootRef with visibilityRoot from hook - useEffect(() => { - visibilityRootRef.current = visibilityRoot ?? containerRef.current - }, [visibilityRoot]) - - const mergedComponents = useMemo(() => { - if (!resizableHeaderComponents) { - return resolvedTableProps.components - } - const existingHeader = resolvedTableProps.components?.header ?? {} - return { - ...resolvedTableProps.components, - header: { - ...existingHeader, - ...resizableHeaderComponents, - }, - } - }, [resolvedTableProps.components, resizableHeaderComponents]) - - const finalTableProps = useMemo>( - () => ({ - ...resolvedTableProps, - components: mergedComponents, - }), - [resolvedTableProps, mergedComponents], - ) - - const {getRowProps: getShortcutRowProps} = useTableKeyboardShortcuts({ - containerRef, - dataSource, - rowKey, - rowSelection, - keyboardShortcuts, - active, - }) - - // Typed as antd's onRow so the merged props object stays assignable to TableProps. - const mergedOnRow = useCallback["onRow"]>>( - (record, index) => { - const baseOnRow = finalTableProps.onRow - const baseProps = baseOnRow ? baseOnRow(record, index) : {} - const shortcutProps = getShortcutRowProps - ? (getShortcutRowProps(record, index) ?? {}) - : {} - - const baseOnClick = baseProps?.onClick - const guardedOnClick = - !disableInteractiveClickGuard && baseOnClick - ? (event: React.MouseEvent) => { - if (shouldIgnoreRowClick(event)) return - baseOnClick(event) - } - : baseOnClick - - const hasShortcuts = shortcutProps && Object.keys(shortcutProps).length > 0 - if (!hasShortcuts) { - if (guardedOnClick === baseOnClick) return baseProps - return {...baseProps, onClick: guardedOnClick} - } - return { - ...baseProps, - ...shortcutProps, - className: clsx(baseProps?.className, shortcutProps?.className), - onMouseEnter: mergeHandlers(baseProps?.onMouseEnter, shortcutProps?.onMouseEnter), - onClick: guardedOnClick, - } - }, - [finalTableProps.onRow, getShortcutRowProps, disableInteractiveClickGuard], - ) - - const tablePropsWithShortcuts = useMemo>(() => { - const needsMerge = - getShortcutRowProps || (Boolean(finalTableProps.onRow) && !disableInteractiveClickGuard) - if (!needsMerge) { - return finalTableProps - } - return { - ...finalTableProps, - onRow: mergedOnRow, - } - }, [finalTableProps, getShortcutRowProps, mergedOnRow, disableInteractiveClickGuard]) - - const tableRowSelection = useTableRowSelection(rowSelection) - - // Expandable rows support - const expandableConfig = useExpandableRows({ - config: expandable, - rowKey, - }) - - // Build expandable prop for Ant Design Table - const tableExpandable = useMemo(() => { - if (!expandable) return undefined - return { - expandedRowKeys: expandableConfig.expandedRowKeys, - onExpand: expandableConfig.onExpand, - expandedRowRender: expandableConfig.expandedRowRender, - expandIcon: expandableConfig.expandIcon, - rowExpandable: expandableConfig.rowExpandable, - columnWidth: expandableConfig.expandColumnWidth, - fixed: expandableConfig.expandFixed, - } - }, [expandable, expandableConfig]) - - const columnVisibilityVersion = version - - useEffect(() => { - const key = resolvedScopeId - if (!key) return undefined - const nextCount = (scopeUsageCounts.get(key) ?? 0) + 1 - scopeUsageCounts.set(key, nextCount) - if (nextCount > 1 && process.env.NODE_ENV !== "production") { - console.warn( - `[InfiniteVirtualTable] Duplicate scopeId "${key}" detected. Column visibility state will be shared across tables.`, - ) - } - return () => { - const current = scopeUsageCounts.get(key) ?? 0 - if (current <= 1) { - scopeUsageCounts.delete(key) - } else { - scopeUsageCounts.set(key, current - 1) - } - } - }, [resolvedScopeId]) - - return ( - - - controls={columnVisibilityControls} - registerHeader={visibilityRegistration} - version={columnVisibilityVersion} - renderMenuContent={columnVisibility?.renderMenuContent} - renderMenuTrigger={columnVisibility?.renderMenuTrigger} - scopeId={resolvedScopeId} - > - - {beforeTable} -
- - ref={tableRef as React.Ref} - className={tableClassName} - columns={finalColumns} - dataSource={dataSource} - rowKey={rowKey} - pagination={false} - onScroll={handleScroll} - rowSelection={tableRowSelection} - expandable={tableExpandable} - {...tablePropsWithShortcuts} - scroll={{ - x: scrollConfig.x, - y: scrollConfig.y, - }} - virtual - /> -
-
- -
- ) -} - -// Memoize the inner component to create a render boundary -// This prevents re-renders when parent re-renders with referentially equal props -const InfiniteVirtualTableInner = memo( - InfiniteVirtualTableInnerBase, -) as typeof InfiniteVirtualTableInnerBase - -export default InfiniteVirtualTableInner diff --git a/web/oss/src/components/InfiniteVirtualTable/components/TableDescription.tsx b/web/oss/src/components/InfiniteVirtualTable/components/TableDescription.tsx deleted file mode 100644 index f65daaaf17..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/TableDescription.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type {ReactNode} from "react" - -import {Typography} from "antd" -import clsx from "clsx" - -export interface TableDescriptionProps { - /** The description text or content */ - children: ReactNode - /** Additional CSS class names */ - className?: string - /** Maximum width constraint (default: "prose" for readable line length) */ - maxWidth?: "prose" | "full" | "none" -} - -/** - * A reusable description component for table headers. - * Provides consistent styling and can be enhanced with additional functionality. - * - * @example - * ```tsx - * - * Manage your testsets for evaluations. - * - * - * - * Specify column names similar to the Input parameters. - * A column with 'correct_answer' name will be treated as a ground truth column. - * - * ``` - */ -const TableDescription = ({children, className, maxWidth = "prose"}: TableDescriptionProps) => { - const maxWidthClass = { - prose: "max-w-prose", - full: "max-w-full", - none: "", - }[maxWidth] - - return ( - - {children} - - ) -} - -export default TableDescription diff --git a/web/oss/src/components/InfiniteVirtualTable/components/TableShell.tsx b/web/oss/src/components/InfiniteVirtualTable/components/TableShell.tsx deleted file mode 100644 index 98a5b62b9f..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/TableShell.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type {ReactNode} from "react" -import {useLayoutEffect, useRef} from "react" - -import clsx from "clsx" - -interface TableShellProps { - title?: ReactNode - description?: ReactNode - badge?: ReactNode - header?: ReactNode - /** Additional content to render in the header row (e.g., tabs) */ - headerExtra?: ReactNode - filters?: ReactNode - primaryActions?: ReactNode - secondaryActions?: ReactNode - className?: string - contentClassName?: string - onHeaderHeightChange?: (height: number) => void - children: ReactNode -} - -const TableShell = ({ - title, - description, - badge, - header, - headerExtra, - filters, - primaryActions, - secondaryActions, - className, - contentClassName, - onHeaderHeightChange, - children, -}: TableShellProps) => { - const headerRef = useRef(null) - const lastHeightRef = useRef(0) - - useLayoutEffect(() => { - if (!onHeaderHeightChange) return - const element = headerRef.current - if (!element) { - if (lastHeightRef.current !== 0) { - lastHeightRef.current = 0 - onHeaderHeightChange(0) - } - return - } - const update = () => { - const nextHeight = element.getBoundingClientRect().height - // Only call callback if height actually changed - // This prevents infinite loops during horizontal scroll - if (lastHeightRef.current !== nextHeight) { - lastHeightRef.current = nextHeight - onHeaderHeightChange(nextHeight) - } - } - update() - const observer = new ResizeObserver(() => update()) - observer.observe(element) - return () => observer.disconnect() - }, [onHeaderHeightChange]) - - const renderDefaultHeader = () => ( -
- {title || headerExtra || (!filters && (primaryActions || secondaryActions)) ? ( -
- {title ? ( -
-
{title}
- {badge} -
- ) : ( -
- )} - -
- {headerExtra} - {!filters ? ( -
- {secondaryActions} - {primaryActions} -
- ) : null} -
-
- ) : null} - - {description ?
{description}
: null} - - {filters ? ( -
-
{filters}
-
- {secondaryActions} - {primaryActions} -
-
- ) : null} -
- ) - - const headerNode = header ?? renderDefaultHeader() - - return ( -
- {headerNode ? ( -
- {headerNode} -
- ) : null} -
{children}
-
- ) -} - -export default TableShell diff --git a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger.tsx b/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger.tsx deleted file mode 100644 index 793495f0ba..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type {ReactNode} from "react" - -import {useColumnVisibilityContext} from "../../context/ColumnVisibilityContext" -import type {ColumnVisibilityState} from "../../types" -import ColumnVisibilityTrigger from "../ColumnVisibilityTrigger" - -import ColumnVisibilityPopoverContent, { - type ColumnVisibilityNodeMeta, - type ColumnVisibilityPopoverContentProps, -} from "./ColumnVisibilityPopoverContent" - -interface ColumnVisibilityMenuTriggerProps extends Omit< - ColumnVisibilityPopoverContentProps, - "onClose" -> { - variant?: "icon" | "button" - label?: string - controls?: ColumnVisibilityState - renderContent?: ( - controls: ColumnVisibilityState, - close: () => void, - context: {scopeId: string | null}, - ) => ReactNode -} - -const ColumnVisibilityMenuTrigger = ({ - variant = "button", - label = "Columns", - controls, - renderContent, - scopeId, - resolveNodeMeta, -}: ColumnVisibilityMenuTriggerProps) => { - const { - controls: fallbackControls, - renderMenuContent: contextRenderContent, - renderMenuTrigger: contextRenderTrigger, - scopeId: contextScopeId, - } = useColumnVisibilityContext() - const visibilityControls = controls ?? fallbackControls - const effectiveScopeId = scopeId ?? contextScopeId ?? null - - const contentRenderer = renderContent ?? contextRenderContent - - // If a custom trigger renderer is provided, use it instead of the default popover trigger - if (contextRenderTrigger) { - return <>{contextRenderTrigger(visibilityControls, {scopeId: effectiveScopeId})} - } - - return ( - - contentRenderer ? ( - contentRenderer(ctrls, close, {scopeId: effectiveScopeId}) - ) : ( - - ) - } - /> - ) -} - -export default ColumnVisibilityMenuTrigger - -export type {ColumnVisibilityNodeMeta} diff --git a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent.tsx b/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent.tsx deleted file mode 100644 index bca26ab2aa..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import {useCallback, useEffect, useMemo, useState} from "react" - -import {FolderOpenOutlined, FileOutlined} from "@ant-design/icons" -import {ArrowCounterClockwise} from "@phosphor-icons/react" -import {Button, Input, Space, Tree, Typography} from "antd" -import type {DataNode} from "antd/es/tree" -import {LOW_PRIORITY, useSetAtomWithSchedule} from "jotai-scheduler" - -import {getColumnWidthsAtom} from "../../atoms/columnWidths" -import {useColumnVisibilityControls, type ColumnVisibilityState} from "../../InfiniteVirtualTable" -import type { - ColumnTreeNode, - ColumnVisibilityNodeMeta, - ColumnVisibilityNodeMetaResolver, -} from "../../types" - -export interface ColumnVisibilityPopoverContentProps { - onClose: () => void - controls?: ColumnVisibilityState - scopeId?: string | null - resolveNodeMeta?: ColumnVisibilityNodeMetaResolver - onExport?: () => void - isExporting?: boolean - /** Additional content to render before the visibility controls */ - additionalContent?: React.ReactNode -} - -type VisibilityTreeNode = DataNode & {searchLabel: string} - -const ColumnVisibilityPopoverContent = ({ - onClose, - controls, - scopeId = null, - resolveNodeMeta, - onExport, - isExporting, - additionalContent, -}: ColumnVisibilityPopoverContentProps) => { - const fallbackControls = useColumnVisibilityControls() - const visibilityControls = controls ?? fallbackControls - const {columnTree, leafKeys, toggleColumn, toggleTree, reset, setHiddenKeys} = - visibilityControls - - const columnWidthsAtom = useMemo(() => getColumnWidthsAtom(scopeId), [scopeId]) - const setColumnWidths = useSetAtomWithSchedule(columnWidthsAtom, { - priority: LOW_PRIORITY, - }) - - const [search, setSearch] = useState("") - const allTreeKeys = useMemo(() => { - const keys: string[] = [] - const walk = (nodes: typeof columnTree) => { - nodes.forEach((node) => { - keys.push(String(node.key)) - if (node.children?.length) { - walk(node.children) - } - }) - } - walk(columnTree) - return keys - }, [columnTree]) - const [expandedKeys, setExpandedKeys] = useState(allTreeKeys) - - useEffect(() => { - setExpandedKeys(allTreeKeys) - }, [allTreeKeys]) - - const allNodes = useMemo(() => { - const nodes: ColumnTreeNode[] = [] - const walk = (items: typeof columnTree) => { - items.forEach((node) => { - nodes.push(node) - if (node.children?.length) { - walk(node.children) - } - }) - } - walk(columnTree) - return nodes - }, [columnTree]) - - const [resolvedNodeMetaMap, setResolvedNodeMetaMap] = useState( - () => new Map(), - ) - - useEffect(() => { - if (!resolveNodeMeta) { - setResolvedNodeMetaMap(new Map()) - return - } - let active = true - setResolvedNodeMetaMap(new Map()) - - allNodes.forEach((node) => { - const key = String(node.key) - Promise.resolve(resolveNodeMeta(node)).then((meta) => { - if (!active || !meta) return - setResolvedNodeMetaMap((prev) => { - const existing = prev.get(key) - if (existing === meta) return prev - const next = new Map(prev) - next.set(key, meta) - return next - }) - }) - }) - - return () => { - active = false - } - }, [allNodes, resolveNodeMeta]) - - const defaultNodeMeta = useCallback( - (node: ColumnTreeNode, hasChildren: boolean): ColumnVisibilityNodeMeta => { - const key = String(node.key) - const label = node.titleNode ?? node.label ?? key - return { - title: - typeof label === "string" ? ( - - {label} - - ) : ( - label - ), - searchValues: [typeof label === "string" ? label : undefined, key], - icon: hasChildren ? : , - } - }, - [], - ) - - const treeData = useMemo(() => { - const mapNodes = (nodes: typeof columnTree): VisibilityTreeNode[] => - nodes.map((node) => { - const hasChildren = Boolean(node.children?.length) - const key = String(node.key) - const customMeta = resolvedNodeMetaMap.get(key) - const defaultMeta = defaultNodeMeta(node, hasChildren) - const meta = customMeta ?? defaultMeta - const title = meta.title ?? defaultMeta.title - const icon = - meta.icon ?? - defaultMeta.icon ?? - (hasChildren ? : ) - const searchValues = meta.searchValues ?? - defaultMeta.searchValues ?? [ - node.label ?? undefined, - typeof node.key === "string" ? node.key : key, - ] - const searchLabel = searchValues - .filter((segment): segment is string => Boolean(segment)) - .join(" ") - - const children = hasChildren ? mapNodes(node.children) : undefined - - return { - key, - title, - icon, - children, - selectable: false, - searchLabel, - checked: node.checked, - indeterminate: node.indeterminate, - } as VisibilityTreeNode - }) - - return mapNodes(columnTree) - }, [columnTree, defaultNodeMeta, resolvedNodeMetaMap]) - - const filterTreeData = useCallback( - (nodes: VisibilityTreeNode[], query: string): VisibilityTreeNode[] => - nodes - .map((node) => { - const children = Array.isArray(node.children) - ? filterTreeData(node.children as VisibilityTreeNode[], query) - : undefined - const matches = node.searchLabel.toLowerCase().includes(query) - if (matches || (children && children.length)) { - return {...node, children} - } - return null - }) - .filter(Boolean) as VisibilityTreeNode[], - [], - ) - - const filteredTreeData = useMemo(() => { - const query = search.trim().toLowerCase() - if (!query) return treeData - return filterTreeData(treeData, query) - }, [filterTreeData, search, treeData]) - - const checkedKeys = useMemo(() => { - const keys: string[] = [] - const gather = (nodes: typeof columnTree) => { - nodes.forEach((node) => { - if (node.checked) keys.push(String(node.key)) - if (node.children?.length) gather(node.children) - }) - } - gather(columnTree) - return keys - }, [columnTree]) - - const halfCheckedKeys = useMemo(() => { - const keys: string[] = [] - const gather = (nodes: typeof columnTree) => { - nodes.forEach((node) => { - if (node.indeterminate) keys.push(String(node.key)) - if (node.children?.length) gather(node.children) - }) - } - gather(columnTree) - return keys - }, [columnTree]) - - const handleExpandAll = useCallback(() => { - setExpandedKeys(allTreeKeys) - }, [allTreeKeys]) - - const handleCollapseAll = useCallback(() => { - setExpandedKeys([]) - }, []) - - const handleShowAll = useCallback(() => { - setHiddenKeys([]) - }, [setHiddenKeys]) - - const handleHideAll = useCallback(() => { - setHiddenKeys(leafKeys) - }, [leafKeys, setHiddenKeys]) - - const handleResetLayout = useCallback(() => { - reset() - setColumnWidths(() => ({})) - setSearch("") - setExpandedKeys(allTreeKeys) - }, [allTreeKeys, reset, setColumnWidths]) - - return ( -
- {additionalContent} - - setSearch(event.target.value)} - /> - -
- - Visibility - - - - - - - -
-
-
- setExpandedKeys(keys as string[])} - treeData={filteredTreeData} - onCheck={(_, info) => { - const key = String(info.node.key) - const nodeItem = info.node as VisibilityTreeNode - const hasNestedChildren = - Array.isArray(nodeItem.children) && nodeItem.children.length > 0 - if (hasNestedChildren) { - toggleTree(key) - } else { - toggleColumn(key) - } - }} - /> -
-
- -
- - -
-
- ) -} - -export default ColumnVisibilityPopoverContent - -export type {ColumnVisibilityNodeMeta, ColumnVisibilityNodeMetaResolver} diff --git a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/TableSettingsDropdown.tsx b/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/TableSettingsDropdown.tsx deleted file mode 100644 index f8fb6e81f3..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/TableSettingsDropdown.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import {type ReactNode, useState, useMemo, useCallback} from "react" - -import {DownloadSimple, Eye, GearSix, Trash} from "@phosphor-icons/react" -import {Button, Dropdown, Popover, Tooltip} from "antd" -import type {MenuProps} from "antd" - -import type {ColumnVisibilityState} from "../../types" - -export interface TableSettingsDropdownProps { - controls: ColumnVisibilityState - onExport?: () => void - isExporting?: boolean - onDelete?: () => void - deleteDisabled?: boolean - deleteLabel?: string - renderColumnVisibilityContent: ( - controls: ColumnVisibilityState, - close: () => void, - ) => ReactNode - /** Additional menu items to render after Column visibility */ - additionalMenuItems?: MenuProps["items"] -} - -/** - * A dropdown menu triggered by a gear icon that provides table settings actions. - * Opens a dropdown with options like "Export" and "Column Visibility". - * Column visibility opens a nested popover with the full column visibility UI. - */ -const TableSettingsDropdown = ({ - controls, - onExport, - isExporting, - onDelete, - deleteDisabled, - deleteLabel = "Delete", - renderColumnVisibilityContent, - additionalMenuItems, -}: TableSettingsDropdownProps) => { - const [dropdownOpen, setDropdownOpen] = useState(false) - const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false) - - const handleCloseColumnVisibility = useCallback(() => { - setColumnVisibilityOpen(false) - }, []) - - const handleOpenColumnVisibility = useCallback(() => { - setDropdownOpen(false) - // Small delay to let dropdown close before opening popover - setTimeout(() => { - setColumnVisibilityOpen(true) - }, 100) - }, []) - - const menuItems = useMemo(() => { - const items: MenuProps["items"] = [] - - // Column Visibility option - items.push({ - key: "column-visibility", - label: "Column visibility", - icon: , - onClick: (e) => { - e.domEvent.stopPropagation() - handleOpenColumnVisibility() - }, - }) - - // Additional menu items (e.g., Row height) - if (additionalMenuItems?.length) { - items.push({type: "divider"}) - items.push(...additionalMenuItems) - } - - // Export option (if enabled) - if (onExport) { - items.push({type: "divider"}) - items.push({ - key: "export", - label: isExporting ? "Exporting..." : "Export to CSV", - icon: , - disabled: isExporting, - onClick: (e) => { - e.domEvent.stopPropagation() - onExport() - setDropdownOpen(false) - }, - }) - } - - // Delete option (if enabled) - if (onDelete) { - items.push({type: "divider"}) - items.push({ - key: "delete", - label: deleteLabel, - icon: , - disabled: deleteDisabled, - danger: true, - onClick: (e) => { - e.domEvent.stopPropagation() - onDelete() - setDropdownOpen(false) - }, - }) - } - - return items - }, [ - additionalMenuItems, - deleteDisabled, - deleteLabel, - handleOpenColumnVisibility, - isExporting, - onDelete, - onExport, - ]) - - return ( - { - if (!open) { - setColumnVisibilityOpen(false) - } - }} - content={renderColumnVisibilityContent(controls, handleCloseColumnVisibility)} - destroyOnHidden - > - { - // Don't open dropdown if column visibility popover is open - if (columnVisibilityOpen && open) return - setDropdownOpen(open) - }} - menu={{items: menuItems}} - styles={{ - root: { - minWidth: 180, - }, - }} - > - - - - ) -} - -export default FiltersPopoverTrigger diff --git a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityContext.ts b/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityContext.ts deleted file mode 100644 index 0babcf7ca2..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityContext.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {createContext, useContext} from "react" -import type {Key} from "react" - -import type {VisibilityRegistrationHandler} from "../components/ColumnVisibilityHeader" -import type { - ColumnVisibilityState, - ColumnVisibilityMenuRenderer, - ColumnVisibilityMenuTriggerRenderer, -} from "../types" - -const noop = () => undefined - -const defaultColumnVisibilityControls: ColumnVisibilityState = { - allKeys: [], - leafKeys: [], - hiddenKeys: [], - setHiddenKeys: (_keys: Key[]) => undefined, - isHidden: () => false, - showColumn: noop, - hideColumn: noop, - toggleColumn: noop, - toggleTree: noop, - reset: noop, - visibleColumns: [], - columnTree: [], - version: 0, -} - -export interface ColumnVisibilityContextValue { - controls: ColumnVisibilityState - registerHeader: VisibilityRegistrationHandler | null - version: number - renderMenuContent?: ColumnVisibilityMenuRenderer - renderMenuTrigger?: ColumnVisibilityMenuTriggerRenderer - scopeId: string | null -} - -export const defaultColumnVisibilityContextValue: ColumnVisibilityContextValue = { - controls: defaultColumnVisibilityControls, - registerHeader: null, - version: 0, - renderMenuContent: undefined, - renderMenuTrigger: undefined, - scopeId: null, -} - -const ColumnVisibilityContext = createContext( - defaultColumnVisibilityContextValue, -) - -export const useColumnVisibilityContext = () => - useContext(ColumnVisibilityContext) as ColumnVisibilityContextValue - -export const useColumnVisibilityControls = () => - useColumnVisibilityContext().controls - -export {defaultColumnVisibilityControls} - -export default ColumnVisibilityContext diff --git a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityFlagContext.tsx b/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityFlagContext.tsx deleted file mode 100644 index fba8025fb4..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityFlagContext.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {createContext, useContext, useMemo, type PropsWithChildren} from "react" - -import {IMMEDIATE_PRIORITY, useAtomValueWithSchedule} from "jotai-scheduler" - -import { - // getScopedColumnVisibilityAtom, - scopedColumnVisibilityAtomFamily, -} from "../atoms/columnVisibility" - -interface ColumnVisibilityFlagContextValue { - scopeId: string | null -} - -const ColumnVisibilityFlagContext = createContext(null) - -export const ColumnVisibilityFlagProvider = ({ - scopeId, - children, -}: PropsWithChildren<{scopeId: string | null}>) => { - const value = useMemo(() => ({scopeId}), [scopeId]) - return ( - - {children} - - ) -} - -const useColumnVisibilityFlagContext = () => useContext(ColumnVisibilityFlagContext) - -export const useColumnVisibilityFlag = (columnKey?: string): boolean => { - const ctx = useColumnVisibilityFlagContext() - const scopeId = ctx?.scopeId ?? null - const visibilityAtom = useMemo( - () => scopedColumnVisibilityAtomFamily({scopeId, columnKey: columnKey ?? ""}), - [scopeId, columnKey], - ) - // Use IMMEDIATE_PRIORITY to ensure visibility updates don't lag behind scroll - // but still allow batching with other updates - const isVisible = - useAtomValueWithSchedule(visibilityAtom, {priority: IMMEDIATE_PRIORITY}) ?? false - - return isVisible -} - -export default ColumnVisibilityFlagContext diff --git a/web/oss/src/components/InfiniteVirtualTable/context/VirtualTableScrollContainerContext.ts b/web/oss/src/components/InfiniteVirtualTable/context/VirtualTableScrollContainerContext.ts deleted file mode 100644 index b695ca6ae7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/context/VirtualTableScrollContainerContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {createContext, useContext} from "react" - -const VirtualTableScrollContainerContext = createContext(null) - -export const useVirtualTableScrollContainer = () => useContext(VirtualTableScrollContainerContext) - -export default VirtualTableScrollContainerContext diff --git a/web/oss/src/components/InfiniteVirtualTable/createInfiniteDatasetStore.ts b/web/oss/src/components/InfiniteVirtualTable/createInfiniteDatasetStore.ts deleted file mode 100644 index e72b133da7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/createInfiniteDatasetStore.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type {Key} from "react" - -import type {Atom, PrimitiveAtom} from "jotai" -import {atom, useAtom, useAtomValue} from "jotai" -import {atomFamily} from "jotai/utils" - -import {createInfiniteTableStore} from "./createInfiniteTableStore" -import type {InfiniteTableStore} from "./createInfiniteTableStore" -import useInfiniteTablePagination from "./hooks/useInfiniteTablePagination" -import type {InfiniteTableFetchResult, InfiniteTableRowBase, WindowingState} from "./types" - -interface ScopeParams { - scopeId: string | null -} - -interface TablePagesParams { - scopeId: string | null - pageSize: number -} - -export interface InfiniteDatasetStoreConfig { - key: string - metaAtom: Atom - createSkeletonRow: (params: { - scopeId: string | null - offset: number - index: number - windowing: WindowingState | null - rowKey: string - }) => Row - mergeRow: (params: {skeleton: Row; apiRow?: ApiRow}) => Row - fetchPage: (params: { - meta: Meta - limit: number - offset: number - cursor: string | null - windowing: WindowingState | null - }) => Promise> - isEnabled?: (meta: Meta | undefined) => boolean - /** - * Optional atom that provides client-side rows (e.g., unsaved drafts) - * These rows will be prepended to server rows - */ - clientRowsAtom?: Atom - /** - * Optional atom providing IDs of rows to exclude from display - * Useful for filtering out soft-deleted rows before save - */ - excludeRowIdsAtom?: Atom> -} - -export interface InfiniteDatasetStore { - store: InfiniteTableStore - config: InfiniteDatasetStoreConfig - atoms: { - rowsAtom: (params: TablePagesParams) => Atom - paginationAtom: (params: TablePagesParams) => Atom<{ - hasMore: boolean - nextCursor: string | null - nextOffset: number | null - isFetching: boolean - totalCount: number | null - nextWindowing: WindowingState | null - }> - selectionAtom: (params: ScopeParams) => PrimitiveAtom - } - hooks: { - usePagination: (params: { - scopeId: string | null - pageSize: number - resetOnScopeChange?: boolean - }) => ReturnType> - useRowSelection: ( - params: ScopeParams, - ) => [Key[], (next: Key[] | ((prev: Key[]) => Key[])) => void] - } -} - -export const createInfiniteDatasetStore = ( - config: InfiniteDatasetStoreConfig, -): InfiniteDatasetStore => { - const selectionAtomFamily = atomFamily( - ({scopeId}: ScopeParams) => atom([]), - (a, b) => a.scopeId === b.scopeId, - ) - - const tableStore = createInfiniteTableStore({ - key: config.key, - createSkeletonRow: config.createSkeletonRow, - mergeRow: config.mergeRow, - getQueryMeta: ({get}) => get(config.metaAtom), - isEnabled: ({meta}) => { - if (config.isEnabled) { - return config.isEnabled(meta) - } - return Boolean(meta) - }, - fetchPage: async ({limit, offset, cursor, windowing, meta}) => { - if (!meta) { - return { - rows: [], - totalCount: 0, - hasMore: false, - nextOffset: null, - nextCursor: null, - nextWindowing: null, - } - } - - return config.fetchPage({ - meta, - limit, - offset, - cursor, - windowing, - }) - }, - }) - - // Create custom pagination hook that uses wrapped atoms (with client rows) - const usePagination = ({ - scopeId, - pageSize, - resetOnScopeChange, - }: { - scopeId: string | null - pageSize: number - resetOnScopeChange?: boolean - }) => { - // Get the base pagination result from tableStore - const basePagination = useInfiniteTablePagination({ - store: tableStore, - scopeId, - pageSize, - resetOnScopeChange, - }) - - // Always get wrapped atoms (even if not using them - to satisfy rules of hooks) - const wrappedRowsAtom = rowsWithClientAtomFamily({scopeId, pageSize}) - const wrappedPaginationAtom = paginationWithClientAtomFamily({scopeId, pageSize}) - - // Always read from wrapped atoms (rules of hooks) - const wrappedRows = useAtomValue(wrappedRowsAtom) as Row[] - const wrappedPaginationInfo = useAtomValue(wrappedPaginationAtom) - - // If no client rows, return base pagination as-is - if (!config.clientRowsAtom) { - return basePagination - } - - // Override with wrapped data - return { - ...basePagination, - rows: wrappedRows, - rowsAtom: wrappedRowsAtom, - totalRows: wrappedPaginationInfo.totalCount || 0, - paginationInfo: wrappedPaginationInfo, - } - } - - const useRowSelection = ({scopeId}: ScopeParams) => useAtom(selectionAtomFamily({scopeId})) - - // Create wrapper atoms that merge client rows if clientRowsAtom is provided - // Use atomFamily to cache derived atoms by params - const rowsWithClientAtomFamily = atomFamily( - (params: TablePagesParams) => { - const baseRowsAtom = tableStore.atoms.combinedRowsAtomFamily(params) - - return atom((get) => { - let baseRows = get(baseRowsAtom) - - // Apply exclusion filter if provided (e.g., filter out soft-deleted rows) - if (config.excludeRowIdsAtom) { - const excludeIds = get(config.excludeRowIdsAtom) - baseRows = baseRows.filter((row) => { - const rowId = - (typeof row.id === "string" || typeof row.id === "number" - ? String(row.id) - : null) ?? String(row.key) - return !excludeIds.has(rowId) - }) - } - - // Guard: only read from clientRowsAtom if it exists - if (!config.clientRowsAtom) { - return baseRows - } - - const clientRows = get(config.clientRowsAtom) - - // Prepend client rows to server rows - return [...clientRows, ...baseRows] - }) - }, - (a, b) => a.scopeId === b.scopeId && a.pageSize === b.pageSize, - ) - - const paginationWithClientAtomFamily = atomFamily( - (params: TablePagesParams) => { - const basePaginationAtom = tableStore.atoms.paginationInfoAtomFamily(params) - const baseRowsAtom = tableStore.atoms.combinedRowsAtomFamily(params) - - return atom((get) => { - const basePagination = get(basePaginationAtom) - - // Calculate actual count after filtering excluded rows - let serverRowCount = basePagination.totalCount || 0 - if (config.excludeRowIdsAtom) { - const excludeIds = get(config.excludeRowIdsAtom) - const baseRows = get(baseRowsAtom) - serverRowCount = baseRows.filter((row) => { - const rowId = - (typeof row.id === "string" || typeof row.id === "number" - ? String(row.id) - : null) ?? String(row.key) - return !excludeIds.has(rowId) - }).length - } - - // Guard: only read from clientRowsAtom if it exists - if (!config.clientRowsAtom) { - return { - ...basePagination, - totalCount: serverRowCount, - } - } - - const clientRows = get(config.clientRowsAtom) - - return { - ...basePagination, - totalCount: serverRowCount + clientRows.length, - } - }) - }, - (a, b) => a.scopeId === b.scopeId && a.pageSize === b.pageSize, - ) - - const rowsAtomGetter = (params: TablePagesParams) => { - if (!config.clientRowsAtom) { - return tableStore.atoms.combinedRowsAtomFamily(params) - } - return rowsWithClientAtomFamily(params) - } - - const paginationAtomGetter = (params: TablePagesParams) => { - if (!config.clientRowsAtom) { - return tableStore.atoms.paginationInfoAtomFamily(params) - } - return paginationWithClientAtomFamily(params) - } - - return { - store: tableStore, - config, - atoms: { - rowsAtom: rowsAtomGetter, - paginationAtom: paginationAtomGetter, - selectionAtom: (params) => selectionAtomFamily(params), - }, - hooks: { - usePagination, - useRowSelection, - }, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/createInfiniteTableStore.ts b/web/oss/src/components/InfiniteVirtualTable/createInfiniteTableStore.ts deleted file mode 100644 index 42238b3d5a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/createInfiniteTableStore.ts +++ /dev/null @@ -1,370 +0,0 @@ -import {atom} from "jotai" -import type {Atom, WritableAtom} from "jotai" -import {atomFamily} from "jotai/utils" -import {atomWithQuery} from "jotai-tanstack-query" -import type {AtomWithQueryResult} from "jotai-tanstack-query" - -import type { - InfiniteTableFetchParams, - InfiniteTableFetchResult, - InfiniteTablePage, - InfiniteTableRowBase, - WindowingState, -} from "./types" - -export interface TableRowAtomKey { - scopeId: string | null - offset: number - limit: number - cursor: string | null - windowing?: WindowingState | null -} - -export interface TablePagesKey { - scopeId: string | null - pageSize: number -} - -const createRandomId = () => { - const globalCrypto = typeof globalThis !== "undefined" ? (globalThis as any).crypto : undefined - if (globalCrypto?.randomUUID) { - return globalCrypto.randomUUID() - } - return `ivt-row-${Math.random().toString(36).slice(2)}` -} - -type PagesWriteArg = - | {pages: InfiniteTablePage[]} - | ((prev: {pages: InfiniteTablePage[]}) => { - pages: InfiniteTablePage[] - }) - -type ScheduleWriteArg = null | { - nextCursor: string - nextOffset: number - nextWindowing: WindowingState | null - totalRows: number -} - -export interface InfiniteTableStore { - key: string - atoms: { - pagesAtomFamily: ( - params: TablePagesKey, - ) => WritableAtom<{pages: InfiniteTablePage[]}, [PagesWriteArg], void> - scheduleNextPageAtomFamily: ( - params: TablePagesKey, - ) => WritableAtom - combinedRowsAtomFamily: (params: TablePagesKey) => Atom - paginationInfoAtomFamily: (params: TablePagesKey) => Atom<{ - hasMore: boolean - nextCursor: string | null - nextOffset: number | null - isFetching: boolean - totalCount: number | null - nextWindowing: WindowingState | null - }> - rowsAtomFamily: (params: TableRowAtomKey) => Atom - rowsQueryAtomFamily: ( - params: TableRowAtomKey, - ) => WritableAtom>, [], void> - } - createInitialPage: (pageSize: number) => InfiniteTablePage -} - -interface CreateInfiniteTableStoreOptions< - TableRow extends InfiniteTableRowBase, - ApiRow, - TMeta = unknown, -> { - key: string - createSkeletonRow: (params: { - scopeId: string | null - offset: number - index: number - windowing: WindowingState | null - rowKey: string - }) => TableRow - mergeRow: (params: {skeleton: TableRow; apiRow?: ApiRow}) => TableRow - fetchPage: ( - params: InfiniteTableFetchParams, - ) => Promise> - getQueryMeta?: (params: { - scopeId: string | null - get: InfiniteTableFetchParams["get"] - }) => TMeta - isEnabled?: (params: {scopeId: string | null; meta: TMeta | undefined}) => boolean - keyEquals?: { - row?: (a: TableRowAtomKey, b: TableRowAtomKey) => boolean - page?: (a: TablePagesKey, b: TablePagesKey) => boolean - } - staleTime?: number - gcTime?: number -} - -export const createInfiniteTableStore = < - TableRow extends InfiniteTableRowBase, - ApiRow, - TMeta = unknown, ->( - options: CreateInfiniteTableStoreOptions, -): InfiniteTableStore => { - const skeletonRowsCache = new Map() - - const makeCacheKey = ({scopeId, offset, limit, cursor, windowing}: TableRowAtomKey) => - `${options.key}:${scopeId ?? "scope"}:${offset}:${limit}:${cursor ?? "start"}:$${ - windowing?.next ?? "" - }:${windowing?.stop ?? ""}` - - const ensureSkeletonRows = (key: TableRowAtomKey) => { - const cacheKey = makeCacheKey(key) - let rows = skeletonRowsCache.get(cacheKey) - if (!rows) { - rows = Array.from({length: key.limit}, (_, index) => - options.createSkeletonRow({ - scopeId: key.scopeId, - offset: key.offset, - index, - windowing: key.windowing ?? null, - rowKey: createRandomId(), - }), - ) - skeletonRowsCache.set(cacheKey, rows) - } - return rows - } - - const rowsKeyEquals = - options.keyEquals?.row ?? - ((a: TableRowAtomKey, b: TableRowAtomKey) => { - return ( - a.scopeId === b.scopeId && - a.offset === b.offset && - a.limit === b.limit && - a.cursor === b.cursor && - (a.windowing?.next ?? null) === (b.windowing?.next ?? null) && - (a.windowing?.stop ?? null) === (b.windowing?.stop ?? null) - ) - }) - - const pagesKeyEquals = - options.keyEquals?.page ?? - ((a: TablePagesKey, b: TablePagesKey) => { - return a.scopeId === b.scopeId && a.pageSize === b.pageSize - }) - - const tableRowsQueryAtomFamily = atomFamily( - (params: TableRowAtomKey) => - atomWithQuery>((get) => { - const meta = options.getQueryMeta?.({scopeId: params.scopeId, get}) - const metaKey = meta === undefined ? null : JSON.stringify(meta) - const enabled = options.isEnabled - ? options.isEnabled({scopeId: params.scopeId, meta}) - : Boolean(params.scopeId) - - return { - queryKey: [ - options.key, - params.scopeId, - params.cursor, - params.limit, - params.offset, - params.windowing?.next ?? null, - params.windowing?.stop ?? null, - metaKey, - ], - enabled, - staleTime: options.staleTime ?? 15_000, - gcTime: options.gcTime ?? 60_000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - queryFn: async () => { - return options.fetchPage({ - scopeId: params.scopeId, - cursor: params.cursor, - limit: params.limit, - offset: params.offset, - windowing: params.windowing ?? null, - meta, - get, - }) - }, - } - }), - rowsKeyEquals, - ) - - const tableSkeletonRowsAtomFamily = atomFamily( - (key: TableRowAtomKey) => - atom(() => { - return ensureSkeletonRows(key) - }), - rowsKeyEquals, - ) - - const tableRowsAtomFamily = atomFamily( - (key: TableRowAtomKey) => - atom((get) => { - const skeletonRows = get(tableSkeletonRowsAtomFamily(key)) - const query = get(tableRowsQueryAtomFamily(key)) - const apiRows = query.data?.rows - - if (!apiRows) { - return skeletonRows - } - - if (!apiRows.length) { - return [] - } - - return skeletonRows.slice(0, apiRows.length).map((skeleton, index) => { - const apiRow = apiRows[index] - return options.mergeRow({skeleton, apiRow}) - }) - }), - rowsKeyEquals, - ) - - const tablePagesAtomFamily = atomFamily(({scopeId, pageSize}: TablePagesKey) => { - const baseAtom = atom<{pages: InfiniteTablePage[]}>({ - pages: [ - { - offset: 0, - limit: pageSize, - cursor: null, - windowing: null, - }, - ], - }) - - return atom( - (get) => get(baseAtom), - ( - get, - set, - update: - | {pages: InfiniteTablePage[]} - | ((prev: {pages: InfiniteTablePage[]}) => {pages: InfiniteTablePage[]}), - ) => { - const nextValue = typeof update === "function" ? update(get(baseAtom)) : update - set(baseAtom, nextValue) - }, - ) - }, pagesKeyEquals) - - const tableCombinedRowsAtomFamily = atomFamily( - ({scopeId, pageSize}: TablePagesKey) => - atom((get) => { - const pagesState = get(tablePagesAtomFamily({scopeId, pageSize})) - const combined: TableRow[] = [] - pagesState.pages.forEach(({offset, limit, cursor, windowing}) => { - const rows = get( - tableRowsAtomFamily({scopeId, offset, limit, cursor, windowing}), - ) - combined.push(...rows) - }) - return combined - }), - pagesKeyEquals, - ) - - const tablePaginationInfoAtomFamily = atomFamily( - ({scopeId, pageSize}: TablePagesKey) => - atom((get) => { - const pagesState = get(tablePagesAtomFamily({scopeId, pageSize})) - const lastPage = pagesState.pages[pagesState.pages.length - 1] - if (!lastPage) { - return { - hasMore: false, - nextCursor: null as string | null, - nextOffset: null as number | null, - isFetching: false, - totalCount: null as number | null, - nextWindowing: null as WindowingState | null, - } - } - const query = get( - tableRowsQueryAtomFamily({ - scopeId, - cursor: lastPage.cursor, - limit: lastPage.limit, - offset: lastPage.offset, - windowing: lastPage.windowing ?? undefined, - }), - ) - const data = query.data - return { - hasMore: Boolean(data?.hasMore), - nextCursor: data?.nextCursor ?? null, - nextOffset: data?.nextOffset ?? null, - isFetching: Boolean(query.isFetching || query.isPending), - totalCount: data?.totalCount ?? null, - nextWindowing: data?.nextWindowing ?? null, - } - }), - pagesKeyEquals, - ) - - const createInitialPage = (pageSize: number): InfiniteTablePage => ({ - offset: 0, - limit: pageSize, - cursor: null, - windowing: null, - }) - - const tableScheduleNextPageAtomFamily = atomFamily( - ({scopeId, pageSize}: TablePagesKey) => - atom( - null, - ( - get, - set, - params: null | { - nextCursor: string - nextOffset: number - nextWindowing: WindowingState | null - totalRows: number - }, - ) => { - if (!params) return - set(tablePagesAtomFamily({scopeId, pageSize}), (prev) => { - if ( - prev.pages.some( - (page) => - page.cursor === params.nextCursor && - (page.windowing?.next ?? null) === - (params.nextWindowing?.next ?? params.nextCursor), - ) - ) { - return prev - } - return { - pages: [ - ...prev.pages, - { - offset: params.nextOffset, - limit: pageSize, - cursor: params.nextCursor, - windowing: params.nextWindowing, - }, - ], - } - }) - }, - ), - pagesKeyEquals, - ) - - return { - key: options.key, - atoms: { - pagesAtomFamily: tablePagesAtomFamily, - scheduleNextPageAtomFamily: tableScheduleNextPageAtomFamily, - combinedRowsAtomFamily: tableCombinedRowsAtomFamily, - paginationInfoAtomFamily: tablePaginationInfoAtomFamily, - rowsAtomFamily: tableRowsAtomFamily, - rowsQueryAtomFamily: tableRowsQueryAtomFamily, - }, - createInitialPage, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/features/InfiniteVirtualTableFeatureShell.tsx b/web/oss/src/components/InfiniteVirtualTable/features/InfiniteVirtualTableFeatureShell.tsx deleted file mode 100644 index a420759f92..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/features/InfiniteVirtualTableFeatureShell.tsx +++ /dev/null @@ -1,616 +0,0 @@ -import type {CSSProperties, Key, ReactNode} from "react" -import {useCallback, useEffect, useMemo, useState} from "react" - -import {TrashIcon} from "@phosphor-icons/react" -import {Button, Grid, Tabs, Tooltip} from "antd" -import type {MenuProps} from "antd" -import clsx from "clsx" - -import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" - -import ColumnVisibilityPopoverContent from "../components/columnVisibility/ColumnVisibilityPopoverContent" -import TableSettingsDropdown from "../components/columnVisibility/TableSettingsDropdown" -import TableShell from "../components/TableShell" -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import useTableExport, {type TableExportOptions} from "../hooks/useTableExport" -import InfiniteVirtualTable from "../InfiniteVirtualTable" -import type { - ColumnVisibilityMenuRenderer, - ColumnVisibilityState, - InfiniteTableRowBase, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, -} from "../types" - -type ColumnVisibilityRenderer = ( - controls: ColumnVisibilityState, - close: () => void, - context: {scopeId: string | null}, -) => ReactNode - -export interface TableScopeConfig { - scopeId: string | null - pageSize: number - enableInfiniteScroll?: boolean - columnVisibilityStorageKey?: string | null - columnVisibilityDefaults?: Key[] - viewportTrackingEnabled?: boolean - /** Margin around viewport for preloading columns (e.g., "0px 200px" to preload 200px on left/right) */ - viewportMargin?: string - /** Debounce time in ms before marking a column as hidden after it exits viewport (default: 150) */ - viewportExitDebounceMs?: number -} - -export interface TableFeaturePagination { - rows: Row[] - loadNextPage: () => void - resetPages: () => void -} - -export type TableFeatureExportOptions = TableExportOptions - -export interface TableTabItem { - key: string - label: string -} - -export interface TableTabsConfig { - /** Tab items to render */ - items: TableTabItem[] - /** Currently active tab key */ - activeKey: string - /** Callback when tab changes */ - onChange: (key: string) => void - /** Optional CSS variable for tab indicator color */ - indicatorColor?: string - /** Optional className for the tabs container */ - className?: string -} - -/** Configuration for the built-in delete action */ -export interface TableDeleteConfig { - /** Callback when delete is triggered */ - onDelete: () => void - /** Whether the delete action is disabled */ - disabled?: boolean - /** Tooltip to show when disabled */ - disabledTooltip?: string - /** Button label (default: "Delete") */ - label?: string -} - -/** Configuration for the built-in export action */ -export interface TableExportConfig { - /** Whether the export action is disabled */ - disabled?: boolean - /** Tooltip to show when disabled */ - disabledTooltip?: string - /** Button label (default: "Export CSV") */ - label?: string -} - -export interface InfiniteVirtualTableFeatureProps { - datasetStore: InfiniteDatasetStore - tableScope: TableScopeConfig - columns: InfiniteVirtualTableProps["columns"] - rowKey: InfiniteVirtualTableProps["rowKey"] - title?: ReactNode - /** Tabs configuration for the header */ - tabs?: TableTabsConfig - /** @deprecated Use tabs prop instead. Additional content to render in the header row */ - headerExtra?: ReactNode - filters?: ReactNode - primaryActions?: ReactNode - /** - * Built-in delete action configuration. - * When provided, the shell renders a standard delete button. - * On narrow screens, this moves to the settings dropdown. - */ - deleteAction?: TableDeleteConfig - /** - * Built-in export action configuration. - * When provided along with enableExport, the shell renders a standard export button. - * On narrow screens, export moves to the settings dropdown. - */ - exportAction?: TableExportConfig - /** @deprecated Use deleteAction instead. Custom secondary actions to render */ - secondaryActions?: ReactNode - className?: string - containerClassName?: string - tableClassName?: string - autoHeight?: boolean - rowHeight?: number - fallbackControlsHeight?: number - fallbackHeaderHeight?: number - resizableColumns?: InfiniteVirtualTableProps["resizableColumns"] - tableProps?: InfiniteVirtualTableProps["tableProps"] - beforeTable?: ReactNode - afterTable?: ReactNode - columnVisibilityMenuRenderer?: ColumnVisibilityMenuRenderer | ColumnVisibilityRenderer - columnVisibility?: InfiniteVirtualTableProps["columnVisibility"] - rowSelection?: InfiniteVirtualTableRowSelection - onPaginationStateChange?: (payload: {resetPages: () => void; loadNextPage: () => void}) => void - onRowsChange?: (rows: Row[]) => void - pagination?: TableFeaturePagination - enableExport?: boolean - exportFilename?: string - /** @deprecated Use exportAction instead for button customization */ - renderExportButton?: (props: {onExport: () => void; loading: boolean}) => ReactNode - exportOptions?: TableFeatureExportOptions - /** - * When true, the gear icon opens a dropdown menu with actions (Export, Column Visibility) - * instead of directly opening the column visibility popover. - * Default: false (gear icon opens column visibility popover directly) - */ - useSettingsDropdown?: boolean - /** - * @deprecated Use deleteAction instead. - * Delete action configuration for the settings dropdown. - * Only used when useSettingsDropdown is true. - */ - settingsDropdownDelete?: { - onDelete: () => void - disabled?: boolean - label?: string - } - /** - * Additional menu items for the settings dropdown. - * Only used when useSettingsDropdown is true. - */ - settingsDropdownMenuItems?: MenuProps["items"] - keyboardShortcuts?: InfiniteVirtualTableProps["keyboardShortcuts"] - /** - * Configuration for expandable rows. - * When provided, rows can be expanded to show child content (e.g., variants, revisions). - */ - expandable?: InfiniteVirtualTableProps["expandable"] - /** - * Override the dataSource from pagination. - * Useful when you need to transform rows (e.g., add children for tree data). - */ - dataSource?: Row[] - /** - * Jotai store to use for the table. When provided, the table will use this store - * instead of creating an isolated one. Useful when cells need to read from - * atoms in a shared store (e.g., entity atoms). - */ - store?: InfiniteVirtualTableProps["store"] - /** - * Ref to access the underlying Ant Design Table instance. - * Useful for programmatic scrolling via `tableRef.current?.scrollTo({ index })`. - */ - tableRef?: InfiniteVirtualTableProps["tableRef"] -} - -const DEFAULT_ROW_HEIGHT = 48 -const DEFAULT_CONTROLS_HEIGHT = 72 -const DEFAULT_TABLE_HEADER_HEIGHT = 48 - -interface ColumnVisibilityRendererContext { - scopeId: string | null - onExport?: () => void - isExporting?: boolean -} - -const resolveColumnVisibilityRenderer = ( - renderer: InfiniteVirtualTableFeatureProps["columnVisibilityMenuRenderer"], - config: InfiniteVirtualTableProps["columnVisibility"] | undefined, - context: ColumnVisibilityRendererContext, -): ColumnVisibilityMenuRenderer => { - const {scopeId, onExport, isExporting} = context - if (!renderer) { - return (controls, close) => ( - - ) - } - return (controls, close) => renderer(controls, close, {scopeId, onExport, isExporting}) -} - -function InfiniteVirtualTableFeatureShellBase( - props: InfiniteVirtualTableFeatureProps & {pagination: TableFeaturePagination}, -) { - const { - tableScope, - columns, - rowKey, - title, - tabs, - headerExtra, - filters, - primaryActions, - deleteAction, - exportAction, - secondaryActions, - className, - containerClassName, - tableClassName, - autoHeight = true, - rowHeight = DEFAULT_ROW_HEIGHT, - fallbackControlsHeight = DEFAULT_CONTROLS_HEIGHT, - fallbackHeaderHeight = DEFAULT_TABLE_HEADER_HEIGHT, - resizableColumns = true, - tableProps, - beforeTable, - afterTable, - columnVisibilityMenuRenderer, - columnVisibility, - rowSelection, - onPaginationStateChange, - onRowsChange, - pagination, - enableExport = true, - exportFilename, - renderExportButton, - exportOptions, - useSettingsDropdown = false, - settingsDropdownDelete, - settingsDropdownMenuItems, - keyboardShortcuts, - expandable, - dataSource, - tableRef, - store, - } = props - const {scopeId, pageSize, enableInfiniteScroll = true} = tableScope - const {canExportData} = useProjectPermissions() - const exportEnabled = enableExport && canExportData - - // Responsive breakpoints for built-in action buttons - const screens = Grid.useBreakpoint() - const isNarrowScreen = !screens.lg - - useEffect(() => { - onPaginationStateChange?.({ - resetPages: pagination.resetPages, - loadNextPage: pagination.loadNextPage, - }) - }, [onPaginationStateChange, pagination.loadNextPage, pagination.resetPages]) - - useEffect(() => { - onRowsChange?.(pagination.rows) - }, [onRowsChange, pagination.rows]) - - const handleLoadMore = useCallback(() => { - if (!enableInfiniteScroll) { - return - } - pagination.loadNextPage() - }, [enableInfiniteScroll, pagination.loadNextPage]) - - const [controlsHeight, setControlsHeight] = useState(0) - const [tableHeaderHeight, setTableHeaderHeight] = useState(null) - - const resolvedControlsHeight = controlsHeight || fallbackControlsHeight - const resolvedTableHeaderHeight = tableHeaderHeight ?? fallbackHeaderHeight - const visibleRowCount = pagination.rows.length || pageSize - const bodyHeight = autoHeight ? null : rowHeight * Math.max(visibleRowCount, 1) - const headerHeight = resolvedControlsHeight + resolvedTableHeaderHeight + 32 - const fixedHeight = !autoHeight && bodyHeight !== null ? bodyHeight + headerHeight : undefined - const resolvedContainerClassName = - containerClassName ?? - (autoHeight ? "w-full grow min-h-0 overflow-hidden" : "w-full overflow-hidden") - - const tableExport = useTableExport() - const [isExporting, setIsExporting] = useState(false) - const { - filename: exportOptionsFilename, - isColumnExportable, - getValue: getExportValue, - formatValue: formatExportValue, - includeSkeletonRows, - beforeExport, - resolveValue, - resolveColumnLabel, - columnsOverride: exportColumnsOverride, - } = exportOptions ?? {} - const resolvedExportFilename = exportOptionsFilename ?? exportFilename ?? "table-export.csv" - const exportHandler = useCallback(async () => { - if (!exportEnabled || isExporting) return - setIsExporting(true) - try { - // If rows are selected, export only selected rows; otherwise export all rows - const selectedKeys = rowSelection?.selectedRowKeys - const rowsToExport = - selectedKeys && selectedKeys.length > 0 - ? pagination.rows.filter((row) => { - const key = - typeof rowKey === "function" ? rowKey(row) : row[rowKey as keyof Row] - return selectedKeys.includes(key as Key) - }) - : pagination.rows - await tableExport({ - columns: exportColumnsOverride ?? columns, - rows: rowsToExport, - filename: resolvedExportFilename, - isColumnExportable, - getValue: getExportValue, - formatValue: formatExportValue, - includeSkeletonRows, - beforeExport, - resolveValue, - resolveColumnLabel, - }) - } catch (error) { - console.error("[InfiniteVirtualTable] Failed to export table", error) - } finally { - setIsExporting(false) - } - }, [ - beforeExport, - columns, - getExportValue, - formatExportValue, - includeSkeletonRows, - isExporting, - isColumnExportable, - pagination.rows, - resolveValue, - resolveColumnLabel, - resolvedExportFilename, - exportEnabled, - rowKey, - rowSelection?.selectedRowKeys, - tableExport, - ]) - - const exportButtonNode = useMemo(() => { - if (!exportEnabled) return null - if (renderExportButton) { - return renderExportButton({onExport: exportHandler, loading: isExporting}) - } - // Export button is now rendered inside the column visibility popover - return null - }, [exportEnabled, exportHandler, isExporting, renderExportButton]) - - // Built-in delete button (wide screens only) - const builtInDeleteButton = useMemo(() => { - if (!deleteAction || isNarrowScreen) return null - const {onDelete, disabled, disabledTooltip, label = "Delete"} = deleteAction - const button = ( - - ) - if (disabled && disabledTooltip) { - return {button} - } - return button - }, [deleteAction, isNarrowScreen]) - - // Built-in export button (wide screens only, when exportAction is provided) - const builtInExportButton = useMemo(() => { - if (!exportEnabled || !exportAction || isNarrowScreen) return null - const {disabled, disabledTooltip, label = "Export CSV"} = exportAction - const button = ( - - ) - if (disabled && disabledTooltip) { - return ( - - {button} - - ) - } - return button - }, [exportEnabled, exportAction, exportHandler, isExporting, isNarrowScreen]) - - // Resolve settings dropdown delete config (prefer deleteAction over legacy prop) - const resolvedSettingsDropdownDelete = useMemo(() => { - if (deleteAction && isNarrowScreen) { - return { - onDelete: deleteAction.onDelete, - disabled: deleteAction.disabled, - label: deleteAction.label ? `${deleteAction.label} selected` : "Delete selected", - } - } - return settingsDropdownDelete - }, [deleteAction, isNarrowScreen, settingsDropdownDelete]) - - // Combine secondary actions: built-in buttons + custom secondaryActions + export button - const resolvedSecondaryActions = useMemo(() => { - const actions = [ - builtInDeleteButton, - builtInExportButton, - secondaryActions, - exportButtonNode, - ] - const filtered = actions.filter(Boolean) - if (filtered.length === 0) return undefined - if (filtered.length === 1) return filtered[0] - return ( -
- {filtered.map((action, i) => ( - {action} - ))} -
- ) - }, [builtInDeleteButton, builtInExportButton, secondaryActions, exportButtonNode]) - - // Only show export in settings when enableExport is true AND no custom renderExportButton is provided - const showExportInSettings = exportEnabled && !renderExportButton - - const columnVisibilityRenderer = useMemo( - () => - resolveColumnVisibilityRenderer(columnVisibilityMenuRenderer, columnVisibility, { - scopeId, - onExport: showExportInSettings ? exportHandler : undefined, - isExporting, - }), - [ - columnVisibilityMenuRenderer, - columnVisibility, - scopeId, - showExportInSettings, - exportHandler, - isExporting, - ], - ) - - const viewportTrackingEnabled = useMemo( - () => - tableScope.viewportTrackingEnabled ?? pagination.rows.some((row) => !row.__isSkeleton), - [pagination.rows, tableScope.viewportTrackingEnabled], - ) - - const settingsDropdownRenderer = useCallback( - (controls: ColumnVisibilityState) => ( - - columnVisibilityRenderer(ctrls, close, { - scopeId, - onExport: showExportInSettings ? exportHandler : undefined, - isExporting, - }) - } - /> - ), - [ - columnVisibilityRenderer, - showExportInSettings, - exportHandler, - isExporting, - scopeId, - resolvedSettingsDropdownDelete, - settingsDropdownMenuItems, - ], - ) - - const columnVisibilityConfig = useMemo( - () => ({ - storageKey: tableScope.columnVisibilityStorageKey ?? undefined, - defaultHiddenKeys: tableScope.columnVisibilityDefaults, - viewportTrackingEnabled, - viewportMargin: tableScope.viewportMargin, - viewportExitDebounceMs: tableScope.viewportExitDebounceMs, - renderMenuContent: columnVisibilityRenderer, - renderMenuTrigger: useSettingsDropdown ? settingsDropdownRenderer : undefined, - }), - [ - columnVisibilityRenderer, - settingsDropdownRenderer, - tableScope.columnVisibilityDefaults, - tableScope.columnVisibilityStorageKey, - tableScope.viewportExitDebounceMs, - tableScope.viewportMargin, - useSettingsDropdown, - viewportTrackingEnabled, - ], - ) - - // Render tabs if configured - const tabsNode = useMemo(() => { - if (!tabs) return headerExtra // Fall back to headerExtra for backwards compatibility - return ( -
- ({ - key: item.key, - label: item.label, - }))} - onChange={tabs.onChange} - destroyOnHidden - /> -
- ) - }, [tabs, headerExtra]) - - const effectiveDataSource = dataSource ?? pagination.rows - - return ( -
- - {beforeTable} - - useIsolatedStore={!store} - store={store} - columns={columns} - dataSource={effectiveDataSource} - loadMore={handleLoadMore} - rowKey={rowKey} - rowSelection={rowSelection} - resizableColumns={resizableColumns} - columnVisibility={columnVisibilityConfig} - bodyHeight={bodyHeight} - scopeId={scopeId} - containerClassName={resolvedContainerClassName} - tableClassName={tableClassName} - tableProps={tableProps} - keyboardShortcuts={keyboardShortcuts} - expandable={expandable} - onHeaderHeightChange={setTableHeaderHeight} - tableRef={tableRef} - /> - {afterTable} - -
- ) -} - -const InfiniteVirtualTableFeatureShellWithStore = ( - props: InfiniteVirtualTableFeatureProps, -) => { - const {datasetStore, tableScope} = props - const pagination = datasetStore.hooks.usePagination({ - scopeId: tableScope.scopeId, - pageSize: tableScope.pageSize, - resetOnScopeChange: true, - }) - return -} - -const InfiniteVirtualTableFeatureShell = ( - props: InfiniteVirtualTableFeatureProps, -) => { - if (props.pagination) { - return - } - return -} - -export default InfiniteVirtualTableFeatureShell diff --git a/web/oss/src/components/InfiniteVirtualTable/features/index.ts b/web/oss/src/components/InfiniteVirtualTable/features/index.ts deleted file mode 100644 index b831036fe9..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/features/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export {default as InfiniteVirtualTableFeatureShell} from "./InfiniteVirtualTableFeatureShell" -export type { - InfiniteVirtualTableFeatureProps, - TableScopeConfig, - TableFeaturePagination, - TableFeatureExportOptions, - TableTabItem, - TableTabsConfig, - TableDeleteConfig, - TableExportConfig, -} from "./InfiniteVirtualTableFeatureShell" -export {default as useInfiniteTableFeaturePagination} from "./useInfiniteTableFeaturePagination" diff --git a/web/oss/src/components/InfiniteVirtualTable/features/useInfiniteTableFeaturePagination.ts b/web/oss/src/components/InfiniteVirtualTable/features/useInfiniteTableFeaturePagination.ts deleted file mode 100644 index 6075efc31f..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/features/useInfiniteTableFeaturePagination.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type {InfiniteTableRowBase} from "../types" - -import type {TableScopeConfig, TableFeaturePagination} from "./InfiniteVirtualTableFeatureShell" - -interface UseFeaturePaginationOptions { - resetOnScopeChange?: boolean -} - -const useInfiniteTableFeaturePagination = ( - datasetStore: InfiniteDatasetStore, - tableScope: TableScopeConfig, - options?: UseFeaturePaginationOptions, -): TableFeaturePagination => { - const {scopeId, pageSize} = tableScope - return datasetStore.hooks.usePagination({ - scopeId, - pageSize, - resetOnScopeChange: options?.resetOnScopeChange, - }) -} - -export default useInfiniteTableFeaturePagination diff --git a/web/oss/src/components/InfiniteVirtualTable/helpers/createSimpleTableStore.ts b/web/oss/src/components/InfiniteVirtualTable/helpers/createSimpleTableStore.ts deleted file mode 100644 index 3aa5893222..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/helpers/createSimpleTableStore.ts +++ /dev/null @@ -1,191 +0,0 @@ -import {atom} from "jotai" -import type {Atom} from "jotai" - -import {createInfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type {InfiniteTableFetchResult, InfiniteTableRowBase, WindowingState} from "../types" - -import {createTableRowHelpers} from "./createTableRowHelpers" -import type {TableRowHelpersConfig} from "./createTableRowHelpers" - -/** - * Common date range filter type used across tables - */ -export interface DateRangeFilter { - from?: string | null - to?: string | null -} - -/** - * Base interface for table metadata. - * All table stores should extend this with their specific filters. - */ -export interface BaseTableMeta { - /** Project ID - required for all tables */ - projectId: string | null - /** Search term for filtering */ - searchTerm?: string | null - /** Date range filter */ - dateRange?: DateRangeFilter | null - /** Internal refresh trigger - incrementing this forces a refetch */ - _refreshTrigger?: number -} - -/** - * Configuration for creating a simple table store - */ -export interface SimpleTableStoreConfig< - TRow extends InfiniteTableRowBase, - TApiRow, - TMeta extends BaseTableMeta, -> { - /** Unique key for the store (used for caching) */ - key: string - /** Atom that provides the table metadata */ - metaAtom: Atom - /** Configuration for row helpers (skeleton/merge) */ - rowHelpers: TableRowHelpersConfig - /** - * Fetch function that retrieves data from the API. - * Should handle pagination via limit/offset/cursor/windowing. - */ - fetchData: (params: { - meta: TMeta - limit: number - offset: number - cursor: string | null - windowing: WindowingState | null - }) => Promise> - /** - * Optional custom isEnabled check. - * Defaults to checking if projectId exists. - */ - isEnabled?: (meta: TMeta | undefined) => boolean - /** - * Optional atom that provides client-side rows (e.g., unsaved drafts) - * These rows will be prepended to server rows - */ - clientRowsAtom?: Atom - /** - * Optional atom providing IDs of rows to exclude from display - * Useful for filtering out soft-deleted rows before save - */ - excludeRowIdsAtom?: Atom> -} - -/** - * Result of createSimpleTableStore - */ -export interface SimpleTableStore< - TRow extends InfiniteTableRowBase, - TApiRow, - TMeta extends BaseTableMeta, -> { - /** The underlying infinite dataset store */ - datasetStore: InfiniteDatasetStore - /** Row helpers for creating skeletons and merging data */ - rowHelpers: ReturnType> - /** Refresh trigger atom - increment to force refetch */ - refreshTriggerAtom: ReturnType> -} - -/** - * Creates a simplified table store with common patterns pre-configured. - * Reduces boilerplate for standard paginated tables. - * - * @example - * ```ts - * const {datasetStore, refreshTriggerAtom} = createSimpleTableStore({ - * key: "testsets-table", - * metaAtom: testsetsTableMetaAtom, - * rowHelpers: { - * entityName: "testset", - * skeletonDefaults: {id: "", name: "", created_at: "", updated_at: ""}, - * getRowId: (row) => row.id, - * }, - * fetchData: async ({meta, limit, offset, cursor}) => { - * return fetchTestsetsWindow({projectId: meta.projectId, limit, offset, cursor}) - * }, - * }) - * ``` - */ -export function createSimpleTableStore< - TRow extends InfiniteTableRowBase, - TApiRow, - TMeta extends BaseTableMeta, ->(config: SimpleTableStoreConfig): SimpleTableStore { - const { - key, - metaAtom, - rowHelpers: rowHelpersConfig, - fetchData, - isEnabled, - clientRowsAtom, - excludeRowIdsAtom, - } = config - - // Create row helpers - const rowHelpers = createTableRowHelpers(rowHelpersConfig) - - // Create refresh trigger atom - const refreshTriggerAtom = atom(0) - - // Create the dataset store - const datasetStore = createInfiniteDatasetStore({ - key, - metaAtom, - createSkeletonRow: rowHelpers.createSkeletonRow, - mergeRow: rowHelpers.mergeRow, - isEnabled: isEnabled ?? ((meta) => Boolean(meta?.projectId)), - clientRowsAtom, - excludeRowIdsAtom, - fetchPage: async ({limit, offset, cursor, windowing, meta}) => { - if (!meta?.projectId) { - return { - rows: [], - totalCount: 0, - hasMore: false, - nextOffset: null, - nextCursor: null, - nextWindowing: null, - } - } - - return fetchData({meta, limit, offset, cursor, windowing}) - }, - }) - - return { - datasetStore, - rowHelpers, - refreshTriggerAtom, - } -} - -/** - * Helper to create a meta atom that combines projectId with filters. - * Provides a consistent pattern for table metadata atoms. - */ -export function createTableMetaAtom>(config: { - projectIdAtom: Atom - refreshTriggerAtom: Atom - filterAtoms: {[K in keyof TFilters]: Atom} -}): Atom { - const {projectIdAtom, refreshTriggerAtom, filterAtoms} = config - - return atom((get) => { - const projectId = get(projectIdAtom) - const _refreshTrigger = get(refreshTriggerAtom) - - const filters = {} as TFilters - for (const key of Object.keys(filterAtoms) as (keyof TFilters)[]) { - filters[key] = get(filterAtoms[key]) - } - - return { - projectId, - _refreshTrigger, - ...filters, - } - }) -} diff --git a/web/oss/src/components/InfiniteVirtualTable/helpers/createTableRowHelpers.ts b/web/oss/src/components/InfiniteVirtualTable/helpers/createTableRowHelpers.ts deleted file mode 100644 index 1a4ed21db7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/helpers/createTableRowHelpers.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type {WindowingState, InfiniteTableRowBase} from "../types" - -/** - * Configuration for creating table row helpers - */ -export interface TableRowHelpersConfig { - /** Prefix for skeleton row keys (e.g., "testset", "evaluation-run") */ - entityName: string - /** Default values for skeleton rows */ - skeletonDefaults: Omit - /** Extract the unique ID from an API row (used as the row key) */ - getRowId: (apiRow: TApiRow) => string - /** - * Optional custom merge logic. If not provided, uses simple spread. - * Use this when you need to transform API data or handle null values specially. - */ - customMerge?: (skeleton: TRow, apiRow: TApiRow) => TRow -} - -/** - * Parameters for creating a skeleton row - */ -export interface CreateSkeletonRowParams { - scopeId: string | null - offset: number - index: number - windowing: WindowingState | null - rowKey: string -} - -/** - * Parameters for merging a skeleton with API data - */ -export interface MergeRowParams { - skeleton: TRow - apiRow?: TApiRow -} - -/** - * Creates reusable skeleton and merge row functions for a table. - * Reduces boilerplate by providing a consistent pattern for all tables. - * - * @example - * ```ts - * const {createSkeletonRow, mergeRow} = createTableRowHelpers({ - * entityName: "testset", - * skeletonDefaults: { - * id: "", - * name: "", - * created_at: "", - * updated_at: "", - * }, - * getRowId: (row) => row.id, - * }) - * ``` - */ -export function createTableRowHelpers( - config: TableRowHelpersConfig, -) { - const {entityName, skeletonDefaults, getRowId, customMerge} = config - - /** - * Creates a skeleton row for loading states - */ - const createSkeletonRow = ({scopeId, offset, index, rowKey}: CreateSkeletonRowParams): TRow => { - const computedIndex = offset + index + 1 - const scopePrefix = scopeId ? `${scopeId}::` : "" - const key = `${scopePrefix}skeleton-${entityName}-${computedIndex}-${rowKey}` - - return { - ...skeletonDefaults, - key, - __isSkeleton: true, - } as TRow - } - - /** - * Merges a skeleton row with actual API data - */ - const mergeRow = ({skeleton, apiRow}: MergeRowParams): TRow => { - if (!apiRow) { - return skeleton - } - - if (customMerge) { - return customMerge(skeleton, apiRow) - } - - // Default merge: spread API row and add key + skeleton flag - return { - ...apiRow, - key: getRowId(apiRow), - __isSkeleton: false, - } as unknown as TRow - } - - return { - createSkeletonRow, - mergeRow, - } -} - -export type TableRowHelpers = ReturnType< - typeof createTableRowHelpers -> diff --git a/web/oss/src/components/InfiniteVirtualTable/helpers/index.ts b/web/oss/src/components/InfiniteVirtualTable/helpers/index.ts deleted file mode 100644 index 25e3ec77fa..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/helpers/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export {createTableRowHelpers} from "./createTableRowHelpers" -export type { - TableRowHelpersConfig, - CreateSkeletonRowParams, - MergeRowParams, - TableRowHelpers, -} from "./createTableRowHelpers" - -export {createSimpleTableStore, createTableMetaAtom} from "./createSimpleTableStore" -export type { - DateRangeFilter, - BaseTableMeta, - SimpleTableStoreConfig, - SimpleTableStore, -} from "./createSimpleTableStore" diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnDomRefs.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnDomRefs.ts deleted file mode 100644 index f4c5c4be19..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnDomRefs.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {useLayoutEffect, useRef} from "react" - -import type {ColumnsType} from "antd/es/table" - -interface ColumnDomRefs { - cols: HTMLTableColElement[] - headers: HTMLTableCellElement[] -} - -/** - * Hook to track and manage column DOM element references for live resizing - */ -const useColumnDomRefs = ( - containerRef: React.RefObject, - columns: ColumnsType, -) => { - const columnDomRefs = useRef>(new Map()) - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - columnDomRefs.current = new Map() - return - } - - const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), - ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) - - if (!headerCells.length) { - columnDomRefs.current = new Map() - return - } - - const keyToIndices = new Map() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - const index = cell.cellIndex - if (index < 0) return - if (!keyToIndices.has(key)) { - keyToIndices.set(key, []) - } - keyToIndices.get(key)!.push(index) - }) - - const registry = new Map() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.headers.push(cell) - }) - - const tables = container.querySelectorAll(".ant-table table") - tables.forEach((table) => { - const cols = table.querySelectorAll("colgroup col") - keyToIndices.forEach((indices, key) => { - indices.forEach((idx) => { - const col = cols[idx] - if (!col) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.cols.push(col) - }) - }) - }) - - columnDomRefs.current = registry - }, [columns, containerRef]) - - return columnDomRefs -} - -export default useColumnDomRefs diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibility.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibility.ts deleted file mode 100644 index 80a3fd2b18..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibility.ts +++ /dev/null @@ -1,283 +0,0 @@ -import {useCallback, useMemo, useRef} from "react" -import type {ReactNode} from "react" - -import type {ColumnsType} from "antd/es/table" -import {useAtomValue} from "jotai" -import {LOW_PRIORITY, useSetAtomWithSchedule} from "jotai-scheduler" - -import {getColumnHiddenKeysAtom} from "../atoms/columnHiddenKeys" -import type {ExtendedColumnType} from "../columns/types" - -type Key = string - -interface Options { - storageKey?: string - defaultHiddenKeys?: Key[] -} - -type ColumnLike = ExtendedColumnType - -const isColumnLocked = (column: ColumnLike | null | undefined) => - Boolean(column?.columnVisibilityLocked) - -export interface ColumnTreeNode { - key: Key - label: string - titleNode?: ReactNode - children: ColumnTreeNode[] - checked: boolean - indeterminate: boolean -} - -const toKey = (key: React.Key | undefined): Key | null => - key === undefined || key === null ? null : String(key) - -const collectKeys = (columns: ColumnsType): Key[] => { - const result: Key[] = [] - const visit = (cols: ColumnLike[]) => { - cols.forEach((col) => { - const k = toKey(col.key) - if (k && !isColumnLocked(col)) result.push(k) - if (col.children && col.children.length) visit(col.children as any) - }) - } - visit(columns as any) - return Array.from(new Set(result)) -} - -const collectLeafKeys = (columns: ColumnsType): Key[] => { - const result: Key[] = [] - const visit = (cols: ColumnLike[]) => { - cols.forEach((col) => { - if (col.children && col.children.length) { - visit(col.children as any) - } else { - const k = toKey(col.key) - if (k && !isColumnLocked(col)) result.push(k) - } - }) - } - visit(columns as any) - return Array.from(new Set(result)) -} - -const filterColumnsRecursive = ( - columns: ColumnsType, - hidden: Set, -): ColumnsType => { - const map = (cols: ColumnLike[]): ColumnLike[] => - cols - .map((col) => { - const k = toKey(col.key) - if (k && hidden.has(k) && !isColumnLocked(col)) return null - if (col.children && col.children.length) { - const children = map(col.children as any) - if (!children.length) return null - return {...col, children} as any - } - return col as any - }) - .filter(Boolean) as ColumnLike[] - - return map(columns as any) as any -} - -export const useColumnVisibility = ( - columns: ColumnsType, - {storageKey, defaultHiddenKeys = []}: Options = {}, -) => { - const allKeys = useMemo(() => collectKeys(columns), [columns]) - const leafKeys = useMemo(() => collectLeafKeys(columns), [columns]) - - const defaultHiddenSignature = useMemo( - () => (defaultHiddenKeys.length ? defaultHiddenKeys.join("|") : "__none__"), - [defaultHiddenKeys], - ) - const defaultHiddenSnapshot = useMemo(() => [...defaultHiddenKeys], [defaultHiddenSignature]) - const hiddenKeysAtom = useMemo( - () => getColumnHiddenKeysAtom(storageKey, defaultHiddenSnapshot), - [defaultHiddenSnapshot, storageKey], - ) - const hiddenKeys = useAtomValue(hiddenKeysAtom) - const setHiddenKeys = useSetAtomWithSchedule(hiddenKeysAtom, { - priority: LOW_PRIORITY, - }) - - const hiddenSet = useMemo( - () => new Set(hiddenKeys.map((key) => String(key))) as Set, - [hiddenKeys], - ) - - const visibleColumns = useMemo( - () => filterColumnsRecursive(columns, hiddenSet), - [columns, hiddenSet], - ) - - const isHidden = useCallback((key: Key) => hiddenSet.has(key), [hiddenSet]) - - const showColumn = useCallback( - (key: Key) => { - setHiddenKeys((prev) => prev.filter((k) => k !== key)) - }, - [setHiddenKeys], - ) - - const hideColumn = useCallback( - (key: Key) => { - setHiddenKeys((prev) => (prev.includes(key) ? prev : [...prev, key])) - }, - [setHiddenKeys], - ) - - const toggleColumn = useCallback( - (key: Key) => (hiddenSet.has(key) ? showColumn(key) : hideColumn(key)), - [hideColumn, hiddenSet, showColumn], - ) - - const reset = useCallback( - () => setHiddenKeys(defaultHiddenKeys), - [defaultHiddenKeys, setHiddenKeys], - ) - - const collectDescendantKeys = useCallback( - (cols: ColumnsType, target: Key): Key[] => { - const keys: Key[] = [] - const visit = (items: ColumnLike[]) => { - items.forEach((col) => { - const k = toKey(col.key) - if (k === target) { - // include self and all descendants - const gather = (node: ColumnLike) => { - const nk = toKey(node.key) - if (nk && !isColumnLocked(node)) keys.push(nk) - if (node.children && node.children.length) { - node.children.forEach((child) => gather(child as any)) - } - } - gather(col) - } else if (col.children && col.children.length) { - visit(col.children as any) - } - }) - } - visit(cols as any) - return Array.from(new Set(keys)) - }, - [], - ) - - const toggleTree = useCallback( - (groupKey: Key) => { - const keys = collectDescendantKeys(columns, groupKey) - if (!keys.length) { - toggleColumn(groupKey) - return - } - const anyVisible = keys.some((k) => !hiddenSet.has(k)) - setHiddenKeys((prev) => { - const base = new Set(prev) - if (anyVisible) { - keys.forEach((k) => base.add(k)) - } else { - keys.forEach((k) => base.delete(k)) - } - return Array.from(base) - }) - }, - [collectDescendantKeys, columns, hiddenSet, setHiddenKeys, toggleColumn], - ) - - const getLabel = (col: ColumnLike): string => { - if (typeof col.columnVisibilityLabel === "string" && col.columnVisibilityLabel.length) { - return col.columnVisibilityLabel - } - const title = (col as any)?.title - const label = typeof title === "string" ? title : toKey(col.key) - return label ?? "" - } - - const buildTree = useCallback( - (cols: ColumnsType): ColumnTreeNode[] => { - const map = (items: ColumnLike[]): ColumnTreeNode[] => { - const nodes: ColumnTreeNode[] = [] - items.forEach((col) => { - const k = toKey(col.key) - const children = - col.children && col.children.length ? map(col.children as any) : [] - if (!k || isColumnLocked(col)) { - nodes.push(...children) - return - } - const subtreeKeys: Key[] = [ - k, - ...collectDescendantKeys([col] as any, k).filter((x) => x !== k), - ] - const hiddenCount = subtreeKeys.filter((x) => hiddenSet.has(x)).length - const allHidden = hiddenCount === subtreeKeys.length - const noneHidden = hiddenCount === 0 - nodes.push({ - key: k, - label: getLabel(col), - titleNode: col.columnVisibilityTitle, - children, - checked: noneHidden, - indeterminate: !noneHidden && !allHidden, - }) - }) - return nodes - } - return map(cols as any) - }, - [collectDescendantKeys, hiddenSet], - ) - - const columnTree = useMemo(() => buildTree(columns), [buildTree, columns]) - - const columnTreeStructureSignature = useMemo(() => { - const serialize = (nodes: ColumnTreeNode[]): any => - nodes.map((node) => ({ - key: node.key, - children: serialize(node.children), - })) - return JSON.stringify(serialize(columnTree)) - }, [columnTree]) - - const visibilitySignature = useMemo(() => { - const normalizedHidden = [...hiddenKeys].sort().join("|") - const normalizedLeaf = leafKeys.join("|") - const normalizedAll = allKeys.join("|") - return `${normalizedAll}__${normalizedLeaf}__${normalizedHidden}__${columnTreeStructureSignature}` - }, [allKeys, columnTreeStructureSignature, hiddenKeys, leafKeys]) - - const visibilitySignatureRef = useRef(null) - const versionRef = useRef(0) - - const version = useMemo(() => { - if (!visibilitySignature) { - return versionRef.current - } - if (visibilitySignatureRef.current !== visibilitySignature) { - visibilitySignatureRef.current = visibilitySignature - versionRef.current += 1 - } - return versionRef.current - }, [visibilitySignature]) - - return { - allKeys, - leafKeys, - hiddenKeys, - setHiddenKeys, - isHidden, - showColumn, - hideColumn, - toggleColumn, - toggleTree, - reset, - visibleColumns, - columnTree, - version, - } -} - -export default useColumnVisibility diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibilityControls.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibilityControls.ts deleted file mode 100644 index cb17f10147..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibilityControls.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {useCallback, useMemo} from "react" -import type {Key} from "react" - -import type {ColumnVisibilityState} from "../types" - -interface ColumnVisibilityHookResult { - visibleColumns: any[] - leafKeys: any[] - allKeys: any[] - hiddenKeys: any[] - isHidden: (key: any) => boolean - showColumn: (key: any) => void - hideColumn: (key: any) => void - toggleColumn: (key: any) => void - toggleTree: (key: any) => void - reset: () => void - columnTree: any[] - setHiddenKeys: (keys: any) => void - version: number -} - -/** - * Creates normalized column visibility controls that work with React.Key - */ -const useColumnVisibilityControls = ( - hookResult: ColumnVisibilityHookResult, -): ColumnVisibilityState => { - const { - visibleColumns, - leafKeys, - allKeys, - hiddenKeys, - isHidden, - showColumn, - hideColumn, - toggleColumn, - toggleTree, - reset, - columnTree, - setHiddenKeys, - version, - } = hookResult - - const normalizedIsHidden = useCallback((key: Key) => isHidden(String(key)), [isHidden]) - const normalizedShowColumn = useCallback((key: Key) => showColumn(String(key)), [showColumn]) - const normalizedHideColumn = useCallback((key: Key) => hideColumn(String(key)), [hideColumn]) - const normalizedToggleColumn = useCallback( - (key: Key) => toggleColumn(String(key)), - [toggleColumn], - ) - const normalizedToggleTree = useCallback((key: Key) => toggleTree(String(key)), [toggleTree]) - const normalizedSetHiddenKeys = useCallback( - (keys: Key[]) => setHiddenKeys(keys.map((key) => String(key))), - [setHiddenKeys], - ) - - const controls = useMemo>( - () => ({ - columnTree, - leafKeys, - allKeys, - hiddenKeys, - isHidden: normalizedIsHidden, - showColumn: normalizedShowColumn, - hideColumn: normalizedHideColumn, - toggleColumn: normalizedToggleColumn, - toggleTree: normalizedToggleTree, - reset, - setHiddenKeys: normalizedSetHiddenKeys, - visibleColumns, - version, - }), - [ - columnTree, - leafKeys, - allKeys, - hiddenKeys, - normalizedIsHidden, - normalizedShowColumn, - normalizedHideColumn, - normalizedToggleColumn, - normalizedToggleTree, - reset, - normalizedSetHiddenKeys, - visibleColumns, - version, - ], - ) - - return controls -} - -export default useColumnVisibilityControls diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerResize.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerResize.ts deleted file mode 100644 index 692a4780ef..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerResize.ts +++ /dev/null @@ -1,76 +0,0 @@ -import {useEffect, useLayoutEffect, useState} from "react" - -interface ContainerSize { - width: number - height: number -} - -// Measure before the browser paints on the client; fall back to useEffect on the -// server to avoid the SSR useLayoutEffect warning. -const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect - -/** - * Hook to observe container dimensions using ResizeObserver with RAF throttling. - * - * The initial size is measured synchronously in a layout effect so the first - * painted frame already has the real container height. Without this, the size - * starts at 0 and only updates a frame later (post-paint), which makes the - * virtual table fall back to a ~360px viewport (see `useScrollConfig`) and - * visibly grow to full height on every mount/navigation. - */ -const useContainerResize = ( - containerRef: React.RefObject, -): ContainerSize => { - const [containerSize, setContainerSize] = useState({ - width: 0, - height: 0, - }) - - useIsomorphicLayoutEffect(() => { - const element = containerRef.current - if (!element) return - - const applySize = (nextWidth: number, nextHeight: number) => { - setContainerSize((prev) => { - if (prev.width === nextWidth && prev.height === nextHeight) { - return prev - } - return {width: nextWidth, height: nextHeight} - }) - } - - // Synchronous first measurement so the initial paint uses the real height - // rather than 0 (and therefore the 360px scroll fallback). - applySize(element.clientWidth, element.clientHeight) - - let frameId: number | null = null - const observer = new ResizeObserver((entries) => { - const entry = entries[0] - if (!entry) return - const contentBoxSize = Array.isArray(entry.contentBoxSize) - ? entry.contentBoxSize[0] - : entry.contentBoxSize - const nextWidth = - contentBoxSize?.inlineSize ?? entry.contentRect?.width ?? element.clientWidth - const nextHeight = - contentBoxSize?.blockSize ?? entry.contentRect?.height ?? element.clientHeight - - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - frameId = requestAnimationFrame(() => applySize(nextWidth, nextHeight)) - }) - - observer.observe(element) - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - observer.disconnect() - } - }, [containerRef]) - - return containerSize -} - -export default useContainerResize diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerSize.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerSize.ts deleted file mode 100644 index a2e59d8725..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerSize.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {useEffect, useRef, useState} from "react" - -interface ContainerSize { - width: number - height: number -} - -/** - * Hook to observe and track container dimensions using ResizeObserver - */ -const useContainerSize = () => { - const containerRef = useRef(null) - const [containerSize, setContainerSize] = useState({width: 0, height: 0}) - - useEffect(() => { - const element = containerRef.current - if (!element) return - - let frameId: number | null = null - const observer = new ResizeObserver((entries) => { - const entry = entries[0] - if (!entry) return - const contentBoxSize = Array.isArray(entry.contentBoxSize) - ? entry.contentBoxSize[0] - : entry.contentBoxSize - const nextWidth = - contentBoxSize?.inlineSize ?? entry.contentRect?.width ?? element.clientWidth - const nextHeight = - contentBoxSize?.blockSize ?? entry.contentRect?.height ?? element.clientHeight - - const update = () => { - setContainerSize((prev) => { - if (prev.width === nextWidth && prev.height === nextHeight) { - return prev - } - return {width: nextWidth, height: nextHeight} - }) - } - - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - frameId = requestAnimationFrame(update) - }) - - observer.observe(element) - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - observer.disconnect() - } - }, []) - - return {containerRef, containerSize} -} - -export default useContainerSize diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useEditableTable.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useEditableTable.ts deleted file mode 100644 index 0112ea7a89..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useEditableTable.ts +++ /dev/null @@ -1,454 +0,0 @@ -import {useCallback, useMemo, useState} from "react" - -import type {InfiniteTableRowBase} from "../types" - -export interface EditableTableColumn { - /** Column key/dataIndex */ - key: string - /** Display name */ - name: string -} - -export interface EditableTableConfig { - /** Initial columns derived from data or provided explicitly */ - initialColumns?: EditableTableColumn[] - /** System fields to exclude when deriving columns from row data */ - systemFields?: string[] - /** Callback when a cell value changes */ - onCellChange?: (rowId: string, columnKey: string, value: unknown) => void - /** Callback when columns change (add/rename/delete) */ - onColumnsChange?: (columns: EditableTableColumn[]) => void - /** Callback when rows are added */ - onRowsAdd?: (rows: Row[]) => void - /** Callback when rows are deleted */ - onRowsDelete?: (rowIds: string[]) => void - /** Generate a new row with default values */ - createNewRow?: () => Partial -} - -export interface EditableTableState { - /** Current columns */ - columns: EditableTableColumn[] - /** Local edits map: rowId -> { columnKey: value } */ - localEdits: Map> - /** New rows not yet persisted */ - newRows: Row[] - /** Row IDs marked for deletion */ - deletedRowIds: Set - /** Whether there are unsaved changes */ - hasUnsavedChanges: boolean - /** Derive columns from first row data */ - deriveColumnsFromRow: (row: Row) => void -} - -export interface EditableTableActions { - /** Edit a cell value. Pass originalValue to auto-clear edit when value matches original. */ - editCell: (rowId: string, columnKey: string, value: unknown, originalValue?: unknown) => void - /** Add a new row and return it */ - addRow: () => Row - /** Delete rows by IDs */ - deleteRows: (rowIds: string[]) => void - /** Add a new column */ - addColumn: (name: string) => boolean - /** Rename a column */ - renameColumn: (oldName: string, newName: string) => boolean - /** Delete a column */ - deleteColumn: (columnKey: string) => void - /** Set columns explicitly */ - setColumns: (columns: EditableTableColumn[]) => void - /** Get the display value for a cell (with local edits applied) */ - getCellValue: (row: Row, columnKey: string) => unknown - /** Get all rows with edits applied and new rows included */ - getDisplayRows: (serverRows: Row[]) => Row[] - /** Get final row data for saving (only column values) */ - getFinalRowData: (serverRows: Row[]) => Record[] - /** Clear all local state (after save) */ - clearLocalState: () => void - /** Reset all state including columns (for revision switching) */ - resetAllState: () => void - /** Mark changes as saved */ - markAsSaved: () => void -} - -const DEFAULT_SYSTEM_FIELDS = ["id", "key", "created_at", "updated_at", "__isSkeleton"] - -export function useEditableTable( - config: EditableTableConfig = {}, -): [EditableTableState, EditableTableActions] { - const { - initialColumns = [], - systemFields = DEFAULT_SYSTEM_FIELDS, - onCellChange, - onColumnsChange, - onRowsAdd, - onRowsDelete, - createNewRow, - } = config - - const [columns, setColumnsState] = useState(initialColumns) - const [originalColumns, setOriginalColumns] = useState(initialColumns) - const [localEdits, setLocalEdits] = useState>>(new Map()) - const [newRows, setNewRows] = useState([]) - const [deletedRowIds, setDeletedRowIds] = useState>(new Set()) - - const systemFieldsSet = useMemo(() => new Set(systemFields), [systemFields]) - - // Edit a cell value - const editCell = useCallback( - (rowId: string, columnKey: string, value: unknown, originalValue?: unknown) => { - const isNewRow = newRows.some((r) => String(r.key) === rowId || r.id === rowId) - - if (isNewRow) { - setNewRows((prev) => - prev.map((r) => { - if (String(r.key) === rowId || r.id === rowId) { - return {...r, [columnKey]: value} - } - return r - }), - ) - } else { - setLocalEdits((prev) => { - const next = new Map(prev) - const existing = next.get(rowId) || {} - - // If value matches original, remove this edit - if (originalValue !== undefined && value === originalValue) { - const {[columnKey]: _removed, ...rest} = existing - if (Object.keys(rest).length === 0) { - next.delete(rowId) - } else { - next.set(rowId, rest) - } - } else { - next.set(rowId, {...existing, [columnKey]: value}) - } - - return next - }) - } - - onCellChange?.(rowId, columnKey, value) - }, - [newRows, onCellChange], - ) - - // Add a new row - const addRow = useCallback((): Row => { - const timestamp = Date.now() - const baseRow = createNewRow?.() || {} - const newRow = { - key: `new-${timestamp}`, - id: `new-${timestamp}`, - __isSkeleton: false, - ...baseRow, - } as unknown as Row - - // Initialize all columns with empty strings - columns.forEach((col) => { - if (!(col.key in newRow)) { - ;(newRow as Record)[col.key] = "" - } - }) - - setNewRows((prev) => [...prev, newRow]) - onRowsAdd?.([newRow]) - return newRow - }, [columns, createNewRow, onRowsAdd]) - - // Delete rows - const deleteRows = useCallback( - (rowIds: string[]) => { - const newRowKeys = new Set(newRows.map((r) => String(r.key))) - const existingToDelete = rowIds.filter((id) => !newRowKeys.has(id)) - const newToDelete = rowIds.filter((id) => newRowKeys.has(id)) - - if (newToDelete.length > 0) { - setNewRows((prev) => prev.filter((r) => !newToDelete.includes(String(r.key)))) - } - - if (existingToDelete.length > 0) { - setDeletedRowIds((prev) => { - const next = new Set(prev) - existingToDelete.forEach((id) => next.add(id)) - return next - }) - } - - onRowsDelete?.(rowIds) - }, - [newRows, onRowsDelete], - ) - - // Add a new column - const addColumn = useCallback( - (name: string): boolean => { - const trimmedName = name.trim() - if (!trimmedName) return false - if (columns.some((c) => c.key === trimmedName || c.name === trimmedName)) return false - - const newColumn: EditableTableColumn = {key: trimmedName, name: trimmedName} - const newColumns = [...columns, newColumn] - setColumnsState(newColumns) - onColumnsChange?.(newColumns) - return true - }, - [columns, onColumnsChange], - ) - - // Rename a column - const renameColumn = useCallback( - (oldName: string, newName: string): boolean => { - const trimmedNewName = newName.trim() - if (!trimmedNewName) return false - if (oldName === trimmedNewName) return true - if (columns.some((c) => c.key === trimmedNewName && c.key !== oldName)) return false - - const newColumns = columns.map((c) => - c.key === oldName ? {key: trimmedNewName, name: trimmedNewName} : c, - ) - setColumnsState(newColumns) - - // Update local edits to use new key - setLocalEdits((prev) => { - const next = new Map>() - prev.forEach((edits, rowId) => { - const newEdits: Record = {} - Object.entries(edits).forEach(([key, value]) => { - newEdits[key === oldName ? trimmedNewName : key] = value - }) - next.set(rowId, newEdits) - }) - return next - }) - - // Update new rows - setNewRows((prev) => - prev.map((r) => { - if (oldName in r) { - const newRow = {...r} - ;(newRow as Record)[trimmedNewName] = r[oldName] - delete (newRow as Record)[oldName] - return newRow - } - return r - }), - ) - - onColumnsChange?.(newColumns) - return true - }, - [columns, onColumnsChange], - ) - - // Delete a column - const deleteColumn = useCallback( - (columnKey: string) => { - const newColumns = columns.filter((c) => c.key !== columnKey) - setColumnsState(newColumns) - - // Remove from local edits - setLocalEdits((prev) => { - const next = new Map>() - prev.forEach((edits, rowId) => { - const newEdits: Record = {} - Object.entries(edits).forEach(([key, value]) => { - if (key !== columnKey) { - newEdits[key] = value - } - }) - if (Object.keys(newEdits).length > 0) { - next.set(rowId, newEdits) - } - }) - return next - }) - - // Remove from new rows - setNewRows((prev) => - prev.map((r) => { - const newRow = {...r} - delete (newRow as Record)[columnKey] - return newRow - }), - ) - - onColumnsChange?.(newColumns) - }, - [columns, onColumnsChange], - ) - - // Set columns explicitly - const setColumns = useCallback( - (newColumns: EditableTableColumn[]) => { - setColumnsState(newColumns) - onColumnsChange?.(newColumns) - }, - [onColumnsChange], - ) - - // Get cell value with local edits applied - const getCellValue = useCallback( - (row: Row, columnKey: string): unknown => { - // Always use row.key as the unique identifier - const rowKey = String(row.key) - const edits = localEdits.get(rowKey) - if (edits && columnKey in edits) { - return edits[columnKey] - } - return row[columnKey] - }, - [localEdits], - ) - - // Get display rows with edits applied - // New rows are prepended at the top to avoid UX issues with infinite scrolling - const getDisplayRows = useCallback( - (serverRows: Row[]): Row[] => { - const filteredRows = serverRows - .filter((row) => { - // Always use row.key as the unique identifier - const rowKey = String(row.key) - return !deletedRowIds.has(rowKey) - }) - .map((row) => { - const rowKey = String(row.key) - const edits = localEdits.get(rowKey) - if (edits) { - return {...row, ...edits} - } - return row - }) - - // Prepend new rows at the top (reversed so newest is first) - return [...newRows.slice().reverse(), ...filteredRows] - }, - [deletedRowIds, localEdits, newRows], - ) - - // Get final row data for saving - const getFinalRowData = useCallback( - (serverRows: Row[]): Record[] => { - const displayRows = getDisplayRows(serverRows) - return displayRows.map((row) => { - const rowData: Record = {} - columns.forEach((col) => { - rowData[col.key] = row[col.key] ?? "" - }) - return rowData - }) - }, - [columns, getDisplayRows], - ) - - // Clear local state (edits, new rows, deleted rows) - const clearLocalState = useCallback(() => { - setLocalEdits(new Map()) - setNewRows([]) - setDeletedRowIds(new Set()) - // Also sync original columns with current columns after save - setOriginalColumns(columns) - }, [columns]) - - // Reset all state including columns (for revision switching) - const resetAllState = useCallback(() => { - setLocalEdits(new Map()) - setNewRows([]) - setDeletedRowIds(new Set()) - setColumnsState([]) - setOriginalColumns([]) - }, []) - - // Mark as saved (syncs original columns with current) - const markAsSaved = useCallback(() => { - setOriginalColumns(columns) - }, [columns]) - - // Derive columns from first row if not set - const deriveColumnsFromRow = useCallback( - (row: Row) => { - if (columns.length > 0) return - - const dynamicCols = Object.keys(row) - .filter((key) => !systemFieldsSet.has(key)) - .map((key) => ({key, name: key})) - - if (dynamicCols.length > 0) { - setColumnsState(dynamicCols) - setOriginalColumns(dynamicCols) // Track original columns from server - onColumnsChange?.(dynamicCols) - } - }, - [columns.length, systemFieldsSet, onColumnsChange], - ) - - // Compute hasUnsavedChanges based on actual differences - const hasUnsavedChanges = useMemo(() => { - // Check for new rows - if (newRows.length > 0) return true - - // Check for deleted rows - if (deletedRowIds.size > 0) return true - - // Check for local edits (cell changes) - if (localEdits.size > 0) return true - - // Check for column changes (added, removed, or renamed) - if (columns.length !== originalColumns.length) return true - - // Check if any column was renamed or reordered - const columnsChanged = columns.some((col, index) => { - const origCol = originalColumns[index] - return !origCol || col.key !== origCol.key || col.name !== origCol.name - }) - if (columnsChanged) return true - - return false - }, [newRows.length, deletedRowIds.size, localEdits.size, columns, originalColumns]) - - const state: EditableTableState = { - columns, - localEdits, - newRows, - deletedRowIds, - hasUnsavedChanges, - deriveColumnsFromRow, - } - - const actions: EditableTableActions = useMemo( - () => ({ - editCell, - addRow, - deleteRows, - addColumn, - renameColumn, - deleteColumn, - setColumns, - getCellValue, - getDisplayRows, - getFinalRowData, - clearLocalState, - resetAllState, - markAsSaved, - }), - [ - editCell, - addRow, - deleteRows, - addColumn, - renameColumn, - deleteColumn, - setColumns, - getCellValue, - getDisplayRows, - getFinalRowData, - clearLocalState, - resetAllState, - markAsSaved, - ], - ) - - return [state, actions] -} - -export default useEditableTable diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useExpandableRows.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useExpandableRows.tsx deleted file mode 100644 index a301ea63c4..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useExpandableRows.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import type {Key, ReactNode} from "react" -import {useCallback, useMemo, useRef, useState} from "react" - -import {MinusCircleOutlined, PlusCircleOutlined, LoadingOutlined} from "@ant-design/icons" -import type {TableProps} from "antd/es/table" - -import type {ExpandableRowConfig} from "../types" - -interface ExpandedRowState { - loading: boolean - error: Error | null - children: ChildType[] | null -} - -interface UseExpandableRowsConfig { - config: ExpandableRowConfig | undefined - rowKey: TableProps["rowKey"] - // dataSource is accepted but unused for now (e.g., clearing cache on data change) - dataSource?: RecordType[] -} - -interface UseExpandableRowsReturn { - expandedRowKeys: Key[] - expandedRowRender: ((record: RecordType) => ReactNode) | undefined - onExpand: (expanded: boolean, record: RecordType) => void - expandIcon: - | ((props: { - expanded: boolean - onExpand: (record: RecordType, e: React.MouseEvent) => void - record: RecordType - }) => ReactNode) - | undefined - rowExpandable: ((record: RecordType) => boolean) | undefined - expandColumnWidth: number | undefined - expandFixed: "left" | "right" | undefined - /** - * Render function for the expand icon that can be used within a cell. - * Use this when showExpandIconInCell is true. - */ - renderExpandIcon: (record: RecordType) => ReactNode - /** - * Check if a specific row is expanded - */ - isExpanded: (record: RecordType) => boolean -} - -/** - * Hook to manage expandable row state and behavior for InfiniteVirtualTable. - * Handles async data fetching, caching, and rendering of expanded content. - */ -export function useExpandableRows({ - config, - rowKey, - dataSource, -}: UseExpandableRowsConfig): UseExpandableRowsReturn { - const [expandedRowKeys, setExpandedRowKeys] = useState([]) - const [expandedStates, setExpandedStates] = useState>>( - new Map(), - ) - const childrenCacheRef = useRef>(new Map()) - - // Helper to get row key from record - const getRowKey = useCallback( - (record: RecordType): Key => { - if (typeof rowKey === "function") { - return rowKey(record) - } - return (record as Record)[rowKey as string] as Key - }, - [rowKey], - ) - - // Handle row expand/collapse - const onExpand = useCallback( - async (expanded: boolean, record: RecordType) => { - if (!config) return - - const key = getRowKey(record) - const cacheChildren = config.cacheChildren !== false - - if (expanded) { - // Accordion mode: collapse other rows - if (config.accordion) { - setExpandedRowKeys([key]) - } else { - setExpandedRowKeys((prev) => [...prev, key]) - } - - // Check cache first - if (cacheChildren && childrenCacheRef.current.has(key)) { - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, { - loading: false, - error: null, - children: childrenCacheRef.current.get(key) ?? null, - }) - return next - }) - return - } - - // Set loading state - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, {loading: true, error: null, children: null}) - return next - }) - - // Fetch children - try { - const children = await config.fetchChildren(record) - if (cacheChildren) { - childrenCacheRef.current.set(key, children) - } - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, {loading: false, error: null, children}) - return next - }) - } catch (error) { - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, { - loading: false, - error: error instanceof Error ? error : new Error(String(error)), - children: null, - }) - return next - }) - } - } else { - // Collapse - setExpandedRowKeys((prev) => prev.filter((k) => k !== key)) - } - }, - [config, getRowKey], - ) - - // Render expanded row content - const expandedRowRender = useMemo(() => { - if (!config) return undefined - - return (record: RecordType) => { - const key = getRowKey(record) - const state = expandedStates.get(key) - const loading = state?.loading ?? false - const error = state?.error ?? null - const children = state?.children ?? [] - - return config.renderExpanded(record, children, loading, error) - } - }, [config, expandedStates, getRowKey]) - - // Custom expand icon - const expandIcon = useMemo(() => { - if (!config) return undefined - - return ({ - expanded, - onExpand: triggerExpand, - record, - }: { - expanded: boolean - onExpand: (record: RecordType, e: React.MouseEvent) => void - record: RecordType - }) => { - const key = getRowKey(record) - const state = expandedStates.get(key) - const loading = state?.loading ?? false - - // Check if row is expandable - if (config.isExpandable && !config.isExpandable(record)) { - return - } - - // Custom icon renderer - if (config.expandIcon) { - return config.expandIcon({ - expanded, - onExpand: () => triggerExpand(record, {} as React.MouseEvent), - record, - loading, - }) - } - - // Default icon - circle style matching app registry - return ( - { - e.stopPropagation() - triggerExpand(record, e) - }} - > - {loading ? ( - - ) : expanded ? ( - - ) : ( - - )} - - ) - } - }, [config, expandedStates, getRowKey]) - - // Row expandable check - const rowExpandable = useMemo(() => { - if (!config) return undefined - if (!config.isExpandable) return undefined - return config.isExpandable - }, [config]) - - // Check if a record is expanded - const isExpanded = useCallback( - (record: RecordType): boolean => { - const key = getRowKey(record) - return expandedRowKeys.includes(key) - }, - [expandedRowKeys, getRowKey], - ) - - // Render expand icon for use within a cell (when showExpandIconInCell is true) - const renderExpandIcon = useCallback( - (record: RecordType): ReactNode => { - if (!config) return null - - // Check if row is expandable - if (config.isExpandable && !config.isExpandable(record)) { - return - } - - const key = getRowKey(record) - const expanded = expandedRowKeys.includes(key) - const state = expandedStates.get(key) - const loading = state?.loading ?? false - - // Custom icon renderer - if (config.expandIcon) { - return config.expandIcon({ - expanded, - onExpand: () => onExpand(!expanded, record), - record, - loading, - }) - } - - // Default circle icon - return ( - { - e.stopPropagation() - onExpand(!expanded, record) - }} - > - {loading ? ( - - ) : expanded ? ( - - ) : ( - - )} - - ) - }, - [config, expandedRowKeys, expandedStates, getRowKey, onExpand], - ) - - return { - expandedRowKeys, - expandedRowRender, - onExpand, - expandIcon, - rowExpandable, - expandColumnWidth: config?.showExpandIconInCell ? 0 : (config?.columnWidth ?? 48), - expandFixed: config?.fixed, - renderExpandIcon, - isExpanded, - } -} - -export default useExpandableRows diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useHeaderViewportVisibility.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useHeaderViewportVisibility.ts deleted file mode 100644 index 6cadf8f4d1..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useHeaderViewportVisibility.ts +++ /dev/null @@ -1,435 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, type RefObject} from "react" - -import type {ColumnViewportVisibilityEvent} from "../types" - -type ViewportVisibilityCallback = ( - payload: ColumnViewportVisibilityEvent | ColumnViewportVisibilityEvent[], -) => void - -// const intersectionThresholds = [0, 0.01, 0.02, 0.1] -const intersectionThresholds = [0, 0, 0, 0] - -const useHeaderViewportVisibility = ({ - scopeId, - containerRef, - onVisibilityChange, - onColumnUnregister, - enabled = true, - viewportMargin, - exitDebounceMs = 150, - excludeKeys = [], - suspendUpdates = false, - descendantColumnMap, -}: { - scopeId: string | null - containerRef: RefObject - onVisibilityChange: ViewportVisibilityCallback | undefined - onColumnUnregister?: - | ((payload: {scopeId: string | null; columnKey: string}) => void) - | undefined - enabled?: boolean - viewportMargin?: string - exitDebounceMs?: number - excludeKeys?: string[] - suspendUpdates?: boolean - descendantColumnMap?: Map -}) => { - const excludedKeySet = useMemo(() => new Set(excludeKeys ?? []), [excludeKeys]) - const observerRef = useRef(null) - const keyToElementRef = useRef(new Map()) - const elementToKeyRef = useRef(new Map()) - const fixedKeysRef = useRef(new Set()) - const visibilityStateRef = useRef(new Map()) - const queuedUpdatesRef = useRef | null>(null) - const rafRef = useRef(null) - const hideTimeoutsRef = useRef(new Map()) - const pendingUnregisterTimeoutsRef = useRef(new Map()) - const suspendUpdatesRef = useRef(suspendUpdates) - - useEffect(() => { - suspendUpdatesRef.current = suspendUpdates - }, [suspendUpdates]) - - const clearHideTimeout = useCallback((columnKey: string) => { - const timeoutId = hideTimeoutsRef.current.get(columnKey) - if (timeoutId !== undefined && typeof window !== "undefined") { - window.clearTimeout(timeoutId) - } - hideTimeoutsRef.current.delete(columnKey) - }, []) - - const descendantMapRef = useRef>(descendantColumnMap ?? new Map()) - - useEffect(() => { - descendantMapRef.current = descendantColumnMap ?? new Map() - }, [descendantColumnMap]) - - const emitVisibilityChanges = useCallback( - (changes: {columnKey: string; visible: boolean}[]) => { - if (!scopeId || !changes.length) return - const deduped = new Map() - - const queueChange = (columnKey: string, visible: boolean) => { - const previous = visibilityStateRef.current.get(columnKey) - if (previous === visible) { - return - } - deduped.set(columnKey, visible) - } - - const propagate = (columnKey: string, visible: boolean) => { - queueChange(columnKey, visible) - const descendants = descendantMapRef.current.get(columnKey) ?? [] - descendants.forEach((childKey) => { - if (!childKey) return - propagate(childKey, visible) - }) - } - - changes.forEach(({columnKey, visible}) => { - propagate(columnKey, visible) - }) - const expandedChanges = Array.from(deduped.entries()).map(([columnKey, visible]) => ({ - columnKey, - visible, - })) - expandedChanges.forEach(({columnKey, visible}) => { - visibilityStateRef.current.set(columnKey, visible) - }) - const payload = expandedChanges.map( - ({columnKey, visible}): ColumnViewportVisibilityEvent => ({ - scopeId, - columnKey, - visible, - }), - ) - if (!payload.length) { - return - } - if (payload.length === 1) { - onVisibilityChange?.(payload[0]) - return - } - onVisibilityChange?.(payload) - }, - [onVisibilityChange, scopeId], - ) - - const flushQueuedUpdates = useCallback(() => { - rafRef.current = null - const updates = queuedUpdatesRef.current - queuedUpdatesRef.current = null - if (!updates || updates.size === 0) return - const changes = Array.from(updates.entries()).map(([columnKey, visible]) => ({ - columnKey, - visible, - })) - emitVisibilityChanges(changes) - }, [emitVisibilityChanges]) - - const enqueueVisibilityChange = useCallback( - (columnKey: string, visible: boolean) => { - const previous = visibilityStateRef.current.get(columnKey) - if (previous === visible) { - return - } - let queue = queuedUpdatesRef.current - if (!queue) { - queue = new Map() - queuedUpdatesRef.current = queue - } - queue.set(columnKey, visible) - if (rafRef.current === null && typeof window !== "undefined") { - rafRef.current = window.requestAnimationFrame(flushQueuedUpdates) - } - }, - [flushQueuedUpdates], - ) - - const queueVisibilityUpdate = useCallback( - (columnKey: string, visible: boolean) => { - if (visible) { - clearHideTimeout(columnKey) - enqueueVisibilityChange(columnKey, true) - return - } - const debounce = exitDebounceMs ?? 0 - if (debounce > 0 && typeof window !== "undefined") { - if (hideTimeoutsRef.current.has(columnKey)) { - return - } - const timeoutId = window.setTimeout(() => { - hideTimeoutsRef.current.delete(columnKey) - enqueueVisibilityChange(columnKey, false) - }, debounce) - hideTimeoutsRef.current.set(columnKey, timeoutId) - return - } - enqueueVisibilityChange(columnKey, false) - }, - [clearHideTimeout, enqueueVisibilityChange, exitDebounceMs], - ) - - // Track last known horizontal bounds to filter out vertical-only scroll events - const lastHorizontalBoundsRef = useRef(new Map()) - - const handleEntries = useCallback( - (entries: IntersectionObserverEntry[]) => { - // Skip processing if updates are suspended (e.g., during resize or vertical scroll) - if (suspendUpdatesRef.current) return - if (!onVisibilityChange || !scopeId) return - - // Batch process entries to reduce state updates during rapid scrolling - const updates: {columnKey: string; isVisible: boolean}[] = [] - - entries.forEach((entry) => { - const columnKey = elementToKeyRef.current.get(entry.target as HTMLElement) - if (!columnKey) return - - const boundingRect = entry.boundingClientRect - const intersectionRect = entry.intersectionRect - - // Check if horizontal position actually changed (ignore vertical-only scroll) - const lastBounds = lastHorizontalBoundsRef.current.get(columnKey) - const currentLeft = Math.round(boundingRect.left) - const currentRight = Math.round(boundingRect.right) - - if (lastBounds) { - const horizontalDelta = - Math.abs(currentLeft - lastBounds.left) + - Math.abs(currentRight - lastBounds.right) - // If horizontal position hasn't changed significantly, skip this update - // This filters out intersection events triggered by vertical scrolling - if (horizontalDelta < 2) { - return - } - } - - // Update last known horizontal bounds - lastHorizontalBoundsRef.current.set(columnKey, { - left: currentLeft, - right: currentRight, - }) - - const intersectionWidth = intersectionRect?.width ?? 0 - const intersectionHeight = intersectionRect?.height ?? 0 - const isVisible = - entry.isIntersecting && - intersectionWidth > 0 && - intersectionHeight > 0 && - boundingRect.width > 0 - - updates.push({columnKey, isVisible}) - }) - - // Process all updates together to minimize re-renders - updates.forEach(({columnKey, isVisible}) => { - queueVisibilityUpdate(columnKey, isVisible) - }) - }, - [onVisibilityChange, queueVisibilityUpdate, scopeId], - ) - - const lastRootRef = useRef(null) - const lastMarginRef = useRef(null) - - const ensureObserver = useCallback( - (enabled: boolean) => { - if (!enabled || !onVisibilityChange || !scopeId) { - return null - } - const currentRoot = containerRef.current - // const nextMargin = viewportMargin ?? "200px 200px 200px 200px" - const nextMargin = viewportMargin ?? "0px 0px 0px 0px" - - const createObserver = () => { - if (typeof window === "undefined") { - return null - } - // console.log("createObserver", {currentRoot, nextMargin, intersectionThresholds}) - const observer = new IntersectionObserver(handleEntries, { - root: currentRoot, - rootMargin: nextMargin, - threshold: intersectionThresholds, - }) - observerRef.current = observer - lastRootRef.current = currentRoot ?? null - lastMarginRef.current = nextMargin - keyToElementRef.current.forEach((element) => observer.observe(element)) - return observer - } - - if (observerRef.current) { - const marginChanged = lastMarginRef.current !== nextMargin - const rootChanged = lastRootRef.current !== currentRoot - if (!marginChanged && !rootChanged) { - return observerRef.current - } - observerRef.current.disconnect() - observerRef.current = null - } - - return createObserver() - }, - [containerRef, handleEntries, onVisibilityChange, scopeId, viewportMargin], - ) - - useEffect(() => { - if (!enabled || !onVisibilityChange || !scopeId) { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } - keyToElementRef.current.clear() - elementToKeyRef.current.clear() - visibilityStateRef.current.clear() - queuedUpdatesRef.current = null - if (typeof window !== "undefined") { - hideTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)) - } - hideTimeoutsRef.current.clear() - if (rafRef.current !== null && typeof window !== "undefined") { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - return - } - ensureObserver(enabled) - return () => { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } - keyToElementRef.current.clear() - elementToKeyRef.current.clear() - visibilityStateRef.current.clear() - queuedUpdatesRef.current = null - if (typeof window !== "undefined") { - hideTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)) - } - hideTimeoutsRef.current.clear() - if (typeof window !== "undefined") { - pendingUnregisterTimeoutsRef.current.forEach((timeoutId) => - window.clearTimeout(timeoutId), - ) - } - pendingUnregisterTimeoutsRef.current.clear() - if (rafRef.current !== null && typeof window !== "undefined") { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, [enabled, ensureObserver, onVisibilityChange, scopeId]) - - const isFixedHeaderNode = useCallback((node: HTMLElement | null) => { - if (!node) return false - const thNode = node.closest("th") - if (!thNode) return false - return ( - thNode.classList.contains("ant-table-cell-fix-left") || - thNode.classList.contains("ant-table-cell-fix-right") - ) - }, []) - - const registerHeader = useCallback( - (columnKey: string) => { - if (!enabled || !scopeId || !columnKey) { - return () => undefined - } - return (node: HTMLElement | null) => { - if (!enabled || !scopeId) return - if (node) { - const pendingTimeout = pendingUnregisterTimeoutsRef.current.get(columnKey) - if (pendingTimeout !== undefined && typeof window !== "undefined") { - window.clearTimeout(pendingTimeout) - pendingUnregisterTimeoutsRef.current.delete(columnKey) - } - if (excludedKeySet.has(columnKey) || isFixedHeaderNode(node)) { - fixedKeysRef.current.add(columnKey) - keyToElementRef.current.delete(columnKey) - // emitVisibilityChanges([{columnKey, visible: true}]) - return - } - const existingNode = keyToElementRef.current.get(columnKey) - if (existingNode === node) { - return - } - if (existingNode && observerRef.current) { - elementToKeyRef.current.delete(existingNode) - observerRef.current.unobserve(existingNode) - } - keyToElementRef.current.set(columnKey, node) - elementToKeyRef.current.set(node, columnKey) - const observer = ensureObserver(enabled) - // console.log("scopesWithChanges registerHeader", { - // columnKey, - // timestamp: Date.now(), - // }) - observer?.observe(node) - if (typeof window !== "undefined") { - // console.log("computeImmediateVisibility", {columnKey, node}) - // const visible = computeImmediateVisibility( - // node, - // containerRef.current, - // viewportMargin, - // ) - // emitVisibilityChanges([{columnKey, visible}]) - } - return - } - const wasFixed = fixedKeysRef.current.delete(columnKey) - if (wasFixed) { - // Fixed columns don't need cleanup - return - } - const previousNode = keyToElementRef.current.get(columnKey) - if (previousNode && observerRef.current) { - observerRef.current.unobserve(previousNode) - elementToKeyRef.current.delete(previousNode) - } - keyToElementRef.current.delete(columnKey) - - // Clear visibility state to prevent stale values on re-mount - const scheduleCleanup = () => { - visibilityStateRef.current.delete(columnKey) - // Delete from atom instead of setting to false to prevent stale state - // When column is re-registered, it will default to visible (true) - if (onColumnUnregister && scopeId) { - onColumnUnregister({scopeId, columnKey}) - } - } - - if (typeof window !== "undefined") { - if (!pendingUnregisterTimeoutsRef.current.has(columnKey)) { - const timeoutId = window.setTimeout(() => { - pendingUnregisterTimeoutsRef.current.delete(columnKey) - scheduleCleanup() - }, exitDebounceMs ?? 150) - pendingUnregisterTimeoutsRef.current.set(columnKey, timeoutId) - } - } else { - scheduleCleanup() - } - } - }, - [ - emitVisibilityChanges, - enabled, - ensureObserver, - excludedKeySet, - exitDebounceMs, - isFixedHeaderNode, - onVisibilityChange, - onColumnUnregister, - scopeId, - ], - ) - - if (!enabled || !scopeId) { - return undefined - } - - return registerHeader -} - -export default useHeaderViewportVisibility diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteScroll.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteScroll.ts deleted file mode 100644 index 203810b6fb..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteScroll.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {useCallback, useEffect, useRef} from "react" - -interface UseInfiniteScrollOptions { - loadMore: () => void - scrollThreshold?: number -} - -/** - * Hook to handle infinite scroll loading with RAF-based throttling - */ -const useInfiniteScroll = ({loadMore, scrollThreshold = 300}: UseInfiniteScrollOptions) => { - const scrollRafRef = useRef(null) - const lastScrollTargetRef = useRef(null) - - const handleScroll = useCallback( - (event: React.UIEvent) => { - // Store the scroll target for RAF callback - lastScrollTargetRef.current = event.currentTarget - - // Skip if we already have a pending RAF - if (scrollRafRef.current !== null) { - return - } - - // Defer layout reads to next animation frame to avoid forced reflow during scroll - scrollRafRef.current = requestAnimationFrame(() => { - scrollRafRef.current = null - const target = lastScrollTargetRef.current - if (!target) return - - const distanceToBottom = - target.scrollHeight - target.scrollTop - target.clientHeight - - if (distanceToBottom < scrollThreshold) { - loadMore() - } - }) - }, - [loadMore, scrollThreshold], - ) - - // Cleanup RAF on unmount - useEffect(() => { - return () => { - if (scrollRafRef.current !== null) { - cancelAnimationFrame(scrollRafRef.current) - } - } - }, []) - - return handleScroll -} - -export default useInfiniteScroll diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteTablePagination.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteTablePagination.ts deleted file mode 100644 index 27c83ea448..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteTablePagination.ts +++ /dev/null @@ -1,144 +0,0 @@ -import {useCallback, useEffect, useMemo} from "react" - -import {useSetAtom} from "jotai" -import {LOW_PRIORITY, useAtomValueWithSchedule, useSetAtomWithSchedule} from "jotai-scheduler" - -import type {InfiniteTableStore} from "../createInfiniteTableStore" -import type {InfiniteTableRowBase, WindowingState} from "../types" - -interface UseInfiniteTablePaginationArgs { - store: InfiniteTableStore - scopeId: string | null - pageSize: number - resetOnScopeChange?: boolean -} - -interface PaginationResult { - rows: TableRow[] - rowsAtom: ReturnType["atoms"]["combinedRowsAtomFamily"]> - loadedRowCount: number - totalRows: number - loadNextPage: () => void - resetPages: () => void - paginationInfo: { - hasMore: boolean - nextCursor: string | null - nextOffset: number | null - isFetching: boolean - totalCount: number | null - nextWindowing: WindowingState | null - } -} - -const useInfiniteTablePagination = ({ - store, - scopeId, - pageSize, - resetOnScopeChange = true, -}: UseInfiniteTablePaginationArgs): PaginationResult => { - const debugEnabled = process.env.NEXT_PUBLIC_IVT_DEBUG === "true" - const pagesAtom = useMemo( - () => store.atoms.pagesAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - const combinedRowsAtom = useMemo( - () => store.atoms.combinedRowsAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - const paginationInfoAtom = useMemo( - () => store.atoms.paginationInfoAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - const scheduleAtom = useMemo( - () => store.atoms.scheduleNextPageAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - - const setPagesState = useSetAtom(pagesAtom) - const scheduleNextPage = useSetAtomWithSchedule(scheduleAtom, { - priority: LOW_PRIORITY, - }) - const rows = useAtomValueWithSchedule(combinedRowsAtom, { - priority: LOW_PRIORITY, - }) as TableRow[] - const paginationInfo = useAtomValueWithSchedule(paginationInfoAtom, { - priority: LOW_PRIORITY, - }) as PaginationResult["paginationInfo"] - - const resetPages = useCallback(() => { - setPagesState({ - pages: [store.createInitialPage(pageSize)], - }) - }, [pageSize, setPagesState, store]) - - useEffect(() => { - if (!resetOnScopeChange) return - resetPages() - }, [resetOnScopeChange, resetPages, scopeId]) - - const totalRows = rows.length - const loadedRowCount = useMemo(() => rows.filter((row) => !row.__isSkeleton).length, [rows]) - - const loadNextPage = useCallback(() => { - if (!paginationInfo.hasMore) { - return - } - const nextCursor = paginationInfo.nextCursor - if (!nextCursor || paginationInfo.isFetching) { - return - } - - const nextOffset = paginationInfo.nextOffset ?? totalRows - const nextWindowing = - paginationInfo.nextWindowing ?? - ({ - next: nextCursor, - order: "ascending", - limit: pageSize, - stop: null, - } as WindowingState) - - if (debugEnabled) { - const skeletonCount = rows.filter((row) => row.__isSkeleton).length - - console.log("[IVT] scheduling next page", { - scopeId, - nextCursor, - nextOffset, - totalRows, - skeletonCount, - }) - } - - scheduleNextPage({ - nextCursor, - nextOffset, - nextWindowing, - totalRows, - }) - }, [ - debugEnabled, - pageSize, - paginationInfo.hasMore, - paginationInfo.isFetching, - paginationInfo.nextCursor, - paginationInfo.nextOffset, - paginationInfo.nextWindowing, - rows, - scheduleNextPage, - scopeId, - totalRows, - ]) - - return { - rows, - rowsAtom: combinedRowsAtom, - loadedRowCount, - totalRows, - loadNextPage, - resetPages, - paginationInfo, - } -} - -export default useInfiniteTablePagination diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useResizableColumns.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useResizableColumns.ts deleted file mode 100644 index 388b4698d8..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useResizableColumns.ts +++ /dev/null @@ -1,221 +0,0 @@ -import {useCallback, useMemo, useRef, useState, type HTMLAttributes} from "react" - -import {ResizableTitle} from "@agenta/ui/table" -import type {ColumnsType, ColumnType} from "antd/es/table" -import {useAtom} from "jotai" - -import {getColumnWidthsAtom} from "../atoms/columnWidths" - -const DEFAULT_MIN_WIDTH = 48 - -type ColumnEntry = ColumnsType[number] -type ColumnWithChildren = ColumnType & {children?: ColumnsType} - -const getColumnChildren = (column: ColumnEntry) => - (column as ColumnWithChildren).children - -const collectLeafColumns = (columns: ColumnsType): ColumnType[] => { - const result: ColumnType[] = [] - const visit = (cols: ColumnsType) => { - cols.forEach((col) => { - const children = getColumnChildren(col) - if (children && children.length) { - visit(children) - } else { - result.push(col as ColumnType) - } - }) - } - visit(columns) - return result -} - -const computeTotalWidth = ( - columns: ColumnsType, - widthOverrides: Record, - minWidth: number, -): number => { - const leafColumns = collectLeafColumns(columns) - return leafColumns.reduce((sum, col) => { - const key = (col?.key ?? col?.dataIndex ?? "") as string - const width = widthOverrides[key] ?? (typeof col.width === "number" ? col.width : minWidth) - return sum + width - }, 0) -} - -export interface UseResizableColumnsArgs { - columns: ColumnsType - enabled?: boolean - minWidth?: number - scopeId?: string | null -} - -export interface UseResizableColumnsResult { - columns: ColumnsType - headerComponents: { - cell: typeof ResizableTitle - } | null - getTotalWidth: (cols?: ColumnsType) => number - isResizing: boolean -} - -export const useResizableColumns = ({ - columns, - enabled = false, - minWidth = DEFAULT_MIN_WIDTH, - scopeId = null, -}: UseResizableColumnsArgs): UseResizableColumnsResult => { - const widthsAtom = useMemo(() => getColumnWidthsAtom(scopeId), [scopeId]) - const [columnWidths, setColumnWidths] = useAtom(widthsAtom) - const [isResizing, setIsResizing] = useState(false) - const columnMetaRef = useRef>({}) - - const commitWidth = useCallback( - (colKey: string, width: number) => { - const metaMinWidth = columnMetaRef.current[colKey]?.minWidth ?? minWidth - const clamped = Math.max(width, metaMinWidth) - setColumnWidths((prev) => { - if (prev[colKey] === clamped) { - return prev - } - return { - ...prev, - [colKey]: clamped, - } - }) - }, - [minWidth, setColumnWidths], - ) - - const handleResize = useCallback( - (colKey: string) => - (_: unknown, {size}: {size: {width: number}}) => { - commitWidth(colKey, size.width) - }, - [commitWidth], - ) - - const handleResizeStart = useCallback(() => { - setIsResizing(true) - }, []) - - const handleResizeStop = useCallback( - (colKey: string) => - (_: unknown, {size}: {size: {width: number}}) => { - commitWidth(colKey, size.width) - setIsResizing(false) - }, - [commitWidth], - ) - - const buildHeaderCellProps = useCallback( - (columnKey: string, width: number | undefined, minValue: number) => - ({ - width, - minWidth: minValue, - onResizeStart: handleResizeStart, - onResize: handleResize(columnKey), - onResizeStop: handleResizeStop(columnKey), - }) as unknown as HTMLAttributes, - [handleResize, handleResizeStart, handleResizeStop], - ) - - const makeColumnsResizable = useCallback( - (cols: ColumnsType): ColumnsType => - cols.map((colEntry) => { - const column = colEntry as ColumnType & { - children?: ColumnsType - } - - const colKey = (column.key ?? - (Array.isArray(column.dataIndex) - ? column.dataIndex.join(".") - : typeof column.dataIndex === "string" - ? column.dataIndex - : Math.random().toString(36))) as string - - const hasChildren = Boolean(column.children && column.children.length) - const isFixed = Boolean(column.fixed) - - if (hasChildren) { - const nextChildren = makeColumnsResizable( - column.children as ColumnsType, - ) - if (isFixed) { - return { - ...column, - key: colKey, - children: nextChildren, - } as typeof colEntry - } - const baseWidth = - typeof column.width === "number" - ? column.width - : typeof column.minWidth === "number" - ? column.minWidth - : undefined - const resolvedMinWidth = - typeof column.minWidth === "number" ? column.minWidth : minWidth - const width = columnWidths[colKey] ?? baseWidth ?? resolvedMinWidth - columnMetaRef.current[colKey] = {minWidth: resolvedMinWidth} - return { - ...column, - key: colKey, - width, - minWidth: resolvedMinWidth, - children: nextChildren, - onHeaderCell: () => - buildHeaderCellProps(colKey, width ?? undefined, resolvedMinWidth), - } as typeof colEntry - } - - if (isFixed) { - delete columnMetaRef.current[colKey] - return { - ...column, - key: colKey, - } as typeof colEntry - } - - const baseWidth = - typeof column.width === "number" - ? column.width - : typeof column.minWidth === "number" - ? column.minWidth - : minWidth - const resolvedMinWidth = - typeof column.minWidth === "number" ? column.minWidth : minWidth - const width = columnWidths[colKey] ?? baseWidth - columnMetaRef.current[colKey] = {minWidth: resolvedMinWidth} - return { - ...column, - key: colKey, - width, - minWidth: resolvedMinWidth, - onHeaderCell: () => buildHeaderCellProps(colKey, width, resolvedMinWidth), - } as typeof colEntry - }), - [buildHeaderCellProps, columnWidths, minWidth], - ) - - const resizableColumns = useMemo(() => { - if (!enabled) return columns - columnMetaRef.current = {} - return makeColumnsResizable(columns) - }, [columns, enabled, makeColumnsResizable]) - - const getTotalWidth = useCallback( - (cols: ColumnsType = resizableColumns) => - computeTotalWidth(cols, columnWidths, minWidth), - [columnWidths, minWidth, resizableColumns], - ) - - return { - columns: resizableColumns, - headerComponents: enabled ? {cell: ResizableTitle} : null, - getTotalWidth, - isResizing, - } -} - -export default useResizableColumns diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useRowHeight.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useRowHeight.tsx deleted file mode 100644 index 59375e2114..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useRowHeight.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import {useMemo} from "react" - -import {Rows} from "@phosphor-icons/react" -import type {MenuProps} from "antd" -import {atom, useAtom, useAtomValue} from "jotai" -import {atomWithStorage} from "jotai/utils" - -/** - * Row height size options - */ -export type RowHeightSize = "small" | "medium" | "large" - -/** - * Configuration for a single row height option - */ -export interface RowHeightOption { - /** Pixel height for this size */ - height: number - /** Display label in the menu */ - label: string - /** Optional: max lines to show in cells (for text truncation) */ - maxLines?: number -} - -/** - * Full row height configuration for a table - */ -export interface RowHeightConfig { - /** Configuration for each size option */ - sizes: Record - /** Default size to use */ - defaultSize: RowHeightSize - /** LocalStorage key for persisting the preference */ - storageKey: string -} - -/** - * Default row height configuration - * Can be used as-is or customized per table - */ -export const DEFAULT_ROW_HEIGHT_CONFIG: Omit = { - sizes: { - small: {height: 80, label: "Small", maxLines: 4}, - medium: {height: 160, label: "Medium", maxLines: 10}, - large: {height: 280, label: "Large", maxLines: 18}, - }, - defaultSize: "medium", -} - -/** - * Creates a persisted atom for row height preference - * @param storageKey - LocalStorage key for persistence - * @param defaultSize - Default row height size - */ -export function createRowHeightAtom(storageKey: string, defaultSize: RowHeightSize = "medium") { - return atomWithStorage(storageKey, defaultSize) -} - -/** - * Creates a derived atom that returns the pixel height for the current size - * @param sizeAtom - The row height size atom - * @param config - Row height configuration with size definitions - */ -export function createRowHeightPxAtom( - sizeAtom: ReturnType, - config: RowHeightConfig["sizes"], -) { - return atom((get) => { - const size = get(sizeAtom) - return config[size].height - }) -} - -/** - * Creates a derived atom that returns the max lines for the current size - * @param sizeAtom - The row height size atom - * @param config - Row height configuration with size definitions - */ -export function createRowHeightMaxLinesAtom( - sizeAtom: ReturnType, - config: RowHeightConfig["sizes"], -) { - return atom((get) => { - const size = get(sizeAtom) - return config[size].maxLines ?? 10 - }) -} - -/** - * Return type for useRowHeight hook - */ -export interface UseRowHeightResult { - /** Current row height size (small/medium/large) */ - size: RowHeightSize - /** Set the row height size */ - setSize: (size: RowHeightSize) => void - /** Current row height in pixels */ - heightPx: number - /** Max lines to show in cells */ - maxLines: number - /** Menu items for the settings dropdown */ - menuItems: MenuProps["items"] -} - -/** - * Hook to manage row height state and provide menu items for the settings dropdown - * - * @param sizeAtom - Persisted atom for row height size - * @param config - Row height configuration - * @returns Row height state and menu items - * - * @example - * ```tsx - * // In your table component's state file: - * export const myTableRowHeightAtom = createRowHeightAtom("agenta:my-table:row-height") - * - * // In your table component: - * const rowHeight = useRowHeight(myTableRowHeightAtom, { - * sizes: DEFAULT_ROW_HEIGHT_CONFIG.sizes, - * defaultSize: "medium", - * storageKey: "agenta:my-table:row-height" - * }) - * - * - * ``` - */ -export function useRowHeight( - sizeAtom: ReturnType, - config: RowHeightConfig, -): UseRowHeightResult { - const [size, setSize] = useAtom(sizeAtom) - - const heightPx = useMemo(() => config.sizes[size].height, [config.sizes, size]) - const maxLines = useMemo(() => config.sizes[size].maxLines ?? 10, [config.sizes, size]) - - const menuItems = useMemo(() => { - const sizes: RowHeightSize[] = ["small", "medium", "large"] - return [ - { - key: "row-height", - label: "Row height", - icon: , - children: sizes.map((s) => ({ - key: `row-height-${s}`, - label: config.sizes[s].label, - onClick: () => setSize(s), - style: size === s ? {fontWeight: 600} : undefined, - })), - }, - ] - }, [config.sizes, size, setSize]) - - return { - size, - setSize, - heightPx, - maxLines, - menuItems, - } -} - -/** - * Simplified hook when you only need to read the row height values (not set them) - * Useful in child components that just need the current height/maxLines - * - * @param sizeAtom - Persisted atom for row height size - * @param config - Row height configuration (just the sizes) - */ -export function useRowHeightValue( - sizeAtom: ReturnType, - config: RowHeightConfig["sizes"], -) { - const size = useAtomValue(sizeAtom) - - return useMemo( - () => ({ - size, - heightPx: config[size].height, - maxLines: config[size].maxLines ?? 10, - }), - [size, config], - ) -} diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useScopedColumnVisibility.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useScopedColumnVisibility.tsx deleted file mode 100644 index 71572e3360..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useScopedColumnVisibility.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import {useMemo} from "react" - -import type {ColumnsType} from "antd/es/table" - -import {useColumnVisibility} from "../hooks/useColumnVisibility" - -interface Options { - scopeId: string | null - storageKey?: string - defaultHiddenKeys?: string[] -} - -export const useScopedColumnVisibility = ( - columns: ColumnsType, - {scopeId, storageKey, defaultHiddenKeys = []}: Options, -) => { - const scopedStorageKey = useMemo(() => { - if (!storageKey) return undefined - return scopeId ? `${storageKey}::${scopeId}` : storageKey - }, [scopeId, storageKey]) - - return useColumnVisibility(columns, { - storageKey: scopedStorageKey, - defaultHiddenKeys, - }) -} - -export default useScopedColumnVisibility diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollConfig.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollConfig.ts deleted file mode 100644 index 2bc84e02ad..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollConfig.ts +++ /dev/null @@ -1,108 +0,0 @@ -import {useMemo, useRef, type RefObject} from "react" - -import type {TableProps} from "antd/es/table" - -import {shallowEqual} from "../utils/columnUtils" - -interface UseScrollConfigOptions { - containerRef: RefObject - bodyHeight: number | null - containerWidth: number - containerHeight: number - tableHeaderHeight: number | null - computedScrollX: number - tableProps?: TableProps -} - -interface ScrollConfig { - x: number | string | boolean | undefined - y: number | undefined -} - -/** - * Hook to compute scroll configuration for the virtual table - */ -const useScrollConfig = ({ - containerRef, - bodyHeight, - containerWidth, - containerHeight, - tableHeaderHeight, - computedScrollX, - tableProps, -}: UseScrollConfigOptions): ScrollConfig => { - const lastScrollConfigRef = useRef(null) - - const scrollConfig = useMemo(() => { - const resolvedTableProps = tableProps ?? ({} as TableProps) - - if (typeof bodyHeight === "number" && Number.isFinite(bodyHeight)) { - const resolvedScroll = resolvedTableProps.scroll - const resolvedX = - resolvedScroll && typeof resolvedScroll.x !== "undefined" - ? resolvedScroll.x - : containerWidth > 0 - ? containerWidth - : undefined - return {x: resolvedX, y: bodyHeight} - } - - const headerHeight = - (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) - ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) - ?.offsetHeight) ?? null - - const computedY = Math.max((containerHeight ?? 0) - (headerHeight ?? 0), 0) - const resolvedScroll = resolvedTableProps.scroll - const requestedY = - resolvedScroll && typeof resolvedScroll.y === "number" ? resolvedScroll.y : undefined - const fallbackY = requestedY ?? computedY - let resolvedY = - typeof fallbackY === "number" && Number.isFinite(fallbackY) ? fallbackY : undefined - - const resolvedX = (() => { - const rawX = resolvedScroll?.x - if (typeof rawX === "number" || typeof rawX === "string") { - return rawX - } - if (Number.isFinite(computedScrollX) && computedScrollX > 0) { - return computedScrollX - } - return containerWidth > 0 ? containerWidth : undefined - })() - - if (resolvedY === undefined || resolvedY <= 0) { - const measured = containerHeight ?? 0 - resolvedY = measured > 0 ? Math.max(measured - (headerHeight ?? 0), 0) : 360 - } - - if (resolvedY <= 0) { - resolvedY = 360 - } - - const nextConfig: ScrollConfig = { - x: resolvedX, - y: resolvedY, - } - - const previous = lastScrollConfigRef.current - if (shallowEqual(previous, nextConfig)) { - return previous! - } - lastScrollConfigRef.current = nextConfig - return nextConfig - }, [ - bodyHeight, - computedScrollX, - containerHeight, - containerRef, - containerWidth, - tableHeaderHeight, - tableProps, - ]) - - return scrollConfig -} - -export default useScrollConfig diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollContainer.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollContainer.ts deleted file mode 100644 index 0a82f638a0..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollContainer.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {useEffect, useRef, useState} from "react" - -interface ScrollContainerResult { - scrollContainer: HTMLDivElement | null - visibilityRoot: HTMLDivElement | null -} - -/** - * Hook to detect and track the scrollable container element within the table. - * Optimized to avoid unnecessary state updates during scroll. - */ -const useScrollContainer = ( - containerRef: React.RefObject, - dependencies: {scrollX?: number | string; scrollY?: number; className?: string}, -): ScrollContainerResult => { - const [scrollContainer, setScrollContainer] = useState(null) - const [visibilityRoot, setVisibilityRoot] = useState(null) - // Track last known elements to avoid redundant state updates - const lastScrollContainerRef = useRef(null) - const lastVisibilityRootRef = useRef(null) - - useEffect(() => { - const containerElement = containerRef.current - if (!containerElement) { - if (lastScrollContainerRef.current !== null) { - lastScrollContainerRef.current = null - setScrollContainer(null) - } - if (lastVisibilityRootRef.current !== null) { - lastVisibilityRootRef.current = null - setVisibilityRoot(null) - } - return - } - - const tableBody = containerElement.querySelector(".ant-table-body") ?? null - - const isScrollable = (element: HTMLDivElement | null) => { - if (!element) return false - const style = window.getComputedStyle(element) - const overflowValues = [style.overflow, style.overflowX, style.overflowY] - return overflowValues.some((value) => ["auto", "scroll", "overlay"].includes(value)) - } - - const preferredContainer = isScrollable(tableBody) ? tableBody : null - const nextScrollContainer = preferredContainer ?? containerElement - - // Only update state if the element reference actually changed - if (nextScrollContainer !== lastScrollContainerRef.current) { - lastScrollContainerRef.current = nextScrollContainer - setScrollContainer(nextScrollContainer) - } - - const headerContainer = - containerElement.querySelector(".ant-table-container") ?? - containerElement - - if (headerContainer !== lastVisibilityRootRef.current) { - lastVisibilityRootRef.current = headerContainer - setVisibilityRoot(headerContainer) - } - }, [dependencies.scrollX, dependencies.scrollY, dependencies.className, containerRef]) - - return {scrollContainer, visibilityRoot} -} - -export default useScrollContainer diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts deleted file mode 100644 index 146b65dbfb..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts +++ /dev/null @@ -1,406 +0,0 @@ -import {useCallback, useMemo, useRef, useState, type HTMLAttributes} from "react" - -import {ResizableTitle} from "@agenta/ui/table" -import type {ColumnsType, ColumnType} from "antd/es/table" -import {useAtom} from "jotai" - -import {getColumnWidthsAtom} from "../atoms/columnWidths" - -const DEFAULT_MIN_WIDTH = 150 -const DEFAULT_COLUMN_WIDTH = 200 - -type ColumnEntry = ColumnsType[number] -type ColumnWithChildren = ColumnType & {children?: ColumnsType} - -const getColumnChildren = (column: ColumnEntry) => - (column as ColumnWithChildren).children - -const collectLeafColumns = (columns: ColumnsType): ColumnType[] => { - const result: ColumnType[] = [] - const visit = (cols: ColumnsType) => { - cols.forEach((col) => { - const children = getColumnChildren(col) - if (children && children.length) { - visit(children) - } else { - result.push(col as ColumnType) - } - }) - } - visit(columns) - return result -} - -interface ColumnMeta { - key: string - isFixed: boolean // left/right fixed positioning - hasMaxWidth: boolean // has maxWidth constraint - width: number - minWidth: number - maxWidth?: number -} - -export interface UseSmartResizableColumnsArgs { - columns: ColumnsType - enabled?: boolean - minWidth?: number - scopeId?: string | null - containerWidth: number - selectionColumnWidth: number -} - -export interface UseSmartResizableColumnsResult { - columns: ColumnsType - headerComponents: { - cell: typeof ResizableTitle - } | null - getTotalWidth: (cols?: ColumnsType) => number - isResizing: boolean - /** Whether any column has been manually resized by the user */ - hasUserResizedAny: boolean -} - -/** - * Smart resizable columns hook that intelligently distributes available space - * - * Rules: - * 1. Columns with maxWidth stay at maxWidth (fixed size) - * 2. Columns without maxWidth (flexible) share remaining space proportionally - * 3. On user resize: only resize that column, allow horizontal scroll if needed - * 4. On container resize: redistribute space among flexible columns - */ -export const useSmartResizableColumns = ({ - columns, - enabled = false, - minWidth = DEFAULT_MIN_WIDTH, - scopeId = null, - containerWidth, - selectionColumnWidth, -}: UseSmartResizableColumnsArgs): UseSmartResizableColumnsResult => { - const widthsAtom = useMemo(() => getColumnWidthsAtom(scopeId), [scopeId]) - const [userResizedWidths, setUserResizedWidths] = useAtom(widthsAtom) - const [isResizing, setIsResizing] = useState(false) - const columnMetaRef = useRef>({}) - - // Extract column metadata - const analyzeColumns = useCallback( - (cols: ColumnsType): ColumnMeta[] => { - const leafColumns = collectLeafColumns(cols) - return leafColumns.map((col) => { - const key = (col?.key ?? col?.dataIndex ?? "") as string - const isFixed = Boolean(col.fixed) - const hasMaxWidth = - typeof (col as any).maxWidth === "number" && (col as any).maxWidth > 0 - - const defaultWidth = - typeof col.width === "number" - ? col.width - : typeof col.minWidth === "number" - ? col.minWidth - : DEFAULT_COLUMN_WIDTH - - const resolvedMinWidth = typeof col.minWidth === "number" ? col.minWidth : minWidth - - const maxWidthValue = hasMaxWidth ? (col as any).maxWidth : undefined - - return { - key, - isFixed, - hasMaxWidth, - width: defaultWidth, - minWidth: resolvedMinWidth, - maxWidth: maxWidthValue, - } - }) - }, - [minWidth], - ) - - // Compute smart widths based on available space - // KEY CONSTRAINT: Total width must always >= containerWidth - const computeSmartWidths = useCallback( - (columnsMeta: ColumnMeta[]): Record => { - const result: Record = {} - - // 1. Separate columns by type - const fixedPositionCols = columnsMeta.filter((c) => c.isFixed) - const constrainedCols = columnsMeta.filter((c) => !c.isFixed && c.hasMaxWidth) - const flexibleCols = columnsMeta.filter((c) => !c.isFixed && !c.hasMaxWidth) - - // 2. Calculate fixed widths (these NEVER change) - let fixedWidth = selectionColumnWidth - - // Fixed position columns use their ORIGINAL width (never user-resized) - for (const col of fixedPositionCols) { - result[col.key] = col.width - fixedWidth += col.width - } - - // Constrained columns use their maxWidth - for (const col of constrainedCols) { - const width = col.maxWidth! - result[col.key] = width - fixedWidth += width - } - - // 3. Calculate widths for flexible columns - if (flexibleCols.length === 0) { - return result - } - - // Available space for flexible columns (must be filled!) - const availableForFlexible = Math.max(0, containerWidth - fixedWidth) - - // Separate user-resized and non-resized flexible columns - const userResizedFlexCols = flexibleCols.filter( - (c) => userResizedWidths[c.key] !== undefined, - ) - const nonResizedFlexCols = flexibleCols.filter( - (c) => userResizedWidths[c.key] === undefined, - ) - - // Calculate space taken by user-resized columns - let userResizedTotal = 0 - for (const col of userResizedFlexCols) { - const width = Math.max(userResizedWidths[col.key]!, col.minWidth) - result[col.key] = width - userResizedTotal += width - } - - // Remaining space for non-resized columns - const remainingForNonResized = availableForFlexible - userResizedTotal - - if (nonResizedFlexCols.length === 0) { - // All flexible columns have been user-resized - // If total < available, we need to expand the last resized column - // to maintain the sum constraint - if (userResizedTotal < availableForFlexible && userResizedFlexCols.length > 0) { - const lastCol = userResizedFlexCols[userResizedFlexCols.length - 1] - const deficit = availableForFlexible - userResizedTotal - result[lastCol.key] = (result[lastCol.key] ?? 0) + deficit - } - return result - } - - // Distribute remaining space among non-resized columns - // Use default width as floor to ensure readability, allow horizontal scroll if needed - const totalDefaultWeight = nonResizedFlexCols.reduce((sum, col) => sum + col.width, 0) - - if (remainingForNonResized <= 0) { - // User-resized columns take all space, use default width for others - // This may cause total > container, enabling horizontal scroll - for (const col of nonResizedFlexCols) { - result[col.key] = col.width - } - } else if (remainingForNonResized < totalDefaultWeight) { - // Not enough space for all at default width - use default widths - // and allow horizontal scrolling rather than squeezing columns - for (const col of nonResizedFlexCols) { - result[col.key] = col.width - } - } else { - // Enough space - distribute proportionally. - // - // Widths MUST be integers. The virtual body positions cells by - // the raw width values while the header 's - // rounds each column independently; fractional widths make the - // two diverge and the header/body dividers drift apart left-to- - // right. We floor each column and hand the accumulated rounding - // remainder to the last column so the total still fills exactly. - let distributed = 0 - nonResizedFlexCols.forEach((col, index) => { - if (index === nonResizedFlexCols.length - 1) { - // Last column absorbs the remainder to keep the sum exact. - const remainder = remainingForNonResized - distributed - result[col.key] = Math.max(Math.round(remainder), col.width) - return - } - const proportion = col.width / totalDefaultWeight - // Use default width as floor, not minWidth - const computedWidth = Math.max( - Math.floor(remainingForNonResized * proportion), - col.width, - ) - result[col.key] = computedWidth - distributed += computedWidth - }) - } - - return result - }, - [containerWidth, selectionColumnWidth, userResizedWidths, minWidth], - ) - - const commitWidth = useCallback( - (colKey: string, width: number) => { - const meta = columnMetaRef.current[colKey] - if (!meta) return - - const clamped = Math.max( - width, - meta.minWidth, - meta.maxWidth ? Math.min(width, meta.maxWidth) : width, - ) - - setUserResizedWidths((prev) => { - if (prev[colKey] === clamped) return prev - return { - ...prev, - [colKey]: clamped, - } - }) - }, - [setUserResizedWidths], - ) - - const handleResize = useCallback( - (_colKey: string) => (_: unknown, _size: {size: {width: number}}) => { - // During drag, don't commit to state to avoid jank - // ResizableTitle handles visual feedback - }, - [], - ) - - const handleResizeStart = useCallback(() => { - setIsResizing(true) - }, []) - - const handleResizeStop = useCallback( - (colKey: string) => - (_: unknown, {size}: {size: {width: number}}) => { - // Only commit width when drag ends for smooth performance - commitWidth(colKey, size.width) - setIsResizing(false) - }, - [commitWidth], - ) - - const buildHeaderCellProps = useCallback( - (columnKey: string, width: number | undefined, minValue: number) => - ({ - width, - minWidth: minValue, - onResizeStart: handleResizeStart, - onResize: handleResize(columnKey), - onResizeStop: handleResizeStop(columnKey), - }) as unknown as HTMLAttributes, - [handleResize, handleResizeStart, handleResizeStop], - ) - - const makeColumnsResizable = useCallback( - ( - cols: ColumnsType, - computedWidths: Record, - ): ColumnsType => - cols.map((colEntry) => { - const column = colEntry as ColumnType & { - children?: ColumnsType - } - - const colKey = (column.key ?? - (Array.isArray(column.dataIndex) - ? column.dataIndex.join(".") - : typeof column.dataIndex === "string" - ? column.dataIndex - : Math.random().toString(36))) as string - - const hasChildren = Boolean(column.children && column.children.length) - const isFixed = Boolean(column.fixed) - - if (hasChildren) { - const nextChildren = makeColumnsResizable( - column.children as ColumnsType, - computedWidths, - ) - return { - ...column, - key: colKey, - children: nextChildren, - } as typeof colEntry - } - - const width = computedWidths[colKey] - if (!width) { - // No computed width, use original - return { - ...column, - key: colKey, - } as typeof colEntry - } - - const meta = columnMetaRef.current[colKey] - if (!meta) { - return { - ...column, - key: colKey, - width, - } as typeof colEntry - } - - if (isFixed) { - // Fixed position columns - keep their width but don't make resizable - return { - ...column, - key: colKey, - width, - } as typeof colEntry - } - - return { - ...column, - key: colKey, - width, - minWidth: meta.minWidth, - onHeaderCell: () => buildHeaderCellProps(colKey, width, meta.minWidth), - } as typeof colEntry - }), - [buildHeaderCellProps], - ) - - const resizableColumns = useMemo(() => { - if (!enabled) return columns - - // Analyze columns to build metadata - const meta = analyzeColumns(columns) - columnMetaRef.current = meta.reduce( - (acc, m) => { - acc[m.key] = m - return acc - }, - {} as Record, - ) - - // Compute smart widths - const computedWidths = computeSmartWidths(meta) - - // Apply widths to columns - return makeColumnsResizable(columns, computedWidths) - }, [columns, enabled, analyzeColumns, computeSmartWidths, makeColumnsResizable]) - - const getTotalWidth = useCallback( - (cols: ColumnsType = resizableColumns) => { - const leafColumns = collectLeafColumns(cols) - return leafColumns.reduce((sum, col) => { - const width = typeof col.width === "number" ? col.width : minWidth - return sum + width - }, 0) - }, - [minWidth, resizableColumns], - ) - - // Check if any column has been user-resized - const hasUserResizedAny = useMemo( - () => Object.keys(userResizedWidths).length > 0, - [userResizedWidths], - ) - - return { - columns: resizableColumns, - headerComponents: enabled ? {cell: ResizableTitle} : null, - getTotalWidth, - isResizing, - hasUserResizedAny, - } -} - -export default useSmartResizableColumns diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableActions.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableActions.tsx deleted file mode 100644 index 1d2848fe1b..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableActions.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import {useCallback} from "react" - -import {useRouter} from "next/router" - -import type {InfiniteTableRowBase} from "../types" - -/** - * Configuration for standard table actions - */ -export interface TableActionsConfig { - /** Base URL for navigation (e.g., "/testsets") */ - baseUrl?: string - - /** Callback when viewing details */ - onView?: (record: T) => void - - /** Callback when creating a new item */ - onCreate?: () => void - - /** Callback when cloning an item */ - onClone?: (record: T) => void - - /** Callback when renaming an item */ - onRename?: (record: T) => void - - /** Callback when deleting an item */ - onDelete?: (record: T) => void - - /** Callback when deleting multiple items */ - onDeleteMany?: (records: T[]) => void - - /** Custom ID extractor (default: record.id or record._id) */ - getRecordId?: (record: T) => string -} - -export interface TableActionsReturn { - /** Navigate to view details */ - handleView: (record: T) => void - - /** Handle clone action */ - handleClone: (record: T) => void - - /** Handle rename action */ - handleRename: (record: T) => void - - /** Handle delete single item */ - handleDelete: (record: T) => void - - /** Handle delete multiple items */ - handleDeleteMany: (records: T[]) => void - - /** Handle create new item */ - handleCreate: () => void -} - -/** - * Hook to create standard CRUD action handlers for tables. - * Reduces boilerplate for common table actions. - * - * @example - * ```tsx - * const actions = useTableActions({ - * baseUrl: `${projectURL}/testsets`, - * onClone: (record) => { - * setMode("clone") - * setEditValues(record) - * setModalOpen(true) - * }, - * onDelete: (record) => { - * setDeleteTargets([record]) - * setDeleteModalOpen(true) - * }, - * }) - * - * // Use in column definitions - * const columns = useTableColumns([ - * { key: "name", title: "Name" }, - * { - * type: "actions", - * items: [ - * { key: "view", onClick: actions.handleView }, - * { key: "clone", onClick: actions.handleClone }, - * { key: "delete", onClick: actions.handleDelete }, - * ], - * }, - * ]) - * ``` - */ -export function useTableActions( - config: TableActionsConfig = {}, -): TableActionsReturn { - const router = useRouter() - const {baseUrl, onView, onCreate, onClone, onRename, onDelete, onDeleteMany, getRecordId} = - config - - const defaultGetId = useCallback( - (record: T): string => { - if (getRecordId) return getRecordId(record) - // Try common ID fields - const id = (record as any).id || (record as any)._id || (record as any).key - if (typeof id === "string") return id - throw new Error("Could not extract ID from record. Provide getRecordId function.") - }, - [getRecordId], - ) - - const handleView = useCallback( - (record: T) => { - if (onView) { - onView(record) - return - } - - // Default behavior: navigate to detail page - if (baseUrl) { - const id = defaultGetId(record) - router.push(`${baseUrl}/${id}`) - } - }, - [baseUrl, defaultGetId, onView, router], - ) - - const handleClone = useCallback( - (record: T) => { - if (onClone) { - onClone(record) - } - }, - [onClone], - ) - - const handleRename = useCallback( - (record: T) => { - if (onRename) { - onRename(record) - } - }, - [onRename], - ) - - const handleDelete = useCallback( - (record: T) => { - if (onDelete) { - onDelete(record) - } - }, - [onDelete], - ) - - const handleDeleteMany = useCallback( - (records: T[]) => { - if (onDeleteMany) { - onDeleteMany(records) - } - }, - [onDeleteMany], - ) - - const handleCreate = useCallback(() => { - if (onCreate) { - onCreate() - } - }, [onCreate]) - - return { - handleView, - handleClone, - handleRename, - handleDelete, - handleDeleteMany, - handleCreate, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableExport.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableExport.ts deleted file mode 100644 index 728d7f8940..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableExport.ts +++ /dev/null @@ -1,349 +0,0 @@ -import {useCallback} from "react" - -import type {ColumnsType} from "antd/es/table" - -import type {InfiniteTableRowBase} from "../types" - -export const EXPORT_RESOLVE_SKIP = Symbol("EXPORT_RESOLVE_SKIP") - -const columnIsHidden = ( - column: ColumnsType[number], -): boolean => { - const anyColumn = column as any - if (anyColumn?.visibilityHidden) return true - if (anyColumn?.visibilityLocked === false && anyColumn?.columnProps?.hidden) return true - return false -} - -const flattenColumns = ( - columns: ColumnsType, -): ColumnsType => { - const flat: ColumnsType = [] - columns.forEach((column) => { - if (!column) return - const anyColumn = column as any - if (anyColumn.children && anyColumn.children.length) { - flat.push(...flattenColumns(anyColumn.children as ColumnsType)) - } else { - flat.push(column) - } - }) - return flat -} - -const getColumnIdentifier = (column: ColumnsType[number], index: number) => { - const anyColumn = column as any - const dataIndex = anyColumn?.dataIndex - if (Array.isArray(dataIndex)) { - return dataIndex.join(".") - } - if (dataIndex !== undefined && dataIndex !== null) { - return String(dataIndex) - } - if (anyColumn?.key !== undefined && anyColumn?.key !== null) { - return String(anyColumn.key) - } - return String(index) -} - -const getColumnKey = (column: ColumnsType[number], index: number) => { - const anyColumn = column as any - if (anyColumn?.key !== undefined && anyColumn?.key !== null) { - return String(anyColumn.key) - } - return getColumnIdentifier(column, index) -} - -const getColumnLabel = (column: ColumnsType[number], index: number) => { - const anyColumn = column as any - const title = anyColumn?.exportLabel ?? anyColumn?.exportTitle ?? anyColumn?.title - if (typeof title === "string") return title - if (typeof title === "number") return String(title) - return getColumnIdentifier(column, index) -} - -const getCellText = (value: unknown): string => { - if (value === null || value === undefined) return "" - if (typeof value === "string") return value - if (typeof value === "number" || typeof value === "boolean") return String(value) - return JSON.stringify(value) -} - -const createCsvRow = (values: string[]) => - values - .map((value) => { - if (value.includes(",") || value.includes('"') || value.includes("\n")) { - return `"${value.replace(/"/g, '""')}"` - } - return value - }) - .join(",") - -const getValueFromRowDataIndex = (row: unknown, dataIndex: unknown): unknown => { - if (Array.isArray(dataIndex)) { - return dataIndex.reduce((acc, segment) => { - if (acc === null || acc === undefined) { - return undefined - } - return (acc as any)[segment] - }, row) - } - if ( - typeof dataIndex === "string" || - typeof dataIndex === "number" || - typeof dataIndex === "symbol" - ) { - return (row as any)?.[dataIndex as any] - } - return undefined -} - -const getColumnValueFromMetadata = ({ - column, - columnIndex, - row, -}: TableExportValueArgs): unknown => { - const anyColumn = column as any - - if (typeof anyColumn?.exportValue === "function") { - const value = anyColumn.exportValue(row, column, columnIndex) - if (value !== undefined) { - return value - } - } - - const exportDataIndex = anyColumn?.exportDataIndex ?? anyColumn?.dataIndex - const viaDataIndex = getValueFromRowDataIndex(row, exportDataIndex) - if (viaDataIndex !== undefined) { - return viaDataIndex - } - - if (anyColumn?.key !== undefined && (row as any)?.[anyColumn.key] !== undefined) { - return (row as any)[anyColumn.key] - } - - const identifier = getColumnIdentifier(column, columnIndex) - return (row as any)?.[identifier] -} - -const formatExportValue = ( - value: unknown, - args: TableExportValueArgs, - formatValue?: TableExportOptions["formatValue"], -): string => { - const anyColumn = args.column as any - if (typeof anyColumn?.exportFormatter === "function") { - const formatted = anyColumn.exportFormatter(value, args.row, args.column, args.columnIndex) - if (formatted !== undefined) { - return formatted - } - } - - if (formatValue) { - const formatted = formatValue(value, args) - if (formatted !== undefined) { - return formatted - } - } - - return getCellText(value) -} - -const filterSkeletonRows = ( - rows: Row[], - includeSkeletonRows?: boolean, -) => { - if (includeSkeletonRows) return rows - return rows.filter((row) => !(row as any)?.__isSkeleton) -} - -export interface TableExportColumnContext { - column: ColumnsType[number] - columnIndex: number -} - -export interface TableExportValueArgs< - Row extends InfiniteTableRowBase, -> extends TableExportColumnContext { - row: Row -} - -export interface TableExportOptions { - filename?: string - isColumnExportable?: (context: TableExportColumnContext) => boolean - getValue?: (args: TableExportValueArgs) => unknown - formatValue?: (value: unknown, args: TableExportValueArgs) => string | undefined - includeSkeletonRows?: boolean - beforeExport?: (rows: Row[]) => void | Row[] | Promise - resolveValue?: (args: TableExportResolveArgs) => unknown | Promise - resolveColumnLabel?: (context: TableExportColumnContext) => string | undefined - columnsOverride?: ColumnsType -} - -export interface TableExportParams< - Row extends InfiniteTableRowBase, -> extends TableExportOptions { - columns: ColumnsType - rows: Row[] -} - -export interface TableExportResolveArgs< - Row extends InfiniteTableRowBase, -> extends TableExportValueArgs { - rowIndex: number - columnKey: string - columnIdentifier: string - currentValue: unknown -} - -export const useTableExport = () => { - return useCallback(async (params: TableExportParams) => { - const { - columns, - rows, - filename = "table-export.csv", - isColumnExportable, - getValue, - formatValue, - includeSkeletonRows, - beforeExport, - resolveValue, - resolveColumnLabel, - } = params - - if (!columns.length || !rows.length) return - - let filteredRows = filterSkeletonRows(rows, includeSkeletonRows) - if (!filteredRows.length) return - - if (beforeExport) { - const result = await beforeExport(filteredRows) - // If beforeExport returns rows, use those (allows beforeExport to load more data) - if (result && Array.isArray(result)) { - filteredRows = filterSkeletonRows(result as Row[], includeSkeletonRows) - if (!filteredRows.length) return - } - } - - const flatColumns = flattenColumns(columns).filter((column, index) => { - if (columnIsHidden(column)) return false - const anyColumn = column as any - if (anyColumn?.exportEnabled === false) return false - if (isColumnExportable) { - return isColumnExportable({column, columnIndex: index}) - } - return true - }) - if (!flatColumns.length) return - - const headers = flatColumns.map((column, index) => { - const override = resolveColumnLabel?.({column, columnIndex: index}) - return override ?? getColumnLabel(column, index) - }) - - const csvRows = [createCsvRow(headers)] - - // Build cell metadata for all cells - interface CellMeta { - rowIndex: number - columnIndex: number - column: (typeof flatColumns)[number] - row: Row - columnKey: string - columnIdentifier: string - initialValue: unknown - } - const cellMetas: CellMeta[] = [] - - for (let rowIndex = 0; rowIndex < filteredRows.length; rowIndex += 1) { - const row = filteredRows[rowIndex] - for (let columnIndex = 0; columnIndex < flatColumns.length; columnIndex += 1) { - const column = flatColumns[columnIndex] - const columnKey = getColumnKey(column, columnIndex) - const columnIdentifier = getColumnIdentifier(column, columnIndex) - const context: TableExportValueArgs = {column, columnIndex, row} - const override = getValue !== undefined ? getValue(context) : undefined - const initialValue = - override !== undefined ? override : getColumnValueFromMetadata(context) - - cellMetas.push({ - rowIndex, - columnIndex, - column, - row, - columnKey, - columnIdentifier, - initialValue, - }) - } - } - - // Resolve all cell values at once - the underlying batchers handle API batching - const resolvedValues: unknown[] = new Array(cellMetas.length) - - if (resolveValue) { - const allPromises = cellMetas.map((meta, i) => { - const context: TableExportValueArgs = { - column: meta.column, - columnIndex: meta.columnIndex, - row: meta.row, - } - return Promise.resolve( - resolveValue({ - ...context, - rowIndex: meta.rowIndex, - columnKey: meta.columnKey, - columnIdentifier: meta.columnIdentifier, - currentValue: meta.initialValue, - }), - ).then((resolved: unknown) => ({index: i, value: resolved})) - }) - - const allResults = await Promise.all(allPromises) - for (const {index, value} of allResults) { - if (value === EXPORT_RESOLVE_SKIP) { - resolvedValues[index] = cellMetas[index].initialValue - } else if (value !== undefined) { - resolvedValues[index] = value - } else { - resolvedValues[index] = cellMetas[index].initialValue - } - } - } else { - // No resolver, use initial values - for (let i = 0; i < cellMetas.length; i++) { - resolvedValues[i] = cellMetas[i].initialValue - } - } - - // Build CSV rows from resolved values - const numColumns = flatColumns.length - for (let rowIndex = 0; rowIndex < filteredRows.length; rowIndex += 1) { - const values: string[] = [] - for (let columnIndex = 0; columnIndex < numColumns; columnIndex += 1) { - const cellIndex = rowIndex * numColumns + columnIndex - const meta = cellMetas[cellIndex] - const rawValue = resolvedValues[cellIndex] - const context: TableExportValueArgs = { - column: meta.column, - columnIndex: meta.columnIndex, - row: meta.row, - } - values.push(formatExportValue(rawValue, context, formatValue)) - } - csvRows.push(createCsvRow(values)) - } - - const blob = new Blob([csvRows.join("\n")], {type: "text/csv;charset=utf-8;"}) - const url = URL.createObjectURL(blob) - const link = document.createElement("a") - link.href = url - link.setAttribute("download", filename) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - setTimeout(() => URL.revokeObjectURL(url), 500) - }, []) -} - -export default useTableExport diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts deleted file mode 100644 index 81d5cf8c47..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {useLayoutEffect, useState, type RefObject} from "react" - -import type {ColumnsType, TableProps} from "antd/es/table" - -interface UseTableHeaderHeightOptions { - containerRef: RefObject - columns: ColumnsType - dataSource: RecordType[] - components?: TableProps["components"] -} - -/** - * Hook to observe and track table header height using ResizeObserver - */ -const useTableHeaderHeight = ({ - containerRef, - columns, - dataSource, - components, -}: UseTableHeaderHeightOptions) => { - const [tableHeaderHeight, setTableHeaderHeight] = useState(null) - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - setTableHeaderHeight(null) - return - } - const headerEl = - container.querySelector(".ant-table-thead") ?? - container.querySelector("table thead") - if (!headerEl) { - setTableHeaderHeight(null) - return - } - const updateHeight = () => { - const nextHeight = headerEl.getBoundingClientRect().height - setTableHeaderHeight((prev) => { - if (prev === nextHeight) return prev - return Number.isFinite(nextHeight) ? nextHeight : prev - }) - } - const observer = new ResizeObserver(() => updateHeight()) - observer.observe(headerEl) - updateHeight() - return () => observer.disconnect() - }, [columns, containerRef, dataSource, components]) - - return tableHeaderHeight -} - -export default useTableHeaderHeight diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableKeyboardShortcuts.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableKeyboardShortcuts.ts deleted file mode 100644 index b15ad2a8be..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableKeyboardShortcuts.ts +++ /dev/null @@ -1,663 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from "react" -import type {Key, MutableRefObject, RefObject} from "react" - -import type { - InfiniteVirtualTableKeyboardRowShortcuts, - InfiniteVirtualTableKeyboardSelectionShortcuts, - InfiniteVirtualTableKeyboardShortcuts, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, -} from "../types" - -interface UseTableKeyboardShortcutsParams { - containerRef: RefObject - dataSource: RecordType[] - rowKey: InfiniteVirtualTableProps["rowKey"] - rowSelection?: InfiniteVirtualTableRowSelection - keyboardShortcuts?: InfiniteVirtualTableKeyboardShortcuts - active: boolean -} - -interface SelectableEntry { - key: Key - record: RecordType - position: number -} - -interface NormalizedSelectionShortcuts { - enabled: boolean - navigation: boolean - range: boolean - selectAll: boolean - clear: boolean -} - -interface NormalizedRowShortcuts { - enabled: boolean - autoHighlightFirstRow: boolean - highlightOnHover: boolean - highlightClassName: string - scrollIntoViewOnChange: boolean - toggleSelectionWithSpace: boolean - onHighlightChange?: (payload: {key: Key | null; record: RecordType | null}) => void - onOpen?: (payload: {key: Key; record: RecordType}) => void - onDelete?: (payload: { - key: Key - record: RecordType - selected: boolean - selection: Key[] - }) => void - onExport?: (payload: {key: Key | null; record: RecordType | null; selection: Key[]}) => void -} - -export interface TableShortcutRowProps { - "data-ivt-row-key"?: Key - className?: string - onMouseEnter?: () => void -} - -interface TableShortcutResult { - getRowProps?: (record: RecordType, index?: number) => TableShortcutRowProps | undefined -} - -const DEFAULT_HIGHLIGHT_CLASS = "ivt-row--highlighted" - -const isInteractiveTarget = (element: HTMLElement | null) => { - if (!element) return false - if (element.isContentEditable) return true - const tag = element.tagName.toLowerCase() - if (tag === "input" || tag === "textarea" || tag === "select") { - return true - } - const role = element.getAttribute("role") - if (role && ["textbox", "combobox", "menuitem", "button"].includes(role)) { - return true - } - return Boolean(element.closest("[data-ivt-shortcuts='ignore']")) -} - -const normalizeSelectionShortcuts = ( - enabled: boolean, - selection?: boolean | InfiniteVirtualTableKeyboardSelectionShortcuts, -): NormalizedSelectionShortcuts => { - const config = selection ?? {} - const selectionEnabled = - typeof config === "object" ? (config.enabled ?? true) : config !== false - return { - enabled: enabled && selectionEnabled, - navigation: typeof config === "object" ? (config.navigation ?? true) : config !== false, - range: typeof config === "object" ? (config.range ?? true) : config !== false, - selectAll: typeof config === "object" ? (config.selectAll ?? true) : config !== false, - clear: typeof config === "object" ? (config.clear ?? true) : config !== false, - } -} - -const normalizeRowShortcuts = ( - config?: InfiniteVirtualTableKeyboardRowShortcuts, -): NormalizedRowShortcuts => ({ - enabled: config?.enabled ?? true, - autoHighlightFirstRow: config?.autoHighlightFirstRow ?? false, - highlightOnHover: config?.highlightOnHover ?? true, - highlightClassName: config?.highlightClassName ?? DEFAULT_HIGHLIGHT_CLASS, - scrollIntoViewOnChange: config?.scrollIntoViewOnChange ?? true, - toggleSelectionWithSpace: config?.toggleSelectionWithSpace ?? true, - onHighlightChange: config?.onHighlightChange, - onOpen: config?.onOpen, - onDelete: config?.onDelete, - onExport: config?.onExport, -}) - -const normalizeKeyboardShortcutConfig = ( - config?: InfiniteVirtualTableKeyboardShortcuts, -) => { - const enabled = config?.enabled ?? true - return { - enabled, - selection: normalizeSelectionShortcuts(enabled, config?.selection), - rows: normalizeRowShortcuts(config?.rows), - } -} - -const resolveRowKey = ( - rowKey: InfiniteVirtualTableProps["rowKey"], - record: RecordType, - index?: number, -): Key | null => { - if (typeof rowKey === "function") { - const value = rowKey(record, index) - return value === undefined || value === null ? null : (value as Key) - } - if (typeof rowKey === "string") { - const value = (record as Record)[rowKey] - return value === undefined || value === null ? null : (value as Key) - } - const fallback = (record as Record).key ?? index - return (fallback as Key) ?? null -} - -const usePointerScopeTracker = ( - containerRef: RefObject, - active: boolean, - enabled: boolean, -): MutableRefObject => { - const scopeRef = useRef(false) - - useEffect(() => { - if (!enabled) return - const handlePointerDown = (event: PointerEvent) => { - const container = containerRef.current - if (!container || !active) { - scopeRef.current = false - return - } - scopeRef.current = container.contains(event.target as Node) - } - document.addEventListener("pointerdown", handlePointerDown, true) - return () => document.removeEventListener("pointerdown", handlePointerDown, true) - }, [active, containerRef, enabled]) - - useEffect(() => { - if (!enabled) return - const container = containerRef.current - if (!container) return - const handlePointerEnter = () => { - if (!active) return - scopeRef.current = true - } - const handlePointerLeave = (event: PointerEvent) => { - const related = event.relatedTarget as Node | null - if (related && container.contains(related)) return - scopeRef.current = false - } - container.addEventListener("pointerenter", handlePointerEnter, true) - container.addEventListener("pointerleave", handlePointerLeave, true) - return () => { - container.removeEventListener("pointerenter", handlePointerEnter, true) - container.removeEventListener("pointerleave", handlePointerLeave, true) - } - }, [active, containerRef, enabled]) - - useEffect(() => { - if (!active) { - scopeRef.current = false - } - }, [active]) - - return scopeRef -} - -const dedupeKeys = (keys: Key[]) => { - const seen = new Set() - const result: Key[] = [] - keys.forEach((key) => { - if (seen.has(key)) return - seen.add(key) - result.push(key) - }) - return result -} - -const escapeSelector = (value: Key) => { - const str = String(value) - if ( - typeof window !== "undefined" && - typeof window.CSS !== "undefined" && - typeof window.CSS.escape === "function" - ) { - return window.CSS.escape(str) - } - return str.replace(/['"\\]/g, "\\$&") -} - -function useTableKeyboardShortcuts({ - containerRef, - dataSource, - rowKey, - rowSelection, - keyboardShortcuts, - active, -}: UseTableKeyboardShortcutsParams): TableShortcutResult { - const resolvedConfig = useMemo( - () => normalizeKeyboardShortcutConfig(keyboardShortcuts), - [keyboardShortcuts], - ) - const selectionShortcuts = resolvedConfig.selection - const rowShortcuts = resolvedConfig.rows - const hasSelectionControls = Boolean(rowSelection && rowSelection.onChange) - const selectionEnabled = selectionShortcuts.enabled && hasSelectionControls - - const navigableEntries = useMemo[]>(() => { - const entries: SelectableEntry[] = [] - dataSource.forEach((record, index) => { - const key = resolveRowKey(rowKey, record, index) - if (key === null || key === undefined) return - if ((record as any)?.__isSkeleton) return - const position = entries.length - entries.push({key, record, position}) - }) - return entries - }, [dataSource, rowKey]) - - const navigableMap = useMemo(() => { - const map = new Map>() - navigableEntries.forEach((entry) => { - map.set(entry.key, entry) - }) - return map - }, [navigableEntries]) - - const selectableEntries = useMemo[]>(() => { - if (!selectionEnabled || !rowSelection) return [] - const entries: SelectableEntry[] = [] - dataSource.forEach((record, index) => { - const key = resolveRowKey(rowKey, record, index) - if (key === null || key === undefined) return - const checkboxProps = rowSelection.getCheckboxProps?.(record) ?? {} - if (checkboxProps.disabled) return - const position = entries.length - entries.push({key, record, position}) - }) - return entries - }, [dataSource, rowKey, rowSelection, selectionEnabled]) - - const keyToEntry = useMemo(() => { - const map = new Map>() - selectableEntries.forEach((entry) => { - map.set(entry.key, entry) - }) - return map - }, [selectableEntries]) - - const selectedKeys = useMemo(() => { - if (!selectionEnabled || !rowSelection) return [] - return (rowSelection.selectedRowKeys ?? []).filter((key) => keyToEntry.has(key)) - }, [keyToEntry, rowSelection, selectionEnabled]) - - const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]) - const allowsMultipleSelection = rowSelection?.type !== "radio" - - const anchorKeyRef = useRef(null) - const activeKeyRef = useRef(null) - const highlightEntryRef = useRef | null>(null) - const [highlightedKey, setHighlightedKey] = useState(null) - - useEffect(() => { - if (!selectionEnabled) { - anchorKeyRef.current = null - activeKeyRef.current = null - return - } - if (!selectedKeys.length) { - anchorKeyRef.current = null - activeKeyRef.current = null - return - } - const lastKey = selectedKeys[selectedKeys.length - 1] - activeKeyRef.current = lastKey - if (!anchorKeyRef.current || !selectedKeySet.has(anchorKeyRef.current)) { - anchorKeyRef.current = lastKey - } - }, [selectedKeySet, selectedKeys, selectionEnabled]) - - const pointerScopeRef = usePointerScopeTracker(containerRef, active, resolvedConfig.enabled) - - const triggerSelectionChange = useCallback( - (nextKeys: Key[], opts?: {anchorKey?: Key | null; activeKey?: Key | null}) => { - if (!rowSelection?.onChange) return - const normalizedKeys = dedupeKeys( - nextKeys.filter((key) => keyToEntry.has(key)), - ) as Key[] - const rows = normalizedKeys.map((key) => keyToEntry.get(key)!.record) - rowSelection.onChange(normalizedKeys, rows) - if (opts) { - if ("anchorKey" in opts) { - anchorKeyRef.current = opts.anchorKey ?? null - } - if ("activeKey" in opts) { - activeKeyRef.current = opts.activeKey ?? null - } - } - }, - [keyToEntry, rowSelection], - ) - - const handleSelectAll = useCallback(() => { - if (!selectionEnabled || !selectionShortcuts.selectAll) return - if (!allowsMultipleSelection) return - if (!selectableEntries.length) return - const keys = selectableEntries.map((entry) => entry.key) - const firstKey = keys[0] - const lastKey = keys[keys.length - 1] - triggerSelectionChange(keys, {anchorKey: firstKey, activeKey: lastKey}) - }, [ - allowsMultipleSelection, - selectableEntries, - selectionEnabled, - selectionShortcuts.selectAll, - triggerSelectionChange, - ]) - - const handleClearSelection = useCallback(() => { - if (!selectionEnabled || !selectionShortcuts.clear) return - triggerSelectionChange([], {anchorKey: null, activeKey: null}) - }, [selectionEnabled, selectionShortcuts.clear, triggerSelectionChange]) - - const handleMove = useCallback( - (direction: 1 | -1, extend: boolean) => { - if (!selectionEnabled || !selectionShortcuts.navigation) return - if (!selectableEntries.length) return - - const currentActiveKey = activeKeyRef.current - const activeEntry = currentActiveKey ? keyToEntry.get(currentActiveKey) : undefined - let nextPosition: number - if (!activeEntry) { - nextPosition = direction > 0 ? 0 : selectableEntries.length - 1 - } else { - nextPosition = activeEntry.position + direction - if (nextPosition < 0 || nextPosition >= selectableEntries.length) { - return - } - } - const nextEntry = selectableEntries[nextPosition] - if (!nextEntry) return - - const shouldExtend = - extend && - allowsMultipleSelection && - selectionShortcuts.range && - selectableEntries.length - - if (!shouldExtend) { - triggerSelectionChange([nextEntry.key], { - anchorKey: nextEntry.key, - activeKey: nextEntry.key, - }) - return - } - - const anchorKey = anchorKeyRef.current ?? nextEntry.key - const anchorEntry = keyToEntry.get(anchorKey) - if (!anchorEntry) { - triggerSelectionChange([nextEntry.key], { - anchorKey: nextEntry.key, - activeKey: nextEntry.key, - }) - return - } - - const start = Math.min(anchorEntry.position, nextPosition) - const end = Math.max(anchorEntry.position, nextPosition) - const rangeKeys = selectableEntries.slice(start, end + 1).map((entry) => entry.key) - triggerSelectionChange(rangeKeys, { - anchorKey: anchorEntry.key, - activeKey: nextEntry.key, - }) - }, - [ - allowsMultipleSelection, - keyToEntry, - selectableEntries, - selectionEnabled, - selectionShortcuts.navigation, - selectionShortcuts.range, - triggerSelectionChange, - ], - ) - - const scrollRowIntoView = useCallback( - (key: Key) => { - if (!rowShortcuts.scrollIntoViewOnChange) return - const container = containerRef.current - if (!container) return - const selector = escapeSelector(key) - const row = - container.querySelector(`[data-row-key="${selector}"]`) ?? - container.querySelector(`[data-row-key='${selector}']`) - row?.scrollIntoView({block: "nearest"}) - }, - [containerRef, rowShortcuts.scrollIntoViewOnChange], - ) - - const setHighlightEntry = useCallback( - (entry: SelectableEntry | null, options?: {scroll?: boolean}) => { - highlightEntryRef.current = entry - const nextKey = entry?.key ?? null - setHighlightedKey((current) => (current === nextKey ? current : nextKey)) - rowShortcuts.onHighlightChange?.({key: nextKey, record: entry?.record ?? null}) - if (options?.scroll && entry?.key) { - scrollRowIntoView(entry.key) - } - }, - [rowShortcuts, scrollRowIntoView], - ) - - useEffect(() => { - if (!rowShortcuts.enabled) return - if (highlightEntryRef.current && navigableMap.has(highlightEntryRef.current.key)) { - return - } - if (!rowShortcuts.autoHighlightFirstRow) { - setHighlightEntry(null) - return - } - const firstEntry = navigableEntries[0] ?? null - setHighlightEntry(firstEntry ?? null, {scroll: false}) - }, [ - navigableEntries, - navigableMap, - rowShortcuts.autoHighlightFirstRow, - rowShortcuts.enabled, - setHighlightEntry, - ]) - - const moveHighlight = useCallback( - (direction: 1 | -1) => { - if (!rowShortcuts.enabled || !navigableEntries.length) return false - const current = highlightEntryRef.current - if (!current) { - const target = - direction > 0 - ? navigableEntries[0] - : navigableEntries[navigableEntries.length - 1] - setHighlightEntry(target, {scroll: true}) - return Boolean(target) - } - const nextIndex = current.position + direction - if (nextIndex < 0 || nextIndex >= navigableEntries.length) { - return false - } - const nextEntry = navigableEntries[nextIndex] - setHighlightEntry(nextEntry, {scroll: true}) - return true - }, - [navigableEntries, rowShortcuts.enabled, setHighlightEntry], - ) - - const toggleHighlightedSelection = useCallback(() => { - if (!rowShortcuts.enabled || !rowShortcuts.toggleSelectionWithSpace) return false - if (!rowSelection?.onChange) return false - const entry = highlightEntryRef.current - if (!entry) return false - const isSelected = selectedKeySet.has(entry.key) - // Annotated: array-literal construction widens Key's unique-symbol member to symbol. - const nextKeys: Key[] = isSelected - ? selectedKeys.filter((key) => key !== entry.key) - : [...selectedKeys, entry.key] - triggerSelectionChange(nextKeys) - return true - }, [ - rowSelection, - rowShortcuts.enabled, - rowShortcuts.toggleSelectionWithSpace, - selectedKeySet, - selectedKeys, - triggerSelectionChange, - ]) - - const openHighlightedRow = useCallback(() => { - if (!rowShortcuts.enabled || !rowShortcuts.onOpen) return false - const entry = highlightEntryRef.current - if (!entry) return false - rowShortcuts.onOpen({key: entry.key, record: entry.record}) - return true - }, [rowShortcuts]) - - const deleteHighlightedRow = useCallback(() => { - if (!rowShortcuts.enabled || !rowShortcuts.onDelete) return false - const entry = highlightEntryRef.current - if (!entry) return false - const isSelected = selectedKeySet.has(entry.key) - rowShortcuts.onDelete({ - key: entry.key, - record: entry.record, - selected: isSelected, - selection: selectedKeys, - }) - return true - }, [rowShortcuts, selectedKeySet, selectedKeys]) - - const getRowProps = useCallback( - (record: RecordType, index?: number) => { - if (!rowShortcuts.enabled) return undefined - const key = resolveRowKey(rowKey, record, index) - if (key === null || key === undefined) return undefined - const isHighlighted = highlightedKey !== null && key === highlightedKey - const props: TableShortcutRowProps = {"data-ivt-row-key": key} - if (isHighlighted) { - props.className = rowShortcuts.highlightClassName - } - if (rowShortcuts.highlightOnHover !== false) { - props.onMouseEnter = () => { - const entry = navigableMap.get(key) - if (entry) { - setHighlightEntry(entry) - } - } - } - return props - }, - [highlightedKey, navigableMap, rowKey, rowShortcuts, setHighlightEntry], - ) - - useEffect(() => { - if (!resolvedConfig.enabled || (!selectionEnabled && !rowShortcuts.enabled)) return - const handleKeyDown = (event: KeyboardEvent) => { - if (!active) return - if (!pointerScopeRef.current) return - const target = event.target as HTMLElement | null - if (isInteractiveTarget(target)) { - return - } - - const isArrowKey = event.key === "ArrowDown" || event.key === "ArrowUp" - const direction = event.key === "ArrowDown" ? 1 : -1 - - if (isArrowKey) { - let handled = false - if (rowShortcuts.enabled) { - handled = moveHighlight(direction as 1 | -1) || handled - } - if (selectionShortcuts.navigation) { - handleMove(direction as 1 | -1, event.shiftKey) - handled = true - } - if (handled) { - event.preventDefault() - return - } - } - - const isModifier = event.metaKey || event.ctrlKey - if ( - selectionShortcuts.selectAll && - allowsMultipleSelection && - isModifier && - event.key.toLowerCase() === "a" - ) { - event.preventDefault() - handleSelectAll() - return - } - - if (event.key === "Escape") { - let handled = false - if (selectionShortcuts.clear && selectedKeys.length) { - handleClearSelection() - handled = true - } else if ( - rowShortcuts.enabled && - highlightEntryRef.current && - !selectedKeySet.has(highlightEntryRef.current.key) - ) { - setHighlightEntry(null) - handled = true - } - if (handled) { - event.preventDefault() - return - } - } - - if (rowShortcuts.enabled && (event.key === " " || event.code === "Space")) { - if (toggleHighlightedSelection()) { - event.preventDefault() - } - return - } - - if ( - rowShortcuts.enabled && - rowShortcuts.onExport && - isModifier && - (event.key === "Enter" || event.key.toLowerCase() === "e") - ) { - rowShortcuts.onExport({ - key: highlightEntryRef.current?.key ?? null, - record: highlightEntryRef.current?.record ?? null, - selection: selectedKeys, - }) - event.preventDefault() - return - } - - if (rowShortcuts.enabled && event.key === "Enter") { - if (openHighlightedRow()) { - event.preventDefault() - } - return - } - - if (rowShortcuts.enabled && event.key === "Backspace") { - if (deleteHighlightedRow()) { - event.preventDefault() - } - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [ - active, - allowsMultipleSelection, - deleteHighlightedRow, - handleClearSelection, - handleMove, - handleSelectAll, - moveHighlight, - openHighlightedRow, - pointerScopeRef, - resolvedConfig.enabled, - rowShortcuts.enabled, - selectionEnabled, - selectionShortcuts.clear, - selectionShortcuts.navigation, - selectionShortcuts.selectAll, - toggleHighlightedSelection, - ]) - - return { - getRowProps: rowShortcuts.enabled ? getRowProps : undefined, - } -} - -export default useTableKeyboardShortcuts diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx deleted file mode 100644 index 2c3c610497..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import type {Key, MouseEvent, ReactNode, RefObject} from "react" -import {useCallback, useEffect, useMemo, useRef, useState} from "react" - -import {Grid, Input} from "antd" -import type {ColumnsType} from "antd/es/table" -import clsx from "clsx" -import {atom, useAtom} from "jotai" -import type {WritableAtom} from "jotai" - -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type { - TableScopeConfig, - TableFeaturePagination, - InfiniteVirtualTableFeatureProps, - TableDeleteConfig, - TableExportConfig, -} from "../features/InfiniteVirtualTableFeatureShell" -import type { - InfiniteTableRowBase, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, -} from "../types" - -import useTableExport from "./useTableExport" - -/** Stable no-op atom used when no external search atom is provided (hooks can't be conditional) */ -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" - -/** - * Returns true when the click originated from an interactive element (button, link, - * dropdown, checkbox, etc.) and should not bubble up to the row navigation handler. - */ -export const shouldIgnoreRowClick = (event: MouseEvent): boolean => { - const target = event.target as HTMLElement | null - return Boolean(target?.closest(INTERACTIVE_SELECTOR)) -} - -/** Configuration for built-in search. When provided, the hook manages search state internally. */ -export interface TableSearchConfig { - /** Placeholder text (default: "Search") */ - placeholder?: string - /** Custom className for the search input (default: "max-w-[320px]") */ - className?: string - /** Whether search is disabled */ - disabled?: boolean - /** External Jotai atom to sync search term with (for cross-component access) */ - atom?: WritableAtom -} - -export interface UseTableManagerConfig { - /** The dataset store for this table */ - datasetStore: InfiniteDatasetStore - - /** Unique scope ID for this table instance */ - scopeId: string - - /** Number of items per page (default: 50) */ - pageSize?: number - - /** Row height in pixels (default: 48) */ - rowHeight?: number - - /** Callback when a row is clicked */ - onRowClick?: (record: T) => void - - /** - * Built-in search configuration. When provided, the hook manages search state - * and renders a search input in the filters slot of shellProps. - * Pass `true` for defaults, or an object for customization. - */ - search?: TableSearchConfig | boolean - - /** Dependencies that should trigger pagination reset (e.g., search term) */ - searchDeps?: any[] - - /** Whether rows should be clickable (default: true) */ - clickableRows?: boolean - - /** Custom className for rows */ - rowClassName?: string | ((record: T) => string) - - /** Storage key for column visibility persistence */ - columnVisibilityStorageKey?: string | null - - /** Enable infinite scroll (default: true) */ - enableInfiniteScroll?: boolean - - /** Callback when bulk delete is triggered */ - onBulkDelete?: (records: T[]) => void - - /** Label for delete button (default: "Delete") */ - deleteLabel?: string - - /** Tooltip when delete is disabled (default: "Select items to delete") */ - deleteDisabledTooltip?: string - - /** Label for export button (default: "Export CSV") */ - exportLabel?: string - - /** Tooltip when export is disabled (default: "Select items to export") */ - exportDisabledTooltip?: string - - /** Filename for CSV export (default: "table-export.csv") */ - exportFilename?: string -} - -export interface UseTableManagerReturn { - /** Pagination state and controls */ - pagination: ReturnType["hooks"]["usePagination"]> - - /** Current rows from pagination */ - rows: T[] - - /** Selected row keys */ - selectedRowKeys: Key[] - - /** Update selected row keys */ - setSelectedRowKeys: (keys: Key[] | ((prev: Key[]) => Key[])) => void - - /** Row selection configuration for the table */ - rowSelection: InfiniteVirtualTableRowSelection - - /** Table props configuration */ - tableProps: InfiniteVirtualTableProps["tableProps"] - - /** Table scope configuration */ - tableScope: TableScopeConfig - - /** Pagination configuration for FeatureShell */ - tablePagination: TableFeaturePagination - - /** Get currently selected records */ - getSelectedRecords: () => T[] - - /** Clear selection */ - clearSelection: () => void - - /** Whether running on narrow screen (< lg breakpoint) */ - isNarrowScreen: boolean - - /** Delete action config for the shell */ - deleteAction: TableDeleteConfig | undefined - - /** Export action config for the shell */ - exportAction: TableExportConfig | undefined - - /** Handler to export a single row */ - handleExportRow: (record: T) => Promise - - /** Whether a row is currently being exported */ - rowExportingKey: string | null - - /** Ref to store current columns for export */ - columnsRef: RefObject | null> - - /** Search term value (only meaningful when search config is provided) */ - searchTerm: string - - /** Search term setter (only meaningful when search config is provided) */ - setSearchTerm: (value: string) => void - - /** Spread these props directly to InfiniteVirtualTableFeatureShell */ - shellProps: Pick< - InfiniteVirtualTableFeatureProps, - | "datasetStore" - | "tableScope" - | "pagination" - | "rowSelection" - | "tableProps" - | "deleteAction" - | "exportAction" - | "useSettingsDropdown" - | "rowKey" - | "filters" - > -} - -/** - * Hook to manage common table setup and reduce boilerplate. - * - * Consolidates: - * - Pagination setup and auto-reset - * - Row selection state and config - * - Row click handlers with smart ignore logic - * - Table props with sensible defaults - * - Scope and pagination configs - * - * @example - * ```tsx - * const table = useTableManager({ - * datasetStore: testsetsDatasetStore, - * scopeId: "testsets-page", - * pageSize: 50, - * onRowClick: (record) => router.push(`/testsets/${record._id}`), - * searchDeps: [searchTerm], - * }) - * - * return ( - * - * ) - * ``` - */ -export function useTableManager({ - datasetStore, - scopeId, - pageSize = 50, - rowHeight = 48, - onRowClick, - search, - searchDeps: externalSearchDeps = [], - clickableRows = true, - rowClassName, - columnVisibilityStorageKey, - enableInfiniteScroll = true, - onBulkDelete, - deleteLabel = "Delete", - deleteDisabledTooltip = "Select items to delete", - exportLabel = "Export CSV", - exportDisabledTooltip = "Select items to export", - exportFilename = "table-export.csv", -}: UseTableManagerConfig): UseTableManagerReturn { - // Responsive breakpoints - const screens = Grid.useBreakpoint() - const isNarrowScreen = !screens.lg - - // Normalize search config - const searchConfig = search === true ? {} : search || undefined - const searchAtom = searchConfig?.atom - - // Built-in search state (local or atom-backed) - const [localSearchTerm, setLocalSearchTerm] = useState("") - const [atomSearchTerm, setAtomSearchTerm] = useAtom(searchAtom || dummySearchAtom) - - const searchTerm = searchConfig ? (searchAtom ? atomSearchTerm : localSearchTerm) : "" - const setSearchTerm = useCallback( - (value: string) => { - if (searchAtom) { - setAtomSearchTerm(value) - } else { - setLocalSearchTerm(value) - } - }, - [searchAtom, setAtomSearchTerm], - ) - - // Merge built-in search deps with any external searchDeps - const searchDeps = searchConfig ? [searchTerm, ...externalSearchDeps] : externalSearchDeps - - // Pagination - const pagination = datasetStore.hooks.usePagination({ - scopeId, - pageSize, - resetOnScopeChange: false, - }) - - const {rows, loadNextPage, resetPages} = pagination - - // Selection state - const [selectedRowKeys, setSelectedRowKeys] = useState([]) - - // Export state - const [rowExportingKey, setRowExportingKey] = useState(null) - const tableExport = useTableExport() - const columnsRef = useRef | null>(null) - - // Auto-reset pagination when search dependencies change (skip initial mount) - const searchDepsInitialized = useRef(false) - useEffect(() => { - if (!searchDepsInitialized.current) { - searchDepsInitialized.current = true - return - } - if (searchDeps.length > 0) { - resetPages() - } - }, [resetPages, ...searchDeps]) - - // Row selection config - const rowSelection = useMemo>( - () => ({ - type: "checkbox" as const, - selectedRowKeys, - onChange: (keys: Key[]) => { - setSelectedRowKeys(keys) - }, - getCheckboxProps: (record: T) => ({ - disabled: Boolean(record.__isSkeleton), - }), - columnWidth: 48, - fixed: true, - }), - [selectedRowKeys], - ) - - // Row click handlers - const buildRowHandlers = useCallback( - (record: T) => { - const isNavigable = clickableRows && !record.__isSkeleton - const customClass = - typeof rowClassName === "function" ? rowClassName(record) : rowClassName - - return { - onClick: (event: MouseEvent) => { - if (!isNavigable) return - if (shouldIgnoreRowClick(event)) return - onRowClick?.(record) - }, - className: clsx(customClass, { - "opacity-60 animate-pulse": record.__isSkeleton, - }), - style: { - cursor: isNavigable ? "pointer" : "default", - height: rowHeight, - minHeight: rowHeight, - } as React.CSSProperties, - } - }, - [clickableRows, onRowClick, rowClassName, rowHeight], - ) - - // Table props with defaults - const tableProps = useMemo( - () => ({ - size: "small" as const, - sticky: true, - bordered: true, - virtual: true, - tableLayout: "fixed" as const, - onRow: buildRowHandlers, - }), - [buildRowHandlers], - ) - - // Table scope config - const tableScope = useMemo( - () => ({ - scopeId, - pageSize, - enableInfiniteScroll, - columnVisibilityStorageKey: columnVisibilityStorageKey ?? undefined, - }), - [scopeId, pageSize, enableInfiniteScroll, columnVisibilityStorageKey], - ) - - // Pagination config for FeatureShell - const tablePagination = useMemo>( - () => ({ - rows, - loadNextPage, - resetPages, - }), - [rows, loadNextPage, resetPages], - ) - - // Helper to get selected records - const getSelectedRecords = useCallback( - () => rows.filter((record) => selectedRowKeys.includes(record.key)), - [rows, selectedRowKeys], - ) - - // Helper to clear selection - const clearSelection = useCallback(() => { - setSelectedRowKeys([]) - }, []) - - // Delete action config - shell handles button rendering and narrow screen behavior - const deleteAction = useMemo( - () => - onBulkDelete - ? { - onDelete: () => onBulkDelete(getSelectedRecords()), - disabled: !selectedRowKeys.length, - disabledTooltip: deleteDisabledTooltip, - label: deleteLabel, - } - : undefined, - [ - onBulkDelete, - selectedRowKeys.length, - getSelectedRecords, - deleteDisabledTooltip, - deleteLabel, - ], - ) - - // Export action config - shell handles button rendering and narrow screen behavior - const exportAction = useMemo( - () => ({ - disabled: !selectedRowKeys.length, - disabledTooltip: exportDisabledTooltip, - label: exportLabel, - }), - [selectedRowKeys.length, exportDisabledTooltip, exportLabel], - ) - - // Handler to export a single row - const handleExportRow = useCallback( - async (record: T) => { - if (!record || record.__isSkeleton || !record.key) return - const snapshot = columnsRef.current - if (!snapshot?.length) { - console.warn("[useTableManager] Cannot export row without columns") - return - } - const sanitizedKey = String(record.key).replace(/[^a-zA-Z0-9-_]+/g, "-") - setRowExportingKey(String(record.key)) - try { - await tableExport({ - columns: snapshot, - rows: [record], - filename: exportFilename.replace(".csv", `-${sanitizedKey}.csv`), - }) - } catch (error) { - console.error("[useTableManager] Failed to export row", error) - } finally { - setRowExportingKey((current) => (current === String(record.key) ? null : current)) - } - }, - [tableExport, exportFilename], - ) - - // Row key extractor - const rowKeyExtractor = useCallback((record: T) => record.key, []) - - // Built-in search node - const searchNode = useMemo(() => { - if (!searchConfig) return undefined - return ( - setSearchTerm(e.target.value)} - placeholder={searchConfig.placeholder ?? "Search"} - allowClear - disabled={searchConfig.disabled} - className={clsx("w-full", searchConfig.className ?? "max-w-[320px]")} - /> - ) - }, [searchConfig, searchTerm, setSearchTerm]) - - // Shell props to spread directly to InfiniteVirtualTableFeatureShell - const shellProps = useMemo( - () => ({ - datasetStore, - tableScope, - pagination: tablePagination, - rowSelection, - tableProps, - deleteAction, - exportAction, - useSettingsDropdown: isNarrowScreen, - rowKey: rowKeyExtractor, - filters: searchNode, - }), - [ - datasetStore, - tableScope, - tablePagination, - rowSelection, - tableProps, - deleteAction, - exportAction, - isNarrowScreen, - rowKeyExtractor, - searchNode, - ], - ) - - return { - pagination, - rows, - selectedRowKeys, - setSelectedRowKeys, - rowSelection, - tableProps, - tableScope, - tablePagination, - getSelectedRecords, - clearSelection, - isNarrowScreen, - deleteAction, - exportAction, - handleExportRow, - rowExportingKey, - columnsRef, - searchTerm, - setSearchTerm, - shellProps, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableRowSelection.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableRowSelection.ts deleted file mode 100644 index 1d131934e7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableRowSelection.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {useMemo} from "react" - -import type {TableProps} from "antd/es/table" - -import type {InfiniteVirtualTableRowSelection} from "../types" - -/** - * Hook to transform InfiniteVirtualTableRowSelection into Ant Design TableProps rowSelection - */ -const useTableRowSelection = ( - rowSelection: InfiniteVirtualTableRowSelection | undefined, -): TableProps["rowSelection"] | undefined => { - return useMemo(() => { - if (!rowSelection) return undefined - - const { - selectedRowKeys, - onChange, - getCheckboxProps, - columnWidth, - type = "checkbox", - fixed, - columnTitle, - renderCell, - onCell: customOnCell, - } = rowSelection - - return { - type, - columnWidth: columnWidth ?? 48, - selectedRowKeys, - fixed, - columnTitle, - onCell: (record: RecordType, index?: number) => { - const baseProps = { - align: "center" as const, - className: "flex flex-col items-center justify-center", - } - if (customOnCell) { - const customProps = customOnCell(record, index) - return { - ...baseProps, - ...customProps, - className: `${baseProps.className} ${customProps.className || ""}`.trim(), - } - } - return baseProps - }, - onChange, - getCheckboxProps, - renderCell, - } - }, [rowSelection]) -} - -export default useTableRowSelection diff --git a/web/oss/src/components/InfiniteVirtualTable/index.ts b/web/oss/src/components/InfiniteVirtualTable/index.ts deleted file mode 100644 index 617a45fd6a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -export {createInfiniteTableStore} from "./createInfiniteTableStore" -export type {InfiniteTableStore} from "./createInfiniteTableStore" -export {createInfiniteDatasetStore} from "./createInfiniteDatasetStore" -export {createTableColumns} from "./columns/createTableColumns" -export { - createTextCell, - createComponentCell, - createStatusCell, - createActionsCell, - createViewportAwareCell, - createColumnVisibilityAwareCell, -} from "./columns/cells" -export * from "./columns/types" -export {default as useInfiniteTablePagination} from "./hooks/useInfiniteTablePagination" -export {useTableManager, shouldIgnoreRowClick} from "./hooks/useTableManager" -export type { - UseTableManagerConfig, - UseTableManagerReturn, - TableSearchConfig, -} from "./hooks/useTableManager" -export {useTableActions} from "./hooks/useTableActions" -export type {TableActionsConfig, TableActionsReturn} from "./hooks/useTableActions" -export { - createStandardColumns, - createTextColumn, - createDateColumn, - createUserColumn, - createActionsColumn, -} from "./columns/createStandardColumns" -export type { - StandardColumnDef, - TextColumnDef, - DateColumnDef, - UserColumnDef, - ActionsColumnDef, - ActionItem, -} from "./columns/createStandardColumns" -// Table store helpers -export {createTableRowHelpers, createSimpleTableStore, createTableMetaAtom} from "./helpers" -export type { - TableRowHelpersConfig, - CreateSkeletonRowParams, - MergeRowParams, - TableRowHelpers, - DateRangeFilter, - BaseTableMeta, - SimpleTableStoreConfig, - SimpleTableStore, -} from "./helpers" -export { - default as InfiniteVirtualTable, - InfiniteVirtualTableStoreProvider, - useVirtualTableScrollContainer, - useColumnVisibilityControls, -} from "./InfiniteVirtualTable" -export {default as ColumnVisibilityTrigger} from "./components/ColumnVisibilityTrigger" -export {default as ColumnVisibilityMenuTrigger} from "./components/columnVisibility/ColumnVisibilityMenuTrigger" -export {default as ColumnVisibilityPopoverContent} from "./components/columnVisibility/ColumnVisibilityPopoverContent" -export {default as TableSettingsDropdown} from "./components/columnVisibility/TableSettingsDropdown" -export {default as FiltersPopoverTrigger} from "./components/filters/FiltersPopoverTrigger" -export {default as TableShell} from "./components/TableShell" -export {default as TableDescription} from "./components/TableDescription" -export type {TableDescriptionProps} from "./components/TableDescription" -export {InfiniteVirtualTableFeatureShell, useInfiniteTableFeaturePagination} from "./features" -export type { - TableScopeConfig, - TableFeaturePagination, - TableFeatureExportOptions, - InfiniteVirtualTableFeatureProps, - TableTabItem, - TableTabsConfig, - TableDeleteConfig, - TableExportConfig, -} from "./features" -export {default as ColumnVisibilityHeader} from "./components/ColumnVisibilityHeader" -export {default as ColumnVisibilityProvider} from "./providers/ColumnVisibilityProvider" -export {useColumnVisibilityContext} from "./context/ColumnVisibilityContext" -export {useExpandableRows} from "./hooks/useExpandableRows" -export {useEditableTable} from "./hooks/useEditableTable" -export type { - EditableTableColumn, - EditableTableConfig, - EditableTableState, - EditableTableActions, -} from "./hooks/useEditableTable" -export { - useRowHeight, - useRowHeightValue, - createRowHeightAtom, - createRowHeightPxAtom, - createRowHeightMaxLinesAtom, - DEFAULT_ROW_HEIGHT_CONFIG, -} from "./hooks/useRowHeight" -export type { - RowHeightSize, - RowHeightOption, - RowHeightConfig, - UseRowHeightResult, -} from "./hooks/useRowHeight" -export * from "./types" -export type {ExpandableRowConfig, ExpandIconRenderProps} from "./types" -export type {VisibilityRegistrationHandler} from "./components/ColumnVisibilityHeader" diff --git a/web/oss/src/components/InfiniteVirtualTable/providers/ColumnVisibilityProvider.tsx b/web/oss/src/components/InfiniteVirtualTable/providers/ColumnVisibilityProvider.tsx deleted file mode 100644 index 42a5f89f97..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/providers/ColumnVisibilityProvider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {useMemo, type PropsWithChildren} from "react" - -import type {VisibilityRegistrationHandler} from "../components/ColumnVisibilityHeader" -import ColumnVisibilityContext, { - defaultColumnVisibilityContextValue, - type ColumnVisibilityContextValue, -} from "../context/ColumnVisibilityContext" -import type { - ColumnVisibilityState, - ColumnVisibilityMenuRenderer, - ColumnVisibilityMenuTriggerRenderer, -} from "../types" - -interface ColumnVisibilityProviderProps extends PropsWithChildren { - controls: ColumnVisibilityState | null - registerHeader?: VisibilityRegistrationHandler | null - version?: number - renderMenuContent?: ColumnVisibilityMenuRenderer - renderMenuTrigger?: ColumnVisibilityMenuTriggerRenderer - scopeId?: string | null -} - -const ColumnVisibilityProvider = ({ - controls, - registerHeader = null, - version = 0, - renderMenuContent, - renderMenuTrigger, - scopeId = null, - children, -}: ColumnVisibilityProviderProps) => { - const value = useMemo>( - () => ({ - controls: - controls ?? - (defaultColumnVisibilityContextValue.controls as ColumnVisibilityState), - registerHeader, - version, - renderMenuContent, - renderMenuTrigger, - scopeId, - }), - [controls, registerHeader, renderMenuContent, renderMenuTrigger, scopeId, version], - ) - - return ( - - {children} - - ) -} - -export default ColumnVisibilityProvider diff --git a/web/oss/src/components/InfiniteVirtualTable/providers/InfiniteVirtualTableStoreProvider.tsx b/web/oss/src/components/InfiniteVirtualTable/providers/InfiniteVirtualTableStoreProvider.tsx deleted file mode 100644 index 5c77fb77f4..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/providers/InfiniteVirtualTableStoreProvider.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type {ReactNode} from "react" -import {useRef} from "react" - -import {useQueryClient} from "@tanstack/react-query" -import {Provider} from "jotai" -import {useHydrateAtoms} from "jotai/react/utils" -import {createStore} from "jotai/vanilla" -import type {Store} from "jotai/vanilla/store" -import {queryClientAtom} from "jotai-tanstack-query" - -export const InfiniteVirtualTableStoreHydrator = ({ - queryClient, - children, -}: { - queryClient: ReturnType - children: ReactNode -}) => { - useHydrateAtoms([[queryClientAtom, queryClient]]) - return <>{children} -} - -export const InfiniteVirtualTableStoreProvider = ({ - store, - children, -}: { - store?: Store - children: ReactNode -}) => { - const queryClient = useQueryClient() - const storeRef = useRef(store ?? createStore()) - return ( - - - {children} - - - ) -} diff --git a/web/oss/src/components/InfiniteVirtualTable/types.ts b/web/oss/src/components/InfiniteVirtualTable/types.ts deleted file mode 100644 index afe89dc7f0..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/types.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type {Key, ReactNode} from "react" - -import type {ColumnsType, TableProps} from "antd/es/table" -import type {Getter} from "jotai" -import type {Store} from "jotai/vanilla/store" - -import type {VisibilityRegistrationHandler} from "./components/ColumnVisibilityHeader" - -export interface WindowingState { - next: string | null - oldest?: string | null - newest?: string | null - stop?: string | null - order?: string | null - limit?: number | null -} - -export interface InfiniteTablePage { - offset: number - limit: number - cursor: string | null - windowing: WindowingState | null -} - -export interface InfiniteTableRowBase { - key: React.Key - __isSkeleton: boolean - [key: string]: unknown -} - -export interface InfiniteTableFetchParams { - scopeId: string | null - cursor: string | null - limit: number - offset: number - windowing: WindowingState | null - meta: TMeta | undefined - get: Getter -} - -export interface InfiniteTableFetchResult { - rows: ApiRow[] - totalCount: number | null - hasMore: boolean - nextOffset: number | null - nextCursor: string | null - nextWindowing: WindowingState | null -} - -export interface ColumnViewportVisibilityEvent { - scopeId: string | null - columnKey: string - visible: boolean -} - -export interface ColumnVisibilityState { - allKeys: Key[] - leafKeys: Key[] - hiddenKeys: Key[] - setHiddenKeys: (keys: Key[]) => void - isHidden: (key: Key) => boolean - showColumn: (key: Key) => void - hideColumn: (key: Key) => void - toggleColumn: (key: Key) => void - toggleTree: (key: Key) => void - reset: () => void - visibleColumns: ColumnsType - columnTree: ColumnTreeNode[] - version: number -} - -export interface ColumnTreeNode { - key: Key - label: string - titleNode?: ReactNode - checked: boolean - indeterminate: boolean - children: ColumnTreeNode[] -} - -export interface ColumnVisibilityNodeMeta { - title?: ReactNode - searchValues?: (string | undefined)[] - icon?: ReactNode -} - -export type ColumnVisibilityNodeMetaResolver = ( - node: ColumnTreeNode, -) => ColumnVisibilityNodeMeta | Promise - -export interface ColumnVisibilityMenuRendererContext { - scopeId: string | null - onExport?: () => void - isExporting?: boolean -} - -export type ColumnVisibilityMenuRenderer = ( - controls: ColumnVisibilityState, - close: () => void, - context: ColumnVisibilityMenuRendererContext, -) => ReactNode - -export type ColumnVisibilityMenuTriggerRenderer = ( - controls: ColumnVisibilityState, - context: ColumnVisibilityMenuRendererContext, -) => ReactNode - -export interface ColumnVisibilityConfig { - storageKey?: string - defaultHiddenKeys?: Key[] - viewportTrackingEnabled?: boolean - viewportMargin?: string - viewportExitDebounceMs?: number - onStateChange?: (state: ColumnVisibilityState) => void - onViewportVisibilityChange?: ( - payload: ColumnViewportVisibilityEvent | ColumnViewportVisibilityEvent[], - ) => void - onContextChange?: (payload: { - controls: ColumnVisibilityState - registerHeader: VisibilityRegistrationHandler | null - version: number - }) => void - renderMenuContent?: ColumnVisibilityMenuRenderer - /** - * Custom renderer for the menu trigger (gear icon). - * When provided, replaces the default gear icon popover trigger. - * Useful for rendering a dropdown menu instead of a popover. - */ - renderMenuTrigger?: ColumnVisibilityMenuTriggerRenderer - resolveNodeMeta?: ColumnVisibilityNodeMetaResolver -} - -export interface InfiniteVirtualTableRowSelection { - type?: "checkbox" | "radio" - selectedRowKeys: Key[] - onChange?: (selectedRowKeys: Key[], selectedRows: RecordType[]) => void - getCheckboxProps?: (record: RecordType) => { - disabled?: boolean - indeterminate?: boolean - } - columnWidth?: number - /** Matches antd's rowSelection.fixed (FixedType) — forwarded verbatim */ - fixed?: boolean | "left" | "right" - /** Custom title for the selection column header (replaces checkbox) */ - columnTitle?: React.ReactNode - /** Custom render for the selection cell */ - renderCell?: ( - value: boolean, - record: RecordType, - index: number, - originNode: React.ReactNode, - ) => React.ReactNode - /** Custom cell props for the selection column */ - onCell?: (record: RecordType, index?: number) => React.TdHTMLAttributes -} - -export interface InfiniteVirtualTableKeyboardSelectionShortcuts { - enabled?: boolean - navigation?: boolean - range?: boolean - selectAll?: boolean - clear?: boolean -} - -export interface InfiniteVirtualTableKeyboardRowShortcuts { - enabled?: boolean - autoHighlightFirstRow?: boolean - highlightOnHover?: boolean - highlightClassName?: string - scrollIntoViewOnChange?: boolean - toggleSelectionWithSpace?: boolean - onHighlightChange?: (payload: {key: Key | null; record: RecordType | null}) => void - onOpen?: (payload: {key: Key; record: RecordType}) => void - onDelete?: (payload: { - key: Key - record: RecordType - selected: boolean - selection: Key[] - }) => void - onExport?: (payload: {key: Key | null; record: RecordType | null; selection: Key[]}) => void -} - -export interface InfiniteVirtualTableKeyboardShortcuts { - enabled?: boolean - selection?: boolean | InfiniteVirtualTableKeyboardSelectionShortcuts - rows?: InfiniteVirtualTableKeyboardRowShortcuts -} - -export interface ResizableColumnsConfig { - minWidth?: number -} - -/** - * Expand icon render props passed to custom renderers - */ -export interface ExpandIconRenderProps { - expanded: boolean - onExpand: () => void - record: RecordType - loading: boolean -} - -/** - * Configuration for expandable rows in InfiniteVirtualTable. - * Provides a minimal API for consumers to define how rows expand. - */ -export interface ExpandableRowConfig { - /** - * Function to fetch child data when a row is expanded. - * Should return a promise that resolves to an array of child items. - */ - fetchChildren: (record: RecordType) => Promise - - /** - * Render function for the expanded content. - * Receives the parent record and its fetched children. - */ - renderExpanded: ( - record: RecordType, - children: ChildType[], - loading: boolean, - error: Error | null, - ) => ReactNode - - /** - * Optional: Determine if a row is expandable. - * Defaults to true for all rows if not provided. - */ - isExpandable?: (record: RecordType) => boolean - - /** - * Optional: Custom expand icon renderer. - */ - expandIcon?: (props: ExpandIconRenderProps) => ReactNode - - /** - * Optional: Width of the expand column (default: 48) - * Set to 0 when using showExpandIconInCell to hide the column. - */ - columnWidth?: number - - /** - * Optional: Fixed position of expand column (default: undefined) - */ - fixed?: "left" | "right" - - /** - * Optional: Cache fetched children to avoid re-fetching on collapse/expand. - * Default: true - */ - cacheChildren?: boolean - - /** - * Optional: Accordion mode - only one row can be expanded at a time. - * Default: false - */ - accordion?: boolean - - /** - * When true, the expand icon column is hidden and consumers should - * render the expand icon within their own cell using renderExpandIcon. - * Default: false - */ - showExpandIconInCell?: boolean -} - -export interface InfiniteVirtualTableProps { - columns: ColumnsType - dataSource: RecordType[] - loadMore: () => void - rowKey: TableProps["rowKey"] - active?: boolean - scrollThreshold?: number - containerClassName?: string - tableClassName?: string - tableProps?: Omit, "columns" | "dataSource" | "onScroll" | "pagination"> - rowSelection?: InfiniteVirtualTableRowSelection - resizableColumns?: boolean | ResizableColumnsConfig - columnVisibility?: ColumnVisibilityConfig - /** - * When true, disables the built-in guard that prevents row-click navigation - * from firing when the click originates from an interactive element (button, - * checkbox, dropdown, etc.). Defaults to false — the guard is on by default. - */ - disableInteractiveClickGuard?: boolean - onColumnToggle?: (payload: { - scopeId: string | null - columnKey: string - visible: boolean - }) => void - scopeId?: string | null - beforeTable?: React.ReactNode - useIsolatedStore?: boolean - store?: Store | null - bodyHeight?: number | null - onHeaderHeightChange?: (height: number | null) => void - keyboardShortcuts?: InfiniteVirtualTableKeyboardShortcuts - /** - * Configuration for expandable rows. - * When provided, rows can be expanded to show child content. - */ - expandable?: ExpandableRowConfig - /** - * Ref to access the underlying Ant Design Table instance. - * Useful for programmatic scrolling via `tableRef.current?.scrollTo({ index })`. - */ - tableRef?: React.RefObject<{ - scrollTo: (config: {index: number; align?: "top" | "bottom" | "auto"}) => void - } | null> -} diff --git a/web/oss/src/components/InfiniteVirtualTable/utils/columnUtils.ts b/web/oss/src/components/InfiniteVirtualTable/utils/columnUtils.ts deleted file mode 100644 index 5bdc247e3a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/utils/columnUtils.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type {Key} from "react" - -import type {ColumnsType} from "antd/es/table" - -/** - * Collects all column keys that have `fixed` property set - */ -export const collectFixedColumnKeys = ( - columns: ColumnsType, -): string[] => { - const keys = new Set() - const visit = (cols: ColumnsType) => { - cols.forEach((column) => { - const typedColumn = column as any - if (!typedColumn) return - const columnKey = typedColumn.key - const isFixed = Boolean(typedColumn.fixed) - if (isFixed && columnKey !== undefined && columnKey !== null) { - keys.add(String(columnKey)) - } - if (typedColumn.children && typedColumn.children.length) { - visit(typedColumn.children as ColumnsType) - } - }) - } - visit(columns) - return Array.from(keys) -} - -/** - * Converts a Key to string or null - */ -export const toColumnKey = (key: Key | undefined): string | null => - key === undefined || key === null ? null : String(key) - -/** - * Builds a map of parent column keys to their descendant leaf keys - */ -export const buildColumnDescendantMap = ( - columns: ColumnsType, -): Map => { - const map = new Map() - const gatherDescendants = (column: ColumnsType[number]): string[] => { - const typedColumn = column as any - if (!typedColumn) return [] - const key = toColumnKey(typedColumn.key) - const childColumns = Array.isArray(typedColumn.children) - ? (typedColumn.children as ColumnsType) - : null - if (!childColumns || childColumns.length === 0) { - return key ? [key] : [] - } - const descendantLeaves = childColumns.flatMap((child) => gatherDescendants(child)) - if (key && descendantLeaves.length) { - map.set(key, Array.from(new Set(descendantLeaves))) - } - return descendantLeaves.length ? descendantLeaves : key ? [key] : [] - } - columns.forEach((column) => gatherDescendants(column)) - return map -} - -/** - * Merges two optional event handlers into one - */ -export const mergeHandlers = < - T extends (...args: any[]) => void | undefined, - U extends (...args: any[]) => void | undefined, ->( - first?: T, - second?: U, -): ((...args: Parameters) => void) | ((...args: Parameters) => void) | undefined => { - if (!first && !second) { - return undefined - } - if (!first) { - return second as any - } - if (!second) { - return first as any - } - return ((...args: any[]) => { - first(...(args as Parameters)) - second(...(args as Parameters)) - }) as any -} - -/** - * Shallow equality check for objects - */ -export const shallowEqual = (a: Record | null, b: Record): boolean => { - if (a === b) return true - if (!a || !b) return false - const keysA = Object.keys(a) - const keysB = Object.keys(b) - if (keysA.length !== keysB.length) return false - for (const key of keysA) { - if (a[key] !== b[key]) return false - } - return true -} diff --git a/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx b/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx index f6fb4d95d3..44c26f94a7 100644 --- a/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx +++ b/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx @@ -16,10 +16,10 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" import type {PreviewPanelRenderProps} from "@agenta/playground-ui/components" import {EnhancedModal, ModalContent, ModalFooter} from "@agenta/ui" import {message} from "@agenta/ui/app-message" +import {useRowHeight} from "@agenta/ui/table" import {PlusOutlined} from "@ant-design/icons" import {Button, Input, Typography} from "antd" -import {useRowHeight} from "@/oss/components/InfiniteVirtualTable" import TestcaseEditDrawer from "@/oss/components/SharedDrawers/TestcaseDrawer" import {TestcasesTableShell} from "@/oss/components/TestcasesTableNew/components/TestcasesTableShell" import {useTestcasesTable} from "@/oss/components/TestcasesTableNew/hooks/useTestcasesTable" diff --git a/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx b/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx index 94e73a6274..fc8da233c0 100644 --- a/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx +++ b/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx @@ -1,8 +1,8 @@ import {useMemo} from "react" +import {useRowHeight} from "@agenta/ui/table" import {Typography} from "antd" -import {useRowHeight} from "@/oss/components/InfiniteVirtualTable" import {TestcasesTableShell} from "@/oss/components/TestcasesTableNew/components/TestcasesTableShell" import {useTestcasesTable} from "@/oss/components/TestcasesTableNew/hooks/useTestcasesTable" import { diff --git a/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx b/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx index 29ee62ea50..073cbcdeee 100644 --- a/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx +++ b/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx @@ -1,12 +1,12 @@ import {useEffect, useMemo, useState, type CSSProperties} from "react" +import {TableDescription} from "@agenta/ui/table" import {DownOutlined, MoreOutlined} from "@ant-design/icons" import {Export, Link, PencilSimple, Trash} from "@phosphor-icons/react" import {Button, Dropdown, Popover, Space, Typography} from "antd" import {useSetAtom} from "jotai" import {useRouter} from "next/router" -import {TableDescription} from "@/oss/components/InfiniteVirtualTable" import {UserReference} from "@/oss/components/References/UserReference" import type {ExportFileType} from "@/oss/services/testsets/api" import {enableRevisionsListQueryAtom} from "@/oss/state/entities/testset" diff --git a/web/oss/src/components/TestcasesTableNew/index.tsx b/web/oss/src/components/TestcasesTableNew/index.tsx index 9a803f0a93..b2f342bfab 100644 --- a/web/oss/src/components/TestcasesTableNew/index.tsx +++ b/web/oss/src/components/TestcasesTableNew/index.tsx @@ -1,10 +1,10 @@ import {useEffect, useMemo, useState} from "react" +import {useRowHeight} from "@agenta/ui/table" import {useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {useRouter} from "next/router" -import {useRowHeight} from "@/oss/components/InfiniteVirtualTable" import TestcaseEditDrawer from "@/oss/components/SharedDrawers/TestcaseDrawer" import useBlockNavigation from "@/oss/hooks/useBlockNavigation" import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" diff --git a/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts b/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts index d30a4f6d25..768aa659c8 100644 --- a/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts +++ b/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts @@ -2,7 +2,7 @@ import { createRowHeightAtom, DEFAULT_ROW_HEIGHT_CONFIG, type RowHeightConfig, -} from "@/oss/components/InfiniteVirtualTable" +} from "@agenta/ui/table" /** * Testcase table row height configuration diff --git a/web/oss/src/components/TestsetsTable/TestsetsTable.tsx b/web/oss/src/components/TestsetsTable/TestsetsTable.tsx index 8556d47fe8..b5727b029b 100644 --- a/web/oss/src/components/TestsetsTable/TestsetsTable.tsx +++ b/web/oss/src/components/TestsetsTable/TestsetsTable.tsx @@ -2,6 +2,7 @@ import {useCallback, useEffect, useMemo, useState} from "react" import {testsetMolecule} from "@agenta/entities/testset" import {message} from "@agenta/ui/app-message" +import {InfiniteVirtualTableFeatureShell, useTableManager, useTableActions} from "@agenta/ui/table" import {PlusOutlined} from "@ant-design/icons" import {ArchiveIcon, CaretDown, DownloadSimple} from "@phosphor-icons/react" import {Button, Dropdown, Space} from "antd" @@ -10,11 +11,6 @@ import {useAtom, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {useRouter} from "next/router" -import { - InfiniteVirtualTableFeatureShell, - useTableManager, - useTableActions, -} from "@/oss/components/InfiniteVirtualTable" import TestsetsHeaderFilters from "@/oss/components/TestsetsTable/components/TestsetsHeaderFilters" import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" import useURL from "@/oss/hooks/useURL" @@ -368,7 +364,8 @@ const TestsetsTable = ({ // Table manager - consolidates pagination, selection, row handlers, export, delete buttons const table = useTableManager({ - datasetStore: tableState.paginatedStore.store, + // Store generic is invariant on ApiRow; matches DeploymentsTable's cast. + datasetStore: tableState.paginatedStore.store as never, scopeId: isArchivedView ? "archived-testsets-page" : scopeId, pageSize: 50, rowHeight: 48, diff --git a/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx b/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx index 2c4556c59b..2df7139b55 100644 --- a/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx +++ b/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx @@ -1,4 +1,5 @@ import {UserAuthorLabel} from "@agenta/entities/shared/user" +import {createStandardColumns} from "@agenta/ui/table" import {LoadingOutlined, MinusCircleOutlined, PlusCircleOutlined} from "@ant-design/icons" import { ArrowCounterClockwise, @@ -12,7 +13,6 @@ import { import {Tag} from "antd" import type {ColumnsType} from "antd/es/table" -import {createStandardColumns} from "@/oss/components/InfiniteVirtualTable" import CommitMessageCell from "@/oss/components/TestsetsTable/components/CommitMessageCell" import type {ExportFileType} from "@/oss/services/testsets/api" import type {TestsetTableMode, TestsetTableRow} from "@/oss/state/entities/testset" @@ -64,8 +64,8 @@ export function createTestsetsColumns( columnVisibilityLocked: true, render: (_value, record) => { const isRevision = Boolean((record as any).__isRevision) - const isExpanded = expandState.expandedRowKeys.includes(record.key) - const isLoading = expandState.loadingRows.has(record.key) + const isExpanded = expandState.expandedRowKeys.includes(String(record.key)) + const isLoading = expandState.loadingRows.has(String(record.key)) const isSkeleton = record.__isSkeleton if (isRevision) { diff --git a/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts b/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts index 04d98140d4..e5089358a0 100644 --- a/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts +++ b/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts @@ -1,4 +1,5 @@ -import type {WindowingState} from "@/oss/components/InfiniteVirtualTable/types" +import type {WindowingState} from "@agenta/ui/table" + import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" diff --git a/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx b/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx index fab004c71e..cf859b035b 100644 --- a/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx +++ b/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx @@ -1,9 +1,9 @@ import {useCallback, useState} from "react" +import {FiltersPopoverTrigger} from "@agenta/ui/table" import {Input, type PopoverProps} from "antd" import {useAtom} from "jotai" -import {FiltersPopoverTrigger} from "@/oss/components/InfiniteVirtualTable" import {getTestsetTableState, type TestsetTableMode} from "@/oss/state/entities/testset" import TestsetsFiltersContent from "./TestsetsFiltersContent" diff --git a/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts b/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts index e8aa75f44b..71519bdccd 100644 --- a/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts +++ b/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts @@ -75,20 +75,17 @@ import type {Key} from "react" -import {atom} from "jotai" -import type {Atom, PrimitiveAtom, WritableAtom} from "jotai" -import {atomFamily} from "jotai/utils" - import { createSimpleTableStore, type BaseTableMeta, type SimpleTableStore, -} from "@/oss/components/InfiniteVirtualTable/helpers/createSimpleTableStore" -import type { - InfiniteTableFetchResult, - InfiniteTableRowBase, - WindowingState, -} from "@/oss/components/InfiniteVirtualTable/types" + type InfiniteTableFetchResult, + type InfiniteTableRowBase, + type WindowingState, +} from "@agenta/ui/table" +import {atom} from "jotai" +import type {Atom, PrimitiveAtom, WritableAtom} from "jotai" +import {atomFamily} from "jotai/utils" // ============================================================================ // TYPES diff --git a/web/oss/src/state/entities/testcase/paginatedStore.ts b/web/oss/src/state/entities/testcase/paginatedStore.ts index e8191a6686..19dcd7a5ba 100644 --- a/web/oss/src/state/entities/testcase/paginatedStore.ts +++ b/web/oss/src/state/entities/testcase/paginatedStore.ts @@ -24,14 +24,14 @@ * ``` */ -import {atom} from "jotai" - -import type {BaseTableMeta} from "@/oss/components/InfiniteVirtualTable/helpers/createSimpleTableStore" import type { + BaseTableMeta, InfiniteTableFetchResult, InfiniteTableRowBase, WindowingState, -} from "@/oss/components/InfiniteVirtualTable/types" +} from "@agenta/ui/table" +import {atom} from "jotai" + import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" import {projectIdAtom} from "@/oss/state/project" diff --git a/web/oss/src/state/entities/testset/paginatedStore.ts b/web/oss/src/state/entities/testset/paginatedStore.ts index e4c3736d62..35469bc56d 100644 --- a/web/oss/src/state/entities/testset/paginatedStore.ts +++ b/web/oss/src/state/entities/testset/paginatedStore.ts @@ -21,14 +21,10 @@ * ``` */ +import type {BaseTableMeta, InfiniteTableFetchResult, InfiniteTableRowBase} from "@agenta/ui/table" import {atom, getDefaultStore, type Atom} from "jotai" import {atomWithStorage} from "jotai/vanilla/utils" -import type {BaseTableMeta} from "@/oss/components/InfiniteVirtualTable/helpers/createSimpleTableStore" -import type { - InfiniteTableFetchResult, - InfiniteTableRowBase, -} from "@/oss/components/InfiniteVirtualTable/types" import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" import type {ExportFileType} from "@/oss/services/testsets/api" diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts index bc0b982fca..003dfce7f2 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts @@ -122,6 +122,10 @@ export type {TableExportColumnContext} from "./hooks/useTableExport" // Alias for backward compatibility export {default as ColumnVisibilityPopoverContentBase} from "./components/columnVisibility/ColumnVisibilityPopoverContent" +export type { + ColumnVisibilityNodeMeta, + ColumnVisibilityNodeMetaResolver, +} from "./components/columnVisibility/ColumnVisibilityPopoverContent" // NOTE: Internal atoms (columnWidths, columnHiddenKeys) are NOT exported. // They are implementation details used internally by the table components.