diff --git a/examples/src/pages/tests/table/treegrid/is-node-read-only.page.tsx b/examples/src/pages/tests/table/treegrid/is-node-read-only.page.tsx new file mode 100644 index 00000000..e4ab99d4 --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/is-node-read-only.page.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import { + InfiniteTableColumn, + TreeDataSource, + TreeGrid, +} from '@infinite-table/infinite-react'; + +export type FileSystemNode = { + name: string; + type: 'file' | 'folder'; + children?: FileSystemNode[] | null; + sizeKB?: number; + id: string; + collapsed?: boolean; +}; + +export const nodes: FileSystemNode[] = [ + { + name: 'Documents', + type: 'folder', + id: '1', + children: [ + { + name: 'report.doc', + type: 'file', + sizeKB: 100, + id: '2', + }, + { + type: 'folder', + name: 'pictures', + id: '3', + children: [ + { + name: 'mountain.jpg', + type: 'file', + sizeKB: 302, + id: '5', + }, + ], + }, + + { + type: 'file', + name: 'last.txt', + id: '7', + }, + ], + }, +]; + +const columns: Record> = { + name: { + field: 'name', + renderTreeIcon: true, + renderValue: ({ value, data }) => { + return ( + <> + {value} - {data!.id} + + ); + }, + }, + type: { field: 'type' }, + sizeKB: { field: 'sizeKB' }, +}; + +export default function DataTestPage() { + return ( + + + data={nodes} + primaryKey="id" + isNodeReadOnly={(rowInfo) => rowInfo.data.id === '1'} + defaultTreeExpandState={{ + defaultExpanded: true, + collapsedPaths: [['1']], + }} + > + + domProps={{ + style: { + margin: '5px', + height: 900, + border: '1px solid gray', + position: 'relative', + }, + }} + columns={columns} + /> + + + ); +} diff --git a/examples/src/pages/tests/table/treegrid/is-node-read-only.spec.ts b/examples/src/pages/tests/table/treegrid/is-node-read-only.spec.ts new file mode 100644 index 00000000..459800da --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/is-node-read-only.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@testing'; + +export default test.describe('isNodeReadOnly', () => { + test('should work ', async ({ page, rowModel, treeModel, apiModel }) => { + await page.waitForInfinite(); + + expect(await rowModel.getRenderedRowCount()).toBe(1); + + await treeModel.toggleParentNode(0); + + expect(await rowModel.getRenderedRowCount()).toBe(1); + + await apiModel.evaluateDataSource((api) => { + api.treeApi.toggleNode(['1']); + }); + + expect(await rowModel.getRenderedRowCount()).toBe(1); + + // force: true should be respected + await apiModel.evaluateDataSource((api) => { + api.treeApi.toggleNode(['1'], { force: true }); + }); + + expect(await rowModel.getRenderedRowCount()).toBe(5); + }); +}); diff --git a/examples/src/pages/tests/table/treegrid/is-node-selectable.page.tsx b/examples/src/pages/tests/table/treegrid/is-node-selectable.page.tsx new file mode 100644 index 00000000..394f6241 --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/is-node-selectable.page.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; + +import { + InfiniteTableColumn, + TreeDataSource, + TreeGrid, +} from '@infinite-table/infinite-react'; + +export type FileSystemNode = { + name: string; + type: 'file' | 'folder'; + children?: FileSystemNode[] | null; + sizeKB?: number; + id: string; + collapsed?: boolean; +}; + +export const nodes: FileSystemNode[] = [ + { + name: 'Documents', + type: 'folder', + id: '1', + children: [ + { + name: 'report.doc', + type: 'file', + sizeKB: 100, + id: '2', + }, + { + type: 'folder', + name: 'pictures', + id: '3', + children: [ + { + name: 'mountain.jpg', + type: 'file', + sizeKB: 302, + id: '5', + }, + ], + }, + + { + type: 'file', + name: 'last.txt', + id: '7', + }, + ], + }, +]; + +const columns: Record> = { + name: { + field: 'name', + renderTreeIcon: true, + renderSelectionCheckBox: true, + renderValue: ({ value, data }) => { + return ( + <> + {value} - {data!.id} + + ); + }, + }, + type: { field: 'type' }, + sizeKB: { field: 'sizeKB' }, +}; + +export default function DataTestPage() { + return ( + + + data={nodes} + primaryKey="id" + isNodeSelectable={(rowInfo) => rowInfo.data.id !== '3'} + defaultTreeSelection={{ + defaultSelection: false, + selectedPaths: [], + }} + > + + domProps={{ + style: { + margin: '5px', + height: 900, + border: '1px solid gray', + position: 'relative', + }, + }} + columns={columns} + /> + + + ); +} diff --git a/examples/src/pages/tests/table/treegrid/removeDataArray.page.tsx b/examples/src/pages/tests/table/treegrid/removeDataArray.page.tsx new file mode 100644 index 00000000..e779cce3 --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/removeDataArray.page.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; + +import { TreeDataSource, TreeGrid } from '@infinite-table/infinite-react'; +import { + InfiniteTablePropRowStyle, + InfiniteTableRowInfo, + DataSourceApi, + type InfiniteTableColumn, +} from '@infinite-table/infinite-react'; + +export type FileSystemNode = { + name: string; + type: 'file' | 'folder'; + children?: FileSystemNode[] | null; + sizeKB?: number; + id: string; + collapsed?: boolean; +}; + +const nodes: FileSystemNode[] = [ + { + name: 'Documents', + type: 'folder', + id: '1', + children: [ + { + name: 'report.doc', + type: 'file', + sizeKB: 100, + id: '2', + }, + { + type: 'folder', + name: 'pictures', + id: '3', + collapsed: true, + children: [ + { + name: 'mountain.jpg', + type: 'file', + sizeKB: 302, + id: '5', + }, + ], + }, + + { + type: 'file', + name: 'last.txt', + id: '7', + }, + ], + }, +]; + +const columns: Record> = { + name: { + field: 'name', + renderTreeIcon: true, + + renderValue: ({ value, data }) => { + return ( + <> + {value} - {data!.id} + + ); + }, + }, + type: { field: 'type' }, + sizeKB: { field: 'sizeKB' }, +}; +export default function App() { + const [dataSourceApi, setDataSourceApi] = + React.useState | null>(null); + + const removeRowsByPrimaryKey = async () => { + if (!dataSourceApi) { + return; + } + const getAllData = dataSourceApi.getRowInfoArray(); + console.log('data source before remove', getAllData); + + const listOfPrimaryKeys = getAllData.map((row: any) => row.data.id); + console.log('listOfPrimaryKeys', listOfPrimaryKeys); + + await dataSourceApi.removeDataArrayByPrimaryKeys(listOfPrimaryKeys); + console.log('data source after remove', dataSourceApi.getRowInfoArray()); + }; + + const removeRowsByDataRow = async () => { + if (!dataSourceApi) { + return; + } + const getAllData = dataSourceApi.getRowInfoArray(); + console.log('data source before remove', getAllData); + + const listOfRows = getAllData.map((row: any) => row.data); + console.log('listOfRows', listOfRows); + await dataSourceApi.removeDataArray(listOfRows); + console.log('data source after remove', dataSourceApi.getRowInfoArray()); + }; + + const removeById = async () => { + if (!dataSourceApi) { + return; + } + await dataSourceApi.removeDataByPrimaryKey('3'); + }; + + return ( + <> + + + + + onReady={setDataSourceApi} + data={nodes} + primaryKey="id" + nodesKey="children" + > + + domProps={{ + style: { + margin: '5px', + height: 900, + border: '1px solid gray', + position: 'relative', + }, + }} + columns={columns} + /> + + + ); +} diff --git a/examples/src/pages/tests/table/treegrid/removeDataArray.spec.ts b/examples/src/pages/tests/table/treegrid/removeDataArray.spec.ts new file mode 100644 index 00000000..7de15161 --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/removeDataArray.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@testing'; + +export default test.describe.parallel('TreeDataSourceApi', () => { + test('removeDataArrayByPrimaryKeys - works to remove more rows in one go', async ({ + page, + rowModel, + }) => { + await page.waitForInfinite(); + + let rowCount = await rowModel.getRenderedRowCount(); + + expect(rowCount).toBe(5); + + await page.click('button:text("Click to removeRowsByPrimaryKey")'); + + await page.evaluate(() => new Promise(requestAnimationFrame)); + + rowCount = await rowModel.getRenderedRowCount(); + + expect(rowCount).toBe(0); + }); + test('removeDataArray - works to remove more rows in one go', async ({ + page, + rowModel, + }) => { + await page.waitForInfinite(); + + let rowCount = await rowModel.getRenderedRowCount(); + + expect(rowCount).toBe(5); + + await page.click('button:text("Click to removeRowsByDataRow")'); + + await page.evaluate(() => new Promise(requestAnimationFrame)); + + rowCount = await rowModel.getRenderedRowCount(); + + expect(rowCount).toBe(0); + }); + + test('removeDataByPrimaryKey - works to remove one row', async ({ + page, + rowModel, + }) => { + await page.waitForInfinite(); + + let rowCount = await rowModel.getRenderedRowCount(); + + expect(rowCount).toBe(5); + + await page.click('button:text("Click to remove one by id")'); + + await page.evaluate(() => new Promise(requestAnimationFrame)); + + rowCount = await rowModel.getRenderedRowCount(); + + expect(rowCount).toBe(3); + }); +}); diff --git a/examples/src/pages/tests/table/treegrid/update-collapsed-children.page.tsx b/examples/src/pages/tests/table/treegrid/update-collapsed-children.page.tsx new file mode 100644 index 00000000..13d0a6fc --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/update-collapsed-children.page.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; + +import { + DataSourceApi, + InfiniteTableColumn, + TreeDataSource, + TreeGrid, +} from '@infinite-table/infinite-react'; + +export type FileSystemNode = { + name: string; + type: 'file' | 'folder'; + children?: FileSystemNode[] | null; + sizeKB?: number; + id: string; + collapsed?: boolean; +}; + +export const nodes: FileSystemNode[] = [ + { + name: 'Documents', + type: 'folder', + id: '1', + children: [ + { + name: 'report.doc', + type: 'file', + sizeKB: 100, + id: '10', + }, + { + type: 'folder', + name: 'pictures', + id: '11', + children: [ + { + name: 'vacation.jpg', + type: 'file', + sizeKB: 2024, + id: '110', + }, + { + name: 'island.jpg', + type: 'file', + sizeKB: 245, + id: '111', + }, + ], + }, + { + type: 'folder', + name: 'diverse', + id: '12', + children: [ + { + name: 'beach.jpg', + type: 'file', + sizeKB: 2024, + id: '120', + }, + ], + }, + ], + }, +]; + +const columns: Record> = { + name: { + field: 'name', + renderTreeIcon: true, + + renderValue: ({ value, data }) => { + return ( + <> + {value} - {data!.id} + + ); + }, + }, + type: { field: 'type' }, + sizeKB: { field: 'sizeKB' }, +}; + +export default function DataTestPage() { + const [dataSourceApi, setDataSourceApi] = + React.useState | null>(null); + return ( + +
+ + + + + onReady={setDataSourceApi} + data={nodes} + debugMode + primaryKey="id" + nodesKey="children" + defaultTreeExpandState={{ + defaultExpanded: true, + collapsedPaths: [['1', '11']], + }} + > + + wrapRowsHorizontally + domProps={{ + style: { + margin: '5px', + height: '80vh', + border: '1px solid gray', + position: 'relative', + }, + }} + columns={columns} + columnDefaultWidth={250} + /> + +
+
+ ); +} diff --git a/examples/src/pages/tests/table/treegrid/update-collapsed-children.spec.ts b/examples/src/pages/tests/table/treegrid/update-collapsed-children.spec.ts new file mode 100644 index 00000000..69bb9e6f --- /dev/null +++ b/examples/src/pages/tests/table/treegrid/update-collapsed-children.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from '@testing'; + +export default test.describe('TreeApi update collapsed children', () => { + test('works as expected', async ({ page, tableModel, treeModel }) => { + await page.waitForInfinite(); + + const treeCol = tableModel.withColumn('name'); + + let treeData = await treeCol.getValues(); + + const initialFull = [ + 'Documents - 1', + 'report.doc - 10', + 'pictures - 11', + 'vacation.jpg - 110', + 'island.jpg - 111', + 'diverse - 12', + 'beach.jpg - 120', + ]; + const withPicturesCollapsed = [ + 'Documents - 1', + 'report.doc - 10', + 'pictures - 11', + 'diverse - 12', + 'beach.jpg - 120', + ]; + + const fullUpdated = [ + 'Documents - 1', + 'report.doc - 10', + 'pictures - 11', + 'my new vacation.jpg - 110', + 'my new island.jpg - 111', + 'diverse - 12', + 'beach.jpg - 120', + ]; + + expect(treeData).toEqual(withPicturesCollapsed); + + await treeModel.toggleParentNode(2); + + treeData = await treeCol.getValues(); + expect(treeData).toEqual(initialFull); + + await treeModel.toggleParentNode(2); + + treeData = await treeCol.getValues(); + expect(treeData).toEqual(withPicturesCollapsed); + + await page.click('button:text("Update by path")'); + await page.click('button:text("Update by id")'); + + await treeModel.toggleParentNode(2); + + treeData = await treeCol.getValues(); + expect(treeData).toEqual(fullUpdated); + }); +}); diff --git a/source/src/components/DataSource/Indexer.ts b/source/src/components/DataSource/Indexer.ts index ca839345..483db785 100644 --- a/source/src/components/DataSource/Indexer.ts +++ b/source/src/components/DataSource/Indexer.ts @@ -102,7 +102,7 @@ export class Indexer { const pk = toPrimaryKey(item); const nodePath = isTree ? [...parentPath, pk] : undefined; - let isTreeModeForNode = isTree && nodePath; + let isTreeModeForNode = isTree && !!nodePath; let cacheInfo = isTreeModeForNode ? cache?.getMutationsForNodePath(nodePath!) : cache?.getMutationsForPrimaryKey(pk); diff --git a/source/src/components/DataSource/TreeApi.ts b/source/src/components/DataSource/TreeApi.ts index cf3df6e3..d8474529 100644 --- a/source/src/components/DataSource/TreeApi.ts +++ b/source/src/components/DataSource/TreeApi.ts @@ -26,14 +26,16 @@ export function cloneTreeSelection( ); } +type ForceOptions = { force?: boolean }; + export type TreeExpandStateApi = { isNodeExpanded(nodePath: any[]): boolean; isNodeReadOnly(nodePath: any[]): boolean; - expandNode(nodePath: any[]): void; - collapseNode(nodePath: any[]): void; + expandNode(nodePath: any[], options?: ForceOptions): void; + collapseNode(nodePath: any[], options?: ForceOptions): void; - toggleNode(nodePath: any[]): void; + toggleNode(nodePath: any[], options?: ForceOptions): void; getNodeDataByPath(nodePath: any[]): T | null; getRowInfoByPath(nodePath: any[]): InfiniteTableRowInfo | null; @@ -43,10 +45,14 @@ 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; + selectNode(nodePath: NodePath, options?: ForceOptions): void; + setNodeSelection( + nodePath: NodePath, + selected: boolean, + options?: ForceOptions, + ): void; + deselectNode(nodePath: NodePath, options?: ForceOptions): void; + toggleNodeSelection(nodePath: NodePath, options?: ForceOptions): void; selectAll(): void; expandAll(): void; @@ -260,9 +266,9 @@ export class TreeApiImpl implements TreeApi { return rowInfo ? (rowInfo.data as T) : null; } getRowInfoByPath(nodePath: any[]) { - const { pathToIndexDeepMap } = this.getState(); + const { pathToIndexMap } = this.getState(); - const index = pathToIndexDeepMap.get(nodePath); + const index = pathToIndexMap.get(nodePath); if (index !== undefined) { return getRowInfoAt(index, this.getState); } diff --git a/source/src/components/DataSource/getDataSourceApi.ts b/source/src/components/DataSource/getDataSourceApi.ts index 1956704e..3b693878 100644 --- a/source/src/components/DataSource/getDataSourceApi.ts +++ b/source/src/components/DataSource/getDataSourceApi.ts @@ -150,24 +150,25 @@ class DataSourceApiImpl implements DataSourceApi { operation.array.forEach((data, index) => { if (operation.primaryKeys) { const key = operation.primaryKeys[index]; - const rowInfo = this.getRowInfoByPrimaryKey(key); - if (rowInfo && !rowInfo.isGroupRow) { + const originalData = this.getDataByPrimaryKey(key); + if (originalData) { cache.update( operation.primaryKeys[index], data, - rowInfo.data, + originalData, operation.metadata, ); } } else if (operation.nodePaths) { - const rowInfo = this.getRowInfoByNodePath( + const originalData = this.getDataByNodePath( operation.nodePaths[index], ); - if (rowInfo && !rowInfo.isGroupRow) { + + if (originalData) { cache.updateNodePath( operation.nodePaths[index], data, - rowInfo.data, + originalData, operation.metadata, ); } @@ -286,7 +287,7 @@ class DataSourceApiImpl implements DataSourceApi { return map.get(id) ?? -1; }; getIndexByNodePath = (nodePath: NodePath) => { - const map = this.getState().pathToIndexDeepMap; + const map = this.getState().pathToIndexMap; return map.get(nodePath) ?? -1; }; getNodePathById = (id: any): NodePath | null => { @@ -440,6 +441,11 @@ class DataSourceApiImpl implements DataSourceApi { }; removeDataByPrimaryKey = (id: any, options?: DataSourceCRUDParam) => { + const isTree = this.getState().isTree; + if (isTree) { + return this.removeDataByNodePath(this.getNodePathById(id) || [], options); + } + const result = this.batchOperation({ type: 'delete', primaryKeys: [id], @@ -501,11 +507,25 @@ class DataSourceApiImpl implements DataSourceApi { ids: any[], options?: DataSourceCRUDParam, ) => { - const result = this.batchOperation({ - type: 'delete', - primaryKeys: ids, - metadata: options?.metadata, - }); + const isTree = this.getState().isTree; + + const nodePaths = isTree + ? ids.map((id) => { + return this.getNodePathById(id) || []; + }) + : null; + + const result = isTree + ? this.batchOperation({ + type: 'delete', + nodePaths: nodePaths || [], + metadata: options?.metadata, + }) + : this.batchOperation({ + type: 'delete', + primaryKeys: ids, + metadata: options?.metadata, + }); if (options?.flush) { this.commit(); diff --git a/source/src/components/DataSource/state/getInitialState.ts b/source/src/components/DataSource/state/getInitialState.ts index f903aa9d..78b3792a 100644 --- a/source/src/components/DataSource/state/getInitialState.ts +++ b/source/src/components/DataSource/state/getInitialState.ts @@ -117,7 +117,7 @@ export function initSetupState(): DataSourceSetupState { idToIndexMap: new Map(), idToPathMap: new Map(), - pathToIndexDeepMap: new DeepMap(), + pathToIndexMap: new DeepMap(), getDataSourceMasterContextRef: { current: () => undefined }, @@ -205,7 +205,7 @@ export const cleanupDataSource = (state: DataSourceState) => { }; state.treeExpandState?.destroy(); state.treePaths?.clear(); - state.pathToIndexDeepMap?.clear(); + state.pathToIndexMap?.clear(); state.rowDisabledState?.destroy(); state.groupRowsState?.destroy(); state.treeSelectionState?.destroy(); @@ -285,14 +285,14 @@ export const forwardProps = ( const pathToIndexReducer: DataSourceRowInfoReducer = { initialValue: () => { state.idToPathMap.clear(); - state.pathToIndexDeepMap.clear(); + state.pathToIndexMap.clear(); }, reducer: (_, rowInfo) => { if (rowInfo.isTreeNode) { state.idToPathMap.set(rowInfo.id, rowInfo.nodePath); if ( props.debugMode && - state.pathToIndexDeepMap.has(rowInfo.nodePath) + state.pathToIndexMap.has(rowInfo.nodePath) ) { console.warn( `Duplicate node path found in data source (debugId: ${ @@ -300,10 +300,7 @@ export const forwardProps = ( }): ${rowInfo.nodePath}`, ); } - state.pathToIndexDeepMap.set( - rowInfo.nodePath, - rowInfo.indexInAll, - ); + state.pathToIndexMap.set(rowInfo.nodePath, rowInfo.indexInAll); } }, }; diff --git a/source/src/components/DataSource/types.ts b/source/src/components/DataSource/types.ts index 228f97a1..dc5d60cb 100644 --- a/source/src/components/DataSource/types.ts +++ b/source/src/components/DataSource/types.ts @@ -307,7 +307,7 @@ export interface DataSourceSetupState { destroyedRef: React.MutableRefObject; idToIndexMap: Map; idToPathMap: Map; - pathToIndexDeepMap: DeepMap; + pathToIndexMap: DeepMap; detailDataSourcesStateToRestore: Map< any, Partial> @@ -345,7 +345,7 @@ export interface DataSourceSetupState { dataArray: InfiniteTableRowInfo[]; groupDeepMap?: DeepMap>; treeDeepMap?: DeepMap>; - treePaths?: DeepMap; + treePaths?: DeepMap; groupRowsIndexesInDataArray?: number[]; reducerResults?: Record; allRowsSelected: boolean; diff --git a/source/src/components/InfiniteTable/components/CheckBox.css.ts b/source/src/components/InfiniteTable/components/CheckBox.css.ts index bff6479c..0009919b 100644 --- a/source/src/components/InfiniteTable/components/CheckBox.css.ts +++ b/source/src/components/InfiniteTable/components/CheckBox.css.ts @@ -10,6 +10,7 @@ export const CheckBoxCls = style([ selectors: { '&[disabled]': { opacity: 0.7, + cursor: 'auto', }, }, },