├── jest.config.js ├── .prettierrc.js ├── .eslintignore ├── tsconfig.umd.json ├── src ├── index.ts ├── types.ts ├── tree.ts ├── walk-strategy.ts └── node.ts ├── tsconfig.cjs.json ├── .eslintrc.js ├── tsconfig.esm.json ├── tsconfig.base.json ├── .github └── workflows │ └── build-test.yml ├── webpack.config.js ├── LICENSE.md ├── package.json ├── .gitignore ├── README.md └── __tests__ └── index.spec.ts /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(ts|tsx)$': 'ts-jest', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | esm 5 | lib 6 | umd 7 | # don't lint nyc coverage output 8 | coverage -------------------------------------------------------------------------------- /tsconfig.umd.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base", 3 | "compilerOptions": { 4 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Tree from './tree'; 2 | import Node from './node'; 3 | 4 | export { 5 | Model, 6 | ParseArgs, 7 | ParsedArgs, 8 | Options, 9 | NodeVisitorFunction, 10 | } from './types'; 11 | export type { Node }; 12 | export default Tree; 13 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base", 3 | "compilerOptions": { 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parser: '@typescript-eslint/parser', 7 | plugins: ['@typescript-eslint', 'prettier'], 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base", 3 | "compilerOptions": { 4 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 5 | "outDir": "./esm", /* Redirect output structure to the directory. */ 6 | "moduleResolution": "Node", 7 | "declaration": true, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import Node from './node'; 2 | 3 | export type StrategyName = 'pre' | 'post' | 'breadth'; 4 | 5 | export type ParseArgs = (NodeVisitorFunction | Options | undefined)[]; 6 | 7 | export type Model = T & { children?: Model[] }; 8 | 9 | export interface Options { 10 | strategy: StrategyName; 11 | } 12 | 13 | export interface NodeVisitorFunction { 14 | (visitingNode: Node): boolean; 15 | } 16 | 17 | export interface ParsedArgs { 18 | fn: NodeVisitorFunction; 19 | options: Options; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 4 | "strict": true, /* Enable all strict type-checking options. */ 5 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | }, 8 | "include": ["src/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /src/tree.ts: -------------------------------------------------------------------------------- 1 | import type { Model } from './types'; 2 | import Node from './node'; 3 | 4 | class Tree { 5 | private _addChildToNode(node: Node, child: Node) { 6 | child.parent = node; 7 | node.children.push(child); 8 | } 9 | 10 | parse(model: Model): Node { 11 | const node = new Node(model); 12 | 13 | if (model.children) { 14 | model.children.forEach((child: Model) => { 15 | this._addChildToNode(node, this.parse(child)); 16 | }); 17 | } 18 | 19 | return node; 20 | } 21 | } 22 | 23 | export default Tree; 24 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: build-test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**.md' 9 | pull_request: 10 | paths-ignore: 11 | - '**.md' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [10.x, 12.x, 14.x, 15.x] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm run lint 31 | - run: npm test -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); // eslint-disable-line @typescript-eslint/no-var-requires 2 | 3 | module.exports = ['production', 'development'].map((mode) => ({ 4 | mode, 5 | entry: './src/index.ts', 6 | output: { 7 | path: path.resolve(__dirname, 'umd'), 8 | filename: `ts-tree-structure${mode === 'production' ? '.min' : ''}.js`, 9 | library: 'Tree', 10 | libraryTarget: 'umd', 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.ts$/, 16 | exclude: /node_modules/, 17 | use: { 18 | loader: 'ts-loader', 19 | options: { 20 | configFile: 'tsconfig.umd.json', 21 | }, 22 | }, 23 | }, 24 | ], 25 | }, 26 | resolve: { 27 | extensions: ['.ts'], 28 | }, 29 | })); 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present Gen Tamura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/walk-strategy.ts: -------------------------------------------------------------------------------- 1 | import type { NodeVisitorFunction } from './types'; 2 | import Node from './node'; 3 | 4 | class WalkStrategy { 5 | pre(node: Node, callback: NodeVisitorFunction): boolean { 6 | const len = node.children.length; 7 | let keepGoing = callback(node); 8 | 9 | for (let i = 0; i < len; i++) { 10 | if (keepGoing === false) { 11 | return false; 12 | } 13 | 14 | keepGoing = this.pre(node.children[i], callback); 15 | } 16 | 17 | return keepGoing; 18 | } 19 | 20 | post(node: Node, callback: NodeVisitorFunction): boolean { 21 | const len = node.children.length; 22 | let keepGoing; 23 | 24 | for (let i = 0; i < len; i++) { 25 | keepGoing = this.post(node.children[i], callback); 26 | 27 | if (keepGoing === false) { 28 | return false; 29 | } 30 | } 31 | 32 | keepGoing = callback(node); 33 | 34 | return keepGoing; 35 | } 36 | 37 | breadth(node: Node, callback: NodeVisitorFunction): void { 38 | const queue = [node]; 39 | 40 | (function processQueue() { 41 | if (queue.length === 0) { 42 | return; 43 | } 44 | 45 | const node = queue.shift(); 46 | if (node) { 47 | const len = node.children.length; 48 | 49 | for (let i = 0; i < len; i++) { 50 | queue.push(node.children[i]); 51 | } 52 | 53 | if (callback(node) !== false) { 54 | processQueue(); 55 | } 56 | } 57 | })(); 58 | } 59 | } 60 | 61 | export default WalkStrategy; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-tree-structure", 3 | "version": "1.0.2", 4 | "description": "Manipulate and traverse tree-like structures in TypeScript.", 5 | "homepage": "https://github.com/gentamura/ts-tree-structure#readme", 6 | "bugs": "https://github.com/gentamura/ts-tree-structure/issues", 7 | "author": "Gen Tamura ", 8 | "license": "MIT", 9 | "main": "lib/index.js", 10 | "unpkg": "umd/tree-data.min.js", 11 | "browser": "umd/tree-data.min.js", 12 | "module": "esm/index.js", 13 | "types": "esm/index.d.ts", 14 | "files": [ 15 | "lib", 16 | "esm", 17 | "umd" 18 | ], 19 | "scripts": { 20 | "clean": "rimraf esm lib umd", 21 | "build": "npm run build:cjs && npm run build:esm && npm run build:umd", 22 | "build:cjs": "tsc -p tsconfig.cjs.json", 23 | "build:esm": "tsc -p tsconfig.esm.json", 24 | "build:umd": "webpack", 25 | "prepublishOnly": "npm run clean && npm run lint && npm run test && npm run build", 26 | "test": "jest", 27 | "lint": "eslint . --ext .js,.ts", 28 | "format": "eslint . --ext .js,.ts --fix" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^26.0.20", 32 | "@typescript-eslint/eslint-plugin": "^4.15.2", 33 | "@typescript-eslint/parser": "^4.15.2", 34 | "eslint": "^7.21.0", 35 | "eslint-config-prettier": "^8.1.0", 36 | "eslint-plugin-prettier": "^3.3.1", 37 | "jest": "^26.6.3", 38 | "prettier": "^2.2.1", 39 | "rimraf": "^3.0.2", 40 | "ts-jest": "^26.5.1", 41 | "ts-loader": "^8.0.17", 42 | "typescript": "^4.2.2", 43 | "webpack": "^5.24.2", 44 | "webpack-cli": "^4.5.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .node_modules/ 3 | built/* 4 | tests/cases/rwc/* 5 | tests/cases/test262/* 6 | tests/cases/perf/* 7 | !tests/cases/webharness/compilerToString.js 8 | test-args.txt 9 | ~*.docx 10 | \#*\# 11 | .\#* 12 | tests/baselines/local/* 13 | tests/baselines/local.old/* 14 | tests/services/baselines/local/* 15 | tests/baselines/prototyping/local/* 16 | tests/baselines/rwc/* 17 | tests/baselines/test262/* 18 | tests/baselines/reference/projectOutput/* 19 | tests/baselines/local/projectOutput/* 20 | tests/baselines/reference/testresults.tap 21 | tests/services/baselines/prototyping/local/* 22 | tests/services/browser/typescriptServices.js 23 | src/harness/*.js 24 | src/compiler/diagnosticInformationMap.generated.ts 25 | src/compiler/diagnosticMessages.generated.json 26 | src/parser/diagnosticInformationMap.generated.ts 27 | src/parser/diagnosticMessages.generated.json 28 | rwc-report.html 29 | *.swp 30 | build.json 31 | *.actual 32 | tests/webTestServer.js 33 | tests/webTestServer.js.map 34 | tests/webhost/*.d.ts 35 | tests/webhost/webtsc.js 36 | tests/cases/**/*.js 37 | !tests/cases/docker/*.js/ 38 | tests/cases/**/*.js.map 39 | *.config 40 | scripts/eslint/built/ 41 | scripts/debug.bat 42 | scripts/run.bat 43 | scripts/word2md.js 44 | scripts/buildProtocol.js 45 | scripts/ior.js 46 | scripts/authors.js 47 | scripts/configurePrerelease.js 48 | scripts/configureLanguageServiceBuild.js 49 | scripts/open-user-pr.js 50 | scripts/open-cherry-pick-pr.js 51 | scripts/processDiagnosticMessages.d.ts 52 | scripts/processDiagnosticMessages.js 53 | scripts/produceLKG.js 54 | scripts/importDefinitelyTypedTests/importDefinitelyTypedTests.js 55 | scripts/generateLocalizedDiagnosticMessages.js 56 | scripts/request-pr-review.js 57 | scripts/*.js.map 58 | scripts/typings/ 59 | coverage/ 60 | internal/ 61 | **/.DS_Store 62 | .settings 63 | **/.vs 64 | **/.vscode/* 65 | !**/.vscode/tasks.json 66 | !**/.vscode/settings.template.json 67 | !**/.vscode/launch.template.json 68 | !**/.vscode/extensions.json 69 | !tests/cases/projects/projectOption/**/node_modules 70 | !tests/cases/projects/NodeModulesSearch/**/* 71 | !tests/baselines/reference/project/nodeModules*/**/* 72 | .idea 73 | yarn.lock 74 | yarn-error.log 75 | .parallelperf.* 76 | tests/cases/user/*/package-lock.json 77 | tests/cases/user/*/node_modules/ 78 | tests/cases/user/*/**/*.js 79 | tests/cases/user/*/**/*.js.map 80 | tests/cases/user/*/**/*.d.ts 81 | !tests/cases/user/zone.js/ 82 | !tests/cases/user/bignumber.js/ 83 | !tests/cases/user/discord.js/ 84 | tests/baselines/reference/dt 85 | .failed-tests 86 | TEST-results.xml 87 | !package-lock.json 88 | tests/cases/user/TypeScript-React-Starter/TypeScript-React-Starter 89 | tests/cases/user/TypeScript-Node-Starter/TypeScript-Node-Starter 90 | tests/cases/user/TypeScript-React-Native-Starter/TypeScript-React-Native-Starter 91 | tests/cases/user/TypeScript-Vue-Starter/TypeScript-Vue-Starter 92 | tests/cases/user/TypeScript-WeChat-Starter/TypeScript-WeChat-Starter 93 | tests/cases/user/create-react-app/create-react-app 94 | tests/cases/user/fp-ts/fp-ts 95 | tests/cases/user/webpack/webpack 96 | tests/cases/user/puppeteer/puppeteer 97 | tests/cases/user/axios-src/axios-src 98 | tests/cases/user/prettier/prettier 99 | .eslintcache 100 | 101 | esm 102 | lib 103 | umd 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ts-tree-structure 2 | 3 | Manipulate and traverse tree-like structures in TypeScript. 4 | 5 | Inspiration from [tree-model.js](https://github.com/joaonuno/tree-model-js). 6 | 7 | ![Build Status](https://github.com/gentamura/ts-tree-structure/workflows/build-test/badge.svg) 8 | 9 | ## Installation 10 | 11 | ### Node 12 | 13 | Tree is available as an npm module so you can install it with `npm install ts-tree-structure` and use it in your script: 14 | 15 | #### ES Module 16 | ```ts 17 | import Tree from 'ts-tree-structure'; 18 | 19 | const tree = new Tree(); 20 | const root = tree.parse({ id: 1, name: 'foo', children: [{ id: 11, name: 'bar' }]}); 21 | ``` 22 | 23 | #### Common.js 24 | ```js 25 | const Tree = require('ts-tree-structure').default; 26 | 27 | const tree = new Tree(); 28 | const root = tree.parse({ id: 1, name: 'foo', children: [{ id: 11, name: 'bar' }]}); 29 | ``` 30 | 31 | #### UMD 32 | ```html 33 | 34 | 39 | ``` 40 | 41 | #### TypeScript 42 | Type definitions are already bundled with the package, which should just work with npm install. 43 | 44 | You can maually find the definition files in the `src` folder. 45 | 46 | ## API Reference 47 | 48 | ### Create a new Tree 49 | 50 | Create a new Tree with the given options. 51 | 52 | ```js 53 | const tree = new Tree() 54 | ``` 55 | 56 | ### Parse the hierarchy object 57 | 58 | Parse the given user defined model and return the root Node object. 59 | 60 | ```js 61 | tree.parse(model): Node 62 | ``` 63 | 64 | ### Is Root? 65 | 66 | Return `true` if this Node is the root, `false` otherwise. 67 | 68 | ```js 69 | node.isRoot(): boolean 70 | ``` 71 | 72 | ### Has Children? 73 | 74 | Return `true` if this Node has one or more children, `false` otherwise. 75 | 76 | ```js 77 | node.hasChildren(): boolean 78 | ``` 79 | 80 | ### Add a child 81 | 82 | Add the given node as child of this one. Return the child Node. 83 | 84 | ```js 85 | parentNode.addChild(childNode): Node 86 | ``` 87 | 88 | ### Add a child at a given index 89 | 90 | Add the given node as child of this one at the given index. Return the child Node. 91 | 92 | ```js 93 | parentNode.addChildAtIndex(childNode, index): Node 94 | ``` 95 | 96 | ### Set the index of a node among its siblings 97 | 98 | Sets the index of the node among its siblings to the given value. Return the node itself. 99 | 100 | ```js 101 | node.setIndex(index): Node 102 | ``` 103 | 104 | ### Get the index of a node among its siblings 105 | 106 | Gets the index of the node relative to its siblings. Return the index value. 107 | 108 | ```js 109 | node.getIndex(): number 110 | ``` 111 | 112 | ### Get the node path 113 | 114 | Get the array of Nodes representing the path from the root to this Node (inclusive). 115 | 116 | ```js 117 | node.getPath(): Node[] 118 | ``` 119 | 120 | ### Delete a node from the tree 121 | 122 | Drop the subtree starting at this node. Returns the node itself, which is now a root node. 123 | 124 | ```js 125 | node.drop(): Node 126 | ``` 127 | 128 | *Warning* - Dropping a node while walking the tree is not supported. You must first collect the nodes to drop using one of the traversal functions and then drop them. Example: 129 | 130 | ```js 131 | root.all( /* predicate */ ).forEach((node) => { 132 | node.drop(); 133 | }); 134 | ``` 135 | 136 | ### Find a node 137 | 138 | Starting from this node, find the first Node that matches the predicate and return it. The **predicate** is a function wich receives the visited Node and returns `true` if the Node should be picked and `false` otherwise. 139 | 140 | ```js 141 | node.first(predicate): Node 142 | ``` 143 | 144 | ### Find all nodes 145 | 146 | Starting from this node, find all Nodes that match the predicate and return these. 147 | 148 | ```js 149 | node.all(predicate): Node[] 150 | ``` 151 | 152 | ### Walk the tree 153 | 154 | Starting from this node, traverse the subtree calling the action for each visited node. The action is a function which receives the visited Node as argument. The traversal can be halted by returning `false` from the action. 155 | 156 | ```js 157 | node.walk([options], action): void 158 | ``` 159 | 160 | **Note** - `first`, `all` and `walk` can optionally receive as first argument an object with traversal options. Currently the only supported option is the traversal `strategy` which can be any of the following: 161 | 162 | * `{strategy: 'pre'}` - Depth-first pre-order *[default]*; 163 | * `{strategy: 'post'}` - Depth-first post-order; 164 | * `{strategy: 'breadth'}` - Breadth-first. 165 | 166 | These functions can also take, as the last parameter, the *context* on which the action will be called. 167 | 168 | ## Contributing 169 | 170 | ### Setup 171 | 172 | Fork this repository and run `npm install` on the project root folder to make sure you have all project dependencies installed. 173 | 174 | ### Code Linting 175 | 176 | Run `npm run lint` 177 | 178 | This will check both source and tests for code correctness and style compliance. 179 | 180 | ### Running Tests 181 | 182 | Run `npm test` 183 | 184 | ## License 185 | 186 | [MIT](LICENSE.md) 187 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Model, 3 | ParseArgs, 4 | ParsedArgs, 5 | Options, 6 | NodeVisitorFunction, 7 | } from './types'; 8 | import WalkStrategy from './walk-strategy'; 9 | 10 | class Node { 11 | model: Model; 12 | children: Node[]; 13 | parent?: Node; 14 | walkStrategy: WalkStrategy; 15 | 16 | constructor(model: Model) { 17 | this.model = model; 18 | this.children = []; 19 | this.walkStrategy = new WalkStrategy(); 20 | } 21 | 22 | private _addChild(self: Node, child: Node, insertIndex?: number) { 23 | child.parent = self; 24 | self.model.children = self.model.children ?? []; 25 | 26 | if (insertIndex == null) { 27 | self.model.children.push(child.model); 28 | self.children.push(child); 29 | 30 | return child; 31 | } 32 | 33 | if (insertIndex < 0 || insertIndex > self.children.length) { 34 | throw new Error('Invalid index.'); 35 | } 36 | 37 | self.model.children.splice(insertIndex, 0, child.model); 38 | self.children.splice(insertIndex, 0, child); 39 | 40 | return child; 41 | } 42 | 43 | addChild(child: Node): Node { 44 | return this._addChild(this, child); 45 | } 46 | 47 | addChildAtIndex(child: Node, index: number): Node { 48 | return this._addChild(this, child, index); 49 | } 50 | 51 | private _parseArgs(...args: ParseArgs): ParsedArgs { 52 | let parsedArgs: ParsedArgs; 53 | 54 | if (typeof args[0] === 'function') { 55 | parsedArgs = { 56 | fn: args[0], 57 | options: typeof args[1] === 'object' ? args[1] : { strategy: 'pre' }, 58 | }; 59 | } else { 60 | parsedArgs = { 61 | fn: typeof args[1] === 'function' ? args[1] : () => true, 62 | options: args[0] ?? { strategy: 'pre' }, 63 | }; 64 | } 65 | 66 | return parsedArgs; 67 | } 68 | 69 | first(fn?: NodeVisitorFunction, options?: Options): Node | undefined; 70 | first(options?: Options): Node | undefined; 71 | first(...args: ParseArgs): Node | undefined { 72 | let first; 73 | 74 | const { fn, options } = this._parseArgs(...args); 75 | 76 | switch (options.strategy) { 77 | case 'pre': 78 | this.walkStrategy.pre(this, callback); 79 | break; 80 | case 'post': 81 | this.walkStrategy.post(this, callback); 82 | break; 83 | case 'breadth': 84 | this.walkStrategy.breadth(this, callback); 85 | break; 86 | } 87 | 88 | return first; 89 | 90 | function callback(node: Node): boolean { 91 | if (fn(node)) { 92 | first = node; 93 | return false; 94 | } 95 | 96 | return true; 97 | } 98 | } 99 | 100 | all(fn?: NodeVisitorFunction, options?: Options): Node[]; 101 | all(options?: Options): Node[]; 102 | all(...args: ParseArgs): Node[] { 103 | const all: Node[] = []; 104 | 105 | const { fn, options } = this._parseArgs(...args); 106 | 107 | switch (options.strategy) { 108 | case 'pre': 109 | this.walkStrategy.pre(this, callback); 110 | break; 111 | case 'post': 112 | this.walkStrategy.post(this, callback); 113 | break; 114 | case 'breadth': 115 | this.walkStrategy.breadth(this, callback); 116 | break; 117 | } 118 | 119 | return all; 120 | 121 | function callback(node: Node): boolean { 122 | if (fn(node)) { 123 | all.push(node); 124 | } 125 | 126 | return true; 127 | } 128 | } 129 | 130 | drop(): Node { 131 | if (!this.isRoot() && this.parent) { 132 | const indexOfChild = this.parent.children.indexOf(this); 133 | this.parent.children.splice(indexOfChild, 1); // Remove Node from data 134 | this.parent.model.children?.splice(indexOfChild, 1); // remove Model from data 135 | this.parent = undefined; // Delete object references 136 | delete this.parent; // Delete object references 137 | } 138 | 139 | return this; 140 | } 141 | 142 | isRoot(): boolean { 143 | return this.parent === undefined; 144 | } 145 | 146 | setIndex(index: number): Node { 147 | if (this.parent === undefined) { 148 | if (index === 0) { 149 | return this; 150 | } 151 | throw new Error('Invalid index.'); 152 | } 153 | 154 | if (index < 0 || index >= this.parent.children.length) { 155 | throw new Error('Invalid index.'); 156 | } 157 | 158 | const currentIndex = this.parent.children.indexOf(this); 159 | 160 | // Get target node in children by current index. 161 | const node = this.parent.children.splice(currentIndex, 1)[0]; 162 | // Insert the node in children by new index. 163 | this.parent.children.splice(index, 0, node); 164 | 165 | const { children } = this.parent.model; 166 | if (children) { 167 | // Get target model in children by current index. 168 | const model = children.splice(currentIndex, 1)[0]; 169 | // Insert the model in children by new index. 170 | children.splice(index, 0, model); 171 | } 172 | 173 | return this; 174 | } 175 | 176 | getIndex(): number { 177 | if (this.parent === undefined) { 178 | return 0; 179 | } 180 | 181 | return this.parent.children.indexOf(this); 182 | } 183 | 184 | private _addToPath(path: Node[], node: Node) { 185 | path.unshift(node); 186 | 187 | if (!node.isRoot() && node.parent) { 188 | this._addToPath(path, node.parent); 189 | } 190 | 191 | return path; 192 | } 193 | 194 | getPath(): Node[] { 195 | return this._addToPath([], this); 196 | } 197 | 198 | hasChildren(): boolean { 199 | return this.children.length > 0; 200 | } 201 | 202 | walk( 203 | fn: NodeVisitorFunction, 204 | options: Options = { strategy: 'pre' } 205 | ): void { 206 | switch (options.strategy) { 207 | case 'pre': 208 | this.walkStrategy.pre(this, fn); 209 | break; 210 | case 'post': 211 | this.walkStrategy.post(this, fn); 212 | break; 213 | case 'breadth': 214 | this.walkStrategy.breadth(this, fn); 215 | break; 216 | } 217 | } 218 | } 219 | 220 | export default Node; 221 | -------------------------------------------------------------------------------- /__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from '../src/index'; 2 | import Tree from '../src/index'; 3 | 4 | type NodeType = { id: number }; 5 | 6 | describe('Tree', () => { 7 | const idEq = (id: number) => (node: Node) => { 8 | return node.model.id === id; 9 | }; 10 | 11 | let tree: Tree; 12 | 13 | beforeEach(() => { 14 | tree = new Tree(); 15 | }); 16 | 17 | describe('parse()', () => { 18 | it('should create a root node when given a model without children', () => { 19 | const root = tree.parse({ id: 1 }); 20 | 21 | expect(root.parent).toBeUndefined(); 22 | expect(Array.isArray(root.children)).toBe(true); 23 | expect(root.children.length).toEqual(0); 24 | expect(root.model).toEqual({ id: 1 }); 25 | }); 26 | 27 | it('should create a root and the respective children when given a model with children', () => { 28 | const root = tree.parse({ 29 | id: 1, 30 | children: [ 31 | { 32 | id: 11, 33 | children: [{ id: 111 }], 34 | }, 35 | { 36 | id: 12, 37 | children: [ 38 | { id: 121 }, 39 | { id: 122 }, 40 | { id: 123 }, 41 | { id: 124 }, 42 | { id: 125 }, 43 | { id: 126 }, 44 | { id: 127 }, 45 | { id: 128 }, 46 | { id: 129 }, 47 | { id: 1210 }, 48 | { id: 1211 }, 49 | ], 50 | }, 51 | ], 52 | }); 53 | 54 | expect(root.parent).toBeUndefined(); 55 | expect(Array.isArray(root.children)).toBe(true); 56 | expect(root.children.length).toEqual(2); 57 | expect(root.model).toEqual({ 58 | id: 1, 59 | children: [ 60 | { 61 | id: 11, 62 | children: [{ id: 111 }], 63 | }, 64 | { 65 | id: 12, 66 | children: [ 67 | { id: 121 }, 68 | { id: 122 }, 69 | { id: 123 }, 70 | { id: 124 }, 71 | { id: 125 }, 72 | { id: 126 }, 73 | { id: 127 }, 74 | { id: 128 }, 75 | { id: 129 }, 76 | { id: 1210 }, 77 | { id: 1211 }, 78 | ], 79 | }, 80 | ], 81 | }); 82 | expect(root).toEqual(root.children[0].parent); 83 | expect(root).toEqual(root.children[1].parent); 84 | 85 | const node12 = root.children[1]; 86 | expect(Array.isArray(node12.children)).toBe(true); 87 | expect(node12.children.length).toEqual(11); 88 | expect(node12.model).toEqual({ 89 | id: 12, 90 | children: [ 91 | { id: 121 }, 92 | { id: 122 }, 93 | { id: 123 }, 94 | { id: 124 }, 95 | { id: 125 }, 96 | { id: 126 }, 97 | { id: 127 }, 98 | { id: 128 }, 99 | { id: 129 }, 100 | { id: 1210 }, 101 | { id: 1211 }, 102 | ], 103 | }); 104 | expect(node12).toEqual(node12.children[0].parent); 105 | expect(node12).toEqual(node12.children[1].parent); 106 | }); 107 | }); 108 | 109 | describe('addChild()', () => { 110 | let root: Node; 111 | 112 | beforeEach(() => { 113 | root = tree.parse({ id: 1, children: [{ id: 11 }, { id: 12 }] }); 114 | }); 115 | 116 | it('should add child to the end', () => { 117 | root.addChild(tree.parse({ id: 13 })); 118 | root.addChild(tree.parse({ id: 10 })); 119 | expect(root.model.children).toEqual([ 120 | { id: 11 }, 121 | { id: 12 }, 122 | { id: 13 }, 123 | { id: 10 }, 124 | ]); 125 | }); 126 | 127 | it('should add child at index', () => { 128 | root.addChildAtIndex(tree.parse({ id: 13 }), 1); 129 | expect(root.model.children).toEqual([{ id: 11 }, { id: 13 }, { id: 12 }]); 130 | expect(root.children[1].model.id).toEqual(13); 131 | }); 132 | 133 | it('should add child at the end when index matches the children number', () => { 134 | root.addChildAtIndex(tree.parse({ id: 13 }), 2); 135 | expect(root.model.children).toEqual([{ id: 11 }, { id: 12 }, { id: 13 }]); 136 | }); 137 | 138 | it('should add child at index 0 of a leaf', () => { 139 | expect.assertions(1); 140 | 141 | const leaf = root.first(idEq(11)); 142 | if (leaf) { 143 | leaf.addChildAtIndex(tree.parse({ id: 111 }), 0); 144 | expect(leaf.model.children).toEqual([{ id: 111 }]); 145 | } 146 | }); 147 | 148 | it('should throw an error when adding child at negative index', () => { 149 | const child = tree.parse({ id: 13 }); 150 | expect(() => root.addChildAtIndex(child, -1)).toThrow( 151 | new Error('Invalid index.') 152 | ); 153 | }); 154 | 155 | it('should throw an error when adding child at a too high index', () => { 156 | const child = tree.parse({ id: 13 }); 157 | expect(() => root.addChildAtIndex(child, 3)).toThrow( 158 | new Error('Invalid index.') 159 | ); 160 | }); 161 | }); 162 | 163 | describe('setIndex()', () => { 164 | let root: Node; 165 | 166 | beforeEach(() => { 167 | root = tree.parse({ 168 | id: 1, 169 | children: [{ id: 11 }, { id: 12 }, { id: 13 }], 170 | }); 171 | }); 172 | 173 | it('should set the index of the node among its siblings', () => { 174 | const child = root.children[0]; 175 | 176 | for (let i = 0; i < root.children.length; i++) { 177 | child.setIndex(i); 178 | expect(child.getIndex()).toEqual(i); 179 | expect(root.model.children?.indexOf(child.model)).toEqual(i); 180 | } 181 | }); 182 | 183 | it('keeps the order of all other nodes', () => { 184 | let oldOrder, i, j, k, l; 185 | const child = root.children[0]; 186 | 187 | for (i = 0; i < root.children.length; i++) { 188 | oldOrder = []; 189 | for (j = 0; j < root.children.length; j++) { 190 | if (root.children[j] !== child) { 191 | oldOrder.push(root.children[j]); 192 | } 193 | } 194 | 195 | child.setIndex(i); 196 | 197 | for (k = 0; k < root.children.length; k++) { 198 | for (l = 0; l < root.children.length; l++) { 199 | if (root.children[k] !== child && root.children[l] !== child) { 200 | expect(k < l).toEqual( 201 | oldOrder.indexOf(root.children[k]) < 202 | oldOrder.indexOf(root.children[l]) 203 | ); 204 | } 205 | } 206 | } 207 | } 208 | }); 209 | 210 | it('should return itself', () => { 211 | const child = root.children[0]; 212 | expect(child.setIndex(1)).toEqual(child); 213 | }); 214 | 215 | it('should throw an error when node is a root and the index is not zero', () => { 216 | expect(() => root.setIndex(1)).toThrow(new Error('Invalid index.')); 217 | }); 218 | 219 | it('should allow to set the root node index to zero', () => { 220 | expect(root.setIndex(0)).toEqual(root); 221 | }); 222 | 223 | it('should throw an error when setting to a negative index', () => { 224 | expect(() => root.children[0].setIndex(-1)).toThrow( 225 | new Error('Invalid index.') 226 | ); 227 | }); 228 | 229 | it('should throw an error when setting to a too high index', () => { 230 | expect(() => root.children[0].setIndex(root.children.length)).toThrow( 231 | new Error('Invalid index.') 232 | ); 233 | }); 234 | }); 235 | 236 | describe('getPath()', () => { 237 | let root: Node; 238 | 239 | beforeEach(() => { 240 | root = tree.parse({ 241 | id: 1, 242 | children: [ 243 | { 244 | id: 11, 245 | children: [{ id: 111 }], 246 | }, 247 | { 248 | id: 12, 249 | children: [{ id: 121 }, { id: 122 }], 250 | }, 251 | ], 252 | }); 253 | }); 254 | 255 | it('should get an array with the root node if called on the root node', () => { 256 | const pathToRoot = root.getPath(); 257 | expect(pathToRoot.length).toEqual(1); 258 | expect(pathToRoot[0].model.id).toEqual(1); 259 | }); 260 | 261 | it('should get an array of nodes from the root to the node (included)', () => { 262 | expect.assertions(4); 263 | 264 | const node = root.first(idEq(121)); 265 | 266 | if (node) { 267 | const pathToNode121 = node.getPath(); 268 | 269 | expect(pathToNode121.length).toEqual(3); 270 | expect(pathToNode121[0].model.id).toEqual(1); 271 | expect(pathToNode121[1].model.id).toEqual(12); 272 | expect(pathToNode121[2].model.id).toEqual(121); 273 | } 274 | }); 275 | }); 276 | 277 | describe('traversal', () => { 278 | let root: Node, mock121: jest.Mock, mock12: jest.Mock; 279 | 280 | const callback121 = (node: Node) => { 281 | if (node.model.id === 121) { 282 | return false; 283 | } 284 | }; 285 | 286 | const callback12 = (node: Node) => { 287 | if (node.model.id === 12) { 288 | return false; 289 | } 290 | }; 291 | 292 | beforeEach(() => { 293 | root = tree.parse({ 294 | id: 1, 295 | children: [ 296 | { 297 | id: 11, 298 | children: [{ id: 111 }], 299 | }, 300 | { 301 | id: 12, 302 | children: [{ id: 121 }, { id: 122 }], 303 | }, 304 | ], 305 | }); 306 | 307 | mock121 = jest.fn(callback121); 308 | mock12 = jest.fn(callback12); 309 | }); 310 | 311 | describe('walk depthFirstPreOrder by default', () => { 312 | it('should traverse the nodes until the callback returns false', () => { 313 | root.walk(mock121); 314 | expect(mock121).toHaveBeenCalledTimes(5); 315 | expect(mock121).toHaveBeenNthCalledWith(1, root.first(idEq(1))); 316 | expect(mock121).toHaveBeenNthCalledWith(2, root.first(idEq(11))); 317 | expect(mock121).toHaveBeenNthCalledWith(3, root.first(idEq(111))); 318 | expect(mock121).toHaveBeenNthCalledWith(4, root.first(idEq(12))); 319 | expect(mock121).toHaveBeenNthCalledWith(5, root.first(idEq(121))); 320 | }); 321 | }); 322 | 323 | describe('walk depthFirstPostOrder', () => { 324 | it('should traverse the nodes until the callback returns false', () => { 325 | root.walk(mock121, { strategy: 'post' }); 326 | expect(mock121).toHaveBeenCalledTimes(3); 327 | expect(mock121).toHaveBeenNthCalledWith(1, root.first(idEq(111))); 328 | expect(mock121).toHaveBeenNthCalledWith(2, root.first(idEq(11))); 329 | expect(mock121).toHaveBeenNthCalledWith(3, root.first(idEq(121))); 330 | }); 331 | }); 332 | 333 | describe('walk depthFirstPostOrder (2)', () => { 334 | it('should traverse the nodes until the callback returns false', () => { 335 | root.walk(mock12, { strategy: 'post' }); 336 | expect(mock12).toHaveBeenCalledTimes(5); 337 | expect(mock12).toHaveBeenNthCalledWith(1, root.first(idEq(111))); 338 | expect(mock12).toHaveBeenNthCalledWith(2, root.first(idEq(11))); 339 | expect(mock12).toHaveBeenNthCalledWith(3, root.first(idEq(121))); 340 | expect(mock12).toHaveBeenNthCalledWith(4, root.first(idEq(122))); 341 | expect(mock12).toHaveBeenNthCalledWith(5, root.first(idEq(12))); 342 | }); 343 | }); 344 | 345 | describe('walk breadthFirst', () => { 346 | it('should traverse the nodes until the callback returns false', () => { 347 | root.walk(mock121, { strategy: 'breadth' }); 348 | expect(mock121).toHaveBeenCalledTimes(5); 349 | expect(mock121).toHaveBeenNthCalledWith(1, root.first(idEq(1))); 350 | expect(mock121).toHaveBeenNthCalledWith(2, root.first(idEq(11))); 351 | expect(mock121).toHaveBeenNthCalledWith(3, root.first(idEq(12))); 352 | expect(mock121).toHaveBeenNthCalledWith(4, root.first(idEq(111))); 353 | expect(mock121).toHaveBeenNthCalledWith(5, root.first(idEq(121))); 354 | }); 355 | }); 356 | }); 357 | 358 | describe('all()', () => { 359 | let root: Node; 360 | 361 | beforeEach(() => { 362 | root = tree.parse({ 363 | id: 1, 364 | children: [ 365 | { 366 | id: 11, 367 | children: [{ id: 111 }], 368 | }, 369 | { 370 | id: 12, 371 | children: [{ id: 121 }, { id: 122 }], 372 | }, 373 | ], 374 | }); 375 | }); 376 | 377 | it('should get an empty array if no nodes match the predicate', () => { 378 | const idLt0 = root.all((node) => node.model.id < 0); 379 | 380 | expect(idLt0.length).toEqual(0); 381 | }); 382 | 383 | it('should get all nodes if no predicate is given', () => { 384 | const allNodes = root.all(); 385 | 386 | expect(allNodes.length).toEqual(6); 387 | }); 388 | 389 | it('should get an array with the node itself if only the node matches the predicate', () => { 390 | const idEq1 = root.all(idEq(1)); 391 | 392 | expect(idEq1.length).toEqual(1); 393 | expect(idEq1[0]).toEqual(root); 394 | }); 395 | 396 | it('should get an array with all nodes that match a given predicate', () => { 397 | const idGt100 = root.all((node) => node.model.id > 100); 398 | 399 | expect(idGt100.length).toEqual(3); 400 | expect(idGt100[0].model.id).toEqual(111); 401 | expect(idGt100[1].model.id).toEqual(121); 402 | expect(idGt100[2].model.id).toEqual(122); 403 | }); 404 | 405 | it('should get an array with all nodes that match a given predicate (2)', () => { 406 | const idGt10AndChildOfRoot = root.all( 407 | (node) => node.model.id > 10 && node.parent === root 408 | ); 409 | 410 | expect(idGt10AndChildOfRoot.length).toEqual(2); 411 | expect(idGt10AndChildOfRoot[0].model.id).toEqual(11); 412 | expect(idGt10AndChildOfRoot[1].model.id).toEqual(12); 413 | }); 414 | 415 | it('should get an array including all nodes with a different strategy', () => { 416 | const nodes = root.all({ strategy: 'post' }); 417 | 418 | expect(nodes.length).toEqual(6); 419 | expect(nodes[0].model.id).toEqual(111); 420 | expect(nodes[1].model.id).toEqual(11); 421 | expect(nodes[2].model.id).toEqual(121); 422 | expect(nodes[3].model.id).toEqual(122); 423 | expect(nodes[4].model.id).toEqual(12); 424 | expect(nodes[5].model.id).toEqual(1); 425 | }); 426 | 427 | it('should get an array including all nodes with a different strategy (2)', () => { 428 | const nodes = root.all({ strategy: 'breadth' }); 429 | 430 | expect(nodes.length).toEqual(6); 431 | expect(nodes[0].model.id).toEqual(1); 432 | expect(nodes[1].model.id).toEqual(11); 433 | expect(nodes[2].model.id).toEqual(12); 434 | expect(nodes[3].model.id).toEqual(111); 435 | expect(nodes[4].model.id).toEqual(121); 436 | expect(nodes[5].model.id).toEqual(122); 437 | }); 438 | }); 439 | 440 | describe('first()', () => { 441 | let root: Node; 442 | 443 | beforeEach(() => { 444 | root = tree.parse({ 445 | id: 1, 446 | children: [ 447 | { 448 | id: 11, 449 | children: [{ id: 111 }], 450 | }, 451 | { 452 | id: 12, 453 | children: [{ id: 121 }, { id: 122 }], 454 | }, 455 | ], 456 | }); 457 | }); 458 | 459 | it('should get the first node when the predicate returns true', () => { 460 | const first = root.first(() => true); 461 | 462 | expect(first?.model.id).toEqual(1); 463 | }); 464 | 465 | it('should get the first node when no predicate is given', () => { 466 | const first = root.first(); 467 | 468 | expect(first?.model.id).toEqual(1); 469 | }); 470 | 471 | it('should get the first node with a different strategy when the predicate returns true', () => { 472 | const first = root.first(() => true, { strategy: 'post' }); 473 | 474 | expect(first?.model.id).toEqual(111); 475 | }); 476 | 477 | it('should get the first node with a different strategy when no predicate is given', () => { 478 | const first = root.first({ strategy: 'post' }); 479 | 480 | expect(first?.model.id).toEqual(111); 481 | }); 482 | 483 | it('should get the first node with a different strategy when no predicate is given (2)', () => { 484 | const first = root.first({ strategy: 'breadth' }); 485 | 486 | expect(first?.model.id).toEqual(1); 487 | }); 488 | }); 489 | 490 | describe('drop()', function () { 491 | let root: Node; 492 | 493 | beforeEach(function () { 494 | root = tree.parse({ 495 | id: 1, 496 | children: [ 497 | { 498 | id: 11, 499 | children: [{ id: 111 }], 500 | }, 501 | { 502 | id: 12, 503 | children: [{ id: 121 }, { id: 122 }], 504 | }, 505 | ], 506 | }); 507 | }); 508 | 509 | it('should give back the dropped node, even if it is the root', () => { 510 | expect(root.drop()).toEqual(root); 511 | }); 512 | 513 | it('should give back the dropped node, which no longer be found in the original root', () => { 514 | expect(root.first(idEq(11))?.drop().model).toEqual({ 515 | id: 11, 516 | children: [{ id: 111 }], 517 | }); 518 | expect(root.first(idEq(11))).toBeUndefined(); 519 | expect(root.first(idEq(111))).toBeUndefined(); 520 | }); 521 | }); 522 | 523 | describe('hasChildren()', () => { 524 | let root: Node; 525 | 526 | beforeEach(() => { 527 | root = tree.parse({ 528 | id: 1, 529 | children: [ 530 | { 531 | id: 11, 532 | children: [{ id: 111 }], 533 | }, 534 | { 535 | id: 12, 536 | children: [{ id: 121 }, { id: 122 }], 537 | }, 538 | ], 539 | }); 540 | }); 541 | 542 | it('should return true for node with children', () => { 543 | expect(root.hasChildren()).toBe(true); 544 | }); 545 | 546 | it('should return false for node without children', () => { 547 | expect(root.first(idEq(111))?.hasChildren()).toBe(false); 548 | }); 549 | }); 550 | }); 551 | --------------------------------------------------------------------------------