mirror of https://github.com/grafana/grafana.git
				
				
				
			
		
			
				
	
	
		
			621 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			621 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| import { useState, useMemo, useCallback, useRef, useLayoutEffect, RefObject, CSSProperties, useEffect } from 'react';
 | |
| import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
 | |
| 
 | |
| import { compareArrayValues, Field, FieldType, formattedValueToString, reduceField, ReducerID } from '@grafana/data';
 | |
| 
 | |
| import { TableColumnResizeActionCallback } from '../types';
 | |
| 
 | |
| import { TABLE } from './constants';
 | |
| import { FilterType, FooterFieldState, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
 | |
| import {
 | |
|   getDisplayName,
 | |
|   processNestedTableRows,
 | |
|   applySort,
 | |
|   getColumnTypes,
 | |
|   getRowHeight,
 | |
|   computeColWidths,
 | |
|   buildHeaderHeightMeasurers,
 | |
|   buildCellHeightMeasurers,
 | |
|   IS_SAFARI_26,
 | |
| } from './utils';
 | |
| 
 | |
| // Helper function to get displayed value
 | |
| const getDisplayedValue = (row: TableRow, key: string, fields: Field[]) => {
 | |
|   const field = fields.find((field) => getDisplayName(field) === key);
 | |
|   if (!field || !field.display) {
 | |
|     return '';
 | |
|   }
 | |
|   const displayedValue = formattedValueToString(field.display(row[key]));
 | |
|   return displayedValue;
 | |
| };
 | |
| 
 | |
| export interface FilteredRowsResult {
 | |
|   rows: TableRow[];
 | |
|   filter: FilterType;
 | |
|   setFilter: React.Dispatch<React.SetStateAction<FilterType>>;
 | |
|   crossFilterOrder: string[];
 | |
|   crossFilterRows: Record<string, TableRow[]>;
 | |
| }
 | |
| 
 | |
| export interface FilteredRowsOptions {
 | |
|   hasNestedFrames: boolean;
 | |
| }
 | |
| 
 | |
| export function useFilteredRows(
 | |
|   rows: TableRow[],
 | |
|   fields: Field[],
 | |
|   { hasNestedFrames }: FilteredRowsOptions
 | |
| ): FilteredRowsResult {
 | |
|   // TODO: allow persisted filter selection via url
 | |
|   const [filter, setFilter] = useState<FilterType>({});
 | |
|   const filterValues = useMemo(() => Object.entries(filter), [filter]);
 | |
| 
 | |
|   const crossFilterOrder: FilteredRowsResult['crossFilterOrder'] = useMemo(
 | |
|     () => Array.from(new Set(filterValues.map(([key]) => key))),
 | |
|     [filterValues]
 | |
|   );
 | |
| 
 | |
|   const [filteredRows, crossFilterRows] = useMemo(() => {
 | |
|     const crossFilterRows: FilteredRowsResult['crossFilterRows'] = {};
 | |
| 
 | |
|     const filterRows = (row: TableRow): boolean => {
 | |
|       for (const [key, value] of filterValues) {
 | |
|         const displayedValue = getDisplayedValue(row, key, fields);
 | |
|         if (!value.filteredSet.has(displayedValue)) {
 | |
|           return false;
 | |
|         }
 | |
|         // collect rows for crossFilter
 | |
|         crossFilterRows[key] = crossFilterRows[key] ?? [];
 | |
|         crossFilterRows[key].push(row);
 | |
|       }
 | |
|       return true;
 | |
|     };
 | |
| 
 | |
|     const filteredRows = hasNestedFrames
 | |
|       ? processNestedTableRows(rows, (parents) => parents.filter(filterRows))
 | |
|       : rows.filter(filterRows);
 | |
| 
 | |
|     return [filteredRows, crossFilterRows];
 | |
|   }, [filterValues, rows, fields, hasNestedFrames]);
 | |
| 
 | |
|   return {
 | |
|     rows: filteredRows,
 | |
|     filter,
 | |
|     setFilter,
 | |
|     crossFilterOrder,
 | |
|     crossFilterRows,
 | |
|   };
 | |
| }
 | |
| 
 | |
| export interface SortedRowsOptions {
 | |
|   hasNestedFrames: boolean;
 | |
|   initialSortBy?: TableSortByFieldState[];
 | |
| }
 | |
| 
 | |
| export interface SortedRowsResult {
 | |
|   rows: TableRow[];
 | |
|   sortColumns: SortColumn[];
 | |
|   setSortColumns: React.Dispatch<React.SetStateAction<SortColumn[]>>;
 | |
| }
 | |
| 
 | |
| export function useSortedRows(
 | |
|   rows: TableRow[],
 | |
|   fields: Field[],
 | |
|   { initialSortBy, hasNestedFrames }: SortedRowsOptions
 | |
| ): SortedRowsResult {
 | |
|   const initialSortColumns = useMemo<SortColumn[]>(
 | |
|     () =>
 | |
|       initialSortBy?.flatMap(({ displayName, desc }) => {
 | |
|         if (!fields.some((f) => getDisplayName(f) === displayName)) {
 | |
|           return [];
 | |
|         }
 | |
|         return [
 | |
|           {
 | |
|             columnKey: displayName,
 | |
|             direction: desc ? ('DESC' as const) : ('ASC' as const),
 | |
|           },
 | |
|         ];
 | |
|       }) ?? [],
 | |
|     [] // eslint-disable-line react-hooks/exhaustive-deps
 | |
|   );
 | |
|   const [sortColumns, setSortColumns] = useState<SortColumn[]>(initialSortColumns);
 | |
|   const columnTypes = useMemo(() => getColumnTypes(fields), [fields]);
 | |
| 
 | |
|   const sortedRows = useMemo(
 | |
|     () => applySort(rows, fields, sortColumns, columnTypes, hasNestedFrames),
 | |
|     [rows, fields, sortColumns, hasNestedFrames, columnTypes]
 | |
|   );
 | |
| 
 | |
|   return {
 | |
|     rows: sortedRows,
 | |
|     sortColumns,
 | |
|     setSortColumns,
 | |
|   };
 | |
| }
 | |
| 
 | |
| export interface PaginatedRowsOptions {
 | |
|   height: number;
 | |
|   width: number;
 | |
|   rowHeight: NonNullable<CSSProperties['height']> | ((row: TableRow) => number);
 | |
|   headerHeight: number;
 | |
|   footerHeight: number;
 | |
|   paginationHeight?: number;
 | |
|   enabled: boolean;
 | |
| }
 | |
| 
 | |
| export interface PaginatedRowsResult {
 | |
|   rows: TableRow[];
 | |
|   page: number;
 | |
|   setPage: React.Dispatch<React.SetStateAction<number>>;
 | |
|   numPages: number;
 | |
|   rowsPerPage: number;
 | |
|   pageRangeStart: number;
 | |
|   pageRangeEnd: number;
 | |
|   smallPagination: boolean;
 | |
| }
 | |
| 
 | |
| // hand-measured. pagination height is 30px, plus 8px top margin
 | |
| const PAGINATION_HEIGHT = 38;
 | |
| 
 | |
| export function usePaginatedRows(
 | |
|   rows: TableRow[],
 | |
|   { height, width, headerHeight, footerHeight, rowHeight, enabled }: PaginatedRowsOptions
 | |
| ): PaginatedRowsResult {
 | |
|   // TODO: allow persisted page selection via url
 | |
|   const [page, setPage] = useState(0);
 | |
|   const numRows = rows.length;
 | |
| 
 | |
|   // calculate average row height if row height is variable.
 | |
|   const avgRowHeight = useMemo(() => {
 | |
|     if (!enabled) {
 | |
|       return 0;
 | |
|     }
 | |
| 
 | |
|     if (typeof rowHeight === 'number') {
 | |
|       return rowHeight;
 | |
|     }
 | |
| 
 | |
|     // when using auto-sized rows, we're just going to have to pick a number. the alternative
 | |
|     // is to measure each row, which we could do but would be expensive.
 | |
|     if (typeof rowHeight === 'string') {
 | |
|       return TABLE.MAX_CELL_HEIGHT;
 | |
|     }
 | |
| 
 | |
|     // we'll just measure 100 rows to estimate
 | |
|     return rows.slice(0, 100).reduce((avg, row, _, { length }) => avg + rowHeight(row) / length, 0);
 | |
|   }, [rows, rowHeight, enabled]);
 | |
| 
 | |
|   const smallPagination = useMemo(() => enabled && width < TABLE.PAGINATION_LIMIT, [enabled, width]);
 | |
| 
 | |
|   // using dimensions of the panel, calculate pagination parameters
 | |
|   const { numPages, rowsPerPage, pageRangeStart, pageRangeEnd } = useMemo((): {
 | |
|     numPages: number;
 | |
|     rowsPerPage: number;
 | |
|     pageRangeStart: number;
 | |
|     pageRangeEnd: number;
 | |
|   } => {
 | |
|     if (!enabled) {
 | |
|       return { numPages: 0, rowsPerPage: 0, pageRangeStart: 1, pageRangeEnd: numRows };
 | |
|     }
 | |
| 
 | |
|     // calculate number of rowsPerPage based on height stack
 | |
|     const rowAreaHeight = height - headerHeight - footerHeight - PAGINATION_HEIGHT;
 | |
|     const heightPerRow = Math.floor(rowAreaHeight / (avgRowHeight || 1));
 | |
|     // ensure at least one row per page is displayed
 | |
|     let rowsPerPage = heightPerRow > 1 ? heightPerRow : 1;
 | |
| 
 | |
|     // calculate row range for pagination summary display
 | |
|     const pageRangeStart = page * rowsPerPage + 1;
 | |
|     let pageRangeEnd = pageRangeStart + rowsPerPage - 1;
 | |
|     if (pageRangeEnd > numRows) {
 | |
|       pageRangeEnd = numRows;
 | |
|     }
 | |
| 
 | |
|     const numPages = Math.ceil(numRows / rowsPerPage);
 | |
|     return {
 | |
|       numPages,
 | |
|       rowsPerPage,
 | |
|       pageRangeStart,
 | |
|       pageRangeEnd,
 | |
|     };
 | |
|   }, [height, headerHeight, footerHeight, avgRowHeight, enabled, numRows, page]);
 | |
| 
 | |
|   // safeguard against page overflow on panel resize or other factors
 | |
|   useLayoutEffect(() => {
 | |
|     if (!enabled) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (page > numPages) {
 | |
|       // resets pagination to end
 | |
|       setPage(numPages - 1);
 | |
|     }
 | |
|   }, [numPages, enabled, page, setPage]);
 | |
| 
 | |
|   // apply pagination to the sorted rows
 | |
|   const paginatedRows = useMemo(() => {
 | |
|     if (!enabled) {
 | |
|       return rows;
 | |
|     }
 | |
|     const pageOffset = page * rowsPerPage;
 | |
|     return rows.slice(pageOffset, pageOffset + rowsPerPage);
 | |
|   }, [page, rowsPerPage, rows, enabled]);
 | |
| 
 | |
|   return {
 | |
|     rows: paginatedRows,
 | |
|     page: enabled ? page : -1,
 | |
|     setPage,
 | |
|     numPages,
 | |
|     rowsPerPage,
 | |
|     pageRangeStart,
 | |
|     pageRangeEnd,
 | |
|     smallPagination,
 | |
|   };
 | |
| }
 | |
| 
 | |
| const ICON_WIDTH = 16;
 | |
| const ICON_GAP = 4;
 | |
| 
 | |
| interface UseHeaderHeightOptions {
 | |
|   enabled: boolean;
 | |
|   fields: Field[];
 | |
|   columnWidths: number[];
 | |
|   sortColumns: SortColumn[];
 | |
|   typographyCtx: TypographyCtx;
 | |
|   showTypeIcons?: boolean;
 | |
| }
 | |
| 
 | |
| export function useHeaderHeight({
 | |
|   fields,
 | |
|   enabled,
 | |
|   columnWidths,
 | |
|   sortColumns,
 | |
|   typographyCtx,
 | |
|   showTypeIcons = false,
 | |
| }: UseHeaderHeightOptions): number {
 | |
|   const perIconSpace = ICON_WIDTH + ICON_GAP;
 | |
| 
 | |
|   const measurers = useMemo(() => buildHeaderHeightMeasurers(fields, typographyCtx), [fields, typographyCtx]);
 | |
| 
 | |
|   const columnAvailableWidths = useMemo(
 | |
|     () =>
 | |
|       columnWidths.map((c, idx) => {
 | |
|         if (idx >= fields.length) {
 | |
|           return 0; // no width available for this column yet
 | |
|         }
 | |
| 
 | |
|         let width = c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT;
 | |
|         const field = fields[idx];
 | |
| 
 | |
|         // filtering icon
 | |
|         if (field.config?.custom?.filterable) {
 | |
|           width -= perIconSpace;
 | |
|         }
 | |
|         // sorting icon
 | |
|         if (sortColumns.some((col) => col.columnKey === getDisplayName(field))) {
 | |
|           width -= perIconSpace;
 | |
|         }
 | |
|         // type icon
 | |
|         if (showTypeIcons) {
 | |
|           width -= perIconSpace;
 | |
|         }
 | |
|         // sadly, the math for this is off by exactly 1 pixel. shrug.
 | |
|         return Math.floor(width) - 1;
 | |
|       }),
 | |
|     [fields, columnWidths, sortColumns, showTypeIcons, perIconSpace]
 | |
|   );
 | |
| 
 | |
|   const headerHeight = useMemo(() => {
 | |
|     if (!enabled) {
 | |
|       return 0;
 | |
|     }
 | |
|     return getRowHeight(
 | |
|       fields,
 | |
|       -1,
 | |
|       columnAvailableWidths,
 | |
|       TABLE.HEADER_HEIGHT,
 | |
|       measurers,
 | |
|       TABLE.LINE_HEIGHT,
 | |
|       TABLE.CELL_PADDING
 | |
|     );
 | |
|   }, [fields, enabled, columnAvailableWidths, measurers]);
 | |
| 
 | |
|   return headerHeight;
 | |
| }
 | |
| 
 | |
| interface UseRowHeightOptions {
 | |
|   columnWidths: number[];
 | |
|   fields: Field[];
 | |
|   hasNestedFrames: boolean;
 | |
|   defaultHeight: NonNullable<CSSProperties['height']>;
 | |
|   expandedRows: Set<number>;
 | |
|   typographyCtx: TypographyCtx;
 | |
|   maxHeight?: number;
 | |
| }
 | |
| 
 | |
| export function useRowHeight({
 | |
|   columnWidths,
 | |
|   fields,
 | |
|   hasNestedFrames,
 | |
|   defaultHeight,
 | |
|   expandedRows,
 | |
|   typographyCtx,
 | |
|   maxHeight,
 | |
| }: UseRowHeightOptions): NonNullable<CSSProperties['height']> | ((row: TableRow) => number) {
 | |
|   const measurers = useMemo(
 | |
|     () => buildCellHeightMeasurers(fields, typographyCtx, maxHeight),
 | |
|     [fields, typographyCtx, maxHeight]
 | |
|   );
 | |
|   const hasWrappedCols = useMemo(() => measurers?.length ?? 0 > 0, [measurers]);
 | |
| 
 | |
|   const colWidths = useMemo(() => {
 | |
|     const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT;
 | |
|     return columnWidths.map((c) => c - columnWidthAffordance);
 | |
|   }, [columnWidths]);
 | |
| 
 | |
|   const rowHeight = useMemo(() => {
 | |
|     // row height is only complicated when there are nested frames or wrapped columns.
 | |
|     if ((!hasNestedFrames && !hasWrappedCols) || typeof defaultHeight === 'string') {
 | |
|       return defaultHeight;
 | |
|     }
 | |
| 
 | |
|     // this cache should get blown away on resize, data refresh, updated fields, etc.
 | |
|     // caching by __index is ok because sorting does not modify the __index.
 | |
|     const cache: Array<number | undefined> = Array(fields[0].values.length);
 | |
|     return (row: TableRow) => {
 | |
|       // nested rows
 | |
|       if (row.__depth > 0) {
 | |
|         // if unexpanded, height === 0
 | |
|         if (!expandedRows.has(row.__index)) {
 | |
|           return 0;
 | |
|         }
 | |
| 
 | |
|         const rowCount = row.data?.length ?? 0;
 | |
|         if (rowCount === 0) {
 | |
|           return TABLE.NESTED_NO_DATA_HEIGHT + TABLE.CELL_PADDING * 2;
 | |
|         }
 | |
| 
 | |
|         const nestedHeaderHeight = row.data?.meta?.custom?.noHeader ? 0 : defaultHeight;
 | |
|         return defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2;
 | |
|       }
 | |
| 
 | |
|       // regular rows
 | |
|       let result = cache[row.__index];
 | |
|       if (!result) {
 | |
|         result = cache[row.__index] = getRowHeight(fields, row.__index, colWidths, defaultHeight, measurers);
 | |
|       }
 | |
|       return result;
 | |
|     };
 | |
|   }, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, measurers, expandedRows]);
 | |
| 
 | |
|   return rowHeight;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * react-data-grid is a little unwieldy when it comes to column resize events.
 | |
|  * we want to detect a few different column resize signals:
 | |
|  *   - dragging the handle (only want to dispatch when handle is released)
 | |
|  *   - double-clicking the handle (sets the column to the minimum width to fit content)
 | |
|  * `onColumnResize` dispatches events throughout a dragged resize, and `onColumnWidthsChanged` doesn't
 | |
|  * emit an event when double-click resizing occurs, so we have to build something custom on top of these
 | |
|  * behaviors in order to get everything working.
 | |
|  */
 | |
| interface UseColumnResizeState {
 | |
|   columnKey: string | undefined;
 | |
|   width: number;
 | |
| }
 | |
| 
 | |
| const INITIAL_COL_RESIZE_STATE = Object.freeze({ columnKey: undefined, width: 0 }) satisfies UseColumnResizeState;
 | |
| 
 | |
| export function useColumnResize(
 | |
|   onColumnResize: TableColumnResizeActionCallback = () => {}
 | |
| ): DataGridProps<TableRow, TableSummaryRow>['onColumnResize'] {
 | |
|   // these must be refs. if we used setState, we would run into race conditions with these event listeners
 | |
|   const colResizeState = useRef<UseColumnResizeState>({ ...INITIAL_COL_RESIZE_STATE });
 | |
|   const pointerIsDown = useRef(false);
 | |
| 
 | |
|   // to detect whether we got a double-click resize, we track whether the pointer is currently down
 | |
|   useLayoutEffect(() => {
 | |
|     function pointerDown(_event: PointerEvent) {
 | |
|       pointerIsDown.current = true;
 | |
|     }
 | |
| 
 | |
|     function pointerUp(_event: PointerEvent) {
 | |
|       pointerIsDown.current = false;
 | |
|     }
 | |
| 
 | |
|     window.addEventListener('pointerdown', pointerDown);
 | |
|     window.addEventListener('pointerup', pointerUp);
 | |
| 
 | |
|     return () => {
 | |
|       window.removeEventListener('pointerdown', pointerDown);
 | |
|       window.removeEventListener('pointerup', pointerUp);
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   const dispatchEvent = useCallback(() => {
 | |
|     if (colResizeState.current.columnKey) {
 | |
|       onColumnResize(colResizeState.current.columnKey, Math.floor(colResizeState.current.width));
 | |
|       colResizeState.current = { ...INITIAL_COL_RESIZE_STATE };
 | |
|     }
 | |
|     window.removeEventListener('click', dispatchEvent, { capture: true });
 | |
|   }, [onColumnResize]);
 | |
| 
 | |
|   // this is the callback that gets passed to react-data-grid
 | |
|   const dataGridResizeHandler = useCallback(
 | |
|     (column: Column<TableRow, TableSummaryRow>, width: number) => {
 | |
|       if (!colResizeState.current.columnKey) {
 | |
|         window.addEventListener('click', dispatchEvent, { capture: true });
 | |
|       }
 | |
| 
 | |
|       colResizeState.current.columnKey = column.key;
 | |
|       colResizeState.current.width = width;
 | |
| 
 | |
|       // when double clicking to resize, this handler will fire, but the pointer will not be down,
 | |
|       // meaning that we should immediately flush the new width
 | |
|       if (!pointerIsDown.current) {
 | |
|         dispatchEvent();
 | |
|       }
 | |
|     },
 | |
|     [dispatchEvent]
 | |
|   );
 | |
| 
 | |
|   return dataGridResizeHandler;
 | |
| }
 | |
| 
 | |
| export function useScrollbarWidth(ref: RefObject<DataGridHandle>, height: number) {
 | |
|   const [scrollbarWidth, setScrollbarWidth] = useState(0);
 | |
| 
 | |
|   useLayoutEffect(() => {
 | |
|     const el = ref.current?.element;
 | |
| 
 | |
|     if (!el || IS_SAFARI_26) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const updateScrollbarDimensions = () => {
 | |
|       setScrollbarWidth(el.offsetWidth - el.clientWidth);
 | |
|     };
 | |
| 
 | |
|     updateScrollbarDimensions();
 | |
| 
 | |
|     const resizeObserver = new ResizeObserver(updateScrollbarDimensions);
 | |
|     resizeObserver.observe(el);
 | |
|     return () => {
 | |
|       resizeObserver.disconnect();
 | |
|     };
 | |
|   }, [ref, height]);
 | |
| 
 | |
|   return scrollbarWidth;
 | |
| }
 | |
| 
 | |
| const numIsEqual = (a: number, b: number) => a === b;
 | |
| 
 | |
| export function useColWidths(
 | |
|   visibleFields: Field[],
 | |
|   availableWidth: number,
 | |
|   frozenColumns?: number
 | |
| ): [number[], number] {
 | |
|   const [widths, setWidths] = useState<number[]>(computeColWidths(visibleFields, availableWidth));
 | |
| 
 | |
|   // only replace the widths array if something actually changed
 | |
|   useEffect(() => {
 | |
|     const newWidths = computeColWidths(visibleFields, availableWidth);
 | |
|     if (!compareArrayValues(widths, newWidths, numIsEqual)) {
 | |
|       setWidths(newWidths);
 | |
|     }
 | |
|   }, [availableWidth, widths, visibleFields]);
 | |
| 
 | |
|   // this is to avoid buggy situations where all visible columns are frozen
 | |
|   const numFrozenColsFullyInView = useMemo(() => {
 | |
|     if (!frozenColumns || frozenColumns <= 0) {
 | |
|       return -1;
 | |
|     }
 | |
| 
 | |
|     const fullyVisibleCols = widths.reduce(
 | |
|       ([count, remainingWidth], nextWidth) => {
 | |
|         if (remainingWidth - nextWidth >= 0) {
 | |
|           return [count + 1, remainingWidth - nextWidth];
 | |
|         }
 | |
|         return [count, 0];
 | |
|       },
 | |
|       [0, availableWidth]
 | |
|     )[0];
 | |
| 
 | |
|     // de-noise memoized changes to the columns array, and only change this
 | |
|     // number when the number of frozen columns changes or once there are fewer
 | |
|     // visible columns than the number of frozen columns.
 | |
|     return Math.min(fullyVisibleCols, frozenColumns);
 | |
|   }, [widths, availableWidth, frozenColumns]);
 | |
| 
 | |
|   return [widths, numFrozenColsFullyInView];
 | |
| }
 | |
| 
 | |
| const isReducer = (maybeReducer: string): maybeReducer is ReducerID => maybeReducer in ReducerID;
 | |
| const nonMathReducers = new Set<ReducerID>([
 | |
|   ReducerID.allValues,
 | |
|   ReducerID.changeCount,
 | |
|   ReducerID.count,
 | |
|   ReducerID.countAll,
 | |
|   ReducerID.distinctCount,
 | |
|   ReducerID.first,
 | |
|   ReducerID.firstNotNull,
 | |
|   ReducerID.last,
 | |
|   ReducerID.lastNotNull,
 | |
|   ReducerID.uniqueValues,
 | |
| ]);
 | |
| const isNonMathReducer = (reducer: string) => isReducer(reducer) && nonMathReducers.has(reducer);
 | |
| const noFormattingReducers = new Set<ReducerID>([ReducerID.count, ReducerID.countAll]);
 | |
| const shouldReducerSkipFormatting = (reducer: string) => isReducer(reducer) && noFormattingReducers.has(reducer);
 | |
| 
 | |
| export const useReducerEntries = (
 | |
|   field: Field,
 | |
|   rows: TableRow[],
 | |
|   displayName: string,
 | |
|   colIdx: number
 | |
| ): Array<[string, string | null]> => {
 | |
|   return useMemo(() => {
 | |
|     const reducers: string[] = field.config.custom?.footer?.reducers ?? [];
 | |
| 
 | |
|     if (
 | |
|       reducers.length === 0 ||
 | |
|       (field.type !== FieldType.number && reducers.every((reducerId) => !isNonMathReducer(reducerId)))
 | |
|     ) {
 | |
|       return [];
 | |
|     }
 | |
| 
 | |
|     // Create a new state object that matches the original behavior exactly
 | |
|     const newState: FooterFieldState = {
 | |
|       lastProcessedRowCount: 0,
 | |
|       ...(field.state || {}), // Preserve any existing state properties
 | |
|     };
 | |
| 
 | |
|     // Assign back to field
 | |
|     field.state = newState;
 | |
| 
 | |
|     const currentRowCount = rows.length;
 | |
|     const lastRowCount = newState.lastProcessedRowCount;
 | |
| 
 | |
|     // Check if we need to invalidate the cache
 | |
|     if (lastRowCount !== currentRowCount) {
 | |
|       // Cache should be invalidated as row count has changed
 | |
|       if (newState.calcs) {
 | |
|         delete newState.calcs;
 | |
|       }
 | |
|       // Update the row count tracker
 | |
|       newState.lastProcessedRowCount = currentRowCount;
 | |
|     }
 | |
| 
 | |
|     // Calculate all specified reducers
 | |
|     const results: Record<string, number | null> = reduceField({
 | |
|       field: {
 | |
|         ...field,
 | |
|         values: rows.map((row) => row[displayName]),
 | |
|       },
 | |
|       reducers,
 | |
|     });
 | |
| 
 | |
|     return reducers.map((reducerId) => {
 | |
|       if (
 | |
|         results[reducerId] === undefined ||
 | |
|         // For non-number fields, only show special count reducers
 | |
|         (field.type !== FieldType.number && !isNonMathReducer(reducerId)) ||
 | |
|         // for countAll, only show the reducer in the first column
 | |
|         (reducerId === ReducerID.countAll && colIdx !== 0)
 | |
|       ) {
 | |
|         return [reducerId, null];
 | |
|       }
 | |
| 
 | |
|       const value = results[reducerId];
 | |
|       let result = null;
 | |
|       if (!shouldReducerSkipFormatting(reducerId) && field.display) {
 | |
|         result = formattedValueToString(field.display(value));
 | |
|       } else if (value != null) {
 | |
|         result = String(value);
 | |
|       }
 | |
| 
 | |
|       return [reducerId, result];
 | |
|     });
 | |
|   }, [field, rows, displayName, colIdx]);
 | |
| };
 |