import { produce } from 'immer' import { SELECTION_COLUMN_ID } from '../../constants' import type { Column, ColumnGroup, GridTreeIndentSize } from '../../types' import type { ApplyPreferences, ApplyPropsAction, ReorderColumnsAction, ResizeColumnAction, UpdateColumnVisibilityAction, } from '../actions' import type { ColumnAggregationMapType, ColumnPreference, ColumnPreferences, ColumnSortInfo, ColumnState, HeaderHierarchy, StickyColumnDetails, } from '../types' import { createSelector } from '../utils' import { createReducer } from '../utils/createReducer' import { getAggregationValueFn } from '../../utils/aggregation' import { getAdjustedColumnWidths, constrainColumnWidth, constrainParentWidth, getLowestLeafs, getGroupAccumulatedWidth, getChildGroups, getParentGroups, buildHeaderHierarchy, getInferredSticky, getInferredLockedLocation, healPreferencesSortedIds, healPreferencesSplitGroups, } from './column-utils' import { size } from '@planview/pv-utilities' const DEFAULT_COLUMN_WIDTH = 150 export type GridColumnState = { role: 'grid' | 'treegrid' treeIndentSize: GridTreeIndentSize ids: string[] entities: Record sortEntities: ColumnSortInfo groupIds: string[] hiddenIds: string[] headerHierarchy: HeaderHierarchy headersHidden: boolean aggregationConfigurations: ColumnAggregationMapType[] preferences: ColumnPreferences _lastUpdate?: unknown _lastUpdateGroups?: unknown } const initialState: GridColumnState = { role: 'grid', treeIndentSize: 'large', ids: [], entities: {}, sortEntities: {}, groupIds: [], hiddenIds: [], headersHidden: false, headerHierarchy: { groups: [], levels: 0 }, aggregationConfigurations: [], preferences: { sortedIds: null, hiddenIds: null, entities: {}, }, } export default createReducer({ initialState, reducers: { applyProps(state, action: ApplyPropsAction) { if ( state._lastUpdate === action.payload.columns && state._lastUpdateGroups === action.payload.columnGroups && state.headersHidden === !!action.payload.hideHeaders ) { return state } const newIds: string[] = [] const newHiddenIds: string[] = [] const newEntities: typeof state.entities = {} const newSortEntities: typeof state.sortEntities = {} const newAggregations: typeof state.aggregationConfigurations = [] const newGroupIds: typeof state.groupIds = [] let role: GridColumnState['role'] = 'grid' let treeIndentSize: GridTreeIndentSize = 'large' function addGroup(group: ColumnGroup) { newEntities[group.id] = { actualWidth: 0, id: group.id, data: group, childIds: group.children, parentId: action.payload.columnGroups?.find((g) => g.children.includes(group.id) )?.id ?? null, isParent: true, } newGroupIds.push(group.id) } function addColumn(column: Column) { const existing = state.entities[column.id] const existingSort = state.sortEntities[column.id] if (column.tree) { role = 'treegrid' } if (column.treeIndentSize) { treeIndentSize = column.treeIndentSize } /** * Make sure parent has not changed */ const newParentId = action.payload.columnGroups?.find((g) => g.children.includes(column.id) )?.id ?? null if ( existing?.data === column && existing?.parentId === newParentId ) { newEntities[column.id] = existing newSortEntities[column.id] = existingSort } else { const userWidth = selectColumnPreference(state, column.id)?.width ?? null const actualWidth = constrainColumnWidth( userWidth ?? column.width ?? DEFAULT_COLUMN_WIDTH, column ) newEntities[column.id] = { ...existing, id: column.id, actualWidth, data: column, childIds: [], parentId: newParentId, isParent: false, } newSortEntities[column.id] = { sortStrategy: column.sortStrategy ?? 'fast', sortEmptyAs: column.sortEmptyAs, valueLookup: column.cell?.value, } } if (column.footer?.aggregation) { newAggregations.push({ id: column.id, fn: column.footer?.aggregation, value: getAggregationValueFn( column.footer, column.cell ), }) } if (column.hidden) { newHiddenIds.push(column.id) } newIds.push(column.id) } action.payload.columns.forEach(addColumn) action.payload.columnGroups?.forEach(addGroup) const ret = produce(state, (draft) => { draft._lastUpdate = action.payload.columns draft._lastUpdateGroups = action.payload.columnGroups draft.ids = newIds draft.entities = newEntities draft.sortEntities = newSortEntities draft.groupIds = newGroupIds draft.hiddenIds = newHiddenIds draft.headerHierarchy = buildHeaderHierarchy( draft.entities, state.preferences.hiddenIds ?? draft.hiddenIds ) /** * Append actual width on groups after we have all entities in draft */ draft.groupIds.forEach((groupId) => { const columnGroup = draft.entities[groupId] columnGroup.actualWidth = getGroupAccumulatedWidth( columnGroup.id, newEntities, state.preferences.hiddenIds ?? draft.hiddenIds ) }) if (draft.groupIds.length) { //If we have groups, infer sticky from parents Object.values(draft.entities).forEach((entity) => { const inferredSticky = getInferredSticky( entity.id, draft.entities, draft.headerHierarchy ) const inferredLockedLocation = getInferredLockedLocation( entity.id, draft.entities, draft.headerHierarchy ) //Update the lockedLocation attribute in case we have a updated sticky attribute const lockedLocation = inferredSticky && inferredSticky !== 'none' ? inferredSticky : inferredLockedLocation //Update the movable attribute in case we have a updated sticky/lockedLocation attribute const movable = (inferredSticky && inferredSticky !== 'none') || (lockedLocation && lockedLocation !== 'none') ? false : typeof entity.data.movable === 'boolean' ? entity.data.movable : true if ( entity.data.sticky !== inferredSticky || entity.data.lockedLocation !== lockedLocation || entity.data.movable !== movable ) { //Sticky needs to be overridden in possibly read-only object. We need to create a new one. const existing = entity draft.entities[entity.id] = { ...existing, data: { ...existing.data, sticky: inferredSticky, lockedLocation, movable, }, } } }) } draft.headersHidden = action.payload.hideHeaders === true draft.role = role draft.treeIndentSize = treeIndentSize draft.aggregationConfigurations = newAggregations draft.preferences = { ...state.preferences, hiddenIds: state.preferences.hiddenIds, sortedIds: state.preferences.sortedIds ? healPreferencesSplitGroups( healPreferencesSortedIds( newIds, state.preferences.sortedIds ), draft.headerHierarchy.groups ) : null, } }) return ret }, reorderColumns(state, action: ReorderColumnsAction) { return { ...state, preferences: { ...state.preferences, sortedIds: action.payload, }, } }, resizeColumn(state, action: ResizeColumnAction) { const { id, width } = action.payload const column = selectColumn(state, id) if (column.isParent) { const children = getLowestLeafs( column.id, state.entities, state.preferences.hiddenIds ?? state.hiddenIds ) const actualWidth = constrainParentWidth(width, children) const newWidths = getAdjustedColumnWidths(children, actualWidth) const childGroups = getChildGroups(column.id, state.entities) return produce(state, (draft) => { //Update the lowest leafs (columns) newWidths.forEach(({ id, width }) => { draft.entities[id].actualWidth = width draft.preferences.entities[id] = { id, width: width, } }) draft.entities[action.payload.id].actualWidth = actualWidth if (column.parentId) { //Update parent one level up draft.entities[column.parentId].actualWidth = getGroupAccumulatedWidth( draft.entities[column.parentId].id, draft.entities, state.preferences.hiddenIds ?? state.hiddenIds ) } if (childGroups.length) { //Update parents below childGroups.forEach((g) => { draft.entities[g.id].actualWidth = getGroupAccumulatedWidth( draft.entities[g.id].id, draft.entities, state.preferences.hiddenIds ?? state.hiddenIds ) }) } }) } const actualWidth = constrainColumnWidth(width, column.data) return produce(state, (draft) => { draft.entities[id].actualWidth = actualWidth draft.preferences.entities[id] = { id, width: actualWidth, hidden: !!column.data.hidden, } if (column.parentId) { //Update all parents above getParentGroups(column.id, state.entities).forEach((p) => { draft.entities[p.id].actualWidth = getGroupAccumulatedWidth( p.id, draft.entities, state.preferences.hiddenIds ?? state.hiddenIds ) }) } }) }, applyPreferences(state, action: ApplyPreferences) { return produce(state, (draft) => { const { preferences, multiSelection } = action.payload const columnIds = preferences.columns.map((c) => c.id) draft.preferences.hiddenIds = [] draft.preferences.entities = preferences.columns.reduce< Record >((m, c) => { m[c.id] = c const sourceColumn = selectColumn(state, c.id) //In case the column definition has changed to prevent the column from being hidden, force it to be visible const isHidden = sourceColumn.data.hideable === false ? false : c.hidden if (c.width != null) { draft.entities[c.id].actualWidth = constrainColumnWidth( c.width, sourceColumn.data ) } if (isHidden && draft.preferences.hiddenIds) { draft.preferences.hiddenIds.push(c.id) } draft.entities[c.id].data.hidden = isHidden return m }, {}) const parents: ColumnState[] = [] action.payload.preferences.columns.forEach(({ id }) => { parents.push(...getParentGroups(id, draft.entities)) }) const uParents = [...new Set(parents)] uParents.forEach((group) => { draft.entities[group.id].actualWidth = getGroupAccumulatedWidth( group.id, draft.entities, draft.preferences.hiddenIds ?? state.hiddenIds ) }) draft.headerHierarchy = buildHeaderHierarchy( draft.entities, draft.preferences.hiddenIds ) const headerHierarchyIncludingHidden = buildHeaderHierarchy( draft.entities, [] ) /* Add back selection column if missing */ draft.preferences.sortedIds = healPreferencesSplitGroups( multiSelection ? [SELECTION_COLUMN_ID, ...columnIds] : columnIds, headerHierarchyIncludingHidden.groups ) }) }, updateColumnVisibility(state, action: UpdateColumnVisibilityAction) { const { id, visible } = action.payload const column = selectColumn(state, id) //If the column is not meant to be hidden, force it to be visible const finalVisibleState = column.data.hideable === false ? true : visible if (column) { const ret = produce(state, (draft) => { const hiddenIds = state.preferences.hiddenIds ?? state.hiddenIds //Update the hidden ids array const newHiddenIds = finalVisibleState ? hiddenIds.filter((id) => id !== column.id) : [...hiddenIds, column.id] draft.preferences.hiddenIds = newHiddenIds //Update the entity data hidden flag draft.entities[id].data.hidden = !finalVisibleState //Update the actual width of any parent groups getParentGroups(id, state.entities).forEach((group) => { draft.entities[group.id].actualWidth = getGroupAccumulatedWidth( group.id, draft.entities, newHiddenIds ) }) //Update the header hierarchy draft.headerHierarchy = buildHeaderHierarchy( draft.entities, draft.preferences.hiddenIds ) //Update the preferences draft.preferences.entities[id] = { id, width: draft.preferences.entities[id]?.width ?? null, hidden: !finalVisibleState, } }) return ret } return state }, }, }) export const selectTreeIndentSize = (state: GridColumnState) => state.treeIndentSize export const selectAreHeadersHidden = (state: GridColumnState) => state.headersHidden export const selectColumn = (state: GridColumnState, columnId: string) => state.entities[columnId] export const selectColumnSortEntities = (state: GridColumnState) => state.sortEntities export const selectColumnPreference = ( state: GridColumnState, columnId: string ) => state.preferences.entities[columnId] export const selectColumnCount = (state: GridColumnState) => state.ids.length - (state.preferences.hiddenIds ?? state.hiddenIds).length export const selectUnsortedColumnIds = (state: GridColumnState) => state.ids export const selectColumnIdsWithHidden = (state: GridColumnState) => state.preferences.sortedIds ?? state.ids const _selectColumnIdsWithHidden = (state: GridColumnState) => state.ids export const selectHiddenIds = (state: GridColumnState) => state.preferences.hiddenIds ?? state.hiddenIds export const selectGroupIds = (state: GridColumnState) => state.groupIds export const selectHeaderHierarchy = (state: GridColumnState) => state.headerHierarchy export const selectHeaderHeight = (state: GridColumnState) => { const areHeadersHidden = state.headersHidden if (areHeadersHidden) { return 0 } const headerRowCount = selectHeaderHierarchy(state).levels + 1 return headerRowCount * size.small } export const selectIsTreeGrid = (state: GridColumnState) => state.role === 'treegrid' export const selectColumnStickyOffset = ( state: GridColumnState, columnId: string ) => { const columnIdsWithHidden = selectColumnIdsWithHidden(state) const hiddenIds = selectHiddenIds(state) const ids = columnIdsWithHidden.filter((c) => !hiddenIds.includes(c)) const column = selectColumn(state, columnId) if (!column?.data.sticky || column.data.sticky === 'none') { return 0 } const stickyLeft = column.data.sticky === 'left' const index = ids.indexOf(columnId) const otherStickyIds = stickyLeft ? ids.slice(0, index) : ids.slice(index + 1) return otherStickyIds.reduce( (m, id) => m + (selectColumn(state, id)?.actualWidth || 0), 0 ) } export const selectColumnGroupStickyOffset = ( state: GridColumnState, columnId: string ) => { const column = selectColumn(state, columnId) if (!column?.data.sticky || column.data.sticky === 'none') { return 0 } const columns = getLowestLeafs( columnId, state.entities, state.preferences.hiddenIds ?? state.hiddenIds ) if (column.data.sticky === 'left') { const firstColumnInGroup = columns[0] return selectColumnStickyOffset(state, firstColumnInGroup.id) } else if (column.data.sticky === 'right') { const lastColumnInGroup = columns[columns.length - 1] return selectColumnStickyOffset(state, lastColumnInGroup.id) } return 0 } export const selectTotalColumnWidth = (state: GridColumnState) => state.ids .filter( (id) => !(state.preferences.hiddenIds ?? state.hiddenIds).includes(id) ) .reduce((m, id) => m + state.entities[id]?.actualWidth, 0) const _selectColumnSortedIds = (state: GridColumnState) => state.preferences.sortedIds const _selectColumnPreferences = (state: GridColumnState) => state.preferences const _selectColumnEntities = (state: GridColumnState) => state.entities export const generateSelectors = () => { const selectGridSupportsEdit = createSelector( [_selectColumnIdsWithHidden, _selectColumnEntities], (ids, entities) => { /** * If any column contains an editable = true or editable predicate function * then the grid will mark individual cells as read only. */ const anyEdit = ids.some((id) => !!entities[id].data.cell?.editable) return anyEdit } ) const selectStickyColumnDetails = createSelector( [ _selectColumnSortedIds, _selectColumnIdsWithHidden, _selectColumnEntities, selectHiddenIds, ], (sortedIds, columnIds, entities, hiddenIds) => { const ids = sortedIds ?? columnIds return ids .filter((id) => !hiddenIds.includes(id)) .reduce( (memo, id, index) => { const col = entities[id] const sticky = col?.data?.sticky if (sticky === 'left' || sticky === 'right') { const width = col?.actualWidth || 0 memo[sticky].columns.push({ id, index, width, }) memo[sticky].width += width memo.indexes.add(index) } return memo }, { left: { columns: [], width: 0 }, right: { columns: [], width: 0 }, indexes: new Set(), } ) } ) /* Internal means our built in columns. Currently we only have one, but in case we add more in the future, this uses a generic name */ const selectColumnIdsWithoutInternal = createSelector( [_selectColumnSortedIds, _selectColumnIdsWithHidden], (sortedIds, columnIds) => (sortedIds ?? columnIds).filter((id) => id !== SELECTION_COLUMN_ID) ) const selectColumnPreferences = createSelector( [ selectColumnIdsWithoutInternal, _selectColumnPreferences, selectHiddenIds, ], (columnIds, preferences, hiddenIds) => columnIds.map( (columnId) => preferences.entities[columnId] || { id: columnId, width: null, hidden: hiddenIds.includes(columnId), } ) ) const selectColumnIds = createSelector( [selectColumnIdsWithHidden, selectHiddenIds], (ids, hiddenIds) => ids.filter((id) => !hiddenIds.includes(id)) ) const selectColumnWidths = createSelector( [selectColumnEntities, selectColumnIds], (entities, ids) => ids.map((id) => entities[id]?.actualWidth) ) return { selectGridSupportsEdit, selectStickyColumnDetails, selectColumnIdsWithoutInternal, selectColumnPreferences, selectColumnIds, selectColumnWidths, } } export const selectAggregationConfigurations = (state: GridColumnState) => state.aggregationConfigurations export const selectColumnEntities = (state: GridColumnState) => state.entities