/
vshmidt
/
label-studio
Обзор
Документация
Войти
/
vshmidt
/
label-studio
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
web/libs/datamanager/src/components/Common/Table/Table.jsx
658 строк
20 KB
Raul Martin
feat: Migrate SCSS to plain CSS with PostCSS, replace Stylelint with Biome (#9584)
12 мар 2026, 20:57
Не верифицирован
12 мар 2026, 20:57
3f31128
Код
Авторство
О чём код?
import { observer } from "mobx-react"; import { createContext, forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useSDK } from "../../../providers/SDKProvider"; import { isDefined } from "../../../utils/utils"; import { Icon } from "../Icon/Icon"; import { modal } from "../Modal/Modal"; import { IconBraces } from "@humansignal/icons"; import { AutoSizerTable, Button } from "@humansignal/ui"; import "./Table.prefix.css"; import { TableCheckboxCell } from "./TableCheckbox"; import { tableCN, TableContext } from "./TableContext"; import { TableHead } from "./TableHead/TableHead"; import { TableRow } from "./TableRow/TableRow"; import { RowContextMenu } from "./RowContextMenu"; import { prepareColumns } from "./utils"; import { cn } from "../../../utils/bem"; import { FieldsButton } from "../FieldsButton"; import { FF_LOPS_E_3, isFF } from "../../../utils/feature-flags"; import { DensityToggle } from "../../DataManager/Toolbar/DensityToggle"; import { TaskSourceViewer, getTaskSourceViewerStorageKey } from "../TaskSourceViewer"; const Decorator = (decoration) => { return { get(col) { return decoration.find((d) => { let found = false; if (isDefined(d.alias)) { found = d.alias === col.alias; } else if (d.resolver instanceof Function) { found = d.resolver(col); } return found; }); }, }; }; export const Table = observer( ({ view, data, cellViews, selectedItems, focusedItem, decoration, stopInteractions, onColumnResize, onColumnReset, headerExtra, onDensityChange, onRangeSelect, onViewAnalytics, onViewReviewerAnalytics, RowContextMenuComponent, ...props }) => { const colOrderKey = "dm:columnorder"; const tableHead = useRef(); const [colOrder, setColOrder] = useState(JSON.parse(localStorage.getItem(colOrderKey)) ?? {}); const listRef = useRef(); const Decoration = useMemo(() => Decorator(decoration), [decoration]); const { api, type, projectId } = useSDK(); const toolbarHeight = 41; const isQuickView = view.root.isLabeling; const [toolbarVisible, setToolbarVisible] = useState(true); // Track last clicked row ID for shift-click range selection const lastClickedId = useRef(null); // Global context menu state const [contextMenu, setContextMenu] = useState(null); // Maintain hover appearance on row while its context menu is open for better visual feedback const contextMenuRowId = contextMenu?.row?.id ?? null; // Reset virtualizer cache when rowHeight changes useEffect(() => { if (listRef.current?._listRef) { listRef.current._listRef.resetAfterIndex(0); } }, [props.rowHeight]); const headerCheckboxCell = useCallback(() => { return ( <TableCheckboxCell checked={selectedItems.isAllSelected} indeterminate={selectedItems.isIndeterminate} onChange={() => props.onSelectAll()} className="select-all" ariaLabel={`${selectedItems.isAllSelected ? "Unselect" : "Select"} all rows`} /> ); }, [props.onSelectAll, selectedItems]); const rowCheckBoxCell = useCallback( ({ data: rowData }) => { const isChecked = selectedItems.isSelected(rowData.id); return ( <TableCheckboxCell checked={isChecked} onChange={(checked, shiftKey) => { // Handle shift-click for range selection (works for both select and unselect) if (shiftKey && lastClickedId.current !== null && onRangeSelect) { const lastClickedIndex = data.findIndex((item) => item.id === lastClickedId.current); const currentIndex = data.findIndex((item) => item.id === rowData.id); if (lastClickedIndex !== -1 && currentIndex !== -1) { const startIndex = Math.min(lastClickedIndex, currentIndex); const endIndex = Math.max(lastClickedIndex, currentIndex); const rangeIds = data.slice(startIndex, endIndex + 1).map((item) => item.id); // Pass the select state: true = select range, false = unselect range onRangeSelect(rangeIds, checked); lastClickedId.current = rowData.id; return; } } // Normal single-item toggle props.onSelectRow(rowData.id); // Always remember last clicked for shift-click range lastClickedId.current = rowData.id; }} ariaLabel={`${isChecked ? "Unselect" : "Select"} Task ${rowData.id}`} /> ); }, [props.onSelectRow, selectedItems, data, onRangeSelect], ); const columns = prepareColumns(props.columns, props.hiddenColumns); columns.unshift({ id: "select", headerClassName: "table__select-all", cellClassName: "select-row", style: { width: 40, maxWidth: 40, justifyContent: "center", }, onClick: (e) => e.stopPropagation(), Header: headerCheckboxCell, Cell: rowCheckBoxCell, }); columns.push({ id: "show-source", cellClassName: "show-source", headerClassName: "show-source", style: { width: 44, maxWidth: 44, justifyContent: "center", }, onClick: (e) => e.stopPropagation(), Header() { return <div style={{ width: 44 }} />; }, Cell({ data }) { let out = JSON.parse(data.source ?? "{}"); out = { id: out?.id, data: out?.data, annotations: out?.annotations, predictions: out?.predictions, }; const onTaskLoad = async (options = {}) => { if (isFF(FF_LOPS_E_3) && type === "DE") { return new Promise((resolve) => resolve(out)); } const response = await api.task({ taskID: out.id, resolve_uri: options.resolveUri ?? false, }); return response ?? {}; }; return ( <Button look="string" className="w-6 h-6 p-0 text-primary-content hover:text-primary-content-hover" onClick={() => { const modalInstance = modal({ title: `Source for task ${out?.id}`, style: { width: 900 }, header: null, // Will be set by renderToggle body: ( <TaskSourceViewer content={out} onTaskLoad={onTaskLoad} sdkType={type} storageKey={getTaskSourceViewerStorageKey(projectId)} renderToggle={(toggle) => { // Update modal header with toggle modalInstance?.update({ header: toggle }); }} /> ), }); }} leading={<Icon icon={IconBraces} />} tooltip="View Task Source" /> ); }, }); if (Object.keys(colOrder).length > 0) { columns.sort((a, b) => { return colOrder[a.id] < colOrder[b.id] ? -1 : 1; }); } useEffect(() => { localStorage.setItem(colOrderKey, JSON.stringify(colOrder)); }, [colOrder]); // Store columns in a ref to avoid recreating handleContextMenu const columnsRef = useRef(columns); columnsRef.current = columns; // Handle context menu - defined after columns are initialized const handleContextMenu = useCallback((e, row) => { e.preventDefault(); e.stopPropagation(); // Find which column was clicked (actual class: lsf-table__cell) const cell = e.target.closest(".lsf-table__cell"); const cellIndex = cell ? Array.from(cell.parentElement.children).indexOf(cell) : -1; const column = cellIndex >= 0 ? columnsRef.current[cellIndex] : null; setContextMenu({ x: e.clientX, y: e.clientY, row, column, }); }, []); // Empty deps - stable callback // Close context menu on click outside or escape useEffect(() => { if (!contextMenu) return; const handleClose = (e) => { // Don't close if clicking inside the menu if (e.target.closest("[data-context-menu]")) return; setContextMenu(null); }; const handleEscape = (e) => { if (e.key === "Escape") { setContextMenu(null); } }; // Use capture phase and add slight delay to prevent immediate closing const timerId = setTimeout(() => { document.addEventListener("click", handleClose, true); document.addEventListener("contextmenu", handleClose, true); document.addEventListener("keydown", handleEscape); }, 0); return () => { clearTimeout(timerId); document.removeEventListener("click", handleClose, true); document.removeEventListener("contextmenu", handleClose, true); document.removeEventListener("keydown", handleEscape); }; }, [contextMenu]); const contextValue = { columns, data, cellViews, contextMenuRowId, }; const headerHeight = 43; const renderTableToolbar = useCallback(() => { return ( <div className={cn("table-toolbar").mod({ visible: toolbarVisible }).toClassName()}> <FieldsButton multiSelect={true} title={"Columns"} size="small" tooltip={"Customize Columns"} data-testid="columns-picker-quickview" /> <DensityToggle size="small" onChange={onDensityChange} data-testid="density-toggle-quickview" /> </div> ); }, [toolbarVisible, onDensityChange]); const renderTableHeader = useCallback( ({ style }) => ( <TableHead ref={tableHead} style={style} order={props.order} columnHeaderExtra={props.columnHeaderExtra} sortingEnabled={props.sortingEnabled} onSetOrder={props.onSetOrder} stopInteractions={stopInteractions} onTypeChange={props.onTypeChange} decoration={Decoration} onResize={onColumnResize} onReset={onColumnReset} extra={headerExtra} onDragEnd={(updatedColOrder) => setColOrder(updatedColOrder)} /> ), [ props.order, props.columnHeaderExtra, props.sortingEnabled, props.onSetOrder, props.onTypeChange, stopInteractions, view, view.selected.list, view.selected.all, tableHead, ], ); const renderRow = useCallback( ({ style, index }) => { // Both QuickView and Regular mode: Index 0 is header (sticky), Index 1+ are data rows const dataIndex = index - 1; const row = data[dataIndex]; const isEven = dataIndex % 2 === 0; if (isQuickView) { return ( <TableRow key={row.id} data={row} even={isEven} onClick={(row, e) => props.onRowClick(row, e)} stopInteractions={stopInteractions} wrapperStyle={{ ...style, height: props.rowHeight }} style={{ height: props.rowHeight, width: props.fitContent ? "fit-content" : "auto", }} decoration={Decoration} onContextMenu={handleContextMenu} /> ); } // Invert for visual consistency in Regular mode: we want odd rows (2nd, 4th, etc.) to have background const shouldApplyBackground = !isEven; return ( <TableRow key={row.id} data={row} even={shouldApplyBackground} onClick={(row, e) => props.onRowClick(row, e)} stopInteractions={stopInteractions} wrapperStyle={{ ...style, height: props.rowHeight }} style={{ height: props.rowHeight, width: props.fitContent ? "fit-content" : "auto", }} decoration={Decoration} onContextMenu={handleContextMenu} /> ); }, [ data, props.fitContent, props.onRowClick, props.rowHeight, stopInteractions, selectedItems, view, view.selected.list, view.selected.all, isQuickView, Decoration, handleContextMenu, ], ); const isItemLoaded = useCallback( (index) => { return props.isItemLoaded(data, index); }, [props, data], ); const cachedScrollOffset = useRef(); const initialScrollOffset = useCallback( (height) => { if (isDefined(cachedScrollOffset.current)) { return cachedScrollOffset.current; } const h = props.rowHeight; const index = data.indexOf(focusedItem); if (index >= 0) { const scrollOffset = index * h - height / 2 + h / 2; // + headerHeight return (cachedScrollOffset.current = scrollOffset); } return 0; }, [props.rowHeight, data, focusedItem], ); const itemKey = useCallback( (index) => { if (index > data.length - 1) { return index; } return data[index]?.key ?? index; }, [data], ); useEffect(() => { const listComponent = listRef.current?._listRef; if (listComponent) { listComponent.scrollToItem(data.indexOf(focusedItem), "center"); } }, [data]); const tableWrapper = useRef(); const handleScroll = useCallback( ({ scrollOffset }) => { if (isQuickView && scrollOffset >= 0) { setToolbarVisible(scrollOffset === 0); } }, [isQuickView, toolbarHeight], ); return ( <> <div ref={tableWrapper} className={tableCN.mod({ fit: props.fitToContent }).toClassName()}> {isQuickView && renderTableToolbar()} <TableContext.Provider value={contextValue}> <StickyList ref={listRef} overscanCount={10} itemHeight={props.rowHeight} totalCount={props.total} itemCount={data.length + 1} itemKey={itemKey} innerElementType={innerElementType} stickyItems={[0]} stickyItemsHeight={[headerHeight]} stickyComponent={renderTableHeader} initialScrollOffset={initialScrollOffset} isItemLoaded={isItemLoaded} loadMore={props.loadMore} toolbarHeight={toolbarHeight} headerHeight={headerHeight} isQuickView={isQuickView} onScroll={handleScroll} toolbarVisible={toolbarVisible} > {renderRow} </StickyList> </TableContext.Provider> </div> {contextMenu && typeof document !== "undefined" && createPortal( <ContextMenuPortal contextMenu={contextMenu} view={view} onViewAnalytics={onViewAnalytics} onViewReviewerAnalytics={onViewReviewerAnalytics} RowContextMenuComponent={RowContextMenuComponent} onClose={() => setContextMenu(null)} />, document.body, )} </> ); }, ); const StickyListContext = createContext(); StickyListContext.displayName = "StickyListProvider"; const ItemWrapper = ({ data, index, style }) => { const { Renderer, stickyItems } = data; if (stickyItems?.includes(index) === true) { return null; } return <Renderer index={index} style={style} />; }; const StickyList = observer( forwardRef((props, listRef) => { const { children, stickyComponent, stickyItems, stickyItemsHeight, totalCount, isItemLoaded, loadMore, initialScrollOffset, toolbarHeight, headerHeight, isQuickView, onScroll, headerTopOffset, toolbarVisible, ...rest } = props; const itemData = { Renderer: children, StickyComponent: stickyComponent, stickyItems, stickyItemsHeight, headerTopOffset, isQuickView, toolbarHeight, }; const itemSize = (index) => { if (isQuickView) { if (stickyItems.includes(index)) { return headerHeight; } } else { // Regular mode: Index 0 is sticky header if (stickyItems.includes(index)) { return headerHeight; } } // All other indices are data rows return rest.itemHeight; }; // Calculate height adjustment for QuickView mode // Subtract toolbar height (when visible) and app header height const heightAdjustment = useMemo(() => { if (!isQuickView) return 0; // Get app header height from CSS variable const appHeaderHeight = Number.parseInt( getComputedStyle(document.documentElement).getPropertyValue("--header-height") || "0", 10, ); // Add toolbar height only when toolbar is visible const adjustment = appHeaderHeight + (toolbarVisible ? toolbarHeight : 0); return adjustment; }, [isQuickView, toolbarVisible, toolbarHeight]); return ( <StickyListContext.Provider value={itemData}> <AutoSizerTable ref={listRef} totalCount={totalCount} loadMore={loadMore} isItemLoaded={isItemLoaded} itemData={itemData} itemSize={itemSize} initialScrollOffset={initialScrollOffset} className={tableCN.elem("auto-size").mod({ "quick-view": isQuickView }).toClassName()} onScroll={onScroll} heightAdjustment={heightAdjustment} {...rest} > {ItemWrapper} </AutoSizerTable> </StickyListContext.Provider> ); }), ); StickyList.displayName = "StickyList"; const innerElementType = forwardRef(({ children, ...rest }, ref) => { return ( <StickyListContext.Consumer> {({ stickyItems, stickyItemsHeight, StickyComponent, headerTopOffset, isQuickView, toolbarHeight }) => { // Ensure top position is always between 0 and toolbarHeight in QuickView const topPosition = isQuickView ? Math.max(0, Math.min(toolbarHeight, headerTopOffset ?? 0)) : 0; // In QuickView mode, children[0] is the toolbar, children[1+] are data rows const childrenArray = Array.isArray(children) ? children : children ? [children] : []; const toolbar = isQuickView && childrenArray.length > 0 ? childrenArray[0] : null; const bodyRows = isQuickView && childrenArray.length > 0 ? childrenArray.slice(1) : children; return ( <div ref={ref} {...rest}> {stickyItems.map((index) => ( <StickyComponent className={tableCN.elem("sticky-header").toClassName()} key={index} index={index} style={{ height: stickyItemsHeight[index], top: topPosition, }} /> ))} {isQuickView ? ( <> {toolbar} <div className={tableCN.elem("body-rows").toClassName()}>{bodyRows}</div> </> ) : ( children )} </div> ); }} </StickyListContext.Consumer> ); }); // Context menu portal component - positioning now handled by Dropdown component const ContextMenuPortal = memo( ({ contextMenu, view, onViewAnalytics, onViewReviewerAnalytics, onClose, RowContextMenuComponent }) => { const MenuComponent = RowContextMenuComponent || RowContextMenu; const { api, type, projectId } = useSDK(); return ( <MenuComponent row={contextMenu.row} column={contextMenu.column} view={view} api={api} sdkType={type} projectId={projectId} onViewAnalytics={onViewAnalytics} onViewReviewerAnalytics={onViewReviewerAnalytics} cursorPosition={{ x: contextMenu.x, y: contextMenu.y }} onClose={onClose} /> ); }, );