UNPKG

@ui5/webcomponents-react

Version:

React Wrapper for UI5 Web Components and additional components

440 lines (400 loc) 15.5 kB
import { useCallback, useMemo, useReducer, useRef } from 'react'; import makeDefaultPluginHooks from '../makeDefaultPluginHooks.js'; import { useGetLatest, reduceHooks, actions, loopHooks, makePropGetter, makeRenderer } from '../publicUtils.js'; import { linkColumnStructure, flattenColumns, assignColumnAccessor, unpreparedAccessWarning, makeHeaderGroups, decorateColumn } from '../utils.js'; import { useColumnVisibility } from './useColumnVisibility.js'; const defaultInitialState = {}; const defaultColumnInstance = {}; const defaultReducer = state => state; const defaultGetSubRows = (row, _index) => row.subRows || []; const defaultGetRowId = (row, index, parent) => `${parent ? [parent.id, index].join('.') : index}`; const defaultUseControlledState = d => d; function applyDefaults(props) { const { initialState = defaultInitialState, defaultColumn = defaultColumnInstance, getSubRows = defaultGetSubRows, getRowId = defaultGetRowId, stateReducer = defaultReducer, useControlledState = defaultUseControlledState, ...rest } = props; return { ...rest, initialState, defaultColumn, getSubRows, getRowId, stateReducer, useControlledState }; } export const useTable = (props, ...plugins) => { // Apply default props props = applyDefaults(props); // Add core plugins plugins = [useColumnVisibility, ...plugins]; // Create the table instance const instanceRef = useRef({}); // Create a getter for the instance (helps avoid a lot of potential memory leaks) // eslint-disable-next-line react-hooks/refs const getInstance = useGetLatest(instanceRef.current); // Assign the props, plugins and hooks to the instance Object.assign(getInstance(), { ...props, plugins, hooks: makeDefaultPluginHooks() }); // Allow plugins to register hooks as early as possible plugins.filter(Boolean).forEach(plugin => { plugin(getInstance().hooks); }); // Consume all hooks and make a getter for them const getHooks = useGetLatest(getInstance().hooks); getInstance().getHooks = getHooks; delete getInstance().hooks; // Allow useOptions hooks to modify the options coming into the table Object.assign(getInstance(), reduceHooks(getHooks().useOptions, applyDefaults(props))); const { data, columns: userColumns, initialState, defaultColumn, getSubRows, getRowId, stateReducer, useControlledState } = getInstance(); // Setup user reducer ref const getStateReducer = useGetLatest(stateReducer); // Build the reducer const reducer = useCallback((state, action) => { // Detect invalid actions if (!action.type) { console.info({ action }); throw new Error('Unknown Action 👆'); } // Reduce the state from all plugin reducers return [...getHooks().stateReducers, // Allow the user to add their own state reducer(s) ...(Array.isArray(getStateReducer()) ? getStateReducer() : [getStateReducer()])].reduce((s, handler) => handler(s, action, state, getInstance()) || s, state); }, [getHooks, getStateReducer, getInstance]); // Start the reducer const [reducerState, dispatch] = useReducer(reducer, undefined, () => reducer(initialState, { type: actions.init })); // Allow the user to control the final state with hooks const state = reduceHooks([...getHooks().useControlledState, useControlledState], reducerState, { instance: getInstance() }); Object.assign(getInstance(), { state, dispatch }); // Decorate All the columns const columns = useMemo(() => linkColumnStructure(reduceHooks(getHooks().columns, userColumns, { instance: getInstance() })), // eslint-disable-next-line react-hooks/use-memo [getHooks, getInstance, userColumns, // eslint-disable-next-line react-hooks/exhaustive-deps ...reduceHooks(getHooks().columnsDeps, [], { instance: getInstance() })]); getInstance().columns = columns; // Get the flat list of all columns and allow hooks to decorate // those columns (and trigger this memoization via deps) let allColumns = useMemo(() => reduceHooks(getHooks().allColumns, flattenColumns(columns), { instance: getInstance() }).map(assignColumnAccessor), // eslint-disable-next-line react-hooks/use-memo [columns, getHooks, getInstance, // eslint-disable-next-line react-hooks/exhaustive-deps ...reduceHooks(getHooks().allColumnsDeps, [], { instance: getInstance() })]); getInstance().allColumns = allColumns; // Access the row model using initial columns const [rows, flatRows, rowsById] = useMemo(() => { const rows = []; const flatRows = []; const rowsById = {}; const allColumnsQueue = [...allColumns]; while (allColumnsQueue.length) { const column = allColumnsQueue.shift(); accessRowsForColumn({ data, rows, flatRows, rowsById, column, getRowId, getSubRows, accessValueHooks: getHooks().accessValue, getInstance }); } return [rows, flatRows, rowsById]; }, [allColumns, data, getRowId, getSubRows, getHooks, getInstance]); Object.assign(getInstance(), { rows, initialRows: [...rows], flatRows, rowsById }); loopHooks(getHooks().useInstanceAfterData, getInstance()); // Get the flat list of all columns AFTER the rows // have been access, and allow hooks to decorate // those columns (and trigger this memoization via deps) let visibleColumns = useMemo(() => reduceHooks(getHooks().visibleColumns, allColumns, { instance: getInstance() }).map(d => decorateColumn(d, defaultColumn)), // eslint-disable-next-line react-hooks/use-memo [getHooks, allColumns, getInstance, defaultColumn, // eslint-disable-next-line react-hooks/exhaustive-deps ...reduceHooks(getHooks().visibleColumnsDeps, [], { instance: getInstance() })]); // Combine new visible columns with all columns // eslint-disable-next-line react-hooks/immutability,react-hooks/preserve-manual-memoization allColumns = useMemo(() => { const columns = [...visibleColumns]; allColumns.forEach(column => { if (!columns.find(d => d.id === column.id)) { columns.push(column); } }); return columns; }, [allColumns, visibleColumns]); getInstance().allColumns = allColumns; if (process.env.NODE_ENV !== 'production') { const duplicateColumns = allColumns.filter((column, i) => { return allColumns.findIndex(d => d.id === column.id) !== i; }); if (duplicateColumns.length) { console.info(allColumns); throw new Error(`Duplicate columns were found with ids: "${duplicateColumns.map(d => d.id).join(', ')}" in the columns array above`); } } // Make the headerGroups const headerGroups = useMemo(() => reduceHooks(getHooks().headerGroups, makeHeaderGroups(visibleColumns, defaultColumn), getInstance()), // eslint-disable-next-line react-hooks/use-memo [getHooks, visibleColumns, defaultColumn, getInstance, // eslint-disable-next-line react-hooks/exhaustive-deps ...reduceHooks(getHooks().headerGroupsDeps, [], { instance: getInstance() })]); getInstance().headerGroups = headerGroups; // Get the first level of headers const headers = useMemo(() => headerGroups.length ? headerGroups[0].headers : [], [headerGroups]); getInstance().headers = headers; // Provide a flat header list for utilities getInstance().flatHeaders = headerGroups.reduce((all, headerGroup) => [...all, ...headerGroup.headers], []); loopHooks(getHooks().useInstanceBeforeDimensions, getInstance()); // Filter columns down to visible ones const visibleColumnsDep = visibleColumns.filter(d => d.isVisible).map(d => d.id).sort().join('_'); // eslint-disable-next-line react-hooks/immutability visibleColumns = useMemo(() => visibleColumns.filter(d => d.isVisible), // eslint-disable-next-line react-hooks/exhaustive-deps [visibleColumns, visibleColumnsDep]); getInstance().visibleColumns = visibleColumns; // Header Visibility is needed by this point const [totalColumnsMinWidth, totalColumnsWidth, totalColumnsMaxWidth] = calculateHeaderWidths(headers); getInstance().totalColumnsMinWidth = totalColumnsMinWidth; getInstance().totalColumnsWidth = totalColumnsWidth; getInstance().totalColumnsMaxWidth = totalColumnsMaxWidth; loopHooks(getHooks().useInstance, getInstance()); // Each materialized header needs to be assigned a render function and other // prop getter properties here. [...getInstance().flatHeaders, ...getInstance().allColumns].forEach(column => { // Give columns/headers rendering power column.render = makeRenderer(getInstance(), column); // Give columns/headers a default getHeaderProps column.getHeaderProps = makePropGetter(getHooks().getHeaderProps, { instance: getInstance(), column }); // Give columns/headers a default getFooterProps column.getFooterProps = makePropGetter(getHooks().getFooterProps, { instance: getInstance(), column }); }); getInstance().headerGroups = useMemo(() => headerGroups.filter((headerGroup, i) => { // Filter out any headers and headerGroups that don't have visible columns headerGroup.headers = headerGroup.headers.filter(column => { const recurse = headers => headers.filter(column => { if (column.headers) { return recurse(column.headers); } return column.isVisible; }).length; if (column.headers) { return recurse(column.headers); } return column.isVisible; }); // Give headerGroups getRowProps if (headerGroup.headers.length) { headerGroup.getHeaderGroupProps = makePropGetter(getHooks().getHeaderGroupProps, { instance: getInstance(), headerGroup, index: i }); headerGroup.getFooterGroupProps = makePropGetter(getHooks().getFooterGroupProps, { instance: getInstance(), headerGroup, index: i }); return true; } return false; }), [headerGroups, getInstance, getHooks]); getInstance().footerGroups = [...getInstance().headerGroups].reverse(); // The prepareRow function is absolutely necessary and MUST be called on // any rows the user wishes to be displayed. getInstance().prepareRow = useCallback(row => { row.getRowProps = makePropGetter(getHooks().getRowProps, { instance: getInstance(), row }); // Build the visible cells for each row row.allCells = allColumns.map(column => { const value = row.values[column.id]; const cell = { column, row, value }; // Give each cell a getCellProps base cell.getCellProps = makePropGetter(getHooks().getCellProps, { instance: getInstance(), cell }); // Give each cell a renderer function (supports multiple renderers) cell.render = makeRenderer(getInstance(), column, { row, cell, value }); return cell; }); row.cells = visibleColumns.map(column => row.allCells.find(cell => cell.column.id === column.id)); // need to apply any row specific hooks (useExpanded requires this) loopHooks(getHooks().prepareRow, row, { instance: getInstance() }); }, [getHooks, getInstance, allColumns, visibleColumns]); getInstance().getTableProps = makePropGetter(getHooks().getTableProps, { instance: getInstance() }); getInstance().getTableBodyProps = makePropGetter(getHooks().getTableBodyProps, { instance: getInstance() }); loopHooks(getHooks().useFinalInstance, getInstance()); return getInstance(); }; function calculateHeaderWidths(headers, left = 0) { let sumTotalMinWidth = 0; let sumTotalWidth = 0; let sumTotalMaxWidth = 0; let sumTotalFlexWidth = 0; headers.forEach(header => { const { headers: subHeaders } = header; header.totalLeft = left; if (subHeaders && subHeaders.length) { const [totalMinWidth, totalWidth, totalMaxWidth, totalFlexWidth] = calculateHeaderWidths(subHeaders, left); header.totalMinWidth = totalMinWidth; header.totalWidth = totalWidth; header.totalMaxWidth = totalMaxWidth; header.totalFlexWidth = totalFlexWidth; } else { header.totalMinWidth = header.minWidth; header.totalWidth = Math.min(Math.max(header.minWidth, header.width), header.maxWidth); header.totalMaxWidth = header.maxWidth; header.totalFlexWidth = header.canResize ? header.totalWidth : 0; } if (header.isVisible) { left += header.totalWidth; sumTotalMinWidth += header.totalMinWidth; sumTotalWidth += header.totalWidth; sumTotalMaxWidth += header.totalMaxWidth; sumTotalFlexWidth += header.totalFlexWidth; } }); return [sumTotalMinWidth, sumTotalWidth, sumTotalMaxWidth, sumTotalFlexWidth]; } function accessRowsForColumn({ data, rows, flatRows, rowsById, column, getRowId, getSubRows, accessValueHooks, getInstance }) { // Access the row's data column-by-column // We do it this way so we can incrementally add materialized // columns after the first pass and avoid excessive looping const accessRow = (originalRow, rowIndex, depth = 0, parent, parentRows) => { // Keep the original reference around const original = originalRow; const id = getRowId(originalRow, rowIndex, parent); let row = rowsById[id]; // If the row hasn't been created, let's make it if (!row) { row = { id, original, index: rowIndex, depth, cells: [{}], // This is a dummy cell values: {} }; // Override common array functions (and the dummy cell's getCellProps function) // to show an error if it is accessed without calling prepareRow row.cells.map = unpreparedAccessWarning; row.cells.filter = unpreparedAccessWarning; row.cells.forEach = unpreparedAccessWarning; row.cells[0].getCellProps = unpreparedAccessWarning; // Push this row into the parentRows array parentRows.push(row); // Keep track of every row in a flat array flatRows.push(row); // Also keep track of every row by its ID rowsById[id] = row; // Get the original subrows row.originalSubRows = getSubRows(originalRow, rowIndex); // Then recursively access them if (row.originalSubRows) { const subRows = []; row.originalSubRows.forEach((d, i) => accessRow(d, i, depth + 1, row, subRows)); // Keep the new subRows array on the row row.subRows = subRows; } } else if (row.subRows) { // If the row exists, then it's already been accessed // Keep recursing, but don't worry about passing the // accumlator array (those rows already exist) row.originalSubRows.forEach((d, i) => accessRow(d, i, depth + 1, row)); } // If the column has an accessor, use it to get a value if (column.accessor) { row.values[column.id] = column.accessor(originalRow, rowIndex, row, parentRows, data); } // Allow plugins to manipulate the column value row.values[column.id] = reduceHooks(accessValueHooks, row.values[column.id], { row, column, instance: getInstance() }, true); }; data.forEach((originalRow, rowIndex) => accessRow(originalRow, rowIndex, 0, undefined, rows)); }