/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /* eslint-disable import/no-extraneous-dependencies */ import { useCallback, useRef, ReactNode, HTMLProps, MutableRefObject, CSSProperties, DragEvent, useEffect, useMemo, } from 'react'; import { typedMemo, usePrevious } from '@superset-ui/core'; import { t } from '@apache-superset/core/translation'; import { useTable, usePagination, useSortBy, useGlobalFilter, useColumnOrder, PluginHook, TableOptions, FilterType, IdType, Row, } from 'react-table'; import { matchSorter, rankings } from 'match-sorter'; import { isEqual } from 'lodash'; import { Flex, Space } from '@superset-ui/core/components'; import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter'; import SelectPageSize, { SelectPageSizeProps, SizeOption, } from './components/SelectPageSize'; import SimplePagination from './components/Pagination'; import useSticky from './hooks/useSticky'; import { PAGE_SIZE_OPTIONS } from '../consts'; import { sortAlphanumericCaseInsensitive } from './utils/sortAlphanumericCaseInsensitive'; import { SearchOption, SortByItem } from '../types'; import SearchSelectDropdown from './components/SearchSelectDropdown'; export interface DataTableProps extends TableOptions { tableClassName?: string; searchInput?: boolean | GlobalFilterProps['searchInput']; selectPageSize?: boolean | SelectPageSizeProps['selectRenderer']; pageSizeOptions?: SizeOption[]; // available page size options maxPageItemCount?: number; hooks?: PluginHook[]; // any additional hooks width?: string | number; height?: string | number; serverPagination?: boolean; onServerPaginationChange: (pageNumber: number, pageSize: number) => void; serverPaginationData: { pageSize?: number; currentPage?: number; sortBy?: SortByItem[]; searchColumn?: string; }; pageSize?: number; noResults?: string | ((filterString: string) => ReactNode); sticky?: boolean; rowCount: number; wrapperRef?: MutableRefObject; onColumnOrderChange?: () => void; renderGroupingHeaders?: () => JSX.Element; renderTimeComparisonDropdown?: () => JSX.Element; handleSortByChange: (sortBy: SortByItem[]) => void; sortByFromParent: SortByItem[]; manualSearch?: boolean; onSearchChange?: (searchText: string) => void; initialSearchText?: string; searchInputId?: string; onSearchColChange: (searchCol: string) => void; searchOptions: SearchOption[]; onFilteredDataChange?: (rows: Row[], filterValue?: string) => void; onFilteredRowsChange?: (rows: D[]) => void; } export interface RenderHTMLCellProps extends HTMLProps { cellContent: ReactNode; } const sortTypes = { alphanumeric: sortAlphanumericCaseInsensitive, }; // Be sure to pass our updateMyData and the skipReset option export default typedMemo(function DataTable({ tableClassName, columns, data, serverPaginationData, width: initialWidth = '100%', height: initialHeight = 300, pageSize: initialPageSize = 0, initialState: initialState_ = {}, pageSizeOptions = PAGE_SIZE_OPTIONS, maxPageItemCount = 9, sticky: doSticky, searchInput = true, onServerPaginationChange, rowCount, selectPageSize, noResults: noResultsText = 'No data found', hooks, serverPagination, wrapperRef: userWrapperRef, onColumnOrderChange, renderGroupingHeaders, renderTimeComparisonDropdown, handleSortByChange, sortByFromParent = [], manualSearch = false, onSearchChange, initialSearchText, searchInputId, onSearchColChange, searchOptions, onFilteredDataChange, onFilteredRowsChange, ...moreUseTableOptions }: DataTableProps): JSX.Element { const tableHooks: PluginHook[] = [ useGlobalFilter, useSortBy, usePagination, useColumnOrder, doSticky ? useSticky : [], hooks || [], ].flat(); const columnNames = columns.map((column, index) => { const normalizedColumn = column as typeof column & { accessor?: string | ((row: D) => unknown); columnKey?: string; id?: string; }; const accessorName = typeof normalizedColumn.accessor === 'string' ? normalizedColumn.accessor : undefined; return ( normalizedColumn.columnKey ?? normalizedColumn.id ?? accessorName ?? String(index) ); }); const previousColumnNames = usePrevious(columnNames); const resultsSize = serverPagination ? rowCount : data.length; const sortByRef = useRef([]); // cache initial `sortby` so sorting doesn't trigger page reset const pageSizeRef = useRef([initialPageSize, resultsSize]); const hasPagination = initialPageSize > 0 && resultsSize > 0; // pageSize == 0 means no pagination const hasGlobalControl = hasPagination || !!searchInput || renderTimeComparisonDropdown; const initialState = { ...initialState_, // zero length means all pages, the `usePagination` plugin does not // understand pageSize = 0 // sortBy: sortByRef.current, sortBy: serverPagination ? sortByFromParent : sortByRef.current, pageSize: initialPageSize > 0 ? initialPageSize : resultsSize || 10, }; const defaultWrapperRef = useRef(null); const globalControlRef = useRef(null); const paginationRef = useRef(null); const wrapperRef = userWrapperRef || defaultWrapperRef; const paginationData = JSON.stringify(serverPaginationData); const defaultGetTableSize = useCallback(() => { if (wrapperRef.current) { // `initialWidth` and `initialHeight` could be also parameters like `100%` // `Number` returns `NaN` on them, then we fallback to computed size const width = Number(initialWidth) || wrapperRef.current.clientWidth; const height = (Number(initialHeight) || wrapperRef.current.clientHeight) - (globalControlRef.current?.clientHeight || 0) - (paginationRef.current?.clientHeight || 0); return { width, height }; } return undefined; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ initialHeight, initialWidth, wrapperRef, hasPagination, hasGlobalControl, paginationRef, resultsSize, paginationData, ]); const defaultGlobalFilter: FilterType = useCallback( (rows: Row[], columnIds: IdType[], filterValue: string) => { // allow searching by "col1_value col2_value" const joinedString = (row: Row) => columnIds.map(x => row.values[x]).join(' '); return matchSorter(rows, filterValue, { keys: [...columnIds, joinedString], threshold: rankings.ACRONYM, }) as typeof rows; }, [], ); const { rows, // filtered/sorted rows before pagination getTableProps, getTableBodyProps, prepareRow, headerGroups, footerGroups, page, pageCount, gotoPage, preGlobalFilteredRows, setGlobalFilter, setPageSize: setPageSize_, wrapStickyTable, setColumnOrder, allColumns, state: { pageIndex, pageSize, globalFilter: filterValue, sticky = {}, sortBy, }, } = useTable( { columns, data, initialState, getTableSize: defaultGetTableSize, globalFilter: defaultGlobalFilter, sortTypes, autoResetGlobalFilter: !isEqual(columnNames, previousColumnNames), autoResetSortBy: !isEqual(columnNames, previousColumnNames), manualSortBy: !!serverPagination, ...moreUseTableOptions, }, ...tableHooks, ); const rowSignature = useMemo( // sort the rows by id to ensure the total is not recalculated when the rows are only reordered () => rows .map((row, index) => row.id ?? index) .sort() .join('|'), [rows], ); const rowsRef = useRef(rows); rowsRef.current = rows; useEffect(() => { if (!onFilteredDataChange) { return; } const searchText = typeof filterValue === 'string' ? filterValue : undefined; onFilteredDataChange(rowsRef.current, searchText); }, [filterValue, onFilteredDataChange, rowSignature]); const handleSearchChange = useCallback( (query: string) => { if (manualSearch && onSearchChange) { onSearchChange(query); } else { setGlobalFilter(query); } }, [manualSearch, onSearchChange, setGlobalFilter], ); // updating the sort by to the own State of table viz useEffect(() => { const serverSortBy = serverPaginationData?.sortBy || []; if (serverPagination && !isEqual(sortBy, serverSortBy)) { if (Array.isArray(sortBy) && sortBy.length > 0) { const [sortByItem] = sortBy; const matchingColumn = columns.find(col => col?.id === sortByItem?.id); if (matchingColumn && 'columnKey' in matchingColumn) { const sortByWithColumnKey: SortByItem = { ...sortByItem, key: (matchingColumn as { columnKey: string }).columnKey, }; handleSortByChange([sortByWithColumnKey]); } } else { handleSortByChange([]); } } }, [sortBy]); // make setPageSize accept 0 const setPageSize = (size: number) => { if (serverPagination) { onServerPaginationChange(0, size); } // keep the original size if data is empty if (size || resultsSize !== 0) { setPageSize_(size === 0 ? resultsSize : size); } }; const noResults = typeof noResultsText === 'function' ? noResultsText(filterValue as string) : noResultsText; const getNoResults = () =>
{noResults}
; if (!columns || columns.length === 0) { return ( wrapStickyTable ? wrapStickyTable(getNoResults) : getNoResults() ) as JSX.Element; } const shouldRenderFooter = columns.some(x => !!x.Footer); let columnBeingDragged = -1; const onDragStart = (e: DragEvent) => { const el = e.target as HTMLTableCellElement; columnBeingDragged = allColumns.findIndex( col => col.id === el.dataset.columnName, ); e.dataTransfer.setData('text/plain', `${columnBeingDragged}`); }; const onDrop = (e: DragEvent) => { const el = e.target as HTMLTableCellElement; const newPosition = allColumns.findIndex( col => col.id === el.dataset.columnName, ); if (newPosition !== -1) { const currentCols = allColumns.map(c => c.id); const colToBeMoved = currentCols.splice(columnBeingDragged, 1); currentCols.splice(newPosition, 0, colToBeMoved[0]); setColumnOrder(currentCols); onColumnOrderChange?.(); } e.preventDefault(); }; const renderTable = () => ( {renderGroupingHeaders ? renderGroupingHeaders() : null} {headerGroups.map(headerGroup => { const { key: headerGroupKey, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); return ( {headerGroup.headers.map(column => column.render('Header', { key: column.id, ...column.getSortByToggleProps(), onDragStart, onDrop, }), )} ); })} {page && page.length > 0 ? ( page.map(row => { prepareRow(row); const { key: rowKey, ...rowProps } = row.getRowProps(); return ( {row.cells.map(cell => cell.render('Cell', { key: cell.column.id }), )} ); }) ) : ( )} {shouldRenderFooter && ( {footerGroups.map(footerGroup => { const { key: footerGroupKey, ...footerGroupProps } = footerGroup.getHeaderGroupProps(); return ( {footerGroup.headers.map(column => column.render('Footer', { key: column.id }), )} ); })} )}
{noResults}
); // force update the pageSize when it's been update from the initial state if ( pageSizeRef.current[0] !== initialPageSize || // when initialPageSize stays as zero, but total number of records changed, // we'd also need to update page size (initialPageSize === 0 && pageSizeRef.current[1] !== resultsSize) ) { pageSizeRef.current = [initialPageSize, resultsSize]; setPageSize(initialPageSize); } const paginationStyle: CSSProperties = sticky.height ? {} : { visibility: 'hidden' }; let resultPageCount = pageCount; let resultCurrentPageSize = pageSize; let resultCurrentPage = pageIndex; let resultOnPageChange: (page: number) => void = gotoPage; if (serverPagination) { const serverPageSize = serverPaginationData?.pageSize ?? initialPageSize; resultPageCount = Math.ceil(rowCount / serverPageSize); if (!Number.isFinite(resultPageCount)) { resultPageCount = 0; } resultCurrentPageSize = serverPageSize; const foundPageSizeIndex = pageSizeOptions.findIndex( ([option]) => option >= resultCurrentPageSize, ); if (foundPageSizeIndex === -1) { resultCurrentPageSize = 0; } resultCurrentPage = serverPaginationData?.currentPage ?? 0; resultOnPageChange = (pageNumber: number) => onServerPaginationChange(pageNumber, serverPageSize); } // Emit filtered rows to parent in client-side mode (debounced via RAF) const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); const rafRef = useRef(null); const lastSigRef = useRef(''); // Prefer a stable identifier from original row data; otherwise use a deterministic // concatenation of visible values (keys sorted so column order changes are detected). function stableRowKey(r: Row): string { const orig = r.original as Record | undefined; if (orig) { const idLike = (orig as any).id ?? (orig as any).ID ?? (orig as any).key ?? (orig as any).uuid; if (idLike != null) return String(idLike); } // Fallback: derive from row.values, but make it stable against column order changes. const v = r.values as Record; const keys = Object.keys(v).sort(); // detect column order changes return keys.map(k => String(v[k] ?? '')).join('|'); } // Very small, fast hash for strings (no crypto dependency). function hashString(s: string): string { let h = 0; for (let i = 0; i < s.length; i += 1) { // oxlint-disable-next-line unicorn/prefer-math-trunc -- | 0 is intentional for 32-bit integer wrapping in hash h = (h * 31 + s.charCodeAt(i)) | 0; } return String(h); } function signatureOfRows(rs: Row[]): string { const keys = rs.map(stableRowKey); const len = keys.length; const first = keys[0] ?? ''; const last = keys[len - 1] ?? ''; const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions return `${len}|${first}|${last}|${digest}`; } useEffect(() => { if (serverPagination || typeof onFilteredRowsChange !== 'function') { return; } const sig = signatureOfRows(rows); if (sig !== lastSigRef.current) { lastSigRef.current = sig; if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); } rafRef.current = requestAnimationFrame(() => { if (isMountedRef.current) { // Only emit originals when the signature truly changed onFilteredRowsChange(rows.map(r => r.original as D)); } }); } return () => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; }, [rows, serverPagination, onFilteredRowsChange]); return (
{hasGlobalControl ? (
{hasPagination ? ( ) : null} {serverPagination && searchInput && ( {t('Search by')}: )} {searchInput && ( searchInput={ typeof searchInput === 'boolean' ? undefined : searchInput } preGlobalFilteredRows={preGlobalFilteredRows} setGlobalFilter={ manualSearch ? handleSearchChange : setGlobalFilter } filterValue={manualSearch ? initialSearchText : filterValue} id={searchInputId} serverPagination={!!serverPagination} rowCount={rowCount} /> )} {renderTimeComparisonDropdown ? renderTimeComparisonDropdown() : null}
) : null} {wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()} {hasPagination && resultPageCount > 1 ? ( ) : null}
); });