├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── assets ├── plain-tree.png └── plain-tree.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── readme.md ├── src ├── Node.ts ├── Tree.ts ├── create.ts ├── index.ts ├── types.ts ├── utils.ts └── utilsNodeTree.ts ├── tests ├── Node.test.ts ├── Tree.test.ts ├── create.test.ts ├── data.ts └── utils.test.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true 6 | }, 7 | parser: '@typescript-eslint/parser', 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 11 | 'plugin:prettier/recommended' // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 12 | ], 13 | globals: { 14 | Atomics: 'readonly', 15 | SharedArrayBuffer: 'readonly' 16 | }, 17 | parserOptions: { 18 | ecmaVersion: 2018, 19 | sourceType: 'module' 20 | }, 21 | rules: { 22 | '@typescript-eslint/no-explicit-any': 0 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build/ 3 | .rts2* 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .rts2* 3 | tests 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'none', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2 7 | }; 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - ~/.npm 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '12' 10 | - '10' 11 | - '8' 12 | install: npm install 13 | matrix: 14 | fast_finish: true 15 | jobs: 16 | include: 17 | - stage: test 18 | script: 19 | - npm run lint 20 | - npm run test 21 | - stage: release 22 | node_js: '10' 23 | before_script: 24 | - npm run build 25 | on: 26 | branch: release 27 | script: npx semantic-release@15 28 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.autoFixOnSave": true, 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | { "language": "typescript", "autoFix": true }, 7 | { "language": "typescriptreact", "autoFix": true } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2018 Luke Scott 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /assets/plain-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukeaus/plain-tree/6c9254653e0fbce94b9f976b5ef6f868067e0024/assets/plain-tree.png -------------------------------------------------------------------------------- /assets/plain-tree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net-tree 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src', '/tests'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lukeaus/plain-tree", 3 | "description": "A plain tree with a bunch of tree tools", 4 | "version": "0.0.0", 5 | "source": "src/index.ts", 6 | "main": "build/index.js", 7 | "module": "build/index.mjs", 8 | "unpkg": "build/index.umd.js", 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "files": [ 13 | "build", 14 | "README.md" 15 | ], 16 | "scripts": { 17 | "test": "jest", 18 | "test:watch": "jest --watchAll", 19 | "dev": "microbundle watch", 20 | "lint": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}' --quiet --fix", 21 | "commit": "git-cz", 22 | "prebuild": "rimraf build & rimraf .rts2_*", 23 | "build": "microbundle" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/lukeaus/plain-tree.git" 28 | }, 29 | "keywords": [ 30 | "tree", 31 | "trees", 32 | "simple", 33 | "javascript", 34 | "hierarchy", 35 | "hierarchies", 36 | "tool", 37 | "tools", 38 | "util", 39 | "utils", 40 | "utility", 41 | "utilities", 42 | "node", 43 | "nodes", 44 | "create", 45 | "array" 46 | ], 47 | "author": { 48 | "name": "Luke Scott", 49 | "email": "luke.m.scott@gmail.com", 50 | "url": "https://lukescott.co" 51 | }, 52 | "license": "MIT", 53 | "bugs": { 54 | "url": "https://github.com/lukeaus/plain-tree/issues" 55 | }, 56 | "homepage": "https://github.com/lukeaus/plain-tree#readme", 57 | "husky": { 58 | "hooks": { 59 | "pre-commit": "pretty-quick --staged" 60 | } 61 | }, 62 | "config": { 63 | "commitizen": { 64 | "path": "node_modules/cz-conventional-changelog" 65 | } 66 | }, 67 | "devDependencies": { 68 | "@types/jest": "24.0.17", 69 | "@typescript-eslint/eslint-plugin": "2.0.0", 70 | "@typescript-eslint/parser": "2.0.0", 71 | "commitizen": "4.0.3", 72 | "cz-conventional-changelog": "3.0.2", 73 | "eslint": "6.2.0", 74 | "eslint-config-prettier": "6.1.0", 75 | "eslint-plugin-prettier": "3.1.0", 76 | "husky": "3.0.4", 77 | "jest": "24.9.0", 78 | "microbundle": "0.11.0", 79 | "prettier": "1.18.2", 80 | "pretty-quick": "1.11.1", 81 | "rimraf": "3.0.0", 82 | "ts-jest": "24.0.2", 83 | "typescript": "3.5.3" 84 | }, 85 | "dependencies": {} 86 | } 87 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Plain Tree 4 | 5 | Plain Tree logo 6 | 7 | --- 8 | 9 | [![Tests](https://img.shields.io/travis/lukeaus/plain-tree/master.svg)](https://travis-ci.org/lukeaus/plain-tree) 10 | [![MIT License](https://img.shields.io/github/license/lukeaus/plain-tree.svg)](https://github.com/lukeaus/plain-tree/blob/master/LICENSE) 11 | [![version](https://img.shields.io/npm/v/@lukeaus/plain-tree.svg)](https://www.npmjs.com/@lukeaus/plain-tree) 12 | [![npm downloads](https://img.shields.io/npm/dm/@lukeaus/plain-tree.svg)](http://npm-stat.com/charts.html?package=@lukeaus/plain-tree&from=2019-08-20) 13 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 14 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/lukeaus/plain-tree/issues) 15 | [![Speed](https://img.shields.io/badge/speed-blazing%20🔥-brightgreen.svg)](https://twitter.com/captbaritone/status/999996177411133440) 16 | 17 | ## What 18 | 19 | Performant tree and node utility library. 20 | 21 | ## Features 22 | 23 | - Create trees 24 | - manually 25 | - from array 26 | - Create nodes 27 | - manually 28 | - from array 29 | - from object 30 | - Search 31 | - Find one 32 | - Find all 33 | - Some/Every 34 | - Traverse (breath first and depth first) 35 | - Nodes at height 36 | - Nodes count 37 | - all 38 | - by height 39 | - Width 40 | - Height 41 | - Depth 42 | - Manipulate trees and nodes 43 | - FlatMap 44 | - Into single array 45 | - Into array of arrays by height 46 | - Convert to JSON 47 | - ... and more (see below) 48 | 49 | ## Why 50 | 51 | Tree and node tools all in one handy, well tested package. 52 | 53 | ## Install 54 | 55 | ``` 56 | npm i --save @lukeaus/plain-tree 57 | ``` 58 | 59 | ## Usage 60 | 61 | ```javascript 62 | import { Node, Tree } from '@lukeaus/plain-tree'; 63 | 64 | const rootNode = new Node('a'); 65 | const tree = new Tree(rootNode); 66 | rootNode.addChild('b'); 67 | 68 | console.log(tree); 69 | /* 70 | Tree { 71 | root: 72 | Node { 73 | children: [ [Node] ], 74 | id: 'twsychkc3gdj7o30o3s3z6cb7vfpzb2xfgjl', 75 | parent: null, 76 | data: 'a' 77 | } 78 | } 79 | */ 80 | ``` 81 | 82 | ## API 83 | 84 | ### Creating a Tree 85 | 86 | There are multiple ways to create a tree. 87 | 88 | #### Manually via Declared Nodes 89 | 90 | Assign nodes to variables then use `node.addChild` 91 | 92 | ```javascript 93 | import { Node, Tree } from '@lukeaus/plain-tree'; 94 | 95 | const rootNode = new Node('a'); 96 | const tree = new Tree(rootNode); 97 | const nodeB = rootNode.addChild('b'); 98 | const nodeC = nodeB.addChild('c'); 99 | 100 | /* Tree Outline: 101 | * a 102 | * - b 103 | * - c 104 | */ 105 | ``` 106 | 107 | #### Manually via Children 108 | 109 | Add nodes by accessing the root node's children (and their children, and their children's children etc.) 110 | 111 | ```javascript 112 | import { Node, Tree } from '@lukeaus/plain-tree'; 113 | 114 | const rootNode = new Node('a'); 115 | const tree = new Tree(rootNode); 116 | rootNode.addChild('b'); 117 | rootNode.children[0].addChild('c'); 118 | 119 | /* Tree Outline: 120 | * a 121 | * - b 122 | * - c 123 | */ 124 | ``` 125 | 126 | #### Create a Tree From a Flat Array of Objects 127 | 128 | **`createTreeFromFlatArray(arr, ?opts)`** 129 | 130 | Return: Tree instance 131 | 132 | ##### arr 133 | 134 | Type: Array 135 | 136 | Description: An array of objects. Object should have: 137 | 138 | - id 139 | - parent id (optional) 140 | - children (optional) 141 | - some other properties (all other properties will be converted to an object and will be available on node's `data` property ) 142 | 143 | ##### opts 144 | 145 | Type: Object 146 | 147 | Description: Options for creation of tree 148 | 149 | | Parameter | Type | Default | Description | 150 | | ----------- | ------ | ---------- | ------------------------------------------------- | 151 | | idKey | String | 'id' | Object's property whose value is each node's id | 152 | | parentIdKey | String | 'parentId' | Object's property whose value is parent node's id | 153 | | childrenKey | String | 'children' | Object's property whose value is child objects | 154 | 155 | ##### Example 156 | 157 | ```javascript 158 | import { createTreeFromFlatArray } from '@lukeaus/plain-tree'; 159 | 160 | const arr = [ 161 | { 162 | id: 'sports', 163 | name: 'Sports', 164 | parentId: null 165 | }, 166 | { 167 | id: 'ball', 168 | name: 'Ball', 169 | parentId: 'sports' 170 | }, 171 | { 172 | id: 'non-ball', 173 | name: 'Non Ball', 174 | parentId: 'sports' 175 | }, 176 | { 177 | id: 'tennis', 178 | name: 'Tennis', 179 | parentId: 'ball' 180 | } 181 | ]; 182 | 183 | createTreeFromFlatArray(arr); 184 | 185 | /* Tree Outline: 186 | * Sports 187 | * - Ball 188 | * - Tennis 189 | * - Non Ball 190 | */ 191 | ``` 192 | 193 | #### Create a Tree From an Array of Nested Objects 194 | 195 | **`createTreeFromTreeArray(arr, ?opts)`** 196 | 197 | Return: Tree instance 198 | 199 | ##### arr 200 | 201 | Type: Array 202 | 203 | Description: An array of objects. Object should have: 204 | 205 | - id 206 | - parent id (optional) 207 | - children (optional) 208 | - some other properties (all other properties will be converted to an object and will be available on node's `data` property ) 209 | 210 | ##### opts 211 | 212 | Type: Object 213 | 214 | Description: Options for creation of tree 215 | 216 | | Parameter | Type | Default | Description | 217 | | ----------- | ------ | ---------- | ------------------------------------------------- | 218 | | idKey | String | 'id' | Object's property whose value is each node's id | 219 | | parentIdKey | String | 'parentId' | Object's property whose value is parent node's id | 220 | | childrenKey | String | 'children' | Object's property whose value is child objects | 221 | 222 | ##### Example 223 | 224 | ```javascript 225 | import { createTreeFromTreeArray } from '@lukeaus/plain-tree'; 226 | 227 | const arr = [ 228 | { 229 | id: 'sports', 230 | name: 'Sports', 231 | parentId: null, 232 | children: [ 233 | { 234 | id: 'ball', 235 | name: 'Ball', 236 | parentId: 'sports', 237 | children: [ 238 | { 239 | id: 'tennis', 240 | name: 'Tennis', 241 | parentId: 'ball', 242 | children: [] 243 | } 244 | ] 245 | }, 246 | { 247 | id: 'non-ball', 248 | name: 'Non Ball', 249 | parentId: 'sports', 250 | children: [] 251 | } 252 | ] 253 | } 254 | ]; 255 | createTreeFromTreeArray(arr); 256 | 257 | /* Tree Outline: 258 | * Sports 259 | * - Ball 260 | * - Tennis 261 | * - Non Ball 262 | */ 263 | ``` 264 | 265 | ### Adding Additional Nodes to Existing Tree 266 | 267 | #### Manually via Declared Nodes 268 | 269 | Assign nodes to variables then use `node.addChild` 270 | 271 | ```javascript 272 | import { Node } from '@lukeaus/plain-tree'; 273 | 274 | // find/use an existing node 275 | const nodeB = node.addChild('b'); 276 | const nodeC = nodeB.addChild('c'); 277 | ``` 278 | 279 | #### Manually via Children 280 | 281 | Add nodes by accessinga node's children (and their children, and their children's children etc.) 282 | 283 | ```javascript 284 | import { Node, Tree } from '@lukeaus/plain-tree'; 285 | 286 | // find/use an existing node 287 | node.addChild('b'); 288 | node.children[0].addChild('c'); 289 | ``` 290 | 291 | #### From Tree Array 292 | 293 | **`createNodes(arr, ?parentNode, ?opts)`** 294 | 295 | Create nodes from an array of nested objects 296 | 297 | Return: void 298 | 299 | ##### arr 300 | 301 | Type: Array 302 | 303 | Description: An array of objects. Object should have: 304 | 305 | - id 306 | - parent id (optional) 307 | - children (optional) 308 | - some other properties (all other properties will be converted to an object and will be available on node's `data` property ) 309 | 310 | ##### parentNode 311 | 312 | Type: Node | null 313 | 314 | Description: Parent node for nodes in array 315 | 316 | ##### opts 317 | 318 | Type: Object 319 | 320 | Description: Options for creation of tree 321 | 322 | | Parameter | Type | Default | Description | 323 | | ----------- | ------ | ---------- | ------------------------------------------------- | 324 | | idKey | String | 'id' | Object's property whose value is each node's id | 325 | | parentIdKey | String | 'parentId' | Object's property whose value is parent node's id | 326 | | childrenKey | String | 'children' | Object's property whose value is child objects | 327 | 328 | ##### Example 329 | 330 | ```javascript 331 | import { createNodes } from '@lukeaus/plain-tree'; 332 | 333 | const parentNode = new Node('a')); 334 | const arr: any = [ 335 | { 336 | id: 'sports', 337 | name: 'Sports', 338 | parentId: null, 339 | children: [ 340 | { 341 | id: 'ball', 342 | name: 'Ball', 343 | parentId: 'sports', 344 | children: [ 345 | { 346 | id: 'tennis', 347 | name: 'Tennis', 348 | parentId: 'ball', 349 | children: [] 350 | } 351 | ] 352 | }, 353 | { 354 | id: 'non-ball', 355 | name: 'Non Ball', 356 | parentId: 'sports', 357 | children: [] 358 | } 359 | ] 360 | } 361 | ]; 362 | 363 | createNodes(arr, parentNode); 364 | ``` 365 | 366 | ### Tree 367 | 368 | #### `constructor(root)` 369 | 370 | Creates and returns the tree 371 | 372 | ##### root 373 | 374 | Type: Node or null 375 | 376 | Default: null 377 | 378 | Description: The tree root 379 | 380 | #### `traverseBreathFirst(fn)` 381 | 382 | Traverse every node in the tree breath first 383 | 384 | Return: void 385 | 386 | ##### fn 387 | 388 | Type: Function 389 | 390 | | Parameter | Type | Description | 391 | | --------- | ------------ | -------------- | 392 | | 1 | Node \| null | A Node or null | 393 | 394 | #### `traverseDepthFirst(fn)` 395 | 396 | Traverse every node in the tree depth first 397 | 398 | Return: void 399 | 400 | ##### fn 401 | 402 | Type: Function 403 | 404 | | Parameter | Type | Description | 405 | | --------- | ------------ | -------------- | 406 | | 1 | Node \| null | A Node or null | 407 | 408 | #### `findOneBreathFirst(fn)` 409 | 410 | Traverse nodes in the tree breath first. Returns the first matching Node or null. 411 | 412 | Return: Node | null 413 | 414 | ##### fn 415 | 416 | Type: Function 417 | 418 | | Parameter | Type | Description | 419 | | --------- | ------------ | -------------- | 420 | | 1 | Node \| null | A Node or null | 421 | 422 | #### `findOneDepthFirst(fn)` 423 | 424 | Traverse nodes in the tree depth first. Returns the first matching Node or null. 425 | 426 | Return: Node | null 427 | 428 | ##### fn 429 | 430 | Type: Function 431 | 432 | | Parameter | Type | Description | 433 | | --------- | ------------ | -------------- | 434 | | 1 | Node \| null | A Node or null | 435 | 436 | #### `findAllBreathFirst(fn)` 437 | 438 | Traverse nodes in the tree breath first. Returns an array containing all matching Nodes. 439 | 440 | Return: Array 441 | 442 | ##### fn 443 | 444 | Type: Function 445 | 446 | | Parameter | Type | Description | 447 | | --------- | ------------ | -------------- | 448 | | 1 | Node \| null | A Node or null | 449 | 450 | #### `findAllDepthFirst(fn)` 451 | 452 | Traverse nodes in the tree depth first. Returns an array containing all matching Nodes. 453 | 454 | Return: Array 455 | 456 | ##### fn 457 | 458 | Type: Function 459 | 460 | | Parameter | Type | Description | 461 | | --------- | ------------ | -------------- | 462 | | 1 | Node \| null | A Node or null | 463 | 464 | #### `someBreathFirst(fn)` 465 | 466 | Traverse nodes in the tree breath first. Return true if a single node is truthy for fn, else return false. Breaks on first truthy for performance. 467 | 468 | Return: Boolean 469 | 470 | ##### fn 471 | 472 | Type: Function 473 | 474 | | Parameter | Type | Description | 475 | | --------- | ------------ | -------------- | 476 | | 1 | Node \| null | A Node or null | 477 | 478 | #### `someDepthFirst(fn)` 479 | 480 | Traverse nodes in the tree depth first. Return true if a single node is truthy for fn, else return false. Breaks on first truthy for performance. 481 | 482 | Return: Boolean 483 | 484 | ##### fn 485 | 486 | Type: Function 487 | 488 | | Parameter | Type | Description | 489 | | --------- | ------------ | -------------- | 490 | | 1 | Node \| null | A Node or null | 491 | 492 | #### `everyBreathFirst(fn)` 493 | 494 | Traverse every node in the tree breath first. Return true if every node is truthy for fn, else return false. Breaks on first falsey for performance. 495 | 496 | Return: Boolean 497 | 498 | ##### fn 499 | 500 | Type: Function 501 | 502 | | Parameter | Type | Description | 503 | | --------- | ------------ | -------------- | 504 | | 1 | Node \| null | A Node or null | 505 | 506 | #### `everyDepthFirst(fn)` 507 | 508 | Traverse every node in the tree depth first. Return true if every node is truthy for fn, else return false. Breaks on first falsey for performance. 509 | 510 | Return: Boolean 511 | 512 | ##### fn 513 | 514 | Type: Function 515 | 516 | | Parameter | Type | Description | 517 | | --------- | ------------ | -------------- | 518 | | 1 | Node \| null | A Node or null | 519 | 520 | #### `flatMap(?fn)` 521 | 522 | Traverse every node in the tree breath first and flatten the tree into a single array. 523 | 524 | Return: Array 525 | 526 | ##### fn 527 | 528 | Type: Function 529 | 530 | Default: null (if null, flatten will push the node into the array) 531 | 532 | | Parameter | Type | Description | 533 | | --------- | ------------ | -------------- | 534 | | 1 | Node \| null | A Node or null | 535 | 536 | #### `flattenData()` 537 | 538 | Traverse every node in the tree breath first and flatten the tree into a single array. Extract the 'data' property of each Node (if node is not null) and return an array of any. This is a helper method which is essentially `flatten(nodeData)`; 539 | 540 | Return: Array 541 | 542 | #### `flattenByHeight(?fn)` 543 | 544 | Traverse every node in the tree breath first and flatten the tree into an array of arrays, where each array is for each height level in the tree. 545 | 546 | Return: Array> 547 | 548 | ##### fn 549 | 550 | Type: Function 551 | 552 | Default: null (if null, flatten will push the node into the array) 553 | 554 | | Parameter | Type | Description | 555 | | --------- | ------------ | -------------- | 556 | | 1 | Node \| null | A Node or null | 557 | 558 | ##### Example 559 | 560 | ```javascript 561 | import { Node, Tree } from '@lukeaus/plain-tree'; 562 | 563 | const nodeA = new Node('a'); 564 | const tree = new Tree(nodeA); 565 | nodeA.addChild('b'); 566 | const nodeC = nodeA.addChild('c'); 567 | nodeC.addChild('d'); 568 | 569 | tree.flattenByHeight(nodeData); 570 | /* Output 571 | [['a'], ['b', 'c'], ['d']]; 572 | */ 573 | ``` 574 | 575 | #### `flattenDataByHeight()` 576 | 577 | Traverse every node in the tree breath first and flatten the tree into an array of arrays, where each array is for each height level in the tree. Extract the 'data' property of each Node (if node is not null). This is a helper method which is essentially `flattenByHeight(nodeData)`; 578 | 579 | Return: Array> 580 | 581 | #### `widthsByHeight()` 582 | 583 | Return the width of children at each height 584 | 585 | ##### Example 586 | 587 | ```javascript 588 | import { Tree, Node, hasChildren, nodeData } from '@lukeaus/plain-tree'; 589 | 590 | const nodeA = new Node('a'); 591 | const tree = new Tree(nodeA); 592 | nodeA.addChild('b'); 593 | nodeA.addChild('c'); 594 | nodeA.children[0].addChild('d'); 595 | 596 | tree.widthsByHeight(); // [1, 2 , 1] 597 | ``` 598 | 599 | #### `nodesAtHeight(number)` 600 | 601 | Return all the nodes at that height (root is at height 0) 602 | 603 | Return: Array 604 | 605 | ##### number 606 | 607 | Type: Number 608 | 609 | Description: Number indicating the tree height at which you want to obtain all nodes 610 | 611 | #### `maxWidth()` 612 | 613 | Return the maximum width of any height level in the tree 614 | 615 | Return: Number 616 | 617 | #### `height()` 618 | 619 | Return the height of the tree. Equivalent to height of root node. A tree with only root will be height 0. 620 | 621 | Return: Number 622 | 623 | #### `countNodes()` 624 | 625 | Return the number of nodes in a tree. A tree with root of null will return 1. 626 | 627 | Return: Number 628 | 629 | #### `toJson()` 630 | 631 | Stringify the tree. Due to circular dependencies, the `parent` property is dropped and replaced with property `parentId` (type String | null) which is the id of the parent (if it exists) else null. 632 | 633 | If root is null, an empty string is returned. 634 | 635 | Return: String 636 | 637 | ### Node 638 | 639 | #### `constructor(data, ?opts)` 640 | 641 | Create a new Node instance 642 | 643 | Return: Node 644 | 645 | #### `data` 646 | 647 | The node's data (excluding interal node properties) 648 | 649 | Type: any 650 | 651 | #### `parent` 652 | 653 | The node's parent node (if it exists, otherwise null) 654 | 655 | Type: Node | null 656 | 657 | ##### opts 658 | 659 | | Parameter | Type | Description | 660 | | --------- | ------------ | ----------- | 661 | | id | String | id | 662 | | parent | Node \| null | parent node | 663 | 664 | #### `addChild(data)` 665 | 666 | Add a child to this node. Return the newly created child Node instance. 667 | 668 | Return: Node 669 | 670 | ##### data 671 | 672 | Type: any 673 | 674 | #### `removeChildren(fn)` 675 | 676 | Remove all children where fn returns truthy. Use this where data is complex (e.g. data is an Object or Array). Returns removed children. 677 | 678 | Return: Array 679 | 680 | ##### fn 681 | 682 | | Parameter | Type | Description | 683 | | --------- | ------------ | --------------------- | 684 | | 1 | Node \| null | A child Node instance | 685 | 686 | #### `removeChildrenByData(data)` 687 | 688 | Remove all children where child's `data` property matches data. Use `removeChildren` where data is complex (e.g. data is an Object or Array). Returns removed children. 689 | 690 | Return: Array 691 | 692 | ##### data 693 | 694 | Type: any 695 | 696 | #### `removeChildrenById(id)` 697 | 698 | Remove all children where child's `id` property matches id. Use `removeChildren` where data is complex (e.g. data is an Object or Array). Returns removed children. 699 | 700 | Return: Array 701 | 702 | ##### id 703 | 704 | Type: String 705 | 706 | #### `isLeaf()` 707 | 708 | Returns a Boolean. False if this Node instance has children. True if it does have children. 709 | 710 | Return: Boolean 711 | 712 | #### `hasChildren()` 713 | 714 | Returns a Boolean. True if this Node instance has children. False if it does have children. 715 | 716 | Return: Boolean 717 | 718 | #### `widthsByHeight()` 719 | 720 | Return the width of children at each height 721 | 722 | ##### Example 723 | 724 | ```javascript 725 | import { Tree, Node, hasChildren, nodeData } from '@lukeaus/plain-tree'; 726 | 727 | const nodeA = new Node('a'); 728 | nodeA.addChild('b'); 729 | nodeA.addChild('c'); 730 | nodeA.children[0].addChild('d'); 731 | 732 | nodeA.widthsByHeight(); // [1, 2 , 1] 733 | ``` 734 | 735 | #### `height()` 736 | 737 | Return the height of the node. 738 | 739 | Return: Number 740 | 741 | #### `depth()` 742 | 743 | Return the depth of the node. 744 | 745 | Return: Number 746 | 747 | #### `flattenByHeight(?fn)` 748 | 749 | Traverse node and its children breath first and flatten into an array of arrays, where each array is for each height level (with this node at height 0). 750 | 751 | Return: Array> 752 | 753 | ##### fn 754 | 755 | Type: Function 756 | 757 | Default: null (if null, flatten will push the node into the array) 758 | 759 | | Parameter | Type | Description | 760 | | --------- | ------------ | -------------- | 761 | | 1 | Node \| null | A Node or null | 762 | 763 | ##### Example 764 | 765 | ```javascript 766 | import { Node } from '@lukeaus/plain-tree'; 767 | 768 | const nodeA = new Node('a'); 769 | nodeA.addChild('b'); 770 | const nodeC = nodeA.addChild('c'); 771 | nodeC.addChild('d'); 772 | 773 | nodeA.flattenByHeight(nodeData); 774 | /* Output 775 | [['a'], ['b', 'c'], ['d']]; 776 | */ 777 | ``` 778 | 779 | #### `toJson()` 780 | 781 | Stringify the node. Due to circular dependencies, parent property is dropped and replaced with parentId (type String | null). 782 | 783 | Return: String 784 | 785 | ### Utils 786 | 787 | #### `nodeData(any)` 788 | 789 | Return a nodes data. Safe function to protect against accessing `data` property on null. 790 | 791 | Return: any 792 | 793 | ##### any 794 | 795 | Type: any 796 | 797 | #### `nodesData(any)` 798 | 799 | Convenience method to return all node data on an array of nodes. 800 | 801 | Return: Array 802 | 803 | ##### any 804 | 805 | Type: Array 806 | 807 | #### `hasChildren(any)` 808 | 809 | Return `true` if node has children. Return `false` if no children. 810 | 811 | Return: boolean 812 | 813 | ##### any 814 | 815 | Type: any 816 | 817 | ## Contributing 818 | 819 | Contributions are welcomed. How to make a contribution: 820 | 821 | - Create an issue on Github 822 | - Fork project 823 | - Make changes 824 | - Test changes `npm run test` 825 | - Use `npm run commit` to commit 826 | - Create a pull request 827 | -------------------------------------------------------------------------------- /src/Node.ts: -------------------------------------------------------------------------------- 1 | import { generateId } from './utils'; 2 | import { 3 | nodeToJsonFormatter, 4 | widthsByHeight, 5 | flattenByHeight 6 | } from './utilsNodeTree'; 7 | import { NodeOrNull } from './types'; 8 | 9 | class Node { 10 | data: any; 11 | children: Node[] = []; 12 | id: string; 13 | parent: NodeOrNull; 14 | 15 | constructor( 16 | data: any, 17 | { id, parent }: { id?: string; parent?: NodeOrNull } = {} 18 | ) { 19 | this.id = id !== undefined ? id : generateId(); 20 | this.parent = parent || null; 21 | this.data = data; 22 | this.children = []; 23 | } 24 | 25 | addChild(data: any, { id }: { id?: string } = {}): Node { 26 | const node = new Node(data, { id, parent: this }); 27 | this.children.push(node); 28 | return node; 29 | } 30 | 31 | private _removeChildren(fn: Function): Array { 32 | const removedChildren: Array = []; 33 | this.children = this.children.filter(node => { 34 | if (fn(node)) { 35 | removedChildren.push(node); 36 | return false; 37 | } 38 | return true; 39 | }); 40 | return removedChildren; 41 | } 42 | 43 | removeChildren(fn: Function): Array { 44 | return this._removeChildren(fn); 45 | } 46 | 47 | removeChildrenByData(data: any): Array { 48 | const fn: Function = (node: Node) => node.data === data; 49 | return this._removeChildren(fn); 50 | } 51 | 52 | removeChildrenById(id: string): Array { 53 | const fn: Function = (node: Node) => node.id === id; 54 | return this._removeChildren(fn); 55 | } 56 | 57 | isLeaf(): boolean { 58 | return this.parent !== null && !Boolean(this.children.length); 59 | } 60 | 61 | hasChildren(): boolean { 62 | return Boolean(this.children.length); 63 | } 64 | 65 | toJson(): string { 66 | const objectToSerialize = nodeToJsonFormatter(this); 67 | return JSON.stringify(objectToSerialize); 68 | } 69 | 70 | depth(): number { 71 | if (!this.parent) { 72 | return 0; 73 | } else { 74 | let depth = 0; 75 | // eslint-disable-next-line @typescript-eslint/no-this-alias 76 | let currentNode: Node = this; 77 | while (currentNode.parent) { 78 | depth += 1; 79 | currentNode = currentNode.parent; 80 | } 81 | return depth; 82 | } 83 | } 84 | 85 | widthsByHeight(): Array { 86 | return widthsByHeight(this); 87 | } 88 | 89 | height(): number { 90 | return this.widthsByHeight().length - 1; 91 | } 92 | 93 | flattenByHeight(fn: Function | null = null): any[][] { 94 | return flattenByHeight(this, fn); 95 | } 96 | } 97 | 98 | export default Node; 99 | -------------------------------------------------------------------------------- /src/Tree.ts: -------------------------------------------------------------------------------- 1 | import Node from './Node'; 2 | import { nodeData, hasChildren, firstArrayElement } from './utils'; 3 | import { widthsByHeight, flattenByHeight } from './utilsNodeTree'; 4 | import { NodeOrNull } from './types'; 5 | 6 | type TraverseReturn = void | boolean | Array; 7 | type TraverseOptions = { 8 | some?: boolean; 9 | every?: boolean; 10 | returnBoolean?: boolean; 11 | returnArray?: boolean; 12 | }; 13 | 14 | class Tree { 15 | constructor(public root: NodeOrNull = null) { 16 | this.root = root; 17 | } 18 | 19 | private _traverse( 20 | fn: Function, 21 | { some, every, returnBoolean, returnArray }: TraverseOptions = {}, 22 | queueMethod: string 23 | ): TraverseReturn { 24 | const queue = [this.root]; 25 | const results: Array = []; 26 | let didBreak = false; 27 | let lastResult: undefined | boolean; 28 | while (queue.length) { 29 | const node = queue.shift(); 30 | hasChildren(node) && queue[queueMethod](...node.children); 31 | if (some || every) { 32 | const result = fn(node); 33 | if (result && returnArray) { 34 | results.push(node); 35 | } 36 | if ((every && !result) || (some && result)) { 37 | didBreak = true; 38 | lastResult = result; 39 | break; 40 | } 41 | } else { 42 | fn(node); 43 | } 44 | } 45 | if (every) { 46 | if (returnBoolean) { 47 | return !didBreak; 48 | } else if (returnArray) { 49 | return results; 50 | } 51 | } else if (some) { 52 | if (returnBoolean) { 53 | return Boolean(lastResult); 54 | } else if (returnArray) { 55 | return results; 56 | } 57 | } 58 | } 59 | 60 | private _traverseBreathFirst( 61 | fn: Function, 62 | opts?: TraverseOptions 63 | ): TraverseReturn { 64 | return this._traverse(fn, opts, 'push'); 65 | } 66 | 67 | private _traverseDepthFirst( 68 | fn: Function, 69 | opts?: TraverseOptions 70 | ): TraverseReturn { 71 | return this._traverse(fn, opts, 'unshift'); 72 | } 73 | 74 | traverseBreathFirst(fn: Function): TraverseReturn { 75 | this._traverseBreathFirst(fn); 76 | } 77 | 78 | /* 79 | * Hit the bottom of the tree as fast as possible 80 | * Then go up and get parent's children, then go down again 81 | */ 82 | traverseDepthFirst(fn: Function): TraverseReturn { 83 | this._traverseDepthFirst(fn); 84 | } 85 | 86 | /* 87 | * Return true if a single node is truthy for fn, else false 88 | * exit early on first truthy value 89 | */ 90 | someBreathFirst(fn: Function): boolean { 91 | return Boolean( 92 | this._traverseBreathFirst(fn, { 93 | some: true, 94 | returnBoolean: true 95 | }) 96 | ); 97 | } 98 | 99 | /* 100 | * Return true if a single node is truthy for fn, else false 101 | * exit early on first truthy value 102 | */ 103 | someDepthFirst(fn: Function): boolean { 104 | return Boolean( 105 | this._traverseDepthFirst(fn, { 106 | some: true, 107 | returnBoolean: true 108 | }) 109 | ); 110 | } 111 | 112 | /* 113 | * Return true if result of function for every node is truthy 114 | * exit early on first function falsey value 115 | */ 116 | everyBreathFirst(fn: Function): boolean { 117 | return Boolean( 118 | this._traverseDepthFirst(fn, { 119 | every: true, 120 | returnBoolean: true 121 | }) 122 | ); 123 | } 124 | 125 | /* 126 | * Return true if result of function for every node is truthy 127 | * exit early on first function falsey value 128 | */ 129 | everyDepthFirst(fn: Function): boolean { 130 | return Boolean( 131 | this._traverseDepthFirst(fn, { every: true, returnBoolean: true }) 132 | ); 133 | } 134 | 135 | findOneBreathFirst(fn: Function): NodeOrNull { 136 | const result = this._traverseBreathFirst(fn, { 137 | some: true, 138 | returnArray: true 139 | }); 140 | return firstArrayElement(result); 141 | } 142 | 143 | findOneDepthFirst(fn: Function): NodeOrNull { 144 | const result = this._traverseDepthFirst(fn, { 145 | some: true, 146 | returnArray: true 147 | }); 148 | return firstArrayElement(result); 149 | } 150 | 151 | findAllBreathFirst(fn: Function): Array { 152 | const result = this._traverseBreathFirst(fn, { 153 | every: true, 154 | returnArray: true 155 | }); 156 | return Array.isArray(result) ? result : []; 157 | } 158 | 159 | findAllDepthFirst(fn: Function): Array { 160 | const result = this._traverseDepthFirst(fn, { 161 | every: true, 162 | returnArray: true 163 | }); 164 | return Array.isArray(result) ? result : []; 165 | } 166 | 167 | flatMap(fn: Function | null = null): Array { 168 | const acc: Array = []; 169 | this._traverseBreathFirst((node: Node) => { 170 | (fn && acc.push(fn(node))) || acc.push(node); 171 | }); 172 | return acc; 173 | } 174 | 175 | flattenData(): Array { 176 | return this.flatMap(nodeData); 177 | } 178 | 179 | flattenByHeight(fn: Function | null = null): any[][] { 180 | return flattenByHeight(this.root, fn); 181 | } 182 | 183 | flattenDataByHeight(): any[][] { 184 | return this.flattenByHeight(nodeData); 185 | } 186 | 187 | /* 188 | * Get the width of each height of the tree from top to bottom 189 | */ 190 | widthsByHeight(): Array { 191 | return widthsByHeight(this.root); 192 | } 193 | 194 | /* 195 | * Root has height 0 196 | */ 197 | nodesAtHeight(height: number): Array { 198 | const counter = this.root ? [1] : []; 199 | let currentQueue = [this.root]; 200 | if (counter.length === height) { 201 | return currentQueue; 202 | } 203 | let nextQueue: NodeOrNull[] = []; 204 | do { 205 | while (currentQueue.length) { 206 | const node = currentQueue.pop(); 207 | hasChildren(node) && nextQueue.push(...node.children); 208 | } 209 | if (counter.length === height) { 210 | return nextQueue; 211 | break; 212 | } else { 213 | if (nextQueue.length) { 214 | counter[counter.length] = nextQueue.length; 215 | } 216 | [nextQueue, currentQueue] = [currentQueue, nextQueue]; 217 | } 218 | } while (currentQueue.length); 219 | return []; 220 | } 221 | 222 | countNodes(): number { 223 | return this.widthsByHeight().reduce((acc, curr) => acc + curr, 0); 224 | } 225 | 226 | maxWidth(): number { 227 | return Math.max(...this.widthsByHeight()); 228 | } 229 | 230 | height(): number { 231 | return this.root ? this.root.height() : 0; 232 | } 233 | 234 | toJson(): string { 235 | return this.root ? this.root.toJson() : ''; 236 | } 237 | } 238 | 239 | export default Tree; 240 | -------------------------------------------------------------------------------- /src/create.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NodeOrNull, 3 | CreateOptions, 4 | CreateOptionsWithCustomChildrenKey, 5 | ObjectAnyProperties 6 | } from './types'; 7 | import Tree from './Tree'; 8 | import Node from './Node'; 9 | import { filterObject } from './utils'; 10 | 11 | const ID_KEY_DEFAULT = 'id'; 12 | const PARENT_ID_KEY_DEFAULT = 'parentId'; 13 | const CHILDREN_KEY_DEFAULT = 'children'; 14 | 15 | /* 16 | * Create an array of objects representing a tree 17 | * Return array (as tree may have multiple roots). 18 | * Takes a flat array that looks like this: 19 | * [ 20 | * { 21 | * id: 'sports', 22 | * name: 'Sports', 23 | * parentId: null 24 | * }, 25 | * { 26 | * id: 'ball', 27 | * name: 'Ball', 28 | * parentId: 'sports' 29 | * }, 30 | * { 31 | * id: 'non-ball', 32 | * name: 'Non Ball', 33 | * parentId: 'sports' 34 | * }, 35 | * { 36 | * id: 'tennis', 37 | * name: 'Tennis', 38 | * parentId: 'ball' 39 | * } 40 | * ]; 41 | * Returns a tree array that looks like this 42 | * [ 43 | * { 44 | * "id": "sports", 45 | * "name": "Sports", 46 | * "parentId": null, 47 | * "children": [ 48 | * { 49 | * "id": "ball", 50 | * "name": "Ball", 51 | * "parentId": "sports", 52 | * "children": [ 53 | * { 54 | * "id": "tennis", 55 | * "name": "Tennis", 56 | * "parentId": "ball", 57 | * "children": [] 58 | * } 59 | * ] 60 | * }, 61 | * { 62 | * "id": "non-ball", 63 | * "name": "Non Ball", 64 | * "parentId": "sports", 65 | * "children": [] 66 | * } 67 | * ] 68 | * } 69 | * ] 70 | */ 71 | export const createTreeArrayFromFlatArray = ( 72 | data: Array, 73 | { 74 | idKey = ID_KEY_DEFAULT, 75 | parentIdKey = PARENT_ID_KEY_DEFAULT, 76 | childrenKey = CHILDREN_KEY_DEFAULT 77 | }: CreateOptionsWithCustomChildrenKey = {} 78 | ): Array => { 79 | const treeArray: Array = []; 80 | const childrenOf = {}; 81 | data.forEach((obj: any) => { 82 | const id = obj[idKey]; 83 | const parentId = obj[parentIdKey]; 84 | // obj may have children 85 | childrenOf[id] = childrenOf[id] || []; 86 | // init obj's children 87 | obj[childrenKey] = childrenOf[id]; 88 | if (parentId) { 89 | // init obj's parent's children object 90 | childrenOf[parentId] = childrenOf[parentId] || []; 91 | // push obj into its parent's children object 92 | childrenOf[parentId].push(obj); 93 | } else { 94 | treeArray.push(obj); 95 | } 96 | }); 97 | return treeArray; 98 | }; 99 | 100 | /* 101 | * Take an object that looks like a node, and turn it into a node. 102 | * Take all properties from obj that aren't in disallowedKeys and set as 103 | * 'data' on the node. 104 | */ 105 | export const objectToNode = ( 106 | obj: object, 107 | parent: NodeOrNull = null, 108 | { 109 | idKey = ID_KEY_DEFAULT, 110 | parentIdKey = PARENT_ID_KEY_DEFAULT, 111 | childrenKey = CHILDREN_KEY_DEFAULT 112 | }: CreateOptionsWithCustomChildrenKey = {} 113 | ): Node => { 114 | const disallowedKeys = [idKey, parentIdKey, childrenKey]; 115 | const data = filterObject(obj, { disallowedKeys }); 116 | if (parent) { 117 | return parent.addChild(data, { id: obj[idKey] }); 118 | } else { 119 | return new Node(data, { id: obj[idKey] }); 120 | } 121 | }; 122 | 123 | /* 124 | * Create a node for each element in an array, then recursively create child nodes 125 | */ 126 | export const createNodes = ( 127 | data: Array, 128 | parentNode: NodeOrNull = null, 129 | opts: CreateOptionsWithCustomChildrenKey = {} 130 | ): void => { 131 | if (!data.length) { 132 | return; 133 | } 134 | const { childrenKey = CHILDREN_KEY_DEFAULT } = opts; 135 | data.forEach(obj => { 136 | const node = objectToNode(obj, parentNode, opts); 137 | // create all the nodes for the children of this node, with this node as parent 138 | createNodes(obj[childrenKey], node, opts); 139 | }); 140 | }; 141 | 142 | /* 143 | * Tree array to supply (example): 144 | * [ 145 | * { 146 | * "id": "sports", 147 | * "name": "Sports", 148 | * "parentId": null, 149 | * "children": [ 150 | * { 151 | * "id": "ball", 152 | * "name": "Ball", 153 | * "parentId": "sports", 154 | * "children": [ 155 | * { 156 | * "id": "tennis", 157 | * "name": "Tennis", 158 | * "parentId": "ball", 159 | * "children": [] 160 | * } 161 | * ] 162 | * }, 163 | * { 164 | * "id": "non-ball", 165 | * "name": "Non Ball", 166 | * "parentId": "sports", 167 | * "children": [] 168 | * } 169 | * ] 170 | * } 171 | * ] 172 | * Return a Tree instance 173 | */ 174 | export const createTreeFromTreeArray = ( 175 | data: Array, 176 | opts: CreateOptionsWithCustomChildrenKey = {} 177 | ): Tree => { 178 | if (!data.length) { 179 | return new Tree(); 180 | } else if (data.length > 1) { 181 | // TODO: add this feature 182 | throw new Error( 183 | 'Converting an array to tree only accepts an array with 0 or 1 node currently' 184 | ); 185 | } 186 | const { childrenKey = CHILDREN_KEY_DEFAULT } = opts; 187 | const rootObj = data[0]; 188 | const root = objectToNode(rootObj, null, opts); 189 | const tree = new Tree(root); 190 | createNodes(rootObj[childrenKey], root, opts); 191 | return tree; 192 | }; 193 | 194 | /* 195 | * Map the supplied array of objects to what is required required for Node creation 196 | */ 197 | const mapFlatArray = ( 198 | data: Array, 199 | { idKey, parentIdKey }: CreateOptions = {} 200 | ): Array => { 201 | if (idKey || parentIdKey) { 202 | const disallowedKeys = [ 203 | ...(idKey ? [idKey] : []), 204 | ...(parentIdKey ? [parentIdKey] : []) 205 | ]; 206 | return data.map(obj => { 207 | const newObj = filterObject(obj, { disallowedKeys }); 208 | idKey && (newObj[ID_KEY_DEFAULT] = obj[idKey]); 209 | parentIdKey && (newObj[PARENT_ID_KEY_DEFAULT] = obj[parentIdKey]); 210 | return newObj; 211 | }); 212 | } 213 | return data; 214 | }; 215 | 216 | /* 217 | * Flat array to supply example: 218 | * [ 219 | * { 220 | * id: 'sports', 221 | * name: 'Sports', 222 | * parentId: null 223 | * }, 224 | * { 225 | * id: 'ball', 226 | * name: 'Ball', 227 | * parentId: 'sports' 228 | * }, 229 | * { 230 | * id: 'non-ball', 231 | * name: 'Non Ball', 232 | * parentId: 'sports' 233 | * }, 234 | * { 235 | * id: 'tennis', 236 | * name: 'Tennis', 237 | * parentId: 'ball' 238 | * } 239 | * ]; 240 | */ 241 | export const createTreeFromFlatArray = ( 242 | data: Array, 243 | opts: CreateOptions = {} 244 | ): any => { 245 | const mappedFlatArray = mapFlatArray(data, opts); 246 | const treeArray: Array = createTreeArrayFromFlatArray( 247 | mappedFlatArray 248 | ); 249 | if (!treeArray.length) { 250 | return new Tree(); 251 | } else if ((treeArray.length = 1)) { 252 | return createTreeFromTreeArray(treeArray); 253 | } else { 254 | // TODO: add functionality 255 | throw new Error( 256 | 'Converting an array to tree only accepts an array with 0 or 1 node currently' 257 | ); 258 | } 259 | }; 260 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Tree from './Tree'; 2 | import Node from './Node'; 3 | 4 | export { Node, Tree }; 5 | export * from './utils'; 6 | export * from './create'; 7 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './index'; 2 | 3 | export type NodeOrNull = Node | null; 4 | 5 | export interface CreateOptions { 6 | idKey?: string; 7 | parentIdKey?: string; 8 | } 9 | 10 | export interface CreateOptionsWithCustomChildrenKey extends CreateOptions { 11 | childrenKey?: string; 12 | } 13 | 14 | export type SerializedNode = { 15 | data: any; 16 | children: SerializedNode[] | Node[]; 17 | id: string; 18 | parentId: string | null; 19 | }; 20 | 21 | export type ObjectAnyProperties = { 22 | [key: string]: any; 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const nodeData = (node: any): any => { 2 | return node && 'data' in node ? node.data : node; 3 | }; 4 | 5 | export const nodesData = (nodes: Array): any => { 6 | return nodes.map(nodeData); 7 | }; 8 | 9 | export const hasChildren = (node: any): boolean => { 10 | return Boolean(node && node.children && node.children.length); 11 | }; 12 | 13 | const generateChars = (length: number): string => { 14 | const random11Chars = (): string => 15 | Math.random() 16 | .toString(36) 17 | .substring(2, 15); 18 | let chars = ''; 19 | while (chars.length < length) { 20 | chars += random11Chars(); 21 | } 22 | return chars.slice(0, length); 23 | }; 24 | 25 | export const generateId = (): string => { 26 | return generateChars(36); 27 | }; 28 | 29 | export const firstArrayElement = (arr: any): any => { 30 | return Array.isArray(arr) && arr.length ? arr[0] : null; 31 | }; 32 | 33 | /* 34 | * Return a new object without properties in disallowedKeys 35 | */ 36 | export const filterObject = ( 37 | obj: object, 38 | { disallowedKeys = [] }: { disallowedKeys: Array } 39 | ): object => { 40 | const filteredObj = Object.keys(obj) 41 | .filter(key => !disallowedKeys.includes(key)) 42 | .reduce((o, key) => { 43 | o[key] = obj[key]; 44 | return o; 45 | }, {}); 46 | return filteredObj; 47 | }; 48 | -------------------------------------------------------------------------------- /src/utilsNodeTree.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Common Node and Tree utilities 3 | */ 4 | import { NodeOrNull, SerializedNode } from './types'; 5 | import { hasChildren } from './utils'; 6 | import Node from './Node'; 7 | 8 | export const nodeToJsonFormatter = (node: Node): SerializedNode => { 9 | const { parent, data, children, id } = node; 10 | const obj: SerializedNode = { 11 | data, 12 | children, 13 | id, 14 | parentId: null 15 | }; 16 | parent && (obj.parentId = parent.id); 17 | obj.children = (node.children as Node[]).map( 18 | (child: Node): SerializedNode => nodeToJsonFormatter(child) 19 | ); 20 | return obj; 21 | }; 22 | 23 | export const widthsByHeight = (node: NodeOrNull): Array => { 24 | if (node === null) { 25 | return [1]; 26 | } else { 27 | const counter = [1]; 28 | let currentQueue = [node]; 29 | let nextQueue: NodeOrNull[] = []; 30 | do { 31 | while (currentQueue.length) { 32 | const node = currentQueue.pop(); 33 | hasChildren(node) && nextQueue.push(...node.children); 34 | } 35 | if (nextQueue.length) { 36 | counter[counter.length] = nextQueue.length; 37 | } 38 | [nextQueue, currentQueue] = [currentQueue, nextQueue]; 39 | } while (currentQueue.length); 40 | return counter; 41 | } 42 | }; 43 | 44 | export const flattenByHeight = ( 45 | node: NodeOrNull, 46 | fn: Function | null = null 47 | ): any[][] => { 48 | let currentQueue = [node]; 49 | let nextQueue: NodeOrNull[] = []; 50 | const result = [[fn(node)]]; 51 | do { 52 | while (currentQueue.length) { 53 | const node = currentQueue.pop(); 54 | hasChildren(node) && nextQueue.push(...node.children); 55 | } 56 | if (nextQueue.length) { 57 | // explicit argument passing to fn to placate TypeScript 58 | if (fn) { 59 | result[result.length] = nextQueue.map(node => fn(node)); 60 | } else { 61 | result[result.length] = nextQueue; 62 | } 63 | } 64 | [nextQueue, currentQueue] = [currentQueue, nextQueue]; 65 | } while (currentQueue.length); 66 | return result; 67 | }; 68 | -------------------------------------------------------------------------------- /tests/Node.test.ts: -------------------------------------------------------------------------------- 1 | import { Node, nodeData } from '../src/index'; 2 | 3 | describe('Node', () => { 4 | describe('data', () => { 5 | test('has data property', () => { 6 | const node = new Node('x'); 7 | expect(node.data).toEqual('x'); 8 | }); 9 | }); 10 | describe('children', () => { 11 | test('has children property', () => { 12 | const node = new Node('x'); 13 | expect('children' in node).toBe(true); 14 | expect(node.children.length).toEqual(0); 15 | }); 16 | }); 17 | describe('parent', () => { 18 | test('is null be default', () => { 19 | const node = new Node('a'); 20 | expect(node.parent).toBe(null); 21 | }); 22 | test('is created on new node', () => { 23 | const node = new Node('a'); 24 | const nodeB = node.addChild('b'); 25 | expect(nodeB.parent).toBe(node); 26 | }); 27 | }); 28 | describe('addChild', () => { 29 | test('can add a child', () => { 30 | const node = new Node('a'); 31 | node.addChild('b'); 32 | expect(node.children.length).toEqual(1); 33 | expect(node.children[0].children).toEqual([]); 34 | }); 35 | test('returns node', () => { 36 | const nodeA = new Node('a'); 37 | const nodeB = nodeA.addChild('b'); 38 | expect(nodeB instanceof Node).toBe(true); 39 | expect(typeof nodeB).toBe('object'); 40 | }); 41 | }); 42 | describe('removeChildrenByData', () => { 43 | test('can remove a child', () => { 44 | const node = new Node('a'); 45 | node.addChild('b'); 46 | expect(node.children.length).toEqual(1); 47 | node.removeChildrenByData('b'); 48 | expect(node.children.length).toEqual(0); 49 | }); 50 | test('returns removed children', () => { 51 | const node = new Node('a'); 52 | node.addChild('b'); 53 | node.addChild('b'); 54 | node.addChild('c'); 55 | expect(node.children.length).toEqual(3); 56 | const result = node.removeChildrenByData('b'); 57 | expect(result.length).toBe(2); 58 | result.forEach(r => { 59 | expect(r instanceof Node).toBe(true); 60 | }); 61 | }); 62 | }); 63 | describe('removeChildrenById', () => { 64 | test('can remove a child', () => { 65 | const node = new Node('a'); 66 | const nodeB = node.addChild('b'); 67 | expect(node.children.length).toEqual(1); 68 | node.removeChildrenById(nodeB.id); 69 | expect(node.children.length).toEqual(0); 70 | }); 71 | test('returns removed children', () => { 72 | const node = new Node('a'); 73 | const nodeB = node.addChild('b'); 74 | node.addChild('b'); 75 | node.addChild('c'); 76 | expect(node.children.length).toEqual(3); 77 | const result = node.removeChildrenById(nodeB.id); 78 | expect(result.length).toBe(1); 79 | expect(result[0] instanceof Node).toBe(true); 80 | }); 81 | }); 82 | describe('removeChildren', () => { 83 | test('can remove a child', () => { 84 | const node = new Node('x'); 85 | node.addChild('b'); 86 | node.addChild('b'); 87 | node.addChild('c'); 88 | expect(node.children.length).toEqual(3); 89 | const fn = (node: Node): boolean => nodeData(node) === 'b'; 90 | node.removeChildren(fn); 91 | expect(node.children.length).toEqual(1); 92 | }); 93 | test('returns removed children', () => { 94 | const node = new Node('x'); 95 | node.addChild('b'); 96 | node.addChild('b'); 97 | node.addChild('c'); 98 | expect(node.children.length).toEqual(3); 99 | const fn = (node: Node): boolean => nodeData(node) === 'b'; 100 | const result = node.removeChildren(fn); 101 | expect(result.length).toBe(2); 102 | result.forEach(r => { 103 | expect(r instanceof Node).toBe(true); 104 | }); 105 | }); 106 | }); 107 | describe('isLeaf', () => { 108 | test('leaf no children', () => { 109 | const node = new Node('a'); 110 | const nodeB = node.addChild('b'); 111 | expect(nodeB.isLeaf()).toBe(true); 112 | }); 113 | test('root no children', () => { 114 | const node = new Node('a'); 115 | expect(node.isLeaf()).toBe(false); 116 | }); 117 | test('root 1 child', () => { 118 | const node = new Node('a'); 119 | node.addChild('b'); 120 | expect(node.isLeaf()).toBe(false); 121 | }); 122 | test('1 child', () => { 123 | const node = new Node('a'); 124 | node.addChild('b'); 125 | expect(node.isLeaf()).toBe(false); 126 | }); 127 | }); 128 | describe('hasChildren', () => { 129 | test('no children', () => { 130 | const node = new Node('a'); 131 | expect(node.hasChildren()).toBe(false); 132 | }); 133 | test('1 child', () => { 134 | const node = new Node('a'); 135 | node.addChild('b'); 136 | expect(node.hasChildren()).toBe(true); 137 | }); 138 | }); 139 | describe('toJson', () => { 140 | test('works on a single node', () => { 141 | const node = new Node('a', { id: 'id_a' }); 142 | expect(node.toJson()).toBe( 143 | '{"data":"a","children":[],"id":"id_a","parentId":null}' 144 | ); 145 | }); 146 | test('works on parent with child', () => { 147 | const node = new Node('a', { id: 'id_a' }); 148 | node.addChild('b', { id: 'id_b' }); 149 | expect(node.toJson()).toBe( 150 | '{"data":"a","children":[{"data":"b","children":[],"id":"id_b","parentId":"id_a"}],"id":"id_a","parentId":null}' 151 | ); 152 | }); 153 | test('works on parent with children', () => { 154 | const node = new Node('a', { id: 'id_a' }); 155 | node.addChild('b', { id: 'id_b' }); 156 | node.addChild('c', { id: 'id_c' }); 157 | expect(node.toJson()).toBe( 158 | '{"data":"a","children":[{"data":"b","children":[],"id":"id_b","parentId":"id_a"},{"data":"c","children":[],"id":"id_c","parentId":"id_a"}],"id":"id_a","parentId":null}' 159 | ); 160 | }); 161 | test('works on child', () => { 162 | const node = new Node('a', { id: 'id_a' }); 163 | const nodeB = node.addChild('b', { id: 'id_b' }); 164 | expect(nodeB.toJson()).toBe( 165 | '{"data":"b","children":[],"id":"id_b","parentId":"id_a"}' 166 | ); 167 | }); 168 | }); 169 | describe('depth', () => { 170 | test('node without parent should have depth 0', () => { 171 | const node = new Node(null); 172 | expect(node.depth()).toBe(0); 173 | }); 174 | test('node with parent should have depth 1', () => { 175 | const node = new Node(null); 176 | const nodeChild = node.addChild(null); 177 | expect(nodeChild.depth()).toBe(1); 178 | }); 179 | test('node with parent with parent should have depth 2', () => { 180 | const node = new Node(null); 181 | const nodeChild = node.addChild(null); 182 | const nodeChildChild = nodeChild.addChild(null); 183 | expect(nodeChildChild.depth()).toBe(2); 184 | }); 185 | }); 186 | describe('height', () => { 187 | test('node without children should have height 0', () => { 188 | const node = new Node(null); 189 | expect(node.height()).toBe(0); 190 | }); 191 | test('node with single child with no children should have height 1', () => { 192 | const node = new Node(null); 193 | node.addChild(null); 194 | expect(node.height()).toBe(1); 195 | }); 196 | test('node with single child with one child should have hieght 2', () => { 197 | const node = new Node(null); 198 | const nodeChild = node.addChild(null); 199 | nodeChild.addChild(null); 200 | expect(node.height()).toBe(2); 201 | }); 202 | test('node with multiple children with one child should have hieght 2', () => { 203 | const node = new Node(null); 204 | node.addChild(null); 205 | const nodeChildB = node.addChild(null); 206 | nodeChildB.addChild(null); 207 | expect(node.height()).toBe(2); 208 | }); 209 | }); 210 | describe('widthsByHeight', () => { 211 | test('is a function', () => { 212 | const root = new Node('x'); 213 | expect(typeof root.widthsByHeight).toEqual('function'); 214 | }); 215 | test('returns number of nodes at widest point', () => { 216 | const root = new Node(0); 217 | root.addChild(1); 218 | root.addChild(2); 219 | root.addChild(3); 220 | root.children[0].addChild(4); 221 | root.children[2].addChild(5); 222 | expect(root.widthsByHeight()).toEqual([1, 3, 2]); 223 | }); 224 | test('returns number of nodes at widest point', () => { 225 | const root = new Node(0); 226 | root.addChild(1); 227 | root.children[0].addChild(2); 228 | root.children[0].addChild(3); 229 | root.children[0].children[0].addChild(4); 230 | expect(root.widthsByHeight()).toEqual([1, 1, 2, 1]); 231 | }); 232 | }); 233 | }); 234 | describe('flattenByHeight', () => { 235 | test('node is not null', () => { 236 | const node = new Node('a'); 237 | expect(node.flattenByHeight(nodeData)).toEqual([['a']]); 238 | }); 239 | test('it works', () => { 240 | const nodeA = new Node('a'); 241 | nodeA.addChild('b'); 242 | const nodeC = nodeA.addChild('c'); 243 | nodeC.addChild('d'); 244 | expect(nodeA.flattenByHeight(nodeData)).toEqual([['a'], ['b', 'c'], ['d']]); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /tests/Tree.test.ts: -------------------------------------------------------------------------------- 1 | import { Tree, Node, nodesData, nodeData } from '../src'; 2 | import { NodeOrNull } from '../src/types'; 3 | 4 | describe('Tree', () => { 5 | describe('root', () => { 6 | test('can get root', () => { 7 | const root = new Node('a'); 8 | const tree = new Tree(root); 9 | expect(tree.root.data).toBe('a'); 10 | }); 11 | test('is null if no root node passed', () => { 12 | const tree = new Tree(); 13 | expect(tree.root).toEqual(null); 14 | }); 15 | }); 16 | describe('traverseBreathFirst', () => { 17 | test('it works', () => { 18 | const values: Array = []; 19 | const tree = new Tree(); 20 | tree.root = new Node(1); 21 | tree.root.addChild(2); 22 | tree.root.addChild(3); 23 | tree.root.children[0].addChild(4); 24 | tree.traverseBreathFirst((node: Node) => { 25 | values.push(node); 26 | }); 27 | expect(nodesData(values)).toEqual([1, 2, 3, 4]); 28 | }); 29 | }); 30 | describe('traverseDepthFirst', () => { 31 | test('it works', () => { 32 | const values: Array = []; 33 | const tree = new Tree(); 34 | tree.root = new Node(1); 35 | tree.root.addChild(2); 36 | tree.root.addChild(4); 37 | tree.root.children[0].addChild(3); 38 | tree.traverseDepthFirst((node: Node) => { 39 | values.push(node); 40 | }); 41 | expect(nodesData(values)).toEqual([1, 2, 3, 4]); 42 | }); 43 | }); 44 | describe('flatMap', () => { 45 | test('it works', () => { 46 | const tree = new Tree(); 47 | tree.root = new Node(1); 48 | tree.root.addChild(2); 49 | tree.root.addChild(3); 50 | tree.root.children[0].addChild(4); 51 | expect(tree.flatMap(nodeData)).toEqual([1, 2, 3, 4]); 52 | }); 53 | }); 54 | describe('flattenByHeight', () => { 55 | test('root is null', () => { 56 | const tree = new Tree(); 57 | expect(tree.flattenByHeight(nodeData)).toEqual([[null]]); 58 | }); 59 | test('root is not null', () => { 60 | const node = new Node('a'); 61 | const tree = new Tree(node); 62 | expect(tree.flattenByHeight(nodeData)).toEqual([['a']]); 63 | }); 64 | test('it works', () => { 65 | const nodeA = new Node('a'); 66 | const tree = new Tree(nodeA); 67 | nodeA.addChild('b'); 68 | const nodeC = nodeA.addChild('c'); 69 | nodeC.addChild('d'); 70 | expect(tree.flattenByHeight(nodeData)).toEqual([ 71 | ['a'], 72 | ['b', 'c'], 73 | ['d'] 74 | ]); 75 | }); 76 | }); 77 | describe('flattenDataByHeight', () => { 78 | test('root is null', () => { 79 | const tree = new Tree(); 80 | expect(tree.flattenDataByHeight()).toEqual([[null]]); 81 | }); 82 | test('root is not null', () => { 83 | const node = new Node('a'); 84 | const tree = new Tree(node); 85 | expect(tree.flattenDataByHeight()).toEqual([['a']]); 86 | }); 87 | test('it works', () => { 88 | const nodeA = new Node('a'); 89 | const tree = new Tree(nodeA); 90 | nodeA.addChild('b'); 91 | const nodeC = nodeA.addChild('c'); 92 | nodeC.addChild('d'); 93 | expect(tree.flattenDataByHeight()).toEqual([['a'], ['b', 'c'], ['d']]); 94 | }); 95 | }); 96 | describe('flattenData', () => { 97 | test('it works', () => { 98 | const tree = new Tree(); 99 | tree.root = new Node(1); 100 | tree.root.addChild(2); 101 | tree.root.addChild(3); 102 | tree.root.children[0].addChild(4); 103 | expect(tree.flattenData()).toEqual([1, 2, 3, 4]); 104 | }); 105 | }); 106 | describe('someBreathFirst', () => { 107 | describe('returns true', () => { 108 | test('1 node null', () => { 109 | const tree = new Tree(); 110 | const fn = (): boolean => true; 111 | expect(tree.someBreathFirst(fn)).toBe(true); 112 | }); 113 | test('1 node', () => { 114 | const root = new Node('x'); 115 | const tree = new Tree(root); 116 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'x'; 117 | expect(tree.someBreathFirst(fn)).toBe(true); 118 | }); 119 | test('3 nodes', () => { 120 | const root = new Node('x'); 121 | const tree = new Tree(root); 122 | root.addChild('y'); 123 | root.addChild('z'); 124 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'x'; 125 | expect(tree.someBreathFirst(fn)).toBe(true); 126 | }); 127 | test('3 nodes fn only called once', () => { 128 | const root = new Node('x'); 129 | const tree = new Tree(root); 130 | root.addChild('y'); 131 | root.addChild('z'); 132 | const _fn = (node: NodeOrNull): boolean => nodeData(node) === 'x'; 133 | const fn = jest.fn(_fn); 134 | expect(tree.someBreathFirst(fn)).toBe(true); 135 | expect(fn.mock.calls.length).toBe(1); 136 | }); 137 | }); 138 | describe('returns false', () => { 139 | test('1 node null', () => { 140 | const tree = new Tree(); 141 | const fn = (): boolean => false; 142 | expect(tree.someBreathFirst(fn)).toBe(false); 143 | }); 144 | test('1 node', () => { 145 | const root = new Node('x'); 146 | const tree = new Tree(root); 147 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'a'; 148 | expect(tree.someBreathFirst(fn)).toBe(false); 149 | }); 150 | test('3 nodes', () => { 151 | const root = new Node('x'); 152 | const tree = new Tree(root); 153 | root.addChild('y'); 154 | root.addChild('z'); 155 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'a'; 156 | expect(tree.someBreathFirst(fn)).toBe(false); 157 | }); 158 | test('3 nodes fn called correct number of times', () => { 159 | const root = new Node('x'); 160 | const tree = new Tree(root); 161 | root.addChild('y'); 162 | root.addChild('z'); 163 | const _fn = (node: NodeOrNull): boolean => nodeData(node) === 'a'; 164 | const fn = jest.fn(_fn); 165 | expect(tree.someBreathFirst(fn)).toBe(false); 166 | expect(fn.mock.calls.length).toBe(3); 167 | }); 168 | }); 169 | }); 170 | describe('someDepthFirst', () => { 171 | describe('returns true', () => { 172 | test('1 node null', () => { 173 | const tree = new Tree(); 174 | const fn = (): boolean => true; 175 | expect(tree.someDepthFirst(fn)).toBe(true); 176 | }); 177 | test('1 node', () => { 178 | const root = new Node('x'); 179 | const tree = new Tree(root); 180 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'x'; 181 | expect(tree.someDepthFirst(fn)).toBe(true); 182 | }); 183 | test('3 nodes', () => { 184 | const root = new Node('x'); 185 | const tree = new Tree(root); 186 | root.addChild('y'); 187 | root.addChild('z'); 188 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'x'; 189 | expect(tree.someDepthFirst(fn)).toBe(true); 190 | }); 191 | test('3 nodes fn only called once', () => { 192 | const root = new Node('x'); 193 | const tree = new Tree(root); 194 | root.addChild('y'); 195 | root.addChild('z'); 196 | const _fn = (node: NodeOrNull): boolean => nodeData(node) === 'x'; 197 | const fn = jest.fn(_fn); 198 | expect(tree.someDepthFirst(fn)).toBe(true); 199 | expect(fn.mock.calls.length).toBe(1); 200 | }); 201 | }); 202 | describe('returns false', () => { 203 | test('1 node null', () => { 204 | const tree = new Tree(); 205 | const fn = (): boolean => false; 206 | expect(tree.someDepthFirst(fn)).toBe(false); 207 | }); 208 | test('1 node', () => { 209 | const root = new Node('x'); 210 | const tree = new Tree(root); 211 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'a'; 212 | expect(tree.someDepthFirst(fn)).toBe(false); 213 | }); 214 | test('3 nodes', () => { 215 | const root = new Node('x'); 216 | const tree = new Tree(root); 217 | root.addChild('y'); 218 | root.addChild('z'); 219 | const fn = (node: NodeOrNull): boolean => nodeData(node) === 'a'; 220 | expect(tree.someDepthFirst(fn)).toBe(false); 221 | }); 222 | test('3 nodes fn called correct number of times', () => { 223 | const root = new Node('x'); 224 | const tree = new Tree(root); 225 | root.addChild('y'); 226 | root.addChild('z'); 227 | const _fn = (node: NodeOrNull): boolean => nodeData(node) === 'a'; 228 | const fn = jest.fn(_fn); 229 | expect(tree.someDepthFirst(fn)).toBe(false); 230 | expect(fn.mock.calls.length).toBe(3); 231 | }); 232 | }); 233 | }); 234 | describe('everyBreathFirst', () => { 235 | describe('returns true', () => { 236 | test('1 node null', () => { 237 | const tree = new Tree(); 238 | expect(tree.everyBreathFirst((node: Node) => node === null)).toBe(true); 239 | }); 240 | test('1 node', () => { 241 | const root = new Node('x'); 242 | const tree = new Tree(root); 243 | expect( 244 | tree.everyBreathFirst((node: Node) => nodeData(node) === 'x') 245 | ).toBe(true); 246 | }); 247 | test('3 nodes', () => { 248 | const root = new Node('x'); 249 | const tree = new Tree(root); 250 | root.addChild('y'); 251 | root.addChild('z'); 252 | expect( 253 | tree.everyBreathFirst((node: Node) => 254 | ['x', 'y', 'z'].includes(nodeData(node)) 255 | ) 256 | ).toBe(true); 257 | }); 258 | test('3 nodes fn called correct number of times', () => { 259 | const root = new Node('x'); 260 | const tree = new Tree(root); 261 | root.addChild('y'); 262 | root.addChild('z'); 263 | const _fn = (node: Node): boolean => 264 | ['x', 'y', 'z'].includes(nodeData(node)); 265 | const fn = jest.fn(_fn); 266 | expect(tree.everyBreathFirst(fn)).toBe(true); 267 | expect(fn.mock.calls.length).toBe(3); 268 | }); 269 | }); 270 | describe('returns false', () => { 271 | test('1 node null', () => { 272 | const tree = new Tree(); 273 | expect( 274 | tree.everyBreathFirst((node: Node) => nodeData(node) === 'x') 275 | ).toBe(false); 276 | }); 277 | test('1 node', () => { 278 | const root = new Node('x'); 279 | const tree = new Tree(root); 280 | expect( 281 | tree.everyBreathFirst((node: Node) => nodeData(node) === 'a') 282 | ).toBe(false); 283 | }); 284 | test('3 nodes', () => { 285 | const root = new Node('x'); 286 | const tree = new Tree(root); 287 | root.addChild('y'); 288 | root.addChild('z'); 289 | expect( 290 | tree.everyBreathFirst((node: Node) => nodeData(node) === 'x') 291 | ).toBe(false); 292 | }); 293 | test('breaks early', () => { 294 | const root = new Node('x'); 295 | const tree = new Tree(root); 296 | root.addChild('y'); 297 | const fn = jest.fn(); 298 | expect(tree.everyBreathFirst(fn)).toBe(false); 299 | expect(fn.mock.calls.length).toBe(1); 300 | }); 301 | }); 302 | }); 303 | describe('everyDepthFirst', () => { 304 | describe('returns true', () => { 305 | test('1 node', () => { 306 | const root = new Node('x'); 307 | const tree = new Tree(root); 308 | expect( 309 | tree.everyDepthFirst((node: Node) => nodeData(node) === 'x') 310 | ).toBe(true); 311 | }); 312 | test('3 nodes', () => { 313 | const root = new Node('x'); 314 | const tree = new Tree(root); 315 | root.addChild('y'); 316 | root.addChild('z'); 317 | expect( 318 | tree.everyDepthFirst((node: Node) => 319 | ['x', 'y', 'z'].includes(nodeData(node)) 320 | ) 321 | ).toBe(true); 322 | }); 323 | test('3 nodes fn called correct number of times', () => { 324 | const root = new Node('x'); 325 | const tree = new Tree(root); 326 | root.addChild('y'); 327 | root.addChild('z'); 328 | const _fn = (node: Node): boolean => 329 | ['x', 'y', 'z'].includes(nodeData(node)); 330 | const fn = jest.fn(_fn); 331 | expect(tree.everyDepthFirst(fn)).toBe(true); 332 | expect(fn.mock.calls.length).toBe(3); 333 | }); 334 | }); 335 | describe('returns false', () => { 336 | test('1 node', () => { 337 | const root = new Node('x'); 338 | const tree = new Tree(root); 339 | expect( 340 | tree.everyDepthFirst((node: Node) => nodeData(node) === 'a') 341 | ).toBe(false); 342 | }); 343 | test('3 nodes', () => { 344 | const root = new Node('x'); 345 | const tree = new Tree(root); 346 | root.addChild('y'); 347 | root.addChild('z'); 348 | expect( 349 | tree.everyDepthFirst((node: Node) => nodeData(node) === 'x') 350 | ).toBe(false); 351 | }); 352 | test('breaks early', () => { 353 | const root = new Node('x'); 354 | const tree = new Tree(root); 355 | root.addChild('y'); 356 | const fn = jest.fn(); 357 | expect(tree.everyDepthFirst(fn)).toBe(false); 358 | expect(fn.mock.calls.length).toBe(1); 359 | }); 360 | }); 361 | }); 362 | describe('findOneBreathFirst', () => { 363 | describe('returns one node', () => { 364 | test('1 node', () => { 365 | const root = new Node('x'); 366 | const tree = new Tree(root); 367 | expect( 368 | tree.findOneBreathFirst((node: Node) => nodeData(node) === 'x') 369 | ).toBe(root); 370 | }); 371 | test('3 nodes', () => { 372 | const root = new Node('x'); 373 | const tree = new Tree(root); 374 | root.addChild('y'); 375 | root.addChild('z'); 376 | expect( 377 | tree.findOneBreathFirst((node: Node) => 378 | ['x', 'y', 'z'].includes(nodeData(node)) 379 | ) 380 | ).toEqual(root); 381 | }); 382 | test('3 nodes fn called correct number of times', () => { 383 | const root = new Node('x'); 384 | const tree = new Tree(root); 385 | root.addChild('y'); 386 | root.addChild('z'); 387 | const _fn = (node: Node): boolean => 388 | ['x', 'y', 'z'].includes(nodeData(node)); 389 | const fn = jest.fn(_fn); 390 | expect(tree.findOneBreathFirst(fn)).toEqual(root); 391 | expect(fn.mock.calls.length).toBe(1); 392 | }); 393 | }); 394 | describe('returns null', () => { 395 | test('1 node', () => { 396 | const root = new Node('x'); 397 | const tree = new Tree(root); 398 | expect( 399 | tree.findOneBreathFirst((node: Node) => nodeData(node) === 'a') 400 | ).toBe(null); 401 | }); 402 | test('3 nodes', () => { 403 | const root = new Node('x'); 404 | const tree = new Tree(root); 405 | root.addChild('y'); 406 | root.addChild('z'); 407 | expect( 408 | tree.findOneBreathFirst((node: Node) => nodeData(node) === 'x') 409 | ).toBe(root); 410 | }); 411 | test('breaks early', () => { 412 | const root = new Node('x'); 413 | const tree = new Tree(root); 414 | root.addChild('y'); 415 | const fn = jest.fn(); 416 | expect(tree.findOneBreathFirst(fn)).toBe(null); 417 | expect(fn.mock.calls.length).toBe(2); 418 | }); 419 | }); 420 | }); 421 | describe('findOneDepthFirst', () => { 422 | describe('returns one node', () => { 423 | test('1 node', () => { 424 | const root = new Node('x'); 425 | const tree = new Tree(root); 426 | expect( 427 | tree.findOneDepthFirst((node: Node) => nodeData(node) === 'x') 428 | ).toBe(root); 429 | }); 430 | test('3 nodes', () => { 431 | const root = new Node('x'); 432 | const tree = new Tree(root); 433 | root.addChild('y'); 434 | root.addChild('z'); 435 | expect( 436 | tree.findOneDepthFirst((node: Node) => 437 | ['x', 'y', 'z'].includes(nodeData(node)) 438 | ) 439 | ).toEqual(root); 440 | }); 441 | test('3 nodes fn called correct number of times', () => { 442 | const root = new Node('x'); 443 | const tree = new Tree(root); 444 | root.addChild('y'); 445 | root.addChild('z'); 446 | const _fn = (node: Node): boolean => 447 | ['x', 'y', 'z'].includes(nodeData(node)); 448 | const fn = jest.fn(_fn); 449 | expect(tree.findOneDepthFirst(fn)).toEqual(root); 450 | expect(fn.mock.calls.length).toBe(1); 451 | }); 452 | }); 453 | describe('returns null', () => { 454 | test('1 node', () => { 455 | const root = new Node('x'); 456 | const tree = new Tree(root); 457 | expect( 458 | tree.findOneDepthFirst((node: Node) => nodeData(node) === 'a') 459 | ).toBe(null); 460 | }); 461 | test('3 nodes', () => { 462 | const root = new Node('x'); 463 | const tree = new Tree(root); 464 | root.addChild('y'); 465 | root.addChild('z'); 466 | expect( 467 | tree.findOneDepthFirst((node: Node) => nodeData(node) === 'x') 468 | ).toBe(root); 469 | }); 470 | test('breaks early', () => { 471 | const root = new Node('x'); 472 | const tree = new Tree(root); 473 | root.addChild('y'); 474 | const fn = jest.fn(); 475 | expect(tree.findOneDepthFirst(fn)).toBe(null); 476 | expect(fn.mock.calls.length).toBe(2); 477 | }); 478 | }); 479 | }); 480 | describe('findAllBreathFirst', () => { 481 | describe('returns array with nodes', () => { 482 | test('1 node null', () => { 483 | const tree = new Tree(); 484 | expect(tree.findAllBreathFirst((node: Node) => node === null)).toEqual([ 485 | null 486 | ]); 487 | }); 488 | test('1 node', () => { 489 | const root = new Node('x'); 490 | const tree = new Tree(root); 491 | expect( 492 | tree.findAllBreathFirst((node: Node) => nodeData(node) === 'x') 493 | ).toEqual([root]); 494 | }); 495 | test('3 nodes', () => { 496 | const root = new Node('a'); 497 | const tree = new Tree(root); 498 | const nodeB = root.addChild('b'); 499 | const nodeC = root.addChild('c'); 500 | const nodeD = nodeB.addChild('d'); 501 | expect( 502 | tree.findAllBreathFirst((node: Node) => 503 | ['a', 'b', 'c', 'd'].includes(nodeData(node)) 504 | ) 505 | ).toStrictEqual([root, nodeB, nodeC, nodeD]); 506 | }); 507 | }); 508 | describe('returns empty array', () => { 509 | test('1 node null', () => { 510 | const tree = new Tree(); 511 | expect( 512 | tree.findAllBreathFirst((node: Node) => nodeData(node) === 'x') 513 | ).toEqual([]); 514 | }); 515 | test('1 node', () => { 516 | const root = new Node('x'); 517 | const tree = new Tree(root); 518 | expect( 519 | tree.findAllBreathFirst((node: Node) => nodeData(node) === 'a') 520 | ).toEqual([]); 521 | }); 522 | }); 523 | }); 524 | describe('findAllDepthFirst', () => { 525 | describe('returns array with nodes', () => { 526 | test('1 node null', () => { 527 | const tree = new Tree(); 528 | expect(tree.findAllDepthFirst((node: Node) => node === null)).toEqual([ 529 | null 530 | ]); 531 | }); 532 | test('1 node', () => { 533 | const root = new Node('x'); 534 | const tree = new Tree(root); 535 | expect( 536 | tree.findAllDepthFirst((node: Node) => nodeData(node) === 'x') 537 | ).toEqual([root]); 538 | }); 539 | test('3 nodes', () => { 540 | const root = new Node('a'); 541 | const tree = new Tree(root); 542 | const nodeB = root.addChild('b'); 543 | const nodeC = root.addChild('c'); 544 | const nodeD = nodeB.addChild('d'); 545 | expect( 546 | tree.findAllDepthFirst((node: Node) => 547 | ['a', 'b', 'c', 'd'].includes(nodeData(node)) 548 | ) 549 | ).toStrictEqual([root, nodeB, nodeD, nodeC]); 550 | }); 551 | }); 552 | describe('returns empty array', () => { 553 | test('1 node null', () => { 554 | const tree = new Tree(); 555 | expect( 556 | tree.findAllDepthFirst((node: Node) => nodeData(node) === 'x') 557 | ).toEqual([]); 558 | }); 559 | test('1 node', () => { 560 | const root = new Node('x'); 561 | const tree = new Tree(root); 562 | expect( 563 | tree.findAllDepthFirst((node: Node) => nodeData(node) === 'a') 564 | ).toEqual([]); 565 | }); 566 | }); 567 | }); 568 | describe('widthsByHeight', () => { 569 | test('is a function', () => { 570 | const root = new Node('x'); 571 | const tree = new Tree(root); 572 | expect(typeof tree.widthsByHeight).toEqual('function'); 573 | }); 574 | test('root is null', () => { 575 | const tree = new Tree(); 576 | expect(tree.widthsByHeight()).toEqual([1]); 577 | }); 578 | test('returns number of nodes at widest point', () => { 579 | const root = new Node(0); 580 | const tree = new Tree(root); 581 | root.addChild(1); 582 | root.addChild(2); 583 | root.addChild(3); 584 | root.children[0].addChild(4); 585 | root.children[2].addChild(5); 586 | expect(tree.widthsByHeight()).toEqual([1, 3, 2]); 587 | }); 588 | test('returns number of nodes at widest point', () => { 589 | const root = new Node(0); 590 | const tree = new Tree(root); 591 | root.addChild(1); 592 | root.children[0].addChild(2); 593 | root.children[0].addChild(3); 594 | root.children[0].children[0].addChild(4); 595 | expect(tree.widthsByHeight()).toEqual([1, 1, 2, 1]); 596 | }); 597 | }); 598 | describe('nodesAtHeight', () => { 599 | test('root null, height 0', () => { 600 | const tree = new Tree(null); 601 | const result = tree.nodesAtHeight(0); 602 | expect(nodesData(result)).toEqual([null]); 603 | }); 604 | test('height 2', () => { 605 | const root = new Node(0); 606 | const tree = new Tree(root); 607 | root.addChild(1); 608 | root.children[0].addChild(2); 609 | root.children[0].addChild(3); 610 | root.children[0].children[0].addChild(4); 611 | const result = tree.nodesAtHeight(2); 612 | expect(nodesData(result)).toEqual([2, 3]); 613 | }); 614 | test('height 3', () => { 615 | const root = new Node(0); 616 | const tree = new Tree(root); 617 | root.addChild(1); 618 | root.children[0].addChild(2); 619 | root.children[0].addChild(3); 620 | root.children[0].children[0].addChild(4); 621 | const result = tree.nodesAtHeight(3); 622 | expect(nodesData(result)).toEqual([4]); 623 | }); 624 | test('height greater than max height', () => { 625 | const height = 4; 626 | const root = new Node(0); 627 | const tree = new Tree(root); 628 | root.addChild(1); 629 | root.children[0].addChild(2); 630 | root.children[0].addChild(3); 631 | root.children[0].children[0].addChild(4); 632 | const result = tree.nodesAtHeight(height); 633 | expect(tree.height()).toBeLessThan(height); 634 | expect(nodesData(result)).toEqual([]); 635 | }); 636 | }); 637 | describe('maxWidth', () => { 638 | test('it works', () => { 639 | const root = new Node(0); 640 | const tree = new Tree(root); 641 | root.addChild(1); 642 | root.children[0].addChild(2); 643 | root.children[0].addChild(3); 644 | root.children[0].addChild(4); 645 | root.children[0].children[0].addChild(5); 646 | expect(tree.maxWidth()).toEqual(3); 647 | }); 648 | }); 649 | describe('height', () => { 650 | test('tree with root null has height 0', () => { 651 | const tree = new Tree(); 652 | expect(tree.height()).toBe(0); 653 | }); 654 | test('tree with one node height 0', () => { 655 | const root = new Node('x'); 656 | const tree = new Tree(root); 657 | expect(tree.height()).toBe(0); 658 | }); 659 | test('tree with height 2', () => { 660 | const root = new Node(0); 661 | const tree = new Tree(root); 662 | root.addChild(1); 663 | root.children[0].addChild(2); 664 | expect(tree.height()).toBe(2); 665 | }); 666 | }); 667 | describe('countNodes', () => { 668 | test('root is null', () => { 669 | const tree = new Tree(); 670 | expect(tree.countNodes()).toEqual(1); 671 | }); 672 | test('6 nodes', () => { 673 | const root = new Node(0); 674 | const tree = new Tree(root); 675 | root.addChild(1); 676 | root.addChild(2); 677 | root.addChild(3); 678 | root.children[0].addChild(4); 679 | root.children[2].addChild(5); 680 | expect(tree.countNodes()).toEqual(6); 681 | }); 682 | }); 683 | describe('toJson', () => { 684 | test('works on a tree with root null', () => { 685 | const tree = new Tree(null); 686 | expect(tree.toJson()).toBe(''); 687 | }); 688 | test('works on a tree with single node', () => { 689 | const node = new Node('a', { id: 'id_a' }); 690 | const tree = new Tree(node); 691 | expect(tree.toJson()).toBe( 692 | '{"data":"a","children":[],"id":"id_a","parentId":null}' 693 | ); 694 | }); 695 | test('works on a tree with parent with child', () => { 696 | const node = new Node('a', { id: 'id_a' }); 697 | const tree = new Tree(node); 698 | node.addChild('b', { id: 'id_b' }); 699 | expect(tree.toJson()).toBe( 700 | '{"data":"a","children":[{"data":"b","children":[],"id":"id_b","parentId":"id_a"}],"id":"id_a","parentId":null}' 701 | ); 702 | }); 703 | test('works on a tree with parent with children', () => { 704 | const node = new Node('a', { id: 'id_a' }); 705 | const tree = new Tree(node); 706 | node.addChild('b', { id: 'id_b' }); 707 | node.addChild('c', { id: 'id_c' }); 708 | expect(tree.toJson()).toBe( 709 | '{"data":"a","children":[{"data":"b","children":[],"id":"id_b","parentId":"id_a"},{"data":"c","children":[],"id":"id_c","parentId":"id_a"}],"id":"id_a","parentId":null}' 710 | ); 711 | }); 712 | }); 713 | }); 714 | -------------------------------------------------------------------------------- /tests/create.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTreeFromFlatArray, 3 | createTreeFromTreeArray, 4 | createTreeArrayFromFlatArray, 5 | objectToNode, 6 | createNodes 7 | } from '../src/create'; 8 | import Tree from '../src/Tree'; 9 | import Node from '../src/Node'; 10 | import { ObjectAnyProperties } from '../src/types'; 11 | import { testDataComplex } from './data'; 12 | 13 | describe('objectToNode', () => { 14 | describe('no parent', () => { 15 | test('it returns node instance', () => { 16 | const result = objectToNode({ 17 | id: 'sports', 18 | name: 'Sports' 19 | }); 20 | expect(result instanceof Node).toBe(true); 21 | }); 22 | test('it returns node correctly', () => { 23 | const result = objectToNode({ 24 | id: 'sports', 25 | name: 'Sports' 26 | }); 27 | expect(result.id).toBe('sports'); 28 | expect(result.data).toEqual({ name: 'Sports' }); 29 | }); 30 | }); 31 | describe('with parent', () => { 32 | test('it returns node with parent', () => { 33 | const parentNode = new Node('a'); 34 | const result = objectToNode( 35 | { 36 | id: 'sports', 37 | name: 'Sports' 38 | }, 39 | parentNode 40 | ); 41 | expect(result.parent.data).toBe('a'); 42 | expect(result.parent instanceof Node).toBe(true); 43 | }); 44 | }); 45 | }); 46 | 47 | describe('createTreeArrayFromFlatArray', () => { 48 | describe('default options', () => { 49 | let expected: any[]; 50 | beforeAll(() => { 51 | expected = [ 52 | { 53 | id: 'sports', 54 | name: 'Sports', 55 | parentId: null, 56 | children: [ 57 | { 58 | id: 'ball', 59 | name: 'Ball', 60 | parentId: 'sports', 61 | children: [ 62 | { 63 | id: 'tennis', 64 | name: 'Tennis', 65 | parentId: 'ball', 66 | children: [] 67 | } 68 | ] 69 | }, 70 | { 71 | id: 'non-ball', 72 | name: 'Non Ball', 73 | parentId: 'sports', 74 | children: [] 75 | } 76 | ] 77 | } 78 | ]; 79 | }); 80 | test('empty array returns empty array', () => { 81 | const res = createTreeArrayFromFlatArray([]); 82 | expect(res).toEqual([]); 83 | }); 84 | test('parents before children', () => { 85 | const res = createTreeArrayFromFlatArray([ 86 | { 87 | id: 'sports', 88 | name: 'Sports', 89 | parentId: null 90 | }, 91 | { 92 | id: 'ball', 93 | name: 'Ball', 94 | parentId: 'sports' 95 | }, 96 | { 97 | id: 'non-ball', 98 | name: 'Non Ball', 99 | parentId: 'sports' 100 | }, 101 | { 102 | id: 'tennis', 103 | name: 'Tennis', 104 | parentId: 'ball' 105 | } 106 | ]); 107 | expect(JSON.stringify(res)).toEqual(JSON.stringify(expected)); 108 | }); 109 | test('children before parents', () => { 110 | const res = createTreeArrayFromFlatArray([ 111 | { 112 | id: 'tennis', 113 | name: 'Tennis', 114 | parentId: 'ball' 115 | }, 116 | { 117 | id: 'ball', 118 | name: 'Ball', 119 | parentId: 'sports' 120 | }, 121 | { 122 | id: 'non-ball', 123 | name: 'Non Ball', 124 | parentId: 'sports' 125 | }, 126 | { 127 | id: 'sports', 128 | name: 'Sports', 129 | parentId: null 130 | } 131 | ]); 132 | expect(JSON.stringify(res)).toEqual(JSON.stringify(expected)); 133 | }); 134 | }); 135 | test('custom options', () => { 136 | const res = createTreeArrayFromFlatArray( 137 | [ 138 | { 139 | _id: 'sports', 140 | _name: 'Sports', 141 | _parentId: null 142 | }, 143 | { 144 | _id: 'ball', 145 | _name: 'Ball', 146 | _parentId: 'sports' 147 | }, 148 | { 149 | _id: 'non-ball', 150 | _name: 'Non Ball', 151 | _parentId: 'sports' 152 | }, 153 | { 154 | _id: 'tennis', 155 | _name: 'Tennis', 156 | _parentId: 'ball' 157 | } 158 | ], 159 | { idKey: '_id', parentIdKey: '_parentId' } 160 | ); 161 | const expected: any[] = [ 162 | { 163 | _id: 'sports', 164 | _name: 'Sports', 165 | _parentId: null, 166 | children: [ 167 | { 168 | _id: 'ball', 169 | _name: 'Ball', 170 | _parentId: 'sports', 171 | children: [ 172 | { 173 | _id: 'tennis', 174 | _name: 'Tennis', 175 | _parentId: 'ball', 176 | children: [] 177 | } 178 | ] 179 | }, 180 | { 181 | _id: 'non-ball', 182 | _name: 'Non Ball', 183 | _parentId: 'sports', 184 | children: [] 185 | } 186 | ] 187 | } 188 | ]; 189 | expect(JSON.stringify(res)).toEqual(JSON.stringify(expected)); 190 | }); 191 | }); 192 | 193 | describe('createTreeFromFlatArray', () => { 194 | describe('default object properties', () => { 195 | describe('simple tree', () => { 196 | test('empty array returns a tree whose root is null', () => { 197 | const tree = createTreeFromFlatArray([]); 198 | expect(tree instanceof Tree).toBe(true); 199 | expect(tree.root).toBe(null); 200 | }); 201 | test('returns a tree', () => { 202 | const tree = createTreeFromFlatArray([ 203 | { 204 | id: 'abc', 205 | name: 'World', 206 | parentId: null 207 | } 208 | ]); 209 | expect(tree instanceof Tree).toEqual(true); 210 | }); 211 | test('from array with 1 root', () => { 212 | const list: Array = [ 213 | { 214 | id: 'abc', 215 | name: 'World', 216 | parentId: null 217 | } 218 | ]; 219 | const result = createTreeFromFlatArray(list); 220 | expect(result.root.data.name).toBe(list[0].name); 221 | expect(result.root.id).toBe(list[0].id); 222 | expect(result.root.children).toEqual([]); 223 | }); 224 | test('from array with 1 root and 1 child', () => { 225 | const list: Array = [ 226 | { 227 | id: 'abc', 228 | name: 'World', 229 | parentId: null 230 | }, 231 | { 232 | id: 'def', 233 | name: 'World', 234 | parentId: 'abc' 235 | } 236 | ]; 237 | const result = createTreeFromFlatArray(list); 238 | const listRoot = list[0]; 239 | const listChild = list[1]; 240 | const treeRoot = result.root; 241 | const treeChild = result.root.children[0]; 242 | expect(treeRoot.data.name).toBe(listRoot.name); 243 | expect(treeRoot.id).toBe(listRoot.id); 244 | expect(treeChild.data.name).toEqual(listChild.name); 245 | expect(treeChild.id).toEqual(listChild.id); 246 | }); 247 | test('from array with 1 root and 1 child', () => { 248 | const list: Array = [ 249 | { 250 | id: 'abc', 251 | name: 'World', 252 | parentId: null 253 | }, 254 | { 255 | id: 'def', 256 | name: 'Australia', 257 | parentId: 'abc' 258 | } 259 | ]; 260 | const result = createTreeFromFlatArray(list); 261 | const listRoot = list[0]; 262 | const listChild = list[1]; 263 | const treeRoot = result.root; 264 | const treeChild = result.root.children[0]; 265 | expect(treeRoot.data.name).toBe(listRoot.name); 266 | expect(treeRoot.id).toBe(listRoot.id); 267 | expect(treeChild.data.name).toEqual(listChild.name); 268 | expect(treeChild.id).toEqual(listChild.id); 269 | }); 270 | test('from array with 1 root and 1 child with 1 child', () => { 271 | const list: Array = [ 272 | { 273 | id: 'sports', 274 | name: 'Sports', 275 | parentId: null 276 | }, 277 | { 278 | id: 'ball', 279 | name: 'Ball', 280 | parentId: 'sports' 281 | }, 282 | { 283 | id: 'non-ball', 284 | name: 'Non Ball', 285 | parentId: 'sports' 286 | }, 287 | { 288 | id: 'tennis', 289 | name: 'Tennis', 290 | parentId: 'ball' 291 | } 292 | ]; 293 | const result = createTreeFromFlatArray(list); 294 | const listRoot = list[0]; 295 | const treeRoot = result.root; 296 | expect(treeRoot.data.name).toBe(listRoot.name); 297 | expect(treeRoot.id).toBe(listRoot.id); 298 | const listChildA = list[1]; 299 | const treeChildA = result.root.children[0]; 300 | expect(treeChildA.data.name).toEqual(listChildA.name); 301 | expect(treeChildA.id).toEqual(listChildA.id); 302 | const listChildB = list[2]; 303 | const treeChildB = result.root.children[1]; 304 | expect(treeChildB.data.name).toEqual(listChildB.name); 305 | expect(treeChildB.id).toEqual(listChildB.id); 306 | const listChildAChild = list[3]; 307 | const treeChildAChild = result.root.children[0].children[0]; 308 | expect(treeChildAChild.data.name).toEqual(listChildAChild.name); 309 | expect(treeChildAChild.id).toEqual(listChildAChild.id); 310 | }); 311 | }); 312 | describe('complex tree', () => { 313 | test('tree has correct number of nodes', () => { 314 | const tree = createTreeFromFlatArray(testDataComplex); 315 | expect(tree.countNodes()).toBe(testDataComplex.length); 316 | }); 317 | test('tree has correct height', () => { 318 | const tree = createTreeFromFlatArray(testDataComplex); 319 | expect(tree.height()).toBe(3); 320 | }); 321 | test('tree has correct widths', () => { 322 | const tree = createTreeFromFlatArray(testDataComplex); 323 | expect(tree.widthsByHeight()).toEqual([1, 2, 5, 2]); 324 | }); 325 | test('tree has the expected minimum number of children at height 2', () => { 326 | const tree = createTreeFromFlatArray(testDataComplex); 327 | expect(tree.root.children.length).toBe(2); 328 | }); 329 | test('tree is correct', () => { 330 | const tree = createTreeFromFlatArray(testDataComplex); 331 | expect(tree.toJson()).toBe( 332 | '{"data":{"name":"World"},"children":[{"data":{"name":"North America"},"children":[{"data":{"name":"Mexico"},"children":[],"id":"id_mex","parentId":"id_na"},{"data":{"name":"Canada"},"children":[],"id":"id_can","parentId":"id_na"},{"data":{"name":"USA"},"children":[{"data":{"name":"Ohio"},"children":[],"id":"id_oh","parentId":"id_usa"},{"data":{"name":"North Dakota"},"children":[],"id":"id_nd","parentId":"id_usa"}],"id":"id_usa","parentId":"id_na"}],"id":"id_na","parentId":"id_world"},{"data":{"name":"Pacific"},"children":[{"data":{"name":"Australia"},"children":[],"id":"id_aus","parentId":"id_pac"},{"data":{"name":"New Zealand"},"children":[],"id":"id_nz","parentId":"id_pac"}],"id":"id_pac","parentId":"id_world"}],"id":"id_world","parentId":null}' 333 | ); 334 | }); 335 | }); 336 | }); 337 | describe('custom object properties', () => { 338 | test('from array with 1 root', () => { 339 | const list: Array = [ 340 | { 341 | _id: 'abc', 342 | name: 'World', 343 | _parentId: null 344 | } 345 | ]; 346 | const opts = { 347 | idKey: '_id', 348 | parentIdKey: '_parentId' 349 | }; 350 | const result = createTreeFromFlatArray(list, opts); 351 | expect(result.root.data.name).toBe(list[0].name); 352 | expect(result.root.id).toBe(list[0]._id); 353 | expect(result.root.children).toEqual([]); 354 | }); 355 | test('from array with 1 root and 1 child', () => { 356 | const list: Array = [ 357 | { 358 | _id: 'abc', 359 | name: 'World', 360 | _parentId: null 361 | }, 362 | { 363 | _id: 'def', 364 | name: 'World', 365 | _parentId: 'abc' 366 | } 367 | ]; 368 | const opts = { 369 | idKey: '_id', 370 | parentIdKey: '_parentId' 371 | }; 372 | const result = createTreeFromFlatArray(list, opts); 373 | const listRoot = list[0]; 374 | const listChild = list[1]; 375 | const treeRoot = result.root; 376 | const treeChild = result.root.children[0]; 377 | expect(treeRoot.data.name).toBe(listRoot.name); 378 | expect(treeRoot.id).toBe(listRoot._id); 379 | expect(treeChild.data.name).toEqual(listChild.name); 380 | expect(treeChild.id).toEqual(listChild._id); 381 | }); 382 | }); 383 | describe('createTreeFromTreeArray', () => { 384 | describe('default options', () => { 385 | test('empty array returns a tree with root null', () => { 386 | const result = createTreeFromTreeArray([]); 387 | expect(result instanceof Tree).toBe(true); 388 | expect(result.root).toBe(null); 389 | }); 390 | test('returns Tree instance', () => { 391 | const arr: any = [ 392 | { 393 | id: 'sports', 394 | name: 'Sports', 395 | parentId: null, 396 | children: [] 397 | } 398 | ]; 399 | expect(createTreeFromTreeArray(arr) instanceof Tree).toBe(true); 400 | }); 401 | test('returns correct tree', () => { 402 | const arr: any = [ 403 | { 404 | id: 'sports', 405 | name: 'Sports', 406 | parentId: null, 407 | children: [ 408 | { 409 | id: 'ball', 410 | name: 'Ball', 411 | parentId: 'sports', 412 | children: [ 413 | { 414 | id: 'tennis', 415 | name: 'Tennis', 416 | parentId: 'ball', 417 | children: [] 418 | } 419 | ] 420 | }, 421 | { 422 | id: 'non-ball', 423 | name: 'Non Ball', 424 | parentId: 'sports', 425 | children: [] 426 | } 427 | ] 428 | } 429 | ]; 430 | const tree = createTreeFromTreeArray(arr); 431 | expect(tree.root.data).toEqual({ name: 'Sports' }); 432 | expect(tree.root.children[0] instanceof Node).toBe(true); 433 | expect(tree.root.children[0].data).toEqual({ name: 'Ball' }); 434 | expect(tree.root.children[0].children[0].data).toEqual({ 435 | name: 'Tennis' 436 | }); 437 | expect(tree.root.children[1] instanceof Node).toBe(true); 438 | expect(tree.root.children[1].data).toEqual({ name: 'Non Ball' }); 439 | }); 440 | }); 441 | describe('custom options', () => { 442 | test('returns correct tree', () => { 443 | const arr: any = [ 444 | { 445 | _id: 'sports', 446 | name: 'Sports', 447 | _parentId: null, 448 | _children: [ 449 | { 450 | _id: 'ball', 451 | name: 'Ball', 452 | _parentId: 'sports', 453 | _children: [ 454 | { 455 | _id: 'tennis', 456 | name: 'Tennis', 457 | _parentId: 'ball', 458 | _children: [] 459 | } 460 | ] 461 | }, 462 | { 463 | _id: 'non-ball', 464 | name: 'Non Ball', 465 | _parentId: 'sports', 466 | _children: [] 467 | } 468 | ] 469 | } 470 | ]; 471 | const opts = { 472 | idKey: '_id', 473 | parentIdKey: '_parentId', 474 | childrenKey: '_children' 475 | }; 476 | const tree = createTreeFromTreeArray(arr, opts); 477 | expect(tree.root.data).toEqual({ name: 'Sports' }); 478 | expect(tree.root.children[0].data).toEqual({ name: 'Ball' }); 479 | expect(tree.root.children[0].id).toEqual('ball'); 480 | expect(tree.root.children[0].parent.data).toEqual({ name: 'Sports' }); 481 | expect(tree.root.children[0].children[0].data).toEqual({ 482 | name: 'Tennis' 483 | }); 484 | expect(tree.root.children[0].children[0].id).toEqual('tennis'); 485 | expect(tree.root.children[0].children[0].parent.data).toEqual({ 486 | name: 'Ball' 487 | }); 488 | expect(tree.root.children[1].data).toEqual({ name: 'Non Ball' }); 489 | expect(tree.root.children[1].id).toEqual('non-ball'); 490 | expect(tree.root.children[1].parent.data).toEqual({ name: 'Sports' }); 491 | }); 492 | }); 493 | }); 494 | }); 495 | 496 | describe('create nodes', () => { 497 | let expected: string; 498 | beforeEach(() => { 499 | expected = 500 | '{"data":"parent","children":[{"data":{"name":"Sports"},"children":[{"data":{"name":"Ball"},"children":[{"data":{"name":"Tennis"},"children":[],"id":"tennis","parentId":"ball"}],"id":"ball","parentId":"sports"},{"data":{"name":"Non Ball"},"children":[],"id":"non-ball","parentId":"sports"}],"id":"sports","parentId":"id_parent"}],"id":"id_parent","parentId":null}'; 501 | }); 502 | describe('default options', () => { 503 | test('creates nodes', () => { 504 | const parentNode = new Node('parent', { id: 'id_parent' }); 505 | const arr: any = [ 506 | { 507 | id: 'sports', 508 | name: 'Sports', 509 | parentId: null, 510 | children: [ 511 | { 512 | id: 'ball', 513 | name: 'Ball', 514 | parentId: 'sports', 515 | children: [ 516 | { 517 | id: 'tennis', 518 | name: 'Tennis', 519 | parentId: 'ball', 520 | children: [] 521 | } 522 | ] 523 | }, 524 | { 525 | id: 'non-ball', 526 | name: 'Non Ball', 527 | parentId: 'sports', 528 | children: [] 529 | } 530 | ] 531 | } 532 | ]; 533 | createNodes(arr, parentNode); 534 | expect(parentNode.toJson()).toEqual(expected); 535 | }); 536 | }); 537 | describe('custom options', () => { 538 | test('creates nodes', () => { 539 | const parentNode = new Node('parent', { id: 'id_parent' }); 540 | const arr: any = [ 541 | { 542 | _id: 'sports', 543 | name: 'Sports', 544 | _parentId: null, 545 | _children: [ 546 | { 547 | _id: 'ball', 548 | name: 'Ball', 549 | _parentId: 'sports', 550 | _children: [ 551 | { 552 | _id: 'tennis', 553 | name: 'Tennis', 554 | _parentId: 'ball', 555 | _children: [] 556 | } 557 | ] 558 | }, 559 | { 560 | _id: 'non-ball', 561 | name: 'Non Ball', 562 | _parentId: 'sports', 563 | _children: [] 564 | } 565 | ] 566 | } 567 | ]; 568 | const opts = { 569 | idKey: '_id', 570 | parentIdKey: '_parentId', 571 | childrenKey: '_children' 572 | }; 573 | createNodes(arr, parentNode, opts); 574 | expect(parentNode.toJson()).toEqual(expected); 575 | }); 576 | }); 577 | }); 578 | -------------------------------------------------------------------------------- /tests/data.ts: -------------------------------------------------------------------------------- 1 | export const testDataComplex = [ 2 | { 3 | id: 'id_na', 4 | name: 'North America', 5 | parentId: 'id_world' 6 | }, 7 | { 8 | id: 'id_mex', 9 | name: 'Mexico', 10 | parentId: 'id_na' 11 | }, 12 | { 13 | id: 'id_can', 14 | name: 'Canada', 15 | parentId: 'id_na' 16 | }, 17 | { 18 | id: 'id_pac', 19 | name: 'Pacific', 20 | parentId: 'id_world' 21 | }, 22 | { 23 | id: 'id_aus', 24 | name: 'Australia', 25 | parentId: 'id_pac' 26 | }, 27 | { 28 | id: 'id_nz', 29 | name: 'New Zealand', 30 | parentId: 'id_pac' 31 | }, 32 | { 33 | id: 'id_usa', 34 | name: 'USA', 35 | parentId: 'id_na' 36 | }, 37 | { 38 | id: 'id_world', 39 | name: 'World', 40 | parentId: null 41 | }, 42 | { 43 | id: 'id_oh', 44 | name: 'Ohio', 45 | parentId: 'id_usa' 46 | }, 47 | { 48 | id: 'id_nd', 49 | name: 'North Dakota', 50 | parentId: 'id_usa' 51 | } 52 | ]; 53 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateId, 3 | filterObject, 4 | nodeData, 5 | firstArrayElement 6 | } from '../src/utils'; 7 | import Node from '../src/Node'; 8 | 9 | describe('nodeData', () => { 10 | test('returns null for null', () => { 11 | expect(nodeData(null)).toBe(null); 12 | }); 13 | test('returns data for node', () => { 14 | expect(nodeData(new Node('a'))).toBe('a'); 15 | }); 16 | }); 17 | 18 | describe('firstArrayElement', () => { 19 | test('returns null if null', () => { 20 | expect(firstArrayElement(null)).toBe(null); 21 | }); 22 | test('returns first array element', () => { 23 | expect(firstArrayElement(['a', 'b'])).toBe('a'); 24 | }); 25 | test('returns null if empty array', () => { 26 | expect(firstArrayElement([])).toBe(null); 27 | }); 28 | }); 29 | 30 | describe('generateId', () => { 31 | test('returns a string 36 characters long', () => { 32 | const result = generateId(); 33 | expect(typeof result).toBe('string'); 34 | expect(result).toHaveLength(36); 35 | }); 36 | }); 37 | 38 | describe('filterObject', () => { 39 | test('it filters properties', () => { 40 | const obj = { a: 'aValue', b: 'bValue' }; 41 | const result = filterObject(obj, { disallowedKeys: ['a'] }); 42 | expect('a' in obj).toBe(true); 43 | expect('a' in result).toBe(false); 44 | expect('b' in obj).toBe(true); 45 | expect('b' in result).toBe(true); 46 | }); 47 | test('should return a new object', () => { 48 | const obj = {}; 49 | expect(filterObject(obj, { disallowedKeys: [] }) === obj).toBe(false); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "es2015" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | "suppressImplicitAnyIndexErrors": true, 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | "strictNullChecks": false /* Enable strict null checks. */, 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | } 63 | } 64 | --------------------------------------------------------------------------------