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 e25840aa51..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/types.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type {Key, ReactNode} from "react" - -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). - */ -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 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/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/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/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/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. 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[] }