diff --git a/REUSE.toml b/REUSE.toml index cf8f5cf1870..d5ea777c0c7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -39,14 +39,20 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2022 Adobe Inc." SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = "packages/main/src/components/AnalyticalTable/react-table/**" +precedence = "aggregate" +SPDX-FileCopyrightText = "2016 Tanner Linsley" +SPDX-License-Identifier = "MIT" + [[annotations]] path = "packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts" precedence = "aggregate" -SPDX-FileCopyrightText = "2019-2021 Tanner Linsley" +SPDX-FileCopyrightText = "2016 Tanner Linsley" SPDX-License-Identifier = "MIT" [[annotations]] path = "packages/main/src/components/AnalyticalTable/hooks/useColumnResizing.ts" precedence = "aggregate" -SPDX-FileCopyrightText = "2019-2021 Tanner Linsley" +SPDX-FileCopyrightText = "2016 Tanner Linsley" SPDX-License-Identifier = "MIT" diff --git a/packages/main/package.json b/packages/main/package.json index ce4b2923bbf..93afa20ff74 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -814,8 +814,7 @@ "dependencies": { "@tanstack/react-virtual": "3.13.24", "@ui5/webcomponents-react-base": "workspace:~", - "clsx": "2.1.1", - "react-table": "7.8.0" + "clsx": "2.1.1" }, "peerDependencies": { "@types/react": "*", diff --git a/packages/main/src/components/AnalyticalTable/defaults/Column/PopIn.tsx b/packages/main/src/components/AnalyticalTable/defaults/Column/PopIn.tsx index 579556c2598..35e74e9e0d5 100644 --- a/packages/main/src/components/AnalyticalTable/defaults/Column/PopIn.tsx +++ b/packages/main/src/components/AnalyticalTable/defaults/Column/PopIn.tsx @@ -1,9 +1,9 @@ -import { makeRenderer } from 'react-table'; import { AnalyticalTablePopinDisplay } from '../../../../enums/AnalyticalTablePopinDisplay.js'; import { FlexBoxAlignItems } from '../../../../enums/FlexBoxAlignItems.js'; import { FlexBoxDirection } from '../../../../enums/FlexBoxDirection.js'; import { FlexBoxWrap } from '../../../../enums/FlexBoxWrap.js'; import { FlexBox } from '../../../FlexBox/index.js'; +import { makeRenderer } from '../../react-table/index.js'; import type { CellInstance } from '../../types/index.js'; import { RenderColumnTypes } from '../../types/index.js'; diff --git a/packages/main/src/components/AnalyticalTable/hooks/useColumnResizing.ts b/packages/main/src/components/AnalyticalTable/hooks/useColumnResizing.ts index 5b5c186ceca..0e5fd8b5194 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useColumnResizing.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useColumnResizing.ts @@ -1,6 +1,13 @@ import type { MouseEvent, TouchEvent } from 'react'; import { useCallback } from 'react'; -import { actions, defaultColumn, makePropGetter, useGetLatest, useMountedLayoutEffect } from 'react-table'; +import { + actions, + defaultColumn, + getFirstDefined, + makePropGetter, + useGetLatest, + useMountedLayoutEffect, +} from '../react-table/index.js'; import type { ColumnType, ReactTableHooks, TableInstance } from '../types/index.js'; // Default Column @@ -242,15 +249,6 @@ const reducer: TableInstance['stateReducer'] = (state, action) => { } }; -// Replaces react-table's internal `getFirstDefined` from `utils.js` (not publicly exported) -function getFirstDefined(...args: (T | undefined)[]): T | undefined { - for (let i = 0; i < args.length; i += 1) { - if (typeof args[i] !== 'undefined') { - return args[i]; - } - } -} - const useInstanceBeforeDimensions = (instance: TableInstance) => { const { flatHeaders, diff --git a/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts b/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts index 7adc25cfde8..6cb0abb334a 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts @@ -1,5 +1,5 @@ -import { ensurePluginOrder } from 'react-table'; import { AnalyticalTableScaleWidthMode } from '../../../enums/AnalyticalTableScaleWidthMode.js'; +import { ensurePluginOrder } from '../react-table/index.js'; import type { AnalyticalTableColumnDefinition, ColumnType, diff --git a/packages/main/src/components/AnalyticalTable/hooks/useKeyboardNavigation.ts b/packages/main/src/components/AnalyticalTable/hooks/useKeyboardNavigation.ts index 8938743469e..023030953bc 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useKeyboardNavigation.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useKeyboardNavigation.ts @@ -1,6 +1,6 @@ import type { FocusEventHandler, KeyboardEvent, KeyboardEventHandler, MutableRefObject } from 'react'; import { useCallback, useEffect, useRef } from 'react'; -import { actions } from 'react-table'; +import { actions } from '../react-table/index.js'; import type { ColumnType, ReactTableHooks, TableInstance } from '../types/index.js'; import { getLeafHeaders, NAVIGATION_KEYS } from '../util/index.js'; diff --git a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts index 101159fb3e7..ae8a8e46f71 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useRowSelect.ts @@ -1,6 +1,12 @@ import { useCallback, useMemo } from 'react'; -import { actions, makePropGetter, ensurePluginOrder, useGetLatest, useMountedLayoutEffect } from 'react-table'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; +import { + actions, + makePropGetter, + ensurePluginOrder, + useGetLatest, + useMountedLayoutEffect, +} from '../react-table/index.js'; import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; const pluginName = 'useRowSelect'; diff --git a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts index eca328d39ef..23b8b906c00 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useSelectionChangeCallback.ts @@ -1,7 +1,7 @@ import { enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils'; import { useEffect, useRef } from 'react'; -import { ensurePluginOrder } from 'react-table'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; +import { ensurePluginOrder } from '../react-table/index.js'; import type { AnalyticalTablePropTypes, ReactTableHooks, TableInstance } from '../types/index.js'; type OnRowSelectEvent = Parameters>[0]; diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index b81ea6153fd..f75df1b5d54 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -14,7 +14,6 @@ import { import { clsx } from 'clsx'; import type { CSSProperties } from 'react'; import { forwardRef, useCallback, useEffect, useId, useMemo, useRef } from 'react'; -import { useColumnOrder, useExpanded, useFilters, useGlobalFilter, useGroupBy, useSortBy, useTable } from 'react-table'; import { AnalyticalTableNoDataReason } from '../../enums/AnalyticalTableNoDataReason.js'; import { AnalyticalTablePopinDisplay } from '../../enums/AnalyticalTablePopinDisplay.js'; import { AnalyticalTableScaleWidthMode } from '../../enums/AnalyticalTableScaleWidthMode.js'; @@ -74,6 +73,15 @@ import { useStyling } from './hooks/useStyling.js'; import { useSyncScroll } from './hooks/useSyncScroll.js'; import { useToggleRowExpand } from './hooks/useToggleRowExpand.js'; import { useVisibleColumnsWidth } from './hooks/useVisibleColumnsWidth.js'; +import { + useColumnOrder, + useExpanded, + useFilters, + useGlobalFilter, + useGroupBy, + useSortBy, + useTable, +} from './react-table/index.js'; import { VerticalScrollbar } from './scrollbars/VerticalScrollbar.js'; import { VirtualTableBody } from './TableBody/VirtualTableBody.js'; import { VirtualTableBodyContainer } from './TableBody/VirtualTableBodyContainer.js'; diff --git a/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx b/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx index eac5de1a552..ffbfdddacde 100644 --- a/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx +++ b/packages/main/src/components/AnalyticalTable/pluginHooks/useRowDisableSelection.tsx @@ -2,8 +2,8 @@ import { enrichEventWithDetails } from '@ui5/webcomponents-react-base'; import { AnalyticalTableSelectionBehavior } from '../../../enums/AnalyticalTableSelectionBehavior.js'; import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js'; import { CheckBox } from '../../../webComponents/CheckBox/index.js'; +import { getBy } from '../react-table/index.js'; import type { ReactTableHooks, RowType, TableInstance } from '../types/index.js'; -import { getBy } from '../util/index.js'; type DisableRowSelectionType = string | ((row: RowType) => boolean); diff --git a/packages/main/src/components/AnalyticalTable/react-table/aggregations.ts b/packages/main/src/components/AnalyticalTable/react-table/aggregations.ts new file mode 100644 index 00000000000..770dea5bd7b --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/aggregations.ts @@ -0,0 +1,69 @@ +export function sum(values: any[], aggregatedValues: number[]): number { + // It's faster to just add the aggregations together instead of + // process leaf nodes individually + return aggregatedValues.reduce((sum, next) => sum + (typeof next === 'number' ? next : 0), 0); +} + +export function min(values: number[]): number { + let min = values[0] || 0; + + values.forEach((value) => { + if (typeof value === 'number') { + min = Math.min(min, value); + } + }); + + return min; +} + +export function max(values: number[]): number { + let max = values[0] || 0; + + values.forEach((value) => { + if (typeof value === 'number') { + max = Math.max(max, value); + } + }); + + return max; +} + +export function minMax(values: number[]): string { + let min = values[0] || 0; + let max = values[0] || 0; + + values.forEach((value) => { + if (typeof value === 'number') { + min = Math.min(min, value); + max = Math.max(max, value); + } + }); + + return `${min}..${max}`; +} + +export function average(values: number[]): number { + return sum(null, values) / values.length; +} + +export function median(values: number[]): number | null { + if (!values.length) { + return null; + } + + const mid = Math.floor(values.length / 2); + const nums = [...values].sort((a, b) => a - b); + return values.length % 2 !== 0 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2; +} + +export function unique(values: any[]): any[] { + return Array.from(new Set(values).values()); +} + +export function uniqueCount(values: any[]): number { + return new Set(values).size; +} + +export function count(values: any[]): number { + return values.length; +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/filterTypes.ts b/packages/main/src/components/AnalyticalTable/react-table/filterTypes.ts new file mode 100644 index 00000000000..34374c82b89 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/filterTypes.ts @@ -0,0 +1,113 @@ +import type { RowType, FilterFn } from '../types/index.js'; + +export const text: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return String(rowValue).toLowerCase().includes(String(filterValue).toLowerCase()); + }); + }); +}; +text.autoRemove = (val: any) => !val; + +export const exactText: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return rowValue !== undefined ? String(rowValue).toLowerCase() === String(filterValue).toLowerCase() : true; + }); + }); +}; +exactText.autoRemove = (val: any) => !val; + +export const exactTextCase: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return rowValue !== undefined ? String(rowValue) === String(filterValue) : true; + }); + }); +}; +exactTextCase.autoRemove = (val: any) => !val; + +export const includes: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return rowValue.includes(filterValue); + }); + }); +}; +includes.autoRemove = (val: any) => !val || !val.length; + +export const includesAll: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return rowValue && rowValue.length && filterValue.every((val: any) => rowValue.includes(val)); + }); + }); +}; +includesAll.autoRemove = (val: any) => !val || !val.length; + +export const includesSome: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return rowValue && rowValue.length && filterValue.some((val: any) => rowValue.includes(val)); + }); + }); +}; +includesSome.autoRemove = (val: any) => !val || !val.length; + +export const includesValue: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return filterValue.includes(rowValue); + }); + }); +}; +includesValue.autoRemove = (val: any) => !val || !val.length; + +export const exact: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return rowValue === filterValue; + }); + }); +}; +exact.autoRemove = (val: any) => typeof val === 'undefined'; + +export const equals: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + + return rowValue == filterValue; + }); + }); +}; +equals.autoRemove = (val: any) => val == null; + +export const between: FilterFn = (rows: RowType[], ids: string[], filterValue: any): RowType[] => { + let [min, max] = filterValue || []; + + min = typeof min === 'number' ? min : -Infinity; + max = typeof max === 'number' ? max : Infinity; + + if (min > max) { + const temp = min; + min = max; + max = temp; + } + + return rows.filter((row) => { + return ids.some((id) => { + const rowValue = row.values[id]; + return rowValue >= min && rowValue <= max; + }); + }); +}; +between.autoRemove = (val: any) => !val || (typeof val[0] !== 'number' && typeof val[1] !== 'number'); diff --git a/packages/main/src/components/AnalyticalTable/react-table/hooks/useColumnVisibility.ts b/packages/main/src/components/AnalyticalTable/react-table/hooks/useColumnVisibility.ts new file mode 100644 index 00000000000..80b8eccc8c6 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/hooks/useColumnVisibility.ts @@ -0,0 +1,193 @@ +import { useCallback } from 'react'; +import type { ReactTableHooks, TableInstance, ColumnType, PluginHook } from '../../types/index.js'; +import { actions, functionalUpdate, useGetLatest, makePropGetter, useMountedLayoutEffect } from '../publicUtils.js'; + +actions.resetHiddenColumns = 'resetHiddenColumns'; +actions.toggleHideColumn = 'toggleHideColumn'; +actions.setHiddenColumns = 'setHiddenColumns'; +actions.toggleHideAllColumns = 'toggleHideAllColumns'; + +export const useColumnVisibility: PluginHook = (hooks: ReactTableHooks) => { + hooks.getToggleHiddenProps = [defaultGetToggleHiddenProps]; + hooks.getToggleHideAllColumnsProps = [defaultGetToggleHideAllColumnsProps]; + + hooks.stateReducers.push(reducer); + hooks.useInstanceBeforeDimensions.push(useInstanceBeforeDimensions); + hooks.headerGroupsDeps.push((deps, { instance }) => [...deps, instance.state.hiddenColumns]); + hooks.useInstance.push(useInstance); +}; +useColumnVisibility.pluginName = 'useColumnVisibility'; + +const defaultGetToggleHiddenProps = (props: Record, { column }: { column: ColumnType }) => [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + column.toggleHidden(!e.target.checked); + }, + style: { + cursor: 'pointer', + }, + checked: column.isVisible, + title: 'Toggle Column Visible', + }, +]; + +const defaultGetToggleHideAllColumnsProps = (props: Record, { instance }: { instance: TableInstance }) => [ + props, + { + onChange: (e: { target: { checked: boolean } }) => { + instance.toggleHideAllColumns(!e.target.checked); + }, + style: { + cursor: 'pointer', + }, + checked: !instance.allColumnsHidden && !instance.state.hiddenColumns.length, + title: 'Toggle All Columns Hidden', + indeterminate: !instance.allColumnsHidden && instance.state.hiddenColumns.length, + }, +]; + +function reducer( + state: TableInstance['state'], + action: { type: string; columnId?: string; value?: boolean | string[] | ((old: string[]) => string[]) }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.init) { + return { + hiddenColumns: [], + ...state, + }; + } + + if (action.type === actions.resetHiddenColumns) { + return { + ...state, + hiddenColumns: instance.initialState.hiddenColumns || [], + }; + } + + if (action.type === actions.toggleHideColumn) { + const should = typeof action.value !== 'undefined' ? action.value : !state.hiddenColumns.includes(action.columnId); + + const hiddenColumns = should + ? [...state.hiddenColumns, action.columnId] + : state.hiddenColumns.filter((d: string) => d !== action.columnId); + + return { + ...state, + hiddenColumns, + }; + } + + if (action.type === actions.setHiddenColumns) { + return { + ...state, + hiddenColumns: functionalUpdate(action.value, state.hiddenColumns), + }; + } + + if (action.type === actions.toggleHideAllColumns) { + const shouldAll = typeof action.value !== 'undefined' ? action.value : !state.hiddenColumns.length; + + return { + ...state, + hiddenColumns: shouldAll ? instance.allColumns.map((d: ColumnType) => d.id) : [], + }; + } +} + +function useInstanceBeforeDimensions(instance: TableInstance) { + const { + headers, + state: { hiddenColumns }, + } = instance; + + const handleColumn = (column: ColumnType, parentVisible: boolean): number => { + column.isVisible = parentVisible && !hiddenColumns.includes(column.id); + + let totalVisibleHeaderCount = 0; + + if (column.headers && column.headers.length) { + column.headers.forEach( + (subColumn: ColumnType) => (totalVisibleHeaderCount += handleColumn(subColumn, column.isVisible)), + ); + } else { + totalVisibleHeaderCount = column.isVisible ? 1 : 0; + } + + column.totalVisibleHeaderCount = totalVisibleHeaderCount; + + return totalVisibleHeaderCount; + }; + + let totalVisibleHeaderCount = 0; + + headers.forEach((subHeader: ColumnType) => (totalVisibleHeaderCount += handleColumn(subHeader, true))); +} + +function useInstance(instance: TableInstance) { + const { + columns, + flatHeaders, + dispatch, + allColumns, + getHooks, + state: { hiddenColumns }, + autoResetHiddenColumns = true, + } = instance; + + const getInstance = useGetLatest(instance); + + const allColumnsHidden = allColumns.length === hiddenColumns.length; + + const toggleHideColumn = useCallback( + (columnId: string, value?: boolean) => dispatch({ type: actions.toggleHideColumn, columnId, value }), + [dispatch], + ); + + const setHiddenColumns = useCallback( + (value: string[] | ((old: string[]) => string[])) => dispatch({ type: actions.setHiddenColumns, value }), + [dispatch], + ); + + const toggleHideAllColumns = useCallback( + (value?: boolean) => dispatch({ type: actions.toggleHideAllColumns, value }), + [dispatch], + ); + + const getToggleHideAllColumnsProps = makePropGetter(getHooks().getToggleHideAllColumnsProps, { + instance: getInstance(), + }); + + flatHeaders.forEach((column: ColumnType) => { + column.toggleHidden = (value?: boolean) => { + dispatch({ + type: actions.toggleHideColumn, + columnId: column.id, + value, + }); + }; + + column.getToggleHiddenProps = makePropGetter(getHooks().getToggleHiddenProps, { + instance: getInstance(), + column, + }); + }); + + const getAutoResetHiddenColumns = useGetLatest(autoResetHiddenColumns); + + useMountedLayoutEffect(() => { + if (getAutoResetHiddenColumns()) { + dispatch({ type: actions.resetHiddenColumns }); + } + }, [dispatch, columns]); + + Object.assign(instance, { + allColumnsHidden, + toggleHideColumn, + setHiddenColumns, + toggleHideAllColumns, + getToggleHideAllColumnsProps, + }); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/hooks/useTable.ts b/packages/main/src/components/AnalyticalTable/react-table/hooks/useTable.ts new file mode 100644 index 00000000000..75e2c0f0db6 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/hooks/useTable.ts @@ -0,0 +1,564 @@ +import { useCallback, useMemo, useReducer, useRef } from 'react'; +import type { ReactTableHooks, TableInstance, ColumnType, RowType, CellType, PluginHook } from '../../types/index.js'; +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: NonNullable = (state) => state; +const defaultGetSubRows = (row: Record, _index: number) => row.subRows || []; +const defaultGetRowId = (row: Record, index: number, parent?: RowType) => + `${parent ? [parent.id, index].join('.') : index}`; +const defaultUseControlledState = (d: TableInstance['state']) => d; + +function applyDefaults(props: any) { + 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: any, + ...plugins: Array void)> +): TableInstance => { + // 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) as () => any; + + // 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: any, action: { type: string; [key: string]: any }) => { + // 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: any, handler: any) => 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: ColumnType[] = 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: RowType[] = []; + const flatRows: RowType[] = []; + const rowsById: Record = {}; + + 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: ColumnType[] = useMemo( + () => + reduceHooks(getHooks().visibleColumns, allColumns, { + instance: getInstance(), + }).map((d: ColumnType) => 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: ColumnType[], headerGroup: any) => [...all, ...headerGroup.headers], + [], + ); + + loopHooks(getHooks().useInstanceBeforeDimensions, getInstance()); + + // Filter columns down to visible ones + const visibleColumnsDep = visibleColumns + .filter((d: ColumnType) => d.isVisible) + .map((d) => d.id) + .sort() + .join('_'); + + // eslint-disable-next-line react-hooks/immutability + visibleColumns = useMemo( + () => visibleColumns.filter((d: ColumnType) => 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: ColumnType) => { + // 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: any, i: number) => { + // Filter out any headers and headerGroups that don't have visible columns + headerGroup.headers = headerGroup.headers.filter((column: ColumnType) => { + const recurse = (headers: ColumnType[]): number => + headers.filter((column: ColumnType) => { + 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: RowType) => { + 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: CellType = { + column, + row, + value, + } as CellType; + + // 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), + ) as CellType[]; + + // 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: ColumnType[], left = 0): [number, number, number, number] { + 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]; +} + +interface AccessRowsForColumnOptions { + data: Record[]; + rows: RowType[]; + flatRows: RowType[]; + rowsById: Record; + column: ColumnType; + getRowId: (row: Record, index: number, parent?: RowType) => string; + getSubRows: (row: Record, index: number) => Record[]; + accessValueHooks: any[]; + getInstance: () => TableInstance; +} + +function accessRowsForColumn({ + data, + rows, + flatRows, + rowsById, + column, + getRowId, + getSubRows, + accessValueHooks, + getInstance, +}: AccessRowsForColumnOptions) { + // 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: Record, + rowIndex: number, + depth = 0, + parent?: RowType, + parentRows?: RowType[], + ) => { + // 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: [{}] as any, // 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: RowType[] = []; + row.originalSubRows.forEach((d: Record, i: number) => 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: Record, i: number) => 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: Record, rowIndex: number) => + accessRow(originalRow, rowIndex, 0, undefined, rows), + ); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/index.ts b/packages/main/src/components/AnalyticalTable/react-table/index.ts new file mode 100644 index 00000000000..377f46732c3 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/index.ts @@ -0,0 +1,40 @@ +export { useTable } from './hooks/useTable.js'; +export { useExpanded } from './plugin-hooks/useExpanded.js'; +export { useFilters } from './plugin-hooks/useFilters.js'; +export { useGlobalFilter } from './plugin-hooks/useGlobalFilter.js'; +export { useGroupBy, defaultGroupByFn } from './plugin-hooks/useGroupBy.js'; +export { useSortBy, defaultOrderByFn } from './plugin-hooks/useSortBy.js'; +export { useColumnOrder } from './plugin-hooks/useColumnOrder.js'; +export { + actions, + defaultColumn, + makePropGetter, + useGetLatest, + useMountedLayoutEffect, + ensurePluginOrder, + makeRenderer, + functionalUpdate, + reduceHooks, + loopHooks, + flexRender, + useAsyncDebounce, + safeUseLayoutEffect, +} from './publicUtils.js'; +export { + getFirstDefined, + isFunction, + flattenBy, + expandRows, + getBy, + getFilterMethod, + shouldAutoRemoveFilter, + unpreparedAccessWarning, + passiveEventSupported, + findMaxDepth, + linkColumnStructure, + flattenColumns, + assignColumnAccessor, + decorateColumn, + makeHeaderGroups, + getElementDimensions, +} from './utils.js'; diff --git a/packages/main/src/components/AnalyticalTable/react-table/makeDefaultPluginHooks.ts b/packages/main/src/components/AnalyticalTable/react-table/makeDefaultPluginHooks.ts new file mode 100644 index 00000000000..5957583f6d2 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/makeDefaultPluginHooks.ts @@ -0,0 +1,79 @@ +import type { ReactTableHooks, RowType, ColumnType, CellType } from '../types/index.js'; + +const defaultGetTableProps = (props: Record) => ({ + role: 'table', + ...props, +}); + +const defaultGetTableBodyProps = (props: Record) => ({ + role: 'rowgroup', + ...props, +}); + +const defaultGetHeaderProps = (props: Record, { column }: { column: ColumnType }) => ({ + key: `header_${column.id}`, + colSpan: column.totalVisibleHeaderCount, + role: 'columnheader', + ...props, +}); + +const defaultGetFooterProps = (props: Record, { column }: { column: ColumnType }) => ({ + key: `footer_${column.id}`, + colSpan: column.totalVisibleHeaderCount, + ...props, +}); + +const defaultGetHeaderGroupProps = (props: Record, { index }: { index: number }) => ({ + key: `headerGroup_${index}`, + role: 'row', + ...props, +}); + +const defaultGetFooterGroupProps = (props: Record, { index }: { index: number }) => ({ + key: `footerGroup_${index}`, + ...props, +}); + +const defaultGetRowProps = (props: Record, { row }: { row: RowType }) => ({ + key: `row_${row.id}`, + role: 'row', + ...props, +}); + +const defaultGetCellProps = (props: Record, { cell }: { cell: CellType }) => ({ + key: `cell_${cell.row.id}_${cell.column.id}`, + role: 'cell', + ...props, +}); + +export default function makeDefaultPluginHooks(): ReactTableHooks { + return { + useOptions: [], + stateReducers: [], + useControlledState: [], + columns: [], + columnsDeps: [], + allColumns: [], + allColumnsDeps: [], + accessValue: [], + materializedColumns: [], + materializedColumnsDeps: [], + useInstanceAfterData: [], + visibleColumns: [], + visibleColumnsDeps: [], + headerGroups: [], + headerGroupsDeps: [], + useInstanceBeforeDimensions: [], + useInstance: [], + prepareRow: [], + getTableProps: [defaultGetTableProps], + getTableBodyProps: [defaultGetTableBodyProps], + getHeaderGroupProps: [defaultGetHeaderGroupProps], + getFooterGroupProps: [defaultGetFooterGroupProps], + getHeaderProps: [defaultGetHeaderProps], + getFooterProps: [defaultGetFooterProps], + getRowProps: [defaultGetRowProps], + getCellProps: [defaultGetCellProps], + useFinalInstance: [], + }; +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useColumnOrder.ts b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useColumnOrder.ts new file mode 100644 index 00000000000..15ba9b12988 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useColumnOrder.ts @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; +import type { ReactTableHooks, TableInstance, ColumnType, PluginHook } from '../../types/index.js'; +import { functionalUpdate, actions } from '../publicUtils.js'; + +// Actions +actions.resetColumnOrder = 'resetColumnOrder'; +actions.setColumnOrder = 'setColumnOrder'; + +export const useColumnOrder: PluginHook = (hooks: ReactTableHooks) => { + hooks.stateReducers.push(reducer); + hooks.visibleColumnsDeps.push((deps, { instance }) => { + return [...deps, instance.state.columnOrder]; + }); + hooks.visibleColumns.push(visibleColumns); + hooks.useInstance.push(useInstance); +}; +useColumnOrder.pluginName = 'useColumnOrder'; + +function reducer( + state: TableInstance['state'], + action: { type: string; columnOrder?: string[] | ((order: string[]) => string[]) }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.init) { + return { + columnOrder: [], + ...state, + }; + } + + if (action.type === actions.resetColumnOrder) { + return { + ...state, + columnOrder: instance.initialState.columnOrder || [], + }; + } + + if (action.type === actions.setColumnOrder) { + return { + ...state, + columnOrder: functionalUpdate(action.columnOrder, state.columnOrder), + }; + } +} + +function visibleColumns( + columns: ColumnType[], + { + instance: { + state: { columnOrder }, + }, + }: { instance: TableInstance }, +): ColumnType[] { + // If there is no order, return the normal columns + if (!columnOrder || !columnOrder.length) { + return columns; + } + + // If there is an order, make a copy of the columns + const columnOrderCopy = [...columnOrder]; + const columnsCopy = [...columns]; + // And make a new ordered array of the columns + const columnsInOrder: ColumnType[] = []; + + // Loop over the columns and place them in order into the new array + while (columnsCopy.length && columnOrderCopy.length) { + const targetColumnId = columnOrderCopy.shift(); + const foundIndex = columnsCopy.findIndex((d) => d.id === targetColumnId); + if (foundIndex > -1) { + columnsInOrder.push(columnsCopy.splice(foundIndex, 1)[0]); + } + } + + // If there are any columns left, add them to the end + return [...columnsInOrder, ...columnsCopy]; +} + +function useInstance(instance: TableInstance) { + const { dispatch } = instance; + + // eslint-disable-next-line react-hooks/immutability + instance.setColumnOrder = useCallback( + (columnOrder: string[] | ((order: string[]) => string[])) => { + return dispatch({ type: actions.setColumnOrder, columnOrder }); + }, + [dispatch], + ); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useExpanded.ts b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useExpanded.ts new file mode 100644 index 00000000000..f33a6e14738 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useExpanded.ts @@ -0,0 +1,214 @@ +import { useCallback, useMemo } from 'react'; +import type { ReactTableHooks, TableInstance, RowType, PluginHook } from '../../types/index.js'; +import { useGetLatest, actions, useMountedLayoutEffect, makePropGetter, ensurePluginOrder } from '../publicUtils.js'; +import { expandRows } from '../utils.js'; + +// Actions +actions.resetExpanded = 'resetExpanded'; +actions.toggleRowExpanded = 'toggleRowExpanded'; +actions.toggleAllRowsExpanded = 'toggleAllRowsExpanded'; + +export const useExpanded: PluginHook = (hooks: ReactTableHooks) => { + hooks.getToggleAllRowsExpandedProps = [defaultGetToggleAllRowsExpandedProps]; + hooks.getToggleRowExpandedProps = [defaultGetToggleRowExpandedProps]; + hooks.stateReducers.push(reducer); + hooks.useInstance.push(useInstance); + hooks.prepareRow.push(prepareRow); +}; +useExpanded.pluginName = 'useExpanded'; + +const defaultGetToggleAllRowsExpandedProps = ( + props: Record, + { instance }: { instance: TableInstance }, +) => [ + props, + { + onClick: () => { + instance.toggleAllRowsExpanded(); + }, + style: { + cursor: 'pointer', + }, + title: 'Toggle All Rows Expanded', + }, +]; + +const defaultGetToggleRowExpandedProps = (props: Record, { row }: { row: RowType }) => [ + props, + { + onClick: () => { + row.toggleRowExpanded(); + }, + style: { + cursor: 'pointer', + }, + title: 'Toggle Row Expanded', + }, +]; + +function reducer( + state: TableInstance['state'], + action: { type: string; id?: string; value?: boolean }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.init) { + return { + expanded: {}, + ...state, + }; + } + + if (action.type === actions.resetExpanded) { + return { + ...state, + expanded: instance.initialState.expanded || {}, + }; + } + + if (action.type === actions.toggleAllRowsExpanded) { + const { value } = action; + const { rowsById } = instance; + + const isAllRowsExpanded = Object.keys(rowsById).length === Object.keys(state.expanded).length; + + const expandAll = typeof value !== 'undefined' ? value : !isAllRowsExpanded; + + if (expandAll) { + const expanded: Record = {}; + + Object.keys(rowsById).forEach((rowId) => { + expanded[rowId] = true; + }); + + return { + ...state, + expanded, + }; + } + + return { + ...state, + expanded: {}, + }; + } + + if (action.type === actions.toggleRowExpanded) { + const { id, value: setExpanded } = action; + const exists = state.expanded[id]; + + const shouldExist = typeof setExpanded !== 'undefined' ? setExpanded : !exists; + + if (!exists && shouldExist) { + return { + ...state, + expanded: { + ...state.expanded, + [id]: true, + }, + }; + } else if (exists && !shouldExist) { + const { [id]: _, ...rest } = state.expanded; + return { + ...state, + expanded: rest, + }; + } else { + return state; + } + } +} + +function useInstance(instance: TableInstance) { + const { + data, + rows, + rowsById, + manualExpandedKey = 'expanded', + paginateExpandedRows = true, + expandSubRows = true, + autoResetExpanded = true, + getHooks, + plugins, + state: { expanded }, + dispatch, + } = instance; + + ensurePluginOrder(plugins, ['useSortBy', 'useGroupBy', 'usePivotColumns', 'useGlobalFilter'], 'useExpanded'); + + const getAutoResetExpanded = useGetLatest(autoResetExpanded); + + let isAllRowsExpanded = Boolean(Object.keys(rowsById).length && Object.keys(expanded).length); + + if (isAllRowsExpanded) { + if (Object.keys(rowsById).some((id: string) => !expanded[id])) { + isAllRowsExpanded = false; + } + } + + // Bypass any effects from firing when this changes + useMountedLayoutEffect(() => { + if (getAutoResetExpanded()) { + dispatch({ type: actions.resetExpanded }); + } + }, [dispatch, data]); + + const toggleRowExpanded = useCallback( + (id: string, value?: boolean) => { + dispatch({ type: actions.toggleRowExpanded, id, value }); + }, + [dispatch], + ); + + const toggleAllRowsExpanded = useCallback( + (value?: boolean) => dispatch({ type: actions.toggleAllRowsExpanded, value }), + [dispatch], + ); + + const expandedRows = useMemo(() => { + if (paginateExpandedRows) { + return expandRows(rows, { manualExpandedKey, expanded, expandSubRows }); + } + + return rows; + }, [paginateExpandedRows, rows, manualExpandedKey, expanded, expandSubRows]); + + const expandedDepth = useMemo(() => findExpandedDepth(expanded), [expanded]); + + const getInstance = useGetLatest(instance); + + const getToggleAllRowsExpandedProps = makePropGetter(getHooks().getToggleAllRowsExpandedProps, { + instance: getInstance(), + }); + + Object.assign(instance, { + preExpandedRows: rows, + expandedRows, + rows: expandedRows, + expandedDepth, + isAllRowsExpanded, + toggleRowExpanded, + toggleAllRowsExpanded, + getToggleAllRowsExpandedProps, + }); +} + +function prepareRow(row: RowType, { instance: { getHooks }, instance }: { instance: TableInstance }) { + row.toggleRowExpanded = (set?: boolean) => instance.toggleRowExpanded(row.id, set); + + row.getToggleRowExpandedProps = makePropGetter(getHooks().getToggleRowExpandedProps, { + instance, + row, + }); +} + +function findExpandedDepth(expanded: Record): number { + let maxDepth = 0; + + Object.keys(expanded).forEach((id) => { + const splitId = id.split('.'); + maxDepth = Math.max(maxDepth, splitId.length); + }); + + return maxDepth; +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useFilters.ts b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useFilters.ts new file mode 100644 index 00000000000..46667a68634 --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useFilters.ts @@ -0,0 +1,245 @@ +import { useCallback, useMemo } from 'react'; +import type { ReactTableHooks, TableInstance, ColumnType, RowType, PluginHook } from '../../types/index.js'; +import * as filterTypes from '../filterTypes.js'; +import { actions, useGetLatest, functionalUpdate, useMountedLayoutEffect } from '../publicUtils.js'; +import { getFirstDefined, getFilterMethod, shouldAutoRemoveFilter } from '../utils.js'; + +// Actions +actions.resetFilters = 'resetFilters'; +actions.setFilter = 'setFilter'; +actions.setAllFilters = 'setAllFilters'; + +export const useFilters: PluginHook = (hooks: ReactTableHooks) => { + hooks.stateReducers.push(reducer); + hooks.useInstance.push(useInstance); +}; +useFilters.pluginName = 'useFilters'; + +function reducer( + state: TableInstance['state'], + action: { type: string; columnId?: string; filterValue?: any; filters?: any }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.init) { + return { + filters: [], + ...state, + }; + } + + if (action.type === actions.resetFilters) { + return { + ...state, + filters: instance.initialState.filters || [], + }; + } + + if (action.type === actions.setFilter) { + const { columnId, filterValue } = action; + const { allColumns, filterTypes: userFilterTypes } = instance; + + const column = allColumns.find((d: ColumnType) => d.id === columnId); + + if (!column) { + throw new Error(`React-Table: Could not find a column with id: ${columnId}`); + } + + const filterMethod = getFilterMethod(column.filter, userFilterTypes || {}, filterTypes); + + const previousfilter = state.filters.find((d) => d.id === columnId); + + const newFilter = functionalUpdate(filterValue, previousfilter && previousfilter.value); + + if (shouldAutoRemoveFilter(filterMethod.autoRemove, newFilter, column)) { + return { + ...state, + filters: state.filters.filter((d) => d.id !== columnId), + }; + } + + if (previousfilter) { + return { + ...state, + filters: state.filters.map((d) => { + if (d.id === columnId) { + return { id: columnId, value: newFilter }; + } + return d; + }), + }; + } + + return { + ...state, + filters: [...state.filters, { id: columnId, value: newFilter }], + }; + } + + if (action.type === actions.setAllFilters) { + const { filters } = action; + const { allColumns, filterTypes: userFilterTypes } = instance; + + return { + ...state, + // Filter out undefined values + filters: functionalUpdate(filters, state.filters).filter((filter: any) => { + const column = allColumns.find((d: ColumnType) => d.id === filter.id); + const filterMethod = getFilterMethod(column.filter, userFilterTypes || {}, filterTypes); + + if (shouldAutoRemoveFilter(filterMethod.autoRemove, filter.value, column)) { + return false; + } + return true; + }), + }; + } +} + +function useInstance(instance: TableInstance) { + const { + data, + rows, + flatRows, + rowsById, + allColumns, + filterTypes: userFilterTypes, + manualFilters, + defaultCanFilter = false, + disableFilters, + state: { filters }, + dispatch, + autoResetFilters = true, + } = instance; + + const setFilter = useCallback( + (columnId: string, filterValue: any) => { + dispatch({ type: actions.setFilter, columnId, filterValue }); + }, + [dispatch], + ); + + const setAllFilters = useCallback( + (filters: any) => { + dispatch({ + type: actions.setAllFilters, + filters, + }); + }, + [dispatch], + ); + + allColumns.forEach((column: ColumnType) => { + const { id, accessor, defaultCanFilter: columnDefaultCanFilter, disableFilters: columnDisableFilters } = column; + + // Determine if a column is filterable + column.canFilter = accessor + ? getFirstDefined( + columnDisableFilters === true ? false : undefined, + disableFilters === true ? false : undefined, + true, + ) + : getFirstDefined(columnDefaultCanFilter, defaultCanFilter, false); + + // Provide the column a way of updating the filter value + column.setFilter = (val: any) => setFilter(column.id, val); + + // Provide the current filter value to the column for + // convenience + const found = filters.find((d) => d.id === id); + column.filterValue = found && found.value; + }); + + const [filteredRows, filteredFlatRows, filteredRowsById] = useMemo(() => { + if (manualFilters || !filters.length) { + return [rows, flatRows, rowsById]; + } + + const filteredFlatRows: RowType[] = []; + const filteredRowsById: Record = {}; + + // Filters top level and nested rows + const filterRows = (rows: RowType[], depth = 0): RowType[] => { + let filteredRows = rows; + + filteredRows = (filters as any[]).reduce( + (filteredSoFar: RowType[], { id: columnId, value: filterValue }: any) => { + // Find the filters column + const column = allColumns.find((d: ColumnType) => d.id === columnId); + + if (!column) { + return filteredSoFar; + } + + if (depth === 0) { + column.preFilteredRows = filteredSoFar; + } + + const filterMethod = getFilterMethod(column.filter, userFilterTypes || {}, filterTypes); + + if (!filterMethod) { + console.warn(`Could not find a valid 'column.filter' for column with the ID: ${column.id}.`); + return filteredSoFar; + } + + // Pass the rows, id, filterValue and column to the filterMethod + // to get the filtered rows back + column.filteredRows = filterMethod(filteredSoFar, [columnId], filterValue); + + return column.filteredRows; + }, + rows, + ); + + // Apply the filter to any subRows + filteredRows.forEach((row) => { + filteredFlatRows.push(row); + filteredRowsById[row.id] = row; + if (!row.subRows) { + return; + } + + row.subRows = row.subRows && row.subRows.length > 0 ? filterRows(row.subRows, depth + 1) : row.subRows; + }); + + return filteredRows; + }; + + return [filterRows(rows), filteredFlatRows, filteredRowsById]; + }, [manualFilters, filters, rows, flatRows, rowsById, allColumns, userFilterTypes]); + + useMemo(() => { + // Now that each filtered column has it's partially filtered rows, + // lets assign the final filtered rows to all of the other columns + const nonFilteredColumns = allColumns.filter((column: ColumnType) => !filters.find((d) => d.id === column.id)); + + // This essentially enables faceted filter options to be built easily + // using every column's preFilteredRows value + nonFilteredColumns.forEach((column: ColumnType) => { + column.preFilteredRows = filteredRows; + column.filteredRows = filteredRows; + }); + }, [filteredRows, filters, allColumns]); + + const getAutoResetFilters = useGetLatest(autoResetFilters); + + useMountedLayoutEffect(() => { + if (getAutoResetFilters()) { + dispatch({ type: actions.resetFilters }); + } + }, [dispatch, manualFilters ? null : data]); + + Object.assign(instance, { + preFilteredRows: rows, + preFilteredFlatRows: flatRows, + preFilteredRowsById: rowsById, + filteredRows, + filteredFlatRows, + filteredRowsById, + rows: filteredRows, + flatRows: filteredFlatRows, + rowsById: filteredRowsById, + setFilter, + setAllFilters, + }); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useGlobalFilter.ts b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useGlobalFilter.ts new file mode 100644 index 00000000000..7f954d90b9d --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useGlobalFilter.ts @@ -0,0 +1,152 @@ +import { useCallback, useMemo } from 'react'; +import type { ReactTableHooks, TableInstance, ColumnType, RowType, PluginHook } from '../../types/index.js'; +import * as filterTypes from '../filterTypes.js'; +import { actions, useMountedLayoutEffect, functionalUpdate, useGetLatest } from '../publicUtils.js'; +import { getFilterMethod, shouldAutoRemoveFilter, getFirstDefined } from '../utils.js'; + +// Actions +actions.resetGlobalFilter = 'resetGlobalFilter'; +actions.setGlobalFilter = 'setGlobalFilter'; + +export const useGlobalFilter: PluginHook = (hooks: ReactTableHooks) => { + hooks.stateReducers.push(reducer); + hooks.useInstance.push(useInstance); +}; +useGlobalFilter.pluginName = 'useGlobalFilter'; + +function reducer( + state: TableInstance['state'], + action: { type: string; filterValue?: any }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.resetGlobalFilter) { + return { + ...state, + globalFilter: instance.initialState.globalFilter || undefined, + }; + } + + if (action.type === actions.setGlobalFilter) { + const { filterValue } = action; + const { userFilterTypes } = instance; + + const filterMethod = getFilterMethod(instance.globalFilter, userFilterTypes || {}, filterTypes); + + const newFilter = functionalUpdate(filterValue, state.globalFilter); + + if (shouldAutoRemoveFilter(filterMethod.autoRemove, newFilter)) { + const { globalFilter: _gf, ...stateWithoutGlobalFilter } = state; + return stateWithoutGlobalFilter; + } + + return { + ...state, + globalFilter: newFilter, + }; + } +} + +function useInstance(instance: TableInstance) { + const { + data, + rows, + flatRows, + rowsById, + allColumns, + filterTypes: userFilterTypes, + globalFilter, + manualGlobalFilter, + state: { globalFilter: globalFilterValue }, + dispatch, + autoResetGlobalFilter = true, + disableGlobalFilter, + } = instance; + + const setGlobalFilter = useCallback( + (filterValue: any) => { + dispatch({ type: actions.setGlobalFilter, filterValue }); + }, + [dispatch], + ); + + const [globalFilteredRows, globalFilteredFlatRows, globalFilteredRowsById] = useMemo(() => { + if (manualGlobalFilter || typeof globalFilterValue === 'undefined') { + return [rows, flatRows, rowsById]; + } + + const filteredFlatRows: RowType[] = []; + const filteredRowsById: Record = {}; + + const filterMethod = getFilterMethod(globalFilter, userFilterTypes || {}, filterTypes); + + if (!filterMethod) { + console.warn(`Could not find a valid 'globalFilter' option.`); + return rows; + } + + allColumns.forEach((column: ColumnType) => { + const { disableGlobalFilter: columnDisableGlobalFilter } = column; + + column.canFilter = getFirstDefined( + columnDisableGlobalFilter === true ? false : undefined, + disableGlobalFilter === true ? false : undefined, + true, + ); + }); + + const filterableColumns = allColumns.filter((c: ColumnType) => c.canFilter === true); + + // Filters top level and nested rows + const filterRows = (filteredRows: RowType[]): RowType[] => { + filteredRows = filterMethod( + filteredRows, + filterableColumns.map((d: ColumnType) => d.id), + globalFilterValue, + ); + + filteredRows.forEach((row) => { + filteredFlatRows.push(row); + filteredRowsById[row.id] = row; + + row.subRows = row.subRows && row.subRows.length ? filterRows(row.subRows) : row.subRows; + }); + + return filteredRows; + }; + + return [filterRows(rows), filteredFlatRows, filteredRowsById]; + }, [ + manualGlobalFilter, + globalFilterValue, + globalFilter, + userFilterTypes, + allColumns, + rows, + flatRows, + rowsById, + disableGlobalFilter, + ]); + + const getAutoResetGlobalFilter = useGetLatest(autoResetGlobalFilter); + + useMountedLayoutEffect(() => { + if (getAutoResetGlobalFilter()) { + dispatch({ type: actions.resetGlobalFilter }); + } + }, [dispatch, manualGlobalFilter ? null : data]); + + Object.assign(instance, { + preGlobalFilteredRows: rows, + preGlobalFilteredFlatRows: flatRows, + preGlobalFilteredRowsById: rowsById, + globalFilteredRows, + globalFilteredFlatRows, + globalFilteredRowsById, + rows: globalFilteredRows, + flatRows: globalFilteredFlatRows, + rowsById: globalFilteredRowsById, + setGlobalFilter, + disableGlobalFilter, + }); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useGroupBy.ts b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useGroupBy.ts new file mode 100644 index 00000000000..49cfd7343af --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useGroupBy.ts @@ -0,0 +1,388 @@ +import { useCallback, useMemo } from 'react'; +import type { ReactTableHooks, TableInstance, ColumnType, RowType, PluginHook } from '../../types/index.js'; +import * as aggregations from '../aggregations.js'; +import { actions, makePropGetter, ensurePluginOrder, useMountedLayoutEffect, useGetLatest } from '../publicUtils.js'; +import { getFirstDefined, flattenBy } from '../utils.js'; + +const emptyArray: RowType[] = []; +const emptyObject: Record = {}; + +// Actions +actions.resetGroupBy = 'resetGroupBy'; +actions.setGroupBy = 'setGroupBy'; +actions.toggleGroupBy = 'toggleGroupBy'; + +export const useGroupBy: PluginHook = (hooks: ReactTableHooks) => { + hooks.getGroupByToggleProps = [defaultGetGroupByToggleProps]; + hooks.stateReducers.push(reducer); + hooks.visibleColumnsDeps.push((deps, { instance }) => [...deps, instance.state.groupBy]); + hooks.visibleColumns.push(visibleColumns); + hooks.useInstance.push(useInstance); + hooks.prepareRow.push(prepareRow); +}; +useGroupBy.pluginName = 'useGroupBy'; + +const defaultGetGroupByToggleProps = (props: Record, { header }: { header: ColumnType }) => [ + props, + { + onClick: header.canGroupBy + ? (e: { persist: () => void }) => { + e.persist(); + header.toggleGroupBy(); + } + : undefined, + style: { + cursor: header.canGroupBy ? 'pointer' : undefined, + }, + title: 'Toggle GroupBy', + }, +]; + +function reducer( + state: TableInstance['state'], + action: { type: string; columnId?: string; value?: boolean | string[] }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.init) { + return { + groupBy: [], + ...state, + }; + } + + if (action.type === actions.resetGroupBy) { + return { + ...state, + groupBy: instance.initialState.groupBy || [], + }; + } + + if (action.type === actions.setGroupBy) { + const { value } = action; + return { + ...state, + groupBy: value, + }; + } + + if (action.type === actions.toggleGroupBy) { + const { columnId, value: setGroupBy } = action; + + const resolvedGroupBy = typeof setGroupBy !== 'undefined' ? setGroupBy : !state.groupBy.includes(columnId); + + if (resolvedGroupBy) { + return { + ...state, + groupBy: [...state.groupBy, columnId], + }; + } + + return { + ...state, + groupBy: state.groupBy.filter((d: string) => d !== columnId), + }; + } +} + +function visibleColumns( + columns: ColumnType[], + { + instance: { + state: { groupBy }, + }, + }: { instance: TableInstance }, +): ColumnType[] { + // Sort grouped columns to the start of the column list + // before the headers are built + + const groupByColumns = groupBy.map((g: string) => columns.find((col) => col.id === g)).filter(Boolean); + + const nonGroupByColumns = columns.filter((col) => !groupBy.includes(col.id)); + + columns = [...groupByColumns, ...nonGroupByColumns]; + + columns.forEach((column: ColumnType) => { + column.isGrouped = groupBy.includes(column.id); + column.groupedIndex = groupBy.indexOf(column.id); + }); + + return columns; +} + +const defaultUserAggregations: Record any> = {}; + +function useInstance(instance: TableInstance) { + const { + data, + rows, + flatRows, + rowsById, + allColumns, + flatHeaders, + groupByFn = defaultGroupByFn, + manualGroupBy, + aggregations: userAggregations = defaultUserAggregations, + plugins, + state: { groupBy }, + dispatch, + autoResetGroupBy = true, + disableGroupBy, + defaultCanGroupBy, + getHooks, + } = instance; + + ensurePluginOrder(plugins, ['useColumnOrder', 'useFilters'], 'useGroupBy'); + + const getInstance = useGetLatest(instance); + + allColumns.forEach((column: ColumnType) => { + const { accessor, defaultGroupBy: defaultColumnGroupBy, disableGroupBy: columnDisableGroupBy } = column; + + column.canGroupBy = accessor + ? getFirstDefined( + column.canGroupBy, + columnDisableGroupBy === true ? false : undefined, + disableGroupBy === true ? false : undefined, + true, + ) + : getFirstDefined(column.canGroupBy, defaultColumnGroupBy, defaultCanGroupBy, false); + + if (column.canGroupBy) { + column.toggleGroupBy = () => instance.toggleGroupBy(column.id); + } + + column.Aggregated = column.Aggregated || column.Cell; + }); + + const toggleGroupBy = useCallback( + (columnId: string, value?: boolean) => { + dispatch({ type: actions.toggleGroupBy, columnId, value }); + }, + [dispatch], + ); + + const setGroupBy = useCallback( + (value: string[]) => { + dispatch({ type: actions.setGroupBy, value }); + }, + [dispatch], + ); + + flatHeaders.forEach((header: ColumnType) => { + header.getGroupByToggleProps = makePropGetter(getHooks().getGroupByToggleProps, { + instance: getInstance(), + header, + }); + }); + + const [ + groupedRows, + groupedFlatRows, + groupedRowsById, + onlyGroupedFlatRows, + onlyGroupedRowsById, + nonGroupedFlatRows, + nonGroupedRowsById, + ] = useMemo(() => { + if (manualGroupBy || !groupBy.length) { + return [rows, flatRows, rowsById, emptyArray, emptyObject, flatRows, rowsById]; + } + + // Ensure that the list of filtered columns exist + const existingGroupBy = groupBy.filter((g: string) => allColumns.find((col: ColumnType) => col.id === g)); + + // Find the columns that can or are aggregating + // Uses each column to aggregate rows into a single value + const aggregateRowsToValues = (leafRows: RowType[], groupedRows: RowType[], depth: number) => { + const values: Record = {}; + + allColumns.forEach((column: ColumnType) => { + // Don't aggregate columns that are in the groupBy + if (existingGroupBy.includes(column.id)) { + values[column.id] = groupedRows[0] ? groupedRows[0].values[column.id] : null; + return; + } + + // Aggregate the values + const aggregateFn = + typeof column.aggregate === 'function' + ? column.aggregate + : userAggregations[column.aggregate] || (aggregations as Record)[column.aggregate]; + + if (aggregateFn) { + // Get the columnValues to aggregate + const groupedValues = groupedRows.map((row) => row.values[column.id]); + + // Get the columnValues to aggregate + const leafValues = leafRows.map((row) => { + let columnValue = row.values[column.id]; + + if (!depth && column.aggregateValue) { + const aggregateValueFn = + typeof column.aggregateValue === 'function' + ? column.aggregateValue + : userAggregations[column.aggregateValue] || + (aggregations as Record)[column.aggregateValue]; + + if (!aggregateValueFn) { + console.info({ column }); + throw new Error(`React Table: Invalid column.aggregateValue option for column listed above`); + } + + columnValue = aggregateValueFn(columnValue, row, column); + } + return columnValue; + }); + + values[column.id] = aggregateFn(leafValues, groupedValues); + } else if (column.aggregate) { + console.info({ column }); + throw new Error(`React Table: Invalid column.aggregate option for column listed above`); + } else { + values[column.id] = null; + } + }); + + return values; + }; + + const groupedFlatRows: RowType[] = []; + const groupedRowsById: Record = {}; + const onlyGroupedFlatRows: RowType[] = []; + const onlyGroupedRowsById: Record = {}; + const nonGroupedFlatRows: RowType[] = []; + const nonGroupedRowsById: Record = {}; + + // Recursively group the data + const groupUpRecursively = (rows: RowType[], depth = 0, parentId?: string): RowType[] => { + // This is the last level, just return the rows + if (depth === existingGroupBy.length) { + return rows.map((row) => ({ ...row, depth })); + } + + const columnId = existingGroupBy[depth]; + + // Group the rows together for this level + const rowGroupsMap = groupByFn(rows, columnId); + + // Peform aggregations for each group + const aggregatedGroupedRows = Object.entries(rowGroupsMap).map( + ([groupByVal, groupedRows]: [string, RowType[]], index: number) => { + let id = `${columnId}:${groupByVal}`; + id = parentId ? `${parentId}>${id}` : id; + + // First, Recurse to group sub rows before aggregation + const subRows = groupUpRecursively(groupedRows, depth + 1, id); + + // Flatten the leaf rows of the rows in this group + const leafRows: RowType[] = depth ? flattenBy(groupedRows, 'leafRows') : groupedRows; + + const values = aggregateRowsToValues(leafRows, groupedRows, depth); + + const row: any = { + id, + isGrouped: true, + groupByID: columnId, + groupByVal, + values, + subRows, + leafRows, + depth, + index, + }; + + subRows.forEach((subRow: RowType) => { + groupedFlatRows.push(subRow); + groupedRowsById[subRow.id] = subRow; + if (subRow.isGrouped) { + onlyGroupedFlatRows.push(subRow); + onlyGroupedRowsById[subRow.id] = subRow; + } else { + nonGroupedFlatRows.push(subRow); + nonGroupedRowsById[subRow.id] = subRow; + } + }); + + return row; + }, + ); + + return aggregatedGroupedRows; + }; + + const groupedRows = groupUpRecursively(rows); + + groupedRows.forEach((subRow: RowType) => { + groupedFlatRows.push(subRow); + groupedRowsById[subRow.id] = subRow; + if (subRow.isGrouped) { + onlyGroupedFlatRows.push(subRow); + onlyGroupedRowsById[subRow.id] = subRow; + } else { + nonGroupedFlatRows.push(subRow); + nonGroupedRowsById[subRow.id] = subRow; + } + }); + + // Assign the new data + return [ + groupedRows, + groupedFlatRows, + groupedRowsById, + onlyGroupedFlatRows, + onlyGroupedRowsById, + nonGroupedFlatRows, + nonGroupedRowsById, + ]; + }, [manualGroupBy, groupBy, rows, flatRows, rowsById, allColumns, userAggregations, groupByFn]); + + const getAutoResetGroupBy = useGetLatest(autoResetGroupBy); + + useMountedLayoutEffect(() => { + if (getAutoResetGroupBy()) { + dispatch({ type: actions.resetGroupBy }); + } + }, [dispatch, manualGroupBy ? null : data]); + + Object.assign(instance, { + preGroupedRows: rows, + preGroupedFlatRow: flatRows, + preGroupedRowsById: rowsById, + groupedRows, + groupedFlatRows, + groupedRowsById, + onlyGroupedFlatRows, + onlyGroupedRowsById, + nonGroupedFlatRows, + nonGroupedRowsById, + rows: groupedRows, + flatRows: groupedFlatRows, + rowsById: groupedRowsById, + toggleGroupBy, + setGroupBy, + }); +} + +function prepareRow(row: RowType) { + row.allCells.forEach((cell: any) => { + // Grouped cells are in the groupBy and the pivot cell for the row + cell.isGrouped = cell.column.isGrouped && cell.column.id === row.groupByID; + // Placeholder cells are any columns in the groupBy that are not grouped + cell.isPlaceholder = !cell.isGrouped && cell.column.isGrouped; + // Aggregated cells are not grouped, not repeated, but still have subRows + cell.isAggregated = !cell.isGrouped && !cell.isPlaceholder && row.subRows?.length; + }); +} + +export function defaultGroupByFn(rows: RowType[], columnId: string): Record { + return rows.reduce( + (prev, row) => { + const resKey = `${row.values[columnId]}`; + prev[resKey] = Array.isArray(prev[resKey]) ? prev[resKey] : []; + prev[resKey].push(row); + return prev; + }, + {} as Record, + ); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useSortBy.ts b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useSortBy.ts new file mode 100644 index 00000000000..34ea6b2ee6e --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/plugin-hooks/useSortBy.ts @@ -0,0 +1,374 @@ +import { useCallback, useMemo } from 'react'; +import type { + ReactTableHooks, + TableInstance, + ColumnType, + RowType, + PluginHook, + AnalyticalTableState, +} from '../../types/index.js'; +import { + actions, + ensurePluginOrder, + defaultColumn, + makePropGetter, + useGetLatest, + useMountedLayoutEffect, +} from '../publicUtils.js'; +import * as sortTypes from '../sortTypes.js'; +import { getFirstDefined, isFunction } from '../utils.js'; + +// Actions +actions.resetSortBy = 'resetSortBy'; +actions.setSortBy = 'setSortBy'; +actions.toggleSortBy = 'toggleSortBy'; +actions.clearSortBy = 'clearSortBy'; + +defaultColumn.sortType = 'alphanumeric'; +defaultColumn.sortDescFirst = false; + +export const useSortBy: PluginHook = (hooks: ReactTableHooks) => { + hooks.getSortByToggleProps = [defaultGetSortByToggleProps]; + hooks.stateReducers.push(reducer); + hooks.useInstance.push(useInstance); +}; +useSortBy.pluginName = 'useSortBy'; + +const defaultGetSortByToggleProps = ( + props: Record, + { instance, column }: { instance: TableInstance; column: ColumnType }, +) => { + const { isMultiSortEvent = (e: { shiftKey: boolean }) => e.shiftKey } = instance; + + return [ + props, + { + onClick: column.canSort + ? (e: { persist: () => void; shiftKey: boolean }) => { + e.persist(); + column.toggleSortBy(undefined, !instance.disableMultiSort && isMultiSortEvent(e)); + } + : undefined, + style: { + cursor: column.canSort ? 'pointer' : undefined, + }, + title: column.canSort ? 'Toggle SortBy' : undefined, + }, + ]; +}; + +// Reducer +function reducer( + state: TableInstance['state'], + action: { type: string; columnId?: string; sortBy?: AnalyticalTableState['sortBy']; desc?: boolean; multi?: boolean }, + _previousState: TableInstance['state'], + instance: TableInstance, +) { + if (action.type === actions.init) { + return { + sortBy: [], + ...state, + }; + } + + if (action.type === actions.resetSortBy) { + return { + ...state, + sortBy: instance.initialState.sortBy || [], + }; + } + + if (action.type === actions.clearSortBy) { + const { sortBy } = state; + const newSortBy = sortBy.filter((d) => d.id !== action.columnId); + + return { + ...state, + sortBy: newSortBy, + }; + } + + if (action.type === actions.setSortBy) { + const { sortBy } = action; + return { + ...state, + sortBy, + }; + } + + if (action.type === actions.toggleSortBy) { + const { columnId, desc, multi } = action; + + const { + allColumns, + disableMultiSort, + disableSortRemove, + disableMultiRemove, + maxMultiSortColCount = Number.MAX_SAFE_INTEGER, + } = instance; + + const { sortBy } = state; + + // Find the column for this columnId + const column = allColumns.find((d: ColumnType) => d.id === columnId); + const { sortDescFirst } = column; + + // Find any existing sortBy for this column + const existingSortBy = sortBy.find((d) => d.id === columnId); + const existingIndex = sortBy.findIndex((d) => d.id === columnId); + const hasDescDefined = typeof desc !== 'undefined' && desc !== null; + + let newSortBy: AnalyticalTableState['sortBy'] = []; + + // What should we do with this sort action? + let sortAction: string; + + if (!disableMultiSort && multi) { + if (existingSortBy) { + sortAction = 'toggle'; + } else { + sortAction = 'add'; + } + } else { + // Normal mode + if (existingIndex !== sortBy.length - 1 || sortBy.length !== 1) { + sortAction = 'replace'; + } else if (existingSortBy) { + sortAction = 'toggle'; + } else { + sortAction = 'replace'; + } + } + + // Handle toggle states that will remove the sortBy + if ( + sortAction === 'toggle' && // Must be toggling + !disableSortRemove && // If disableSortRemove, disable in general + !hasDescDefined && // Must not be setting desc + (multi ? !disableMultiRemove : true) && // If multi, don't allow if disableMultiRemove + // Finally, detect if it should indeed be removed + ((existingSortBy && existingSortBy.desc && !sortDescFirst) || (!existingSortBy.desc && sortDescFirst)) + ) { + sortAction = 'remove'; + } + + if (sortAction === 'replace') { + newSortBy = [ + { + id: columnId, + desc: hasDescDefined ? desc : sortDescFirst, + }, + ]; + } else if (sortAction === 'add') { + newSortBy = [ + ...sortBy, + { + id: columnId, + desc: hasDescDefined ? desc : sortDescFirst, + }, + ]; + // Take latest n columns + newSortBy.splice(0, newSortBy.length - maxMultiSortColCount); + } else if (sortAction === 'toggle') { + // This flips (or sets) the + newSortBy = sortBy.map((d) => { + if (d.id === columnId) { + return { + ...d, + desc: hasDescDefined ? desc : !existingSortBy.desc, + }; + } + return d; + }); + } else if (sortAction === 'remove') { + newSortBy = sortBy.filter((d) => d.id !== columnId); + } + + return { + ...state, + sortBy: newSortBy, + }; + } +} + +function useInstance(instance: TableInstance) { + const { + data, + rows, + flatRows, + allColumns, + orderByFn = defaultOrderByFn, + sortTypes: userSortTypes, + manualSortBy, + defaultCanSort, + disableSortBy, + flatHeaders, + state: { sortBy }, + dispatch, + plugins, + getHooks, + autoResetSortBy = true, + } = instance; + + ensurePluginOrder(plugins, ['useFilters', 'useGlobalFilter', 'useGroupBy', 'usePivotColumns'], 'useSortBy'); + + const setSortBy = useCallback( + (sortBy: { id: string; desc: boolean }[]) => { + dispatch({ type: actions.setSortBy, sortBy }); + }, + [dispatch], + ); + + // Updates sorting based on a columnId, desc flag and multi flag + const toggleSortBy = useCallback( + (columnId: string, desc?: boolean, multi?: boolean) => { + dispatch({ type: actions.toggleSortBy, columnId, desc, multi }); + }, + [dispatch], + ); + + // use reference to avoid memory leak in #1608 + const getInstance = useGetLatest(instance); + + // Add the getSortByToggleProps method to columns and headers + flatHeaders.forEach((column: ColumnType) => { + const { accessor, canSort: defaultColumnCanSort, disableSortBy: columnDisableSortBy, id } = column; + + const canSort = accessor + ? getFirstDefined( + columnDisableSortBy === true ? false : undefined, + disableSortBy === true ? false : undefined, + true, + ) + : getFirstDefined(defaultCanSort, defaultColumnCanSort, false); + + column.canSort = canSort; + + if (column.canSort) { + column.toggleSortBy = (desc?: boolean, multi?: boolean) => toggleSortBy(column.id, desc, multi); + + column.clearSortBy = () => { + dispatch({ type: actions.clearSortBy, columnId: column.id }); + }; + } + + column.getSortByToggleProps = makePropGetter(getHooks().getSortByToggleProps, { + instance: getInstance(), + column, + }); + + const columnSort = sortBy.find((d) => d.id === id); + column.isSorted = !!columnSort; + column.sortedIndex = sortBy.findIndex((d) => d.id === id); + column.isSortedDesc = column.isSorted ? columnSort.desc : undefined; + }); + + const [sortedRows, sortedFlatRows] = useMemo(() => { + if (manualSortBy || !sortBy.length) { + return [rows, flatRows]; + } + + const sortedFlatRows: RowType[] = []; + + // Filter out sortBys that correspond to non existing columns + const availableSortBy = sortBy.filter((sort) => allColumns.find((col: ColumnType) => col.id === sort.id)); + + const sortData = (rows: RowType[]): RowType[] => { + // Use the orderByFn to compose multiple sortBy's together. + // This will also perform a stable sorting using the row index + // if needed. + const sortedData = orderByFn( + rows, + availableSortBy.map((sort) => { + // Support custom sorting methods for each column + const column = allColumns.find((d: ColumnType) => d.id === sort.id); + + if (!column) { + throw new Error(`React-Table: Could not find a column with id: ${sort.id} while sorting`); + } + + const { sortType } = column; + + // Look up sortBy functions in this order: + // column function + // column string lookup on user sortType + // column string lookup on built-in sortType + // default function + // default string lookup on user sortType + // default string lookup on built-in sortType + const sortMethod = + isFunction(sortType) || (userSortTypes || {})[sortType] || (sortTypes as Record)[sortType]; + + if (!sortMethod) { + throw new Error(`React-Table: Could not find a valid sortType of '${sortType}' for column '${sort.id}'.`); + } + + // Return the correct sortFn. + // This function should always return in ascending order + return (a: RowType, b: RowType) => sortMethod(a, b, sort.id, sort.desc); + }), + // Map the directions + availableSortBy.map((sort) => { + // Detect and use the sortInverted option + const column = allColumns.find((d: ColumnType) => d.id === sort.id); + + if (column && column.sortInverted) { + return sort.desc; + } + + return !sort.desc; + }), + ); + + // If there are sub-rows, sort them + sortedData.forEach((row: RowType) => { + sortedFlatRows.push(row); + if (!row.subRows || row.subRows.length === 0) { + return; + } + row.subRows = sortData(row.subRows); + }); + + return sortedData; + }; + + return [sortData(rows), sortedFlatRows]; + }, [manualSortBy, sortBy, rows, flatRows, allColumns, orderByFn, userSortTypes]); + + const getAutoResetSortBy = useGetLatest(autoResetSortBy); + + useMountedLayoutEffect(() => { + if (getAutoResetSortBy()) { + dispatch({ type: actions.resetSortBy }); + } + }, [manualSortBy ? null : data]); + + Object.assign(instance, { + preSortedRows: rows, + preSortedFlatRows: flatRows, + sortedRows, + sortedFlatRows, + rows: sortedRows, + flatRows: sortedFlatRows, + setSortBy, + toggleSortBy, + }); +} + +export function defaultOrderByFn( + arr: RowType[], + funcs: Array<(a: RowType, b: RowType) => number>, + dirs: boolean[], +): RowType[] { + return [...arr].sort((rowA, rowB) => { + for (let i = 0; i < funcs.length; i += 1) { + const sortFn = funcs[i]; + const desc = dirs[i] === false || (dirs[i] as any) === 'desc'; + const sortInt = sortFn(rowA, rowB); + if (sortInt !== 0) { + return desc ? -sortInt : sortInt; + } + } + return dirs[0] ? rowA.index - rowB.index : rowB.index - rowA.index; + }); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/publicUtils.tsx b/packages/main/src/components/AnalyticalTable/react-table/publicUtils.tsx new file mode 100644 index 00000000000..049d7905e8d --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/publicUtils.tsx @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import type { DependencyList, ReactNode } from 'react'; + +const renderErr = 'Renderer Error ☝️'; + +export const actions: Record = { + init: 'init', +}; + +export const defaultRenderer = ({ value = '' }: { value?: any }) => value; +export const emptyRenderer = () => <> ; + +export const defaultColumn: Record = { + Cell: defaultRenderer, + width: 150, + minWidth: 0, + maxWidth: Number.MAX_SAFE_INTEGER, +}; + +function mergeProps(...propList: Record[]): Record { + return propList.reduce((props, next) => { + const { style, className, ...rest } = next; + + props = { + ...props, + ...rest, + }; + + if (style) { + props.style = props.style ? { ...(props.style || {}), ...(style || {}) } : style; + } + + if (className) { + props.className = props.className ? props.className + ' ' + className : className; + } + + if (props.className === '') { + delete props.className; + } + + return props; + }, {}); +} + +function handlePropGetter(prevProps: Record, userProps: any, meta?: any): Record { + if (typeof userProps === 'function') { + return handlePropGetter({}, userProps(prevProps, meta)); + } + + if (Array.isArray(userProps)) { + return mergeProps(prevProps, ...userProps); + } + + return mergeProps(prevProps, userProps); +} + +export const makePropGetter = (hooks: any[], meta: Record = {}) => { + return (userProps: Record = {}) => + [...hooks, userProps].reduce( + (prev, next) => + handlePropGetter(prev, next, { + ...meta, + userProps, + }), + {}, + ); +}; + +export const reduceHooks = (hooks: any[], initial: any, meta: Record = {}, allowUndefined?: boolean) => + hooks.reduce((prev: any, next: any) => { + const nextValue = next(prev, meta); + if (process.env.NODE_ENV !== 'production') { + if (!allowUndefined && typeof nextValue === 'undefined') { + console.info(next); + throw new Error('React Table: A reducer hook ☝️ just returned undefined! This is not allowed.'); + } + } + return nextValue; + }, initial); + +export const loopHooks = (hooks: any[], context: any, meta: Record = {}) => + hooks.forEach((hook: any) => { + const nextValue = hook(context, meta); + if (process.env.NODE_ENV !== 'production') { + if (typeof nextValue !== 'undefined') { + console.info(hook, nextValue); + throw new Error('React Table: A loop-type hook ☝️ just returned a value! This is not allowed.'); + } + } + }); + +export function ensurePluginOrder( + plugins: Array<{ pluginName?: string } & ((...args: any[]) => void)>, + befores: string[], + pluginName: string, + afters?: string[], +) { + if (process.env.NODE_ENV !== 'production' && afters) { + throw new Error( + `Defining plugins in the "after" section of ensurePluginOrder is no longer supported (see plugin ${pluginName})`, + ); + } + const pluginIndex = plugins.findIndex((plugin) => plugin.pluginName === pluginName); + + if (pluginIndex === -1) { + if (process.env.NODE_ENV !== 'production') { + throw new Error(`The plugin "${pluginName}" was not found in the plugin list! +This usually means you need to need to name your plugin hook by setting the 'pluginName' property of the hook function, eg: + + ${pluginName}.pluginName = '${pluginName}' +`); + } + } + + befores.forEach((before) => { + const beforeIndex = plugins.findIndex((plugin) => plugin.pluginName === before); + if (beforeIndex > -1 && beforeIndex > pluginIndex) { + if (process.env.NODE_ENV !== 'production') { + throw new Error(`React Table: The ${pluginName} plugin hook must be placed after the ${before} plugin hook!`); + } + } + }); +} + +export function functionalUpdate(updater: any, old: any) { + return typeof updater === 'function' ? updater(old) : updater; +} + +export function useGetLatest(obj: T): () => T { + const ref = useRef(obj); + // eslint-disable-next-line react-hooks/refs + ref.current = obj; + + return useCallback(() => ref.current, []); +} + +export const safeUseLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect; + +export function useMountedLayoutEffect(fn: () => void, deps: DependencyList) { + const mountedRef = useRef(false); + + // eslint-disable-next-line react-hooks/refs + safeUseLayoutEffect(() => { + if (mountedRef.current) { + fn(); + } + mountedRef.current = true; + }, deps); +} + +export function useAsyncDebounce(defaultFn: (...args: any[]) => any, defaultWait = 0) { + const debounceRef = useRef({}); + + const getDefaultFn = useGetLatest(defaultFn); + const getDefaultWait = useGetLatest(defaultWait); + + return useCallback( + // eslint-disable-next-line @typescript-eslint/require-await + async (...args: any[]) => { + if (!debounceRef.current.promise) { + debounceRef.current.promise = new Promise((resolve, reject) => { + debounceRef.current.resolve = resolve; + debounceRef.current.reject = reject; + }); + } + + if (debounceRef.current.timeout) { + clearTimeout(debounceRef.current.timeout); + } + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + debounceRef.current.timeout = setTimeout(async () => { + delete debounceRef.current.timeout; + try { + debounceRef.current.resolve(await getDefaultFn()(...args)); + } catch (err) { + debounceRef.current.reject(err); + } finally { + delete debounceRef.current.promise; + } + }, getDefaultWait()); + + return debounceRef.current.promise; + }, + [getDefaultFn, getDefaultWait], + ); +} + +export function makeRenderer(instance: any, column: any, meta: Record = {}) { + return (type: any, userProps: Record = {}): ReactNode => { + const Comp = typeof type === 'string' ? column[type] : type; + + if (typeof Comp === 'undefined') { + console.info(column); + throw new Error(renderErr); + } + + return flexRender(Comp, { ...instance, column, ...meta, ...userProps }); + }; +} + +export function flexRender(Comp: any, props: Record): ReactNode { + return isReactComponent(Comp) ? : Comp; +} + +function isReactComponent(component: any): boolean { + return isClassComponent(component) || typeof component === 'function' || isExoticComponent(component); +} + +function isClassComponent(component: any): boolean { + return ( + typeof component === 'function' && + (() => { + const proto = Object.getPrototypeOf(component); + return proto.prototype && proto.prototype.isReactComponent; + })() + ); +} + +function isExoticComponent(component: any): boolean { + return ( + typeof component === 'object' && + typeof component.$$typeof === 'symbol' && + ['react.memo', 'react.forward_ref'].includes(component.$$typeof.description) + ); +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/sortTypes.ts b/packages/main/src/components/AnalyticalTable/react-table/sortTypes.ts new file mode 100644 index 00000000000..bc8fb26460d --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/sortTypes.ts @@ -0,0 +1,138 @@ +import type { RowType } from '../types/index.js'; + +const reSplitAlphaNumeric = /([0-9]+)/gm; + +// Mixed sorting is slow, but very inclusive of many edge cases. +// It handles numbers, mixed alphanumeric combinations, and even +// null, undefined, and Infinity +export const alphanumeric = (rowA: RowType, rowB: RowType, columnId: string): number => { + let [a, b] = getRowValuesByColumnID(rowA, rowB, columnId); + + // Force to strings (or "" for unsupported types) + a = toString(a); + b = toString(b); + + // Split on number groups, but keep the delimiter + // Then remove falsey split values + a = a.split(reSplitAlphaNumeric).filter(Boolean); + b = b.split(reSplitAlphaNumeric).filter(Boolean); + + // While + while (a.length && b.length) { + const aa = a.shift(); + const bb = b.shift(); + + const an = parseInt(aa, 10); + const bn = parseInt(bb, 10); + + const combo = [an, bn].sort(); + + // Both are string + if (isNaN(combo[0])) { + if (aa > bb) { + return 1; + } + if (bb > aa) { + return -1; + } + continue; + } + + // One is a string, one is a number + if (isNaN(combo[1])) { + return isNaN(an) ? -1 : 1; + } + + // Both are numbers + if (an > bn) { + return 1; + } + if (bn > an) { + return -1; + } + } + + return a.length - b.length; +}; + +export function datetime(rowA: RowType, rowB: RowType, columnId: string): number { + let [a, b] = getRowValuesByColumnID(rowA, rowB, columnId); + + a = a.getTime(); + b = b.getTime(); + + return compareBasic(a, b); +} + +export function basic(rowA: RowType, rowB: RowType, columnId: string): number { + const [a, b] = getRowValuesByColumnID(rowA, rowB, columnId); + + return compareBasic(a, b); +} + +export function string(rowA: RowType, rowB: RowType, columnId: string): number { + let [a, b] = getRowValuesByColumnID(rowA, rowB, columnId); + + a = a.split('').filter(Boolean); + b = b.split('').filter(Boolean); + + while (a.length && b.length) { + const aa: string = a.shift(); + const bb: string = b.shift(); + + const alower = aa.toLowerCase(); + const blower = bb.toLowerCase(); + + // Case insensitive comparison until characters match + if (alower > blower) { + return 1; + } + if (blower > alower) { + return -1; + } + // If lowercase characters are identical + if (aa > bb) { + return 1; + } + if (bb > aa) { + return -1; + } + continue; + } + + return a.length - b.length; +} + +export function number(rowA: RowType, rowB: RowType, columnId: string): number { + let [a, b] = getRowValuesByColumnID(rowA, rowB, columnId); + + const replaceNonNumeric = /[^0-9.]/gi; + + a = Number(String(a).replace(replaceNonNumeric, '')); + b = Number(String(b).replace(replaceNonNumeric, '')); + + return compareBasic(a, b); +} + +// Utils + +function compareBasic(a: any, b: any): number { + return a === b ? 0 : a > b ? 1 : -1; +} + +function getRowValuesByColumnID(row1: RowType, row2: RowType, columnId: string): [any, any] { + return [row1.values[columnId], row2.values[columnId]]; +} + +function toString(a: any): string { + if (typeof a === 'number') { + if (isNaN(a) || a === Infinity || a === -Infinity) { + return ''; + } + return String(a); + } + if (typeof a === 'string') { + return a; + } + return ''; +} diff --git a/packages/main/src/components/AnalyticalTable/react-table/utils.ts b/packages/main/src/components/AnalyticalTable/react-table/utils.ts new file mode 100644 index 00000000000..c59996f8afe --- /dev/null +++ b/packages/main/src/components/AnalyticalTable/react-table/utils.ts @@ -0,0 +1,348 @@ +import type { ColumnType, RowType } from '../types/index.js'; +import { defaultColumn, emptyRenderer } from './publicUtils.js'; + +// Find the depth of the columns +export function findMaxDepth(columns: ColumnType[], depth = 0): number { + return columns.reduce((prev: number, curr: ColumnType) => { + if (curr.columns) { + return Math.max(prev, findMaxDepth(curr.columns, depth + 1)); + } + return depth; + }, 0); +} + +// Build the visible columns, headers and flat column list +export function linkColumnStructure(columns: ColumnType[], parent?: ColumnType, depth = 0): ColumnType[] { + return columns.map((column) => { + column = { + ...column, + parent, + depth, + }; + + assignColumnAccessor(column); + + if (column.columns) { + column.columns = linkColumnStructure(column.columns, column, depth + 1); + } + return column; + }); +} + +export function flattenColumns(columns: ColumnType[]): ColumnType[] { + return flattenBy(columns, 'columns'); +} + +export function assignColumnAccessor(column: ColumnType): ColumnType { + // First check for string accessor + let { id, accessor } = column; + const { Header } = column; + + if (typeof accessor === 'string') { + id = id || accessor; + const accessorPath = accessor.split('.'); + accessor = (row: any) => getBy(row, accessorPath); + } + + if (!id && typeof Header === 'string' && Header) { + id = Header; + } + + if (!id && column.columns) { + console.error(column); + throw new Error('A column ID (or unique "Header" value) is required!'); + } + + if (!id) { + console.error(column); + throw new Error('A column ID (or string accessor) is required!'); + } + + Object.assign(column, { + id, + accessor, + }); + + return column; +} + +export function decorateColumn(column: ColumnType, userDefaultColumn: Record): ColumnType { + if (!userDefaultColumn) { + throw new Error(); + } + Object.assign(column, { + // Make sure there is a fallback header, just in case + Header: emptyRenderer, + Footer: emptyRenderer, + ...defaultColumn, + ...userDefaultColumn, + ...column, + }); + + Object.assign(column, { + originalWidth: column.width, + }); + + return column; +} + +// Build the header groups from the bottom up +export function makeHeaderGroups( + allColumns: ColumnType[], + defaultColumn: Record, + additionalHeaderProperties: (column: ColumnType) => Record = () => ({}), +): any[] { + const headerGroups: any[] = []; + + let scanColumns = allColumns; + + let uid = 0; + const getUID = () => uid++; + + while (scanColumns.length) { + // The header group we are creating + const headerGroup: any = { + headers: [], + }; + + // The parent columns we're going to scan next + const parentColumns: any[] = []; + + const hasParents = scanColumns.some((d: ColumnType) => d.parent); + + // Scan each column for parents + scanColumns.forEach((column: ColumnType) => { + // What is the latest (last) parent column? + const latestParentColumn = [...parentColumns].reverse()[0]; + + let newParent; + + if (hasParents) { + // If the column has a parent, add it if necessary + if (column.parent) { + newParent = { + ...column.parent, + originalId: column.parent.id, + id: `${column.parent.id}_${getUID()}`, + headers: [column], + ...additionalHeaderProperties(column), + }; + } else { + // If other columns have parents, we'll need to add a place holder if necessary + const originalId = `${column.id}_placeholder`; + newParent = decorateColumn( + { + originalId, + id: `${column.id}_placeholder_${getUID()}`, + placeholderOf: column, + headers: [column], + ...additionalHeaderProperties(column), + }, + defaultColumn, + ); + } + + // If the resulting parent columns are the same, just add + // the column and increment the header span + if (latestParentColumn && latestParentColumn.originalId === newParent.originalId) { + latestParentColumn.headers.push(column); + } else { + parentColumns.push(newParent); + } + } + + headerGroup.headers.push(column); + }); + + headerGroups.push(headerGroup); + + // Start scanning the parent columns + scanColumns = parentColumns; + } + + return headerGroups.reverse(); +} + +const pathObjCache = new Map(); + +export function getBy(obj: any, path: string | string[], def?: any): any { + if (!path) { + return obj; + } + const cacheKey: string = typeof path === 'function' ? path : JSON.stringify(path); + + const pathObj = + pathObjCache.get(cacheKey) || + (() => { + const pathObj = makePathArray(path); + pathObjCache.set(cacheKey, pathObj); + return pathObj; + })(); + + let val; + + try { + val = pathObj.reduce((cursor: any, pathPart: string) => cursor[pathPart], obj); + } catch (_e) { + // continue regardless of error + } + return typeof val !== 'undefined' ? val : def; +} + +export function getFirstDefined(...args: any[]): any { + for (let i = 0; i < args.length; i += 1) { + if (typeof args[i] !== 'undefined') { + return args[i]; + } + } +} + +export function getElementDimensions(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const margins = { + left: parseInt(style.marginLeft), + right: parseInt(style.marginRight), + }; + const padding = { + left: parseInt(style.paddingLeft), + right: parseInt(style.paddingRight), + }; + return { + left: Math.ceil(rect.left), + width: Math.ceil(rect.width), + outerWidth: Math.ceil(rect.width + margins.left + margins.right + padding.left + padding.right), + marginLeft: margins.left, + marginRight: margins.right, + paddingLeft: padding.left, + paddingRight: padding.right, + scrollWidth: element.scrollWidth, + }; +} + +export function isFunction(a: any): ((...args: any[]) => any) | undefined { + if (typeof a === 'function') { + return a; + } +} + +export function flattenBy>(arr: T[], key: string): T[] { + const flat: T[] = []; + + const recurse = (arr: T[]) => { + arr.forEach((d) => { + if (!d[key]) { + flat.push(d); + } else { + recurse(d[key]); + } + }); + }; + + recurse(arr); + + return flat; +} + +export function expandRows( + rows: RowType[], + { + manualExpandedKey, + expanded, + expandSubRows = true, + }: { manualExpandedKey: string; expanded: Record; expandSubRows?: boolean }, +): RowType[] { + const expandedRows: RowType[] = []; + + const handleRow = (row: RowType, addToExpandedRows = true) => { + row.isExpanded = (row.original && row.original[manualExpandedKey]) || expanded[row.id]; + + row.canExpand = row.subRows && !!row.subRows.length; + + if (addToExpandedRows) { + expandedRows.push(row); + } + + if (row.subRows && row.subRows.length && row.isExpanded) { + row.subRows.forEach((row: RowType) => handleRow(row, expandSubRows)); + } + }; + + rows.forEach((row) => handleRow(row)); + + return expandedRows; +} + +export function getFilterMethod( + filter: any, + userFilterTypes: Record, + filterTypes: Record, +): any { + return isFunction(filter) || userFilterTypes[filter] || filterTypes[filter] || filterTypes.text; +} + +export function shouldAutoRemoveFilter( + autoRemove: ((value: any, column?: ColumnType) => boolean) | undefined, + value: any, + column?: ColumnType, +): boolean { + return autoRemove ? autoRemove(value, column) : typeof value === 'undefined'; +} + +export function unpreparedAccessWarning(): never { + throw new Error('React-Table: You have not called prepareRow(row) one or more rows you are attempting to render.'); +} + +let passiveSupported: boolean | null = null; +export function passiveEventSupported(): boolean { + // memoize support to avoid adding multiple test events + if (typeof passiveSupported === 'boolean') return passiveSupported; + + let supported = false; + try { + const options = { + get passive() { + supported = true; + return false; + }, + }; + + window.addEventListener('test', null, options); + window.removeEventListener('test', null, options as EventListenerOptions); + } catch (_err) { + supported = false; + } + passiveSupported = supported; + return passiveSupported; +} + +// + +const reOpenBracket = /\[/g; +const reCloseBracket = /\]/g; + +function makePathArray(obj: any): string[] { + return ( + flattenDeep(obj) + // remove all periods in parts + .map((d: any) => String(d).replace('.', '_')) + // join parts using period + .join('.') + // replace brackets with periods + .replace(reOpenBracket, '.') + .replace(reCloseBracket, '') + // split it back out on periods + .split('.') + ); +} + +function flattenDeep(arr: any, newArr: any[] = []): any[] { + if (!Array.isArray(arr)) { + newArr.push(arr); + } else { + for (let i = 0; i < arr.length; i += 1) { + flattenDeep(arr[i], newArr); + } + } + return newArr; +} diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index 00315bf3454..a15a0e0d32c 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -62,7 +62,7 @@ export interface ColumnType extends Omit canSort?: boolean; depth?: number; filterValue?: any; - filteredRows?: Record[]; + filteredRows?: RowType[]; getFooterProps?: (props?: any) => any; getGroupByToggleProps?: (props?: any) => any; getHeaderProps?: (props?: any) => any; @@ -141,10 +141,16 @@ export interface TableInstance { payload?: Record | AnalyticalTableState['popInColumns'] | boolean | string | number; clientX?: number; columnId?: string; + columnOrder?: string[] | ((order: string[]) => string[]); columnWidth?: number; + desc?: boolean; + filterValue?: any; + filters?: any; headerIdWidths?: (string | number)[][]; - value?: boolean; id?: string; + multi?: boolean; + sortBy?: AnalyticalTableState['sortBy']; + value?: boolean | string[] | ((old: string[]) => string[]); }) => void; expandedDepth?: number; expandedRows?: RowType[]; @@ -166,6 +172,7 @@ export interface TableInstance { globalFilteredFlatRows?: RowType[]; globalFilteredRows?: RowType[]; globalFilteredRowsById?: Record; + groupByFn?: (rows: RowType[], columnId: string) => Record; groupedFlatRows?: RowType[]; groupedRows?: RowType[]; groupedRowsById?: Record; @@ -184,7 +191,7 @@ export interface TableInstance { onlyGroupedFlatRows?: RowType[]; onlyGroupedRowsById?: Record; page?: RowType[]; - plugins: ((hooks: ReactTableHooks) => void)[]; + plugins: Array void)>; preExpandedRows?: RowType[]; preFilteredFlatRows?: RowType[]; preFilteredRows?: RowType[]; @@ -228,7 +235,7 @@ export interface TableInstance { toggleAllPageRowsSelected?: (selected?: boolean) => void; toggleAllRowsExpanded?: (expanded?: boolean) => void; toggleAllRowsSelected?: (selected?: boolean) => void; - toggleGroupBy?: (columnId: string, value: boolean) => void; + toggleGroupBy?: (columnId: string, value?: boolean) => void; toggleHideAllColumns?: (hidden?: boolean) => void; toggleHideColumn?: (columnId: string, hidden?: boolean) => void; toggleRowExpanded?: (rowPath: string, expanded?: boolean) => void; @@ -321,20 +328,20 @@ export type CellInstance = TableInstance & { cell: CellType } & Omit< >; export interface RowType { - canExpand: boolean; - cells: CellType[]; - allCells: Record[]; + canExpand?: boolean; + cells?: CellType[]; + allCells?: Record[]; depth: number; id: string; index: number; - isExpanded: boolean | undefined; + isExpanded?: boolean; isGrouped?: boolean; - isSelected: boolean; - isSomeSelected: boolean; - getRowProps: (props?: any) => any; + isSelected?: boolean; + isSomeSelected?: boolean; + getRowProps?: (props?: any) => any; original: Record; - originalSubRows: Record[]; - subRows: RowType[]; + originalSubRows?: Record[]; + subRows?: RowType[]; values: Record; groupByID?: string; groupByVal?: string; @@ -373,7 +380,7 @@ export interface AnalyticalTableState { groupBy: string[]; hiddenColumns: string[]; selectedRowIds: Record; - sortBy: Record[]; + sortBy: { id: string; desc: boolean }[]; globalFilter?: string; tableClientWidth?: number; dndColumn?: string; @@ -1215,17 +1222,27 @@ export interface ReactTableHooks { getRowProps: any[]; getCellProps: any[]; useFinalInstance: any[]; - getToggleHiddenProps: any[]; - getToggleHideAllColumnsProps: any[]; - getGroupByToggleProps: any[]; - getSortByToggleProps: any[]; - getToggleAllRowsExpandedProps: any[]; - getToggleRowExpandedProps: any[]; - getToggleRowSelectedProps: any[]; - getToggleAllRowsSelectedProps: any[]; - getToggleAllPageRowsSelectedProps: any[]; - getResizerProps: (( + getToggleHiddenProps?: any[]; + getToggleHideAllColumnsProps?: any[]; + getGroupByToggleProps?: any[]; + getSortByToggleProps?: any[]; + getToggleAllRowsExpandedProps?: any[]; + getToggleRowExpandedProps?: any[]; + getToggleRowSelectedProps?: any[]; + getToggleAllRowsSelectedProps?: any[]; + getToggleAllPageRowsSelectedProps?: any[]; + getResizerProps?: (( props: Record, meta: { instance: TableInstance; header: ColumnType }, ) => Record | Record[])[]; } + +export type PluginHook = { + (hooks: ReactTableHooks): void; + pluginName: string; +}; + +export interface FilterFn { + (rows: RowType[], columnIds: string[], filterValue: any): RowType[]; + autoRemove?: (filterValue: any, column?: any) => boolean; +} diff --git a/packages/main/src/components/AnalyticalTable/util/index.ts b/packages/main/src/components/AnalyticalTable/util/index.ts index ee09f4ecb9e..21fcd542771 100644 --- a/packages/main/src/components/AnalyticalTable/util/index.ts +++ b/packages/main/src/components/AnalyticalTable/util/index.ts @@ -74,61 +74,6 @@ export function getSelectionColumnWidth(tableRef: RefObject 0 ? cssWidth : DEFAULT_SELECTION_COLUMN_WIDTH; } -// copied from https://github.com/tannerlinsley/react-table/blob/f97fb98509d0b27cc0bebcf3137872afe4f2809e/src/utils.js#L320-L347 (13. Jan 2021) -const reOpenBracket = /\[/g; -const reCloseBracket = /]/g; -function makePathArray(obj) { - return ( - flattenDeep(obj) - // remove all periods in parts - .map((d) => String(d).replace('.', '_')) - // join parts using period - .join('.') - // replace brackets with periods - .replace(reOpenBracket, '.') - .replace(reCloseBracket, '') - // split it back out on periods - .split('.') - ); -} -function flattenDeep(arr, newArr = []) { - if (!Array.isArray(arr)) { - newArr.push(arr); - } else { - for (let i = 0; i < arr.length; i += 1) { - flattenDeep(arr[i], newArr); - } - } - return newArr; -} - -// copied from https://github.com/tannerlinsley/react-table/blob/master/src/utils.js#L169-L191 (13.Jan 2021) -const pathObjCache = new Map(); -export function getBy(obj, path, def) { - if (!path) { - return obj; - } - const cacheKey = typeof path === 'function' ? path : JSON.stringify(path); - - const pathObj = - pathObjCache.get(cacheKey) || - (() => { - const pathObj = makePathArray(path); - pathObjCache.set(cacheKey, pathObj); - return pathObj; - })(); - let val; - - try { - val = pathObj.reduce((cursor, pathPart) => { - return cursor[pathPart]; - }, obj); - } catch (_e) { - // continue regardless of error - } - return typeof val !== 'undefined' ? val : def; -} - export const resolveCellAlignment = (column) => { const style: CSSProperties = {}; diff --git a/yarn.lock b/yarn.lock index 76c0287a143..605660c16ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5509,7 +5509,6 @@ __metadata: "@tanstack/react-virtual": "npm:3.13.24" "@ui5/webcomponents-react-base": "workspace:~" clsx: "npm:2.1.1" - react-table: "npm:7.8.0" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -18945,15 +18944,6 @@ __metadata: languageName: node linkType: hard -"react-table@npm:7.8.0": - version: 7.8.0 - resolution: "react-table@npm:7.8.0" - peerDependencies: - react: ^16.8.3 || ^17.0.0-0 || ^18.0.0 - checksum: 10c0/592938cb331331a4b10a67e881458ccf590c16639e2781a0329ca0803bad92991c9b9a2a3b1db1dce0de9ad325195fc5b38e1ea13813f8a55a35c94dc194757a - languageName: node - linkType: hard - "react-transition-group@npm:^4.4.5": version: 4.4.5 resolution: "react-transition-group@npm:4.4.5"