import React, { useEffect, useImperativeHandle } from 'react' import type { Column, GridRowData, GridProps, GridRowMeta, GridRowId, ColumnGroup, } from '../../types' import { isGridServerData } from '../../type-predicates' import { size } from '@planview/pv-utilities' import { GridBase } from '../grid-base' import { useGridComponents } from '../../context/grid-components-context' import { useGrid } from '../../hooks' import { GridProvider } from '../../context/grid-context' import type { GridApi } from '../../api' import { usePreferencesAdapter } from '../../hooks/use-preferences-adapter' import { ACTIONS_COLUMN_ID, SELECTION_COLUMN_ID } from '../../constants' import { useIntl } from 'react-intl' import { GridActionsMenuProvider } from '../../context/grid-actions-menu-context' import { GridLiveRegionProvider } from '../../context/grid-live-region-context' import { setColumnDefaults, setColumnGroupDefaults } from '../../utils/columns' import { useGridPrivateSettings } from '../../context/grid-private-settings-context' import { emptyIds } from '../../state/compositeSelectors/range-selection' import { getIdsFromRange } from '../../utils/range' const AUTO_SELECTION_DELAY = 500 const gridRowHeights = { small: size.small, medium: size.medium, } const GridImpl = < TDataModel extends GridRowData, TMetaModel extends GridRowMeta, >( { label, columns, columnGroups = [], enableColumnVisibilityMenu = false, rows, rowHeight = 'medium', onRowClick, selection: selectionIncoming, selectionMode = 'multi', onSelectionChange, expandedRows, onExpandedRowsChange, loading, sort, sortMode, defaultSort, onSortChange, multiColumnSort = true, filteredIds, filter, filterMode, preferencesAdapter, onCellChange, emptyContent, actionsMenu, rowDrag, }: GridProps, ref: React.ForwardedRef ) => { const { hideHeaders, initialFocusMode, loopHorizontally, spreadsheet, rangeSelection, onRangeSelectionChange, disableHorizontalScroll, onScrollbarVisibilityChange, } = useGridPrivateSettings() const { GridHeaderCellCheckbox, GridCellCheckbox, GridCellGrabber, GridCellActionsMenu, GridDragController, } = useGridComponents() const grid = useGrid() const { locale } = useIntl() useImperativeHandle(ref, () => grid.api) const { ids: selectionIds, autoScroll = false } = selectionIncoming instanceof Set ? { ids: selectionIncoming } : (selectionIncoming ?? {}) const lastSelection = React.useRef<{ ids: Set time: number } | null>(null) useEffect( () => grid.events.on('onSelectionChange', (selection) => { const { controlled } = grid.getState().selection if (!controlled) { grid.dispatch({ type: 'updateSelection', payload: selection, }) } if (selection.size <= 1) { grid.dispatch({ type: 'setSelectionAnchor', payload: selection.size === 1 ? selection.entries().next().value![0] : null, }) } lastSelection.current = { ids: selection, time: Date.now() } onSelectionChange?.(selection) }), [grid, onSelectionChange] ) useEffect( () => grid.events.on('onRangeSelectionChange', (selection) => { const state = grid.getState() const controlled = grid.selectors.selectIsRangeSelectionControlled(state) if (!controlled) { grid.dispatch({ type: 'updateRangeSelection', payload: selection, }) } const columnIds = grid.selectors.selectColumnIds(state) const rowIds = grid.selectors.selectRowIds(state) const rangeIds = selection ? getIdsFromRange(selection, rowIds, columnIds) : emptyIds onRangeSelectionChange?.(selection, { rowIds: [...rangeIds.rowIds], columnIds: [...rangeIds.columnIds], }) }), [grid, onRangeSelectionChange] ) useEffect( () => grid.events.on('onExpandedRowsChange', (expandedRows, context) => { const expandedControlled = grid.selectors.selectAreExpandedRowsControlled( grid.getState() ) if (!expandedControlled) { grid.dispatch({ type: 'updateExpandedRows', payload: expandedRows, }) } onExpandedRowsChange?.(expandedRows, context) }), [grid, onExpandedRowsChange] ) useEffect( () => grid.events.on('onSortChange', (sort) => { const { controlled } = grid.getState().sort if (!controlled) { grid.dispatch({ type: 'updateSort', payload: sort, }) } onSortChange?.(sort) }), [grid, onSortChange] ) useEffect(() => { grid.dispatch({ type: 'updateSortLocale', payload: locale, }) }, [grid, locale]) useEffect( () => grid.events.on('onCellChange', (payload) => { onCellChange?.(payload) }), [grid, onCellChange] ) useEffect( () => grid.events.on('onRowClick', (rowId) => { onRowClick?.(rowId) }), [grid, onRowClick] ) useEffect( () => grid.events.on('onScrollbarVisibilityChange', (args) => { onScrollbarVisibilityChange?.(args) }), [grid, onScrollbarVisibilityChange] ) const actionsMenuPresent = !!actionsMenu const groupsWithDefaults: ColumnGroup[] = React.useMemo( () => columnGroups.map(setColumnGroupDefaults), [columnGroups] ) const rowDragEnabled = !!rowDrag const columnsWithDefaults: Column[] = React.useMemo(() => { const columnDefs: Column[] = columns.map(setColumnDefaults) if (spreadsheet && rowDragEnabled && selectionMode !== 'multi') { columnDefs.unshift( setColumnDefaults({ id: SELECTION_COLUMN_ID, label: '', width: size.smallCompact, resizable: false, sortable: false, movable: false, hideable: false, sticky: columnDefs[0]?.sticky, lockedLocation: 'left', cell: { Renderer: GridCellGrabber, }, } as Column) ) } if (selectionMode === 'multi') { columnDefs.unshift({ id: SELECTION_COLUMN_ID, label: '', width: spreadsheet && rowDragEnabled ? size.small + size.smallCompact : size.small, resizable: false, sortable: false, movable: false, hideable: false, sticky: columnDefs[0]?.sticky, lockedLocation: 'left', header: { Renderer: GridHeaderCellCheckbox, }, cell: { Renderer: GridCellCheckbox, }, } as Column) } if (actionsMenuPresent) { columnDefs.push({ id: ACTIONS_COLUMN_ID, label: '', width: size.small, resizable: false, sortable: false, movable: false, hideable: false, sticky: 'right', lockedLocation: 'right', cell: { Renderer: GridCellActionsMenu, }, } as Column) } return columnDefs }, [ spreadsheet, columns, selectionMode, actionsMenuPresent, rowDragEnabled, GridHeaderCellCheckbox, GridCellCheckbox, GridCellGrabber, GridCellActionsMenu, ]) const footerEnabled = React.useMemo( () => columnsWithDefaults.some( (c) => c.footer?.aggregation || c.footer?.label ), [columnsWithDefaults] ) const filterFn = React.useMemo(() => { if (filter) { return filter } if (filteredIds) { const filteredIdSet = new Set(filteredIds) return (row: any) => filteredIdSet.has(row.id) } }, [filteredIds, filter]) const rowDragSettings = React.useMemo( () => ({ enabled: rowDragEnabled, mode: rowDrag?.mode ?? 'default', multiple: rowDrag?.multiple === true, enableLeafConversion: rowDrag?.enableLeafConversion, canDrop: rowDrag?.canDrop, previewColumnId: rowDrag?.previewColumnId ?? undefined, }), [ rowDragEnabled, rowDrag?.mode, rowDrag?.multiple, rowDrag?.enableLeafConversion, rowDrag?.canDrop, rowDrag?.previewColumnId, ] ) useEffect(() => { const onDrop = rowDrag?.onDrop return grid.events.on('onRowDrop', (payload) => { onDrop?.(payload) }) }, [grid, rowDrag?.onDrop]) useEffect(() => { grid.dispatch({ type: 'applyProps', payload: { loading, actionsMenuPresent, rows, expandedRows, selection: selectionIds, selectionMode: selectionMode === 'multi-hidden' ? 'multi' : selectionMode, filter: filterFn, filterMode, sort, sortMode, multiColumnSort, defaultSort, columns: columnsWithDefaults, columnGroups: groupsWithDefaults, enableColumnVisibilityMenu: enableColumnVisibilityMenu && columnsWithDefaults.some((c) => c.hideable !== false), footerEnabled, rowDrag: rowDragSettings, hideHeaders, initialFocusMode, loopHorizontally, spreadsheet, rangeSelection, }, }) }, [ grid, rows, expandedRows, selectionIds, sort, sortMode, multiColumnSort, defaultSort, filterFn, filterMode, selectionMode, columnsWithDefaults, actionsMenuPresent, footerEnabled, rowDragSettings, groupsWithDefaults, enableColumnVisibilityMenu, loading, hideHeaders, initialFocusMode, loopHorizontally, spreadsheet, rangeSelection, ]) useEffect(() => { /* Don't apply change while loading */ if (loading) { return } if (autoScroll && selectionIds && selectionIds.size > 0) { if (lastSelection.current) { const { ids, time } = lastSelection.current const now = Date.now() if ( time - now < AUTO_SELECTION_DELAY && ids.size === selectionIds.size && [...selectionIds].every((id) => ids.has(id)) ) { // This is the same as the last update, // we don't need to auto scroll this return } lastSelection.current = null } const rowId = [...selectionIds][selectionIds.size - 1] /* Give time for render of rows and rowVirtualizer to update */ const raf = window.requestAnimationFrame(() => { const columnIds = grid.selectors.selectColumnIds( grid.getState() ) grid.api.scroll.scrollTo(rowId, columnIds[0], 'auto') grid.api.focus.set(rowId, columnIds[0], 'body', 'first') }) return () => { window.cancelAnimationFrame(raf) } } else { lastSelection.current = null } }, [grid, loading, selectionIds, autoScroll]) const rowsFetch = isGridServerData(rows) ? rows.fetch : null useEffect(() => { if (rowsFetch) { return grid.events.on('onVisibleRowsChange', rowsFetch) } }, [grid, rowsFetch]) const preferencesInitialized = usePreferencesAdapter({ grid, preferencesAdapter, }) if (!preferencesInitialized) { return null } return ( ) } /** * **Important:** The Grid relies heavily on receiving immutable data * that changes only when actual changes occur. Even though the examples * will show arrays and objects being passed directly into `columns` * and `rows` props, in your actual application you'll want to ensure * you are passing stable objects into these properties. */ export const Grid = React.forwardRef(GridImpl) as < TDataModel extends GridRowData, TMetaModel extends GridRowMeta, >( props: GridProps & { ref?: React.ForwardedRef } ) => ReturnType