From ab8057c879337e46003583b48f9ca6a5c5958f47 Mon Sep 17 00:00:00 2001 From: radubrehar Date: Tue, 29 Oct 2024 16:29:13 +0200 Subject: [PATCH] fix selection in parent nodes with no children and release version patch --- .../tests/table/treegrid/selection3.page.tsx | 179 ++++++++++ source/src/components/DataSource/TreeApi.ts | 335 ++++++++++++++++++ .../DataSource/TreeSelectionState.ts | 5 +- .../components/DataSource/getDataSourceApi.ts | 4 +- .../src/components/DataSource/getTreeApi.ts | 312 ---------------- .../DataSource/state/getInitialState.ts | 2 +- .../components/DataSource/state/reducer.ts | 7 + source/src/components/DataSource/types.ts | 2 +- .../InfiniteTableColumnCell.tsx | 15 +- .../components/icons/ExpanderIcon.css.ts | 7 + .../components/icons/ExpanderIcon.tsx | 7 +- .../InfiniteTable/eventHandlers/index.ts | 2 +- .../InfiniteTable/eventHandlers/onKeyDown.ts | 2 +- source/src/utils/groupAndPivot/index.ts | 8 +- .../tree-default-selection-example.page.tsx | 7 - ...-controlled-selectedstate-example.page.tsx | 7 - ...ncontrolled-selectedstate-example.page.tsx | 7 - 17 files changed, 562 insertions(+), 346 deletions(-) create mode 100644 examples/src/pages/tests/table/treegrid/selection3.page.tsx create mode 100644 source/src/components/DataSource/TreeApi.ts delete mode 100644 source/src/components/DataSource/getTreeApi.ts diff --git a/examples/src/pages/tests/table/treegrid/selection3.page.tsx b/examples/src/pages/tests/table/treegrid/selection3.page.tsx new file mode 100644 index 00000000..744eadb7 --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/selection3.page.tsx @@ -0,0 +1,179 @@ +import { + DataSourceApi, + InfiniteTableColumn, + TreeDataSource, + TreeGrid, + TreeSelectionValue, +} from '@infinite-table/infinite-react'; +import { useState } from 'react'; + +type FileSystemNode = { + id: string; + name: string; + type: 'folder' | 'file'; + extension?: string; + mimeType?: string; + sizeInKB: number; + children?: FileSystemNode[]; +}; + +const columns: Record> = { + name: { + field: 'name', + header: 'Name', + defaultWidth: 500, + renderValue: ({ value, rowInfo }) => { + return ( + <> + {rowInfo.id} - {value} + + ); + }, + renderTreeIcon: true, + renderSelectionCheckBox: true, + }, + type: { field: 'type', header: 'Type' }, + extension: { field: 'extension', header: 'Extension' }, + mimeType: { field: 'mimeType', header: 'Mime Type' }, + size: { field: 'sizeInKB', type: 'number', header: 'Size (KB)' }, +}; + +const defaultTreeSelection: TreeSelectionValue = { + defaultSelection: true, + deselectedPaths: [['1', '10']], + selectedPaths: [['3']], +}; + +export default function App() { + const [dataSourceApi, setDataSourceApi] = + useState | null>(); + + return ( + <> + +
+ + +
+ + +
+ + ); +} + +const dataSource = () => { + const nodes: FileSystemNode[] = [ + { + id: '1', + name: 'Documents', + sizeInKB: 1200, + type: 'folder', + children: [ + { + id: '10', + name: 'Private', + sizeInKB: 100, + type: 'folder', + children: [ + { + id: '100', + name: 'Report.docx', + sizeInKB: 210, + type: 'file', + extension: 'docx', + mimeType: 'application/msword', + }, + { + id: '101', + name: 'Vacation.docx', + sizeInKB: 120, + type: 'file', + extension: 'docx', + mimeType: 'application/msword', + }, + { + id: '102', + name: 'CV.pdf', + sizeInKB: 108, + type: 'file', + extension: 'pdf', + mimeType: 'application/pdf', + }, + ], + }, + ], + }, + { + id: '2', + name: 'Desktop', + sizeInKB: 1000, + type: 'folder', + children: [ + { + id: '20', + name: 'unknown.txt', + sizeInKB: 100, + type: 'file', + }, + ], + }, + { + id: '3', + name: 'Media', + sizeInKB: 1000, + type: 'folder', + children: [ + { + id: '30', + name: 'Music - empty', + sizeInKB: 0, + type: 'folder', + children: [], + }, + { + id: '31', + name: 'Videos', + sizeInKB: 5400, + type: 'folder', + children: [ + { + id: '310', + name: 'Vacation.mp4', + sizeInKB: 108, + type: 'file', + extension: 'mp4', + mimeType: 'video/mp4', + }, + ], + }, + ], + }, + ]; + return Promise.resolve(nodes); +}; diff --git a/source/src/components/DataSource/TreeApi.ts b/source/src/components/DataSource/TreeApi.ts new file mode 100644 index 00000000..e1b9d8a0 --- /dev/null +++ b/source/src/components/DataSource/TreeApi.ts @@ -0,0 +1,335 @@ +import { DataSourceApi, DataSourceComponentActions } from '.'; + +import { InfiniteTableRowInfo } from '../InfiniteTable/types'; +import { getRowInfoAt } from './dataSourceGetters'; +import { isNodeExpandable } from './state/reducer'; +import { NodePath, TreeExpandState } from './TreeExpandState'; +import { + GetTreeSelectionStateConfig, + TreeSelectionState, + TreeSelectionStateObject, +} from './TreeSelectionState'; +import { DataSourceState } from './types'; + +export type GetTreeSelectionApiParam = { + getState: () => DataSourceState; + actions: { + treeSelection: DataSourceComponentActions['treeSelection']; + }; +}; +export function cloneTreeSelection( + treeSelection: TreeSelectionState | TreeSelectionStateObject, + stateOrGetState: DataSourceState | (() => DataSourceState), +) { + return new TreeSelectionState( + treeSelection, + treeSelectionStateConfigGetter(stateOrGetState), + ); +} + +export type TreeExpandStateApi = { + isNodeExpanded(nodePath: any[]): boolean; + isNodeExpandable(nodePath: any[]): boolean; + + expandNode(nodePath: any[]): void; + collapseNode(nodePath: any[]): void; + + toggleNode(nodePath: any[]): void; + + getNodeDataByPath(nodePath: any[]): T | null; + getRowInfoByPath(nodePath: any[]): InfiniteTableRowInfo | null; +}; + +type TreeSelectionApi<_T = any> = { + get allRowsSelected(): boolean; + isNodeSelected(nodePath: NodePath): boolean | null; + + selectNode(nodePath: NodePath): void; + setNodeSelection(nodePath: NodePath, selected: boolean): void; + deselectNode(nodePath: NodePath): void; + toggleNodeSelection(nodePath: NodePath): void; + + selectAll(): void; + expandAll(): void; + collapseAll(): void; + deselectAll(): void; +}; + +export type TreeApi = TreeExpandStateApi & TreeSelectionApi; + +export type GetTreeApiParam = { + getState: () => DataSourceState; + dataSourceApi: DataSourceApi; + actions: DataSourceComponentActions; +}; + +export function treeSelectionStateConfigGetter( + stateOrStateGetter: DataSourceState | (() => DataSourceState), +): GetTreeSelectionStateConfig { + return () => { + const state = + typeof stateOrStateGetter === 'function' + ? stateOrStateGetter() + : stateOrStateGetter; + + return { + treeDeepMap: state.treeDeepMap!, + }; + }; +} + +export class TreeApiImpl implements TreeApi { + private getState: () => DataSourceState; + private actions: DataSourceComponentActions; + private dataSourceApi: DataSourceApi; + + constructor(param: GetTreeApiParam) { + this.getState = param.getState; + this.actions = param.actions; + this.dataSourceApi = param.dataSourceApi; + } + + setNodeSelection = (nodePath: NodePath, selected: boolean) => { + const { treeSelectionState: treeSelection, selectionMode } = + this.getState(); + + if (selectionMode === 'single-row') { + this.actions.treeSelection = selected + ? (nodePath[nodePath.length - 1] as any) + : null; + return; + } + + if (selectionMode !== 'multi-row') { + throw 'Selection mode is not multi-row or single-row'; + } + if (!(treeSelection instanceof TreeSelectionState)) { + throw 'Invalid tree selection'; + } + + const treeSelectionState = new TreeSelectionState( + treeSelection, + treeSelectionStateConfigGetter(this.getState), + ); + + treeSelectionState.setNodeSelection(nodePath, selected); + this.getState().lastSelectionUpdatedNodePathRef.current = nodePath; + this.actions.treeSelection = treeSelectionState; + }; + get allRowsSelected() { + return this.getState().allRowsSelected; + } + isNodeExpandable(nodePath: any[]) { + const rowInfo = this.getRowInfoByPath(nodePath); + return rowInfo && rowInfo.isTreeNode && rowInfo.isParentNode + ? isNodeExpandable(rowInfo) + : false; + } + isNodeExpanded(nodePath: any[]) { + const state = this.getState(); + const { isNodeExpanded, isNodeCollapsed, treeExpandState } = state; + + const rowInfo = this.getRowInfoByPath(nodePath); + + if (rowInfo) { + if (!rowInfo.isTreeNode || !rowInfo.isParentNode) { + return false; + } + if (isNodeCollapsed) { + return !isNodeExpanded!(rowInfo); + } + if (isNodeExpanded) { + return isNodeExpanded!(rowInfo); + } + } + + return treeExpandState.isNodeExpanded(nodePath); + } + + expandAll() { + const treeExpandState = new TreeExpandState({ + defaultExpanded: true, + collapsedPaths: [], + }); + + this.getState().lastExpandStateInfoRef.current = { + state: 'expanded', + nodePath: null, + }; + this.actions.treeExpandState = treeExpandState; + } + + collapseAll() { + const treeExpandState = new TreeExpandState({ + defaultExpanded: false, + expandedPaths: [], + }); + + this.getState().lastExpandStateInfoRef.current = { + state: 'collapsed', + nodePath: null, + }; + this.actions.treeExpandState = treeExpandState; + } + + expandNode(nodePath: any[]) { + if (!this.isNodeExpandable(nodePath)) { + return; + } + const state = this.getState(); + const treeExpandState = new TreeExpandState(state.treeExpandState); + treeExpandState.expandNode(nodePath); + + this.getState().lastExpandStateInfoRef.current = { + state: 'expanded', + nodePath, + }; + this.actions.treeExpandState = treeExpandState; + + state.onNodeExpand?.(nodePath, this.getCallbackParam(nodePath)); + } + private getCallbackParam = (_nodePath: NodePath) => { + return { + dataSourceApi: this.dataSourceApi, + }; + }; + collapseNode(nodePath: any[]) { + const state = this.getState(); + const treeExpandState = new TreeExpandState(state.treeExpandState); + treeExpandState.collapseNode(nodePath); + + this.getState().lastExpandStateInfoRef.current = { + state: 'collapsed', + nodePath, + }; + this.actions.treeExpandState = treeExpandState; + + state.onNodeCollapse?.(nodePath, this.getCallbackParam(nodePath)); + } + + toggleNode(nodePath: any[]) { + const state = this.getState(); + const treeExpandState = new TreeExpandState(state.treeExpandState); + const newExpanded = !this.isNodeExpanded(nodePath); + + if (!this.isNodeExpandable(nodePath)) { + return; + } + treeExpandState.setNodeExpanded(nodePath, newExpanded); + + this.getState().lastExpandStateInfoRef.current = { + state: newExpanded ? 'expanded' : 'collapsed', + nodePath, + }; + this.actions.treeExpandState = treeExpandState; + + if (newExpanded) { + state.onNodeExpand?.(nodePath, this.getCallbackParam(nodePath)); + } else { + state.onNodeCollapse?.(nodePath, this.getCallbackParam(nodePath)); + } + } + + getNodeDataByPath(nodePath: any[]) { + const { treeDeepMap } = this.getState(); + if (!treeDeepMap || !nodePath.length) { + return null; + } + + const rowInfo = this.getRowInfoByPath(nodePath); + return rowInfo ? (rowInfo.data as T) : null; + } + getRowInfoByPath(nodePath: any[]) { + const { pathToIndexDeepMap } = this.getState(); + + const index = pathToIndexDeepMap.get(nodePath); + if (index !== undefined) { + return getRowInfoAt(index, this.getState); + } + return null; + } + + selectAll() { + const { treeSelectionState: treeSelection, selectionMode } = + this.getState(); + + if (selectionMode !== 'multi-row') { + throw 'Selection mode is not multi-row'; + } + if (!(treeSelection instanceof TreeSelectionState)) { + throw 'Invalid node selection'; + } + + const treeSelectionState = new TreeSelectionState( + treeSelection, + treeSelectionStateConfigGetter(this.getState), + ); + + treeSelectionState.selectAll(); + + this.getState().lastSelectionUpdatedNodePathRef.current = null; + this.actions.treeSelection = treeSelectionState; + } + + deselectAll() { + const { treeSelectionState: treeSelection, selectionMode } = + this.getState(); + + if (selectionMode !== 'multi-row') { + throw 'Selection mode is not multi-row'; + } + if (!(treeSelection instanceof TreeSelectionState)) { + throw 'Invalid node selection'; + } + + const treeSelectionState = new TreeSelectionState( + treeSelection, + treeSelectionStateConfigGetter(this.getState), + ); + + treeSelectionState.deselectAll(); + this.getState().lastSelectionUpdatedNodePathRef.current = null; + this.actions.treeSelection = treeSelectionState; + } + isNodeSelected(nodePath: NodePath) { + const { treeSelection, selectionMode } = this.getState(); + + if (selectionMode === 'single-row') { + const pk = nodePath[nodePath.length - 1]; + if (Array.isArray(treeSelection)) { + // @ts-ignore + return treeSelection.join(',') === nodePath.join(','); + } + return (treeSelection as any) === pk; + } + + if (selectionMode !== 'multi-row') { + throw 'Selection mode is not multi-row or single-row'; + } + if (!(treeSelection instanceof TreeSelectionState)) { + throw 'Invalid tree selection'; + } + + return treeSelection.isNodeSelected(nodePath); + } + + selectNode(nodePath: NodePath) { + this.setNodeSelection(nodePath, true); + } + + deselectNode(nodePath: NodePath) { + this.setNodeSelection(nodePath, false); + } + + toggleNodeSelection(nodePath: NodePath) { + if (this.isNodeSelected(nodePath)) { + this.deselectNode(nodePath); + } else { + this.selectNode(nodePath); + } + } +} + +export function getTreeApi(param: GetTreeApiParam): TreeApi { + return new TreeApiImpl(param); +} diff --git a/source/src/components/DataSource/TreeSelectionState.ts b/source/src/components/DataSource/TreeSelectionState.ts index af5d1e53..5c06a0ea 100644 --- a/source/src/components/DataSource/TreeSelectionState.ts +++ b/source/src/components/DataSource/TreeSelectionState.ts @@ -215,9 +215,8 @@ export class TreeSelectionState { // there are no explicit selection or deselection for any child // so we have to go to the parent to determine the selection state - const res = leafCount - ? this.getNodeBooleanSelectionStateFromParent(nodePath) - : false; + + const res = this.getNodeBooleanSelectionStateFromParent(nodePath); return this.cacheIt(nodePath, { selected: res, diff --git a/source/src/components/DataSource/getDataSourceApi.ts b/source/src/components/DataSource/getDataSourceApi.ts index b6c96f8d..2f3842fc 100644 --- a/source/src/components/DataSource/getDataSourceApi.ts +++ b/source/src/components/DataSource/getDataSourceApi.ts @@ -9,7 +9,7 @@ import { import { InfiniteTableRowInfo } from '../InfiniteTable/types'; import { DataSourceCache } from './DataSourceCache'; import { getRowInfoAt, getRowInfoArray } from './dataSourceGetters'; -import { getTreeApi, TreeApi } from './getTreeApi'; +import { TreeApi, TreeApiImpl } from './TreeApi'; import { RowDisabledState } from './RowDisabledState'; import { NodePath } from './TreeExpandState'; import { DataSourceInsertParam } from './types'; @@ -94,7 +94,7 @@ class DataSourceApiImpl implements DataSourceApi { this.getState = param.getState; this.getState().__apiRef.current = this; this.actions = param.actions; - this.treeApi = getTreeApi({ ...param, dataSourceApi: this }); + this.treeApi = new TreeApiImpl({ ...param, dataSourceApi: this }); } private pendingPromise: Promise | null = null; diff --git a/source/src/components/DataSource/getTreeApi.ts b/source/src/components/DataSource/getTreeApi.ts deleted file mode 100644 index ca4df5e3..00000000 --- a/source/src/components/DataSource/getTreeApi.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { DataSourceApi, DataSourceComponentActions } from '.'; - -import { InfiniteTableRowInfo } from '../InfiniteTable/types'; -import { getRowInfoAt } from './dataSourceGetters'; -import { NodePath, TreeExpandState } from './TreeExpandState'; -import { - GetTreeSelectionStateConfig, - TreeSelectionState, - TreeSelectionStateObject, -} from './TreeSelectionState'; -import { DataSourceState } from './types'; - -export type TreeExpandStateApi = { - isNodeExpanded(nodePath: any[]): boolean; - - expandNode(nodePath: any[]): void; - collapseNode(nodePath: any[]): void; - - toggleNode(nodePath: any[]): void; - - getNodeDataByPath(nodePath: any[]): T | null; - getRowInfoByPath(nodePath: any[]): InfiniteTableRowInfo | null; -}; - -export type GetTreeSelectionApiParam = { - getState: () => DataSourceState; - actions: { - treeSelection: DataSourceComponentActions['treeSelection']; - }; -}; -export function cloneTreeSelection( - treeSelection: TreeSelectionState | TreeSelectionStateObject, - stateOrGetState: DataSourceState | (() => DataSourceState), -) { - return new TreeSelectionState( - treeSelection, - treeSelectionStateConfigGetter(stateOrGetState), - ); -} - -type TreeSelectionApi<_T = any> = { - get allRowsSelected(): boolean; - isNodeSelected(nodePath: NodePath): boolean | null; - - selectNode(nodePath: NodePath): void; - setNodeSelection(nodePath: NodePath, selected: boolean): void; - deselectNode(nodePath: NodePath): void; - toggleNodeSelection(nodePath: NodePath): void; - - selectAll(): void; - expandAll(): void; - collapseAll(): void; - deselectAll(): void; -}; -export type TreeApi = TreeExpandStateApi & TreeSelectionApi; - -export type GetTreeApiParam = { - getState: () => DataSourceState; - dataSourceApi: DataSourceApi; - actions: DataSourceComponentActions; -}; - -export function treeSelectionStateConfigGetter( - stateOrStateGetter: DataSourceState | (() => DataSourceState), -): GetTreeSelectionStateConfig { - return () => { - const state = - typeof stateOrStateGetter === 'function' - ? stateOrStateGetter() - : stateOrStateGetter; - - return { - treeDeepMap: state.treeDeepMap!, - }; - }; -} - -export function getTreeApi(param: GetTreeApiParam): TreeApi { - const { getState, actions, dataSourceApi } = param; - - const setNodeSelection = (nodePath: NodePath, selected: boolean) => { - const { treeSelectionState: treeSelection, selectionMode } = getState(); - - if (selectionMode === 'single-row') { - actions.treeSelection = selected - ? (nodePath[nodePath.length - 1] as any) - : null; - return; - } - - if (selectionMode !== 'multi-row') { - throw 'Selection mode is not multi-row or single-row'; - } - if (!(treeSelection instanceof TreeSelectionState)) { - throw 'Invalid tree selection'; - } - - const treeSelectionState = new TreeSelectionState( - treeSelection, - treeSelectionStateConfigGetter(getState), - ); - - treeSelectionState.setNodeSelection(nodePath, selected); - getState().lastSelectionUpdatedNodePathRef.current = nodePath; - actions.treeSelection = treeSelectionState; - }; - - const getCallbackParam = (_nodePath: NodePath) => { - return { - dataSourceApi, - }; - }; - - const api: TreeApi = { - get allRowsSelected() { - return getState().allRowsSelected; - }, - isNodeExpanded(nodePath: any[]) { - const state = getState(); - const { isNodeExpanded, isNodeCollapsed, treeExpandState } = state; - - const rowInfo = this.getRowInfoByPath(nodePath); - - if (rowInfo) { - if (!rowInfo.isTreeNode || !rowInfo.isParentNode) { - return false; - } - if (isNodeCollapsed) { - return !isNodeExpanded!(rowInfo); - } - if (isNodeExpanded) { - return isNodeExpanded!(rowInfo); - } - } - - return treeExpandState.isNodeExpanded(nodePath); - }, - - expandAll() { - const treeExpandState = new TreeExpandState({ - defaultExpanded: true, - collapsedPaths: [], - }); - - getState().lastExpandStateInfoRef.current = { - state: 'expanded', - nodePath: null, - }; - actions.treeExpandState = treeExpandState; - }, - - collapseAll() { - const treeExpandState = new TreeExpandState({ - defaultExpanded: false, - expandedPaths: [], - }); - - getState().lastExpandStateInfoRef.current = { - state: 'collapsed', - nodePath: null, - }; - actions.treeExpandState = treeExpandState; - }, - - expandNode(nodePath: any[]) { - const state = getState(); - const treeExpandState = new TreeExpandState(state.treeExpandState); - treeExpandState.expandNode(nodePath); - - getState().lastExpandStateInfoRef.current = { - state: 'expanded', - nodePath, - }; - actions.treeExpandState = treeExpandState; - - state.onNodeExpand?.(nodePath, getCallbackParam(nodePath)); - }, - collapseNode(nodePath: any[]) { - const state = getState(); - const treeExpandState = new TreeExpandState(state.treeExpandState); - treeExpandState.collapseNode(nodePath); - - getState().lastExpandStateInfoRef.current = { - state: 'collapsed', - nodePath, - }; - actions.treeExpandState = treeExpandState; - - state.onNodeCollapse?.(nodePath, getCallbackParam(nodePath)); - }, - - toggleNode(nodePath: any[]) { - const state = getState(); - const treeExpandState = new TreeExpandState(state.treeExpandState); - const newExpanded = !api.isNodeExpanded(nodePath); - treeExpandState.setNodeExpanded(nodePath, newExpanded); - - getState().lastExpandStateInfoRef.current = { - state: newExpanded ? 'expanded' : 'collapsed', - nodePath, - }; - actions.treeExpandState = treeExpandState; - - if (newExpanded) { - state.onNodeExpand?.(nodePath, getCallbackParam(nodePath)); - } else { - state.onNodeCollapse?.(nodePath, getCallbackParam(nodePath)); - } - }, - - getNodeDataByPath(nodePath: any[]) { - const { treeDeepMap } = getState(); - if (!treeDeepMap || !nodePath.length) { - return null; - } - - const rowInfo = api.getRowInfoByPath(nodePath); - return rowInfo ? (rowInfo.data as T) : null; - }, - getRowInfoByPath(nodePath: any[]) { - const { pathToIndexDeepMap } = getState(); - - const index = pathToIndexDeepMap.get(nodePath); - if (index !== undefined) { - return getRowInfoAt(index, getState); - } - return null; - }, - - selectAll() { - const { treeSelectionState: treeSelection, selectionMode } = getState(); - - if (selectionMode !== 'multi-row') { - throw 'Selection mode is not multi-row'; - } - if (!(treeSelection instanceof TreeSelectionState)) { - throw 'Invalid node selection'; - } - - const treeSelectionState = new TreeSelectionState( - treeSelection, - treeSelectionStateConfigGetter(getState), - ); - - treeSelectionState.selectAll(); - - getState().lastSelectionUpdatedNodePathRef.current = null; - actions.treeSelection = treeSelectionState; - }, - - deselectAll() { - const { treeSelectionState: treeSelection, selectionMode } = getState(); - - if (selectionMode !== 'multi-row') { - throw 'Selection mode is not multi-row'; - } - if (!(treeSelection instanceof TreeSelectionState)) { - throw 'Invalid node selection'; - } - - const treeSelectionState = new TreeSelectionState( - treeSelection, - treeSelectionStateConfigGetter(getState), - ); - - treeSelectionState.deselectAll(); - getState().lastSelectionUpdatedNodePathRef.current = null; - actions.treeSelection = treeSelectionState; - }, - isNodeSelected(nodePath: NodePath) { - const { treeSelection, selectionMode } = getState(); - - if (selectionMode === 'single-row') { - const pk = nodePath[nodePath.length - 1]; - if (Array.isArray(treeSelection)) { - // @ts-ignore - return treeSelection.join(',') === nodePath.join(','); - } - return (treeSelection as any) === pk; - } - - if (selectionMode !== 'multi-row') { - throw 'Selection mode is not multi-row or single-row'; - } - if (!(treeSelection instanceof TreeSelectionState)) { - throw 'Invalid tree selection'; - } - - return treeSelection.isNodeSelected(nodePath); - }, - - selectNode(nodePath: NodePath) { - setNodeSelection(nodePath, true); - }, - - deselectNode(nodePath: NodePath) { - setNodeSelection(nodePath, false); - }, - setNodeSelection(nodePath: NodePath, selected: boolean) { - setNodeSelection(nodePath, selected); - }, - - toggleNodeSelection(nodePath: NodePath) { - if (this.isNodeSelected(nodePath)) { - this.deselectNode(nodePath); - } else { - this.selectNode(nodePath); - } - }, - }; - return api; -} diff --git a/source/src/components/DataSource/state/getInitialState.ts b/source/src/components/DataSource/state/getInitialState.ts index 542bd42e..f741bac1 100644 --- a/source/src/components/DataSource/state/getInitialState.ts +++ b/source/src/components/DataSource/state/getInitialState.ts @@ -61,7 +61,7 @@ import { TreeSelectionState, TreeSelectionStateObject, } from '../TreeSelectionState'; -import { treeSelectionStateConfigGetter } from '../getTreeApi'; +import { treeSelectionStateConfigGetter } from '../TreeApi'; import { NonUndefined } from '../../types/NonUndefined'; const DataSourceLogger = dbg('DataSource') as DebugLogger; diff --git a/source/src/components/DataSource/state/reducer.ts b/source/src/components/DataSource/state/reducer.ts index 02804eee..dc216419 100644 --- a/source/src/components/DataSource/state/reducer.ts +++ b/source/src/components/DataSource/state/reducer.ts @@ -38,6 +38,12 @@ import { import { TreeExpandState } from '../TreeExpandState'; import { getTreeSelectionState } from './getInitialState'; +export const isNodeExpandable = ( + rowInfo: InfiniteTable_Tree_RowInfoParentNode, +) => { + return rowInfo.totalLeafNodesCount > 0; +}; + export function cleanupEmptyFilterValues( filterValue: DataSourceState['filterValue'], @@ -759,6 +765,7 @@ export function concludeReducer(params: { toPrimaryKey, isNodeSelected, isNodeExpanded, + isNodeExpandable, treeSelectionState, withRowInfo, diff --git a/source/src/components/DataSource/types.ts b/source/src/components/DataSource/types.ts index f9e94f0e..c5e8de9e 100644 --- a/source/src/components/DataSource/types.ts +++ b/source/src/components/DataSource/types.ts @@ -46,7 +46,7 @@ import { CellSelectionPosition, } from './CellSelectionState'; import { DataSourceCache, DataSourceMutation } from './DataSourceCache'; -import { TreeApi } from './getTreeApi'; +import { TreeApi } from './TreeApi'; import { GroupRowsState } from './GroupRowsState'; import { Indexer } from './Indexer'; import { RowDisabledState } from './RowDisabledState'; diff --git a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx index 59e2eea0..39a72070 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableRow/InfiniteTableColumnCell.tsx @@ -8,6 +8,7 @@ import { join } from '../../../../utils/join'; import { stripVar } from '../../../../utils/stripVar'; import { useDataSourceContextValue } from '../../../DataSource/publicHooks/useDataSourceState'; +import { isNodeExpandable } from '../../../DataSource/state/reducer'; import { useCellClassName } from '../../hooks/useCellClassName'; import { useInfiniteTable } from '../../hooks/useInfiniteTable'; @@ -99,10 +100,15 @@ const EXPANDER_STYLE: React.CSSProperties = { export const defaultRenderTreeIcon: InfiniteTableColumn['renderTreeIcon'] = (params) => { - const { toggleCurrentTreeNode, nodeExpanded } = params; + const { rowInfo, toggleCurrentTreeNode, nodeExpanded } = params; return ( { if (rowInfo.isTreeNode) { dataSourceApi.treeApi.setNodeSelection( diff --git a/source/src/components/InfiniteTable/components/icons/ExpanderIcon.css.ts b/source/src/components/InfiniteTable/components/icons/ExpanderIcon.css.ts index 10afc538..4eea56bd 100644 --- a/source/src/components/InfiniteTable/components/icons/ExpanderIcon.css.ts +++ b/source/src/components/InfiniteTable/components/icons/ExpanderIcon.css.ts @@ -25,6 +25,13 @@ export const ExpanderIconClsVariants = recipe({ end: {}, start: {}, }, + disabled: { + true: { + cursor: 'auto', + opacity: 0.4, + }, + false: {}, + }, }, compoundVariants: [ { diff --git a/source/src/components/InfiniteTable/components/icons/ExpanderIcon.tsx b/source/src/components/InfiniteTable/components/icons/ExpanderIcon.tsx index 484dc951..b51d301d 100644 --- a/source/src/components/InfiniteTable/components/icons/ExpanderIcon.tsx +++ b/source/src/components/InfiniteTable/components/icons/ExpanderIcon.tsx @@ -12,12 +12,13 @@ type ExpanderIconProps = { defaultExpanded?: boolean; onChange?: (expanded: boolean) => void; style?: React.CSSProperties; + disabled?: boolean; className?: string; direction?: 'end' | 'start'; }; export function ExpanderIcon(props: ExpanderIconProps) { - const { size = 24, direction = 'start' } = props; + const { size = 24, direction = 'start', disabled = false } = props; const [expanded, setExpanded] = useState( props.expanded ?? props.defaultExpanded, @@ -43,18 +44,20 @@ export function ExpanderIcon(props: ExpanderIconProps) { viewBox="0 0 24 24" width={`${size}px`} style={props.style} - onClick={onClick} + onClick={disabled ? undefined : onClick} className={join( props.className, ExpanderIconCls, ExpanderIconClsVariants({ direction: direction || 'start', expanded, + disabled, }), `${InfiniteTableIconClassName}`, `${InfiniteTableIconClassName}-expander`, `InfiniteIcon-expander--${expanded ? 'expanded' : 'collapsed'}`, `InfiniteIcon-expander--${direction === 'end' ? 'end' : 'start'}`, + disabled ? `InfiniteIcon-expander--disabled` : '', )} > diff --git a/source/src/components/InfiniteTable/eventHandlers/index.ts b/source/src/components/InfiniteTable/eventHandlers/index.ts index aea1b5c9..3796696b 100644 --- a/source/src/components/InfiniteTable/eventHandlers/index.ts +++ b/source/src/components/InfiniteTable/eventHandlers/index.ts @@ -1,6 +1,6 @@ import type { KeyboardEvent, MouseEvent } from 'react'; import { useCallback, useMemo, useEffect } from 'react'; -import { cloneTreeSelection } from '../../DataSource/getTreeApi'; +import { cloneTreeSelection } from '../../DataSource/TreeApi'; import { useDataSourceContextValue } from '../../DataSource/publicHooks/useDataSourceState'; import { CellPositionByIndex } from '../../types/CellPositionByIndex'; import { getColumnApiForColumn } from '../api/getColumnApi'; diff --git a/source/src/components/InfiniteTable/eventHandlers/onKeyDown.ts b/source/src/components/InfiniteTable/eventHandlers/onKeyDown.ts index 9a0d38c1..2de7ffdf 100644 --- a/source/src/components/InfiniteTable/eventHandlers/onKeyDown.ts +++ b/source/src/components/InfiniteTable/eventHandlers/onKeyDown.ts @@ -5,7 +5,7 @@ import { handleKeyboardSelection } from './keyboardSelection'; import { cloneRowSelection } from '../api/getRowSelectionApi'; import { handleBrowserFocusChangeOnKeyboardNavigation } from './handleBrowserFocusChangeOnKeyboardNavigation'; import { eventMatchesKeyboardShortcut } from '../../utils/hotkey'; -import { cloneTreeSelection } from '../../DataSource/getTreeApi'; +import { cloneTreeSelection } from '../../DataSource/TreeApi'; export async function onKeyDown( context: InfiniteTableKeyboardEventHandlerContext, diff --git a/source/src/utils/groupAndPivot/index.ts b/source/src/utils/groupAndPivot/index.ts index f7c5aa43..33ca3158 100644 --- a/source/src/utils/groupAndPivot/index.ts +++ b/source/src/utils/groupAndPivot/index.ts @@ -1461,6 +1461,9 @@ export type EnhancedTreeFlattenParam = { isNodeExpanded?: ( rowInfo: InfiniteTable_Tree_RowInfoParentNode, ) => boolean; + isNodeExpandable?: ( + rowInfo: InfiniteTable_Tree_RowInfoParentNode, + ) => boolean; isNodeSelected?: ( rowInfo: InfiniteTable_Tree_RowInfoNode, ) => boolean | null; @@ -1488,6 +1491,7 @@ function flattenTreeNodes( isNodeSelected, withRowInfo, isNodeExpanded, + isNodeExpandable, treeSelectionState, repeatWrappedGroupRows, rowsPerPage, @@ -1630,7 +1634,9 @@ function flattenTreeNodes( withRowInfo(rowInfo); } let expanded = true; - if (isNodeExpanded) { + if (isNodeExpandable && !isNodeExpandable(rowInfo)) { + expanded = false; + } else if (isNodeExpanded) { rowInfo.selfExpanded = expanded = isNodeExpanded(rowInfo); } if (!parentExpanded) { diff --git a/www/content/docs/learn/tree-grid/tree-default-selection-example.page.tsx b/www/content/docs/learn/tree-grid/tree-default-selection-example.page.tsx index e6d0fa8d..7c56001d 100644 --- a/www/content/docs/learn/tree-grid/tree-default-selection-example.page.tsx +++ b/www/content/docs/learn/tree-grid/tree-default-selection-example.page.tsx @@ -144,13 +144,6 @@ const dataSource = () => { sizeInKB: 1000, type: 'folder', children: [ - { - id: '30', - name: 'Music - empty', - sizeInKB: 0, - type: 'folder', - children: [], - }, { id: '31', name: 'Videos', diff --git a/www/content/docs/reference/datasource-props/tree-controlled-selectedstate-example.page.tsx b/www/content/docs/reference/datasource-props/tree-controlled-selectedstate-example.page.tsx index bf3b36f4..d714b072 100644 --- a/www/content/docs/reference/datasource-props/tree-controlled-selectedstate-example.page.tsx +++ b/www/content/docs/reference/datasource-props/tree-controlled-selectedstate-example.page.tsx @@ -139,13 +139,6 @@ const dataSource = () => { sizeInKB: 1000, type: 'folder', children: [ - { - id: '30', - name: 'Music', - sizeInKB: 0, - type: 'folder', - children: [], - }, { id: '31', name: 'Videos', diff --git a/www/content/docs/reference/datasource-props/tree-uncontrolled-selectedstate-example.page.tsx b/www/content/docs/reference/datasource-props/tree-uncontrolled-selectedstate-example.page.tsx index ac2aad95..9711aedf 100644 --- a/www/content/docs/reference/datasource-props/tree-uncontrolled-selectedstate-example.page.tsx +++ b/www/content/docs/reference/datasource-props/tree-uncontrolled-selectedstate-example.page.tsx @@ -130,13 +130,6 @@ const dataSource = () => { sizeInKB: 1000, type: 'folder', children: [ - { - id: '30', - name: 'Music', - sizeInKB: 0, - type: 'folder', - children: [], - }, { id: '31', name: 'Videos',