├── tests ├── .eslintrc.js ├── __snapshots__ │ ├── mouse.test.js.snap │ ├── options.test.js.snap │ ├── set-tree.test.js.snap │ ├── update.test.js.snap │ └── tree-helpers.test.js.snap ├── update.test.js ├── validate.test.js ├── tree-helpers.test.js ├── constructor.test.js ├── helpers.test.js ├── mouse.test.js ├── set-tree.test.js ├── text.test.js ├── add-entities.js ├── clone.test.js └── options.test.js ├── .gitignore ├── CHANGELOG.md ├── data ├── index.js ├── rock-paper-scissors.js ├── linked-list.js ├── binary.js ├── royals.js └── index.html ├── webpack ├── prod.max.js ├── test-lib.js ├── prod.min.js ├── test-data.js ├── prod.es5.min.js ├── dev-data.js ├── dev-lib.js └── common.js ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── bug_report.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── ci.yml ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── src ├── validate.js ├── index.js ├── set-tree.js ├── helpers.js ├── constructor.js ├── options.js ├── mouse.js ├── text.js ├── tree-helpers.js ├── clone.js ├── add-entities.js └── update.js ├── package.json └── LICENSE /tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'no-undef': 'off', 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/* 4 | !/dist/dependentree.js 5 | dev/ 6 | tests/page/ 7 | .eslintcache 8 | yarn-error.log -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.0.2 / March 10th, 2022 2 | - Enabled adding DependenTree package with `require()` statements 3 | 4 | 5 | ### 1.0.1 / March 9th, 2022 6 | - Initialize -------------------------------------------------------------------------------- /data/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | BinaryTreeMaker: require('./binary'), 3 | LinkedListMaker: require('./linked-list'), 4 | rps: require('./rock-paper-scissors'), 5 | royals: require('./royals'), 6 | } 7 | -------------------------------------------------------------------------------- /data/rock-paper-scissors.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rock: { 3 | _name: 'rock', 4 | _deps: ['paper'] 5 | }, 6 | paper: { 7 | _name: 'paper', 8 | _deps: ['scissors'] 9 | }, 10 | scissors: { 11 | _name: 'scissors', 12 | _deps: ['rock'] 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /webpack/prod.max.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./common'); 3 | const path = require('path'); 4 | 5 | module.exports = merge(common, { 6 | mode: 'none', 7 | output: { 8 | path: path.resolve(__dirname, '../dist'), 9 | filename: 'dependentree.js', 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /webpack/test-lib.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const devLib = require('./dev-lib'); 3 | const path = require('path'); 4 | 5 | const pathToTestPage = path.resolve(__dirname, '../tests/page'); 6 | 7 | module.exports = merge(devLib, { 8 | output: { 9 | path: pathToTestPage, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /webpack/prod.min.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./common'); 3 | const path = require('path'); 4 | 5 | module.exports = merge(common, { 6 | mode: 'production', 7 | output: { 8 | path: path.resolve(__dirname, '../dist'), 9 | filename: 'dependentree.min.js', 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /webpack/test-data.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const devData = require('./dev-data'); 3 | const path = require('path'); 4 | 5 | const pathToTestPage = path.resolve(__dirname, '../tests/page'); 6 | 7 | module.exports = merge(devData, { 8 | output: { 9 | path: pathToTestPage, 10 | }, 11 | }); 12 | 13 | -------------------------------------------------------------------------------- /webpack/prod.es5.min.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./common'); 3 | const path = require('path'); 4 | 5 | module.exports = merge(common, { 6 | mode: 'production', 7 | target: ['web', 'es5'], 8 | output: { 9 | path: path.resolve(__dirname, '../dist'), 10 | filename: 'dependentree.es5.min.js', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['square'], 3 | extends: ['plugin:square/base'], 4 | env: { 5 | node: true, 6 | browser: true, 7 | }, 8 | rules: { 9 | 'prettier/prettier': 0, 10 | 'no-param-reassign': 'off', 11 | 'no-unused-vars': 'off', 12 | 'filenames/match-exported': 'off', 13 | }, 14 | ignorePatterns: ['dist/', 'dev/', 'page/'] 15 | }; 16 | -------------------------------------------------------------------------------- /webpack/dev-data.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const pathToDev = path.resolve(__dirname, '../dev'); 4 | 5 | module.exports = { 6 | entry: './data/index.js', 7 | output: { 8 | library: { 9 | name: 'testData', 10 | export: 'default', 11 | type: 'umd', 12 | }, 13 | path: pathToDev, 14 | filename: 'testData.js', 15 | }, 16 | devtool: 'inline-source-map', 17 | mode: 'development', 18 | }; 19 | -------------------------------------------------------------------------------- /webpack/dev-lib.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./common'); 3 | const path = require('path'); 4 | 5 | const pathToDev = path.resolve(__dirname, '../dev'); 6 | 7 | module.exports = merge(common, { 8 | mode: 'development', 9 | devtool: 'source-map', 10 | devServer: { 11 | contentBase: './dist', 12 | }, 13 | output: { 14 | path: pathToDev, 15 | filename: '[name].js', 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS syntax https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax 2 | # 3 | # Code owners are automatically requested for review when someone opens a pull request that modifies code that they own. 4 | # Code owners are not automatically requested to review draft pull requests 5 | # When you mark a draft pull request as ready for review, code owners are automatically notified. 6 | 7 | * @akambale 8 | -------------------------------------------------------------------------------- /data/linked-list.js: -------------------------------------------------------------------------------- 1 | class LinkedListMaker { 2 | constructor() { 3 | this.obj = {}; 4 | this.arr = []; 5 | } 6 | 7 | createLinkedList(maxDepth) { 8 | if (maxDepth === 1) { return; } 9 | const next = maxDepth - 1; 10 | const obj = { 11 | _name: maxDepth.toString(), 12 | _deps: [next.toString()], 13 | }; 14 | 15 | this.obj[maxDepth] = obj; 16 | this.arr.push(obj); 17 | 18 | this.createLinkedList(next); 19 | } 20 | } 21 | 22 | module.exports = LinkedListMaker; 23 | -------------------------------------------------------------------------------- /.github/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /data/binary.js: -------------------------------------------------------------------------------- 1 | class BinaryTreeMaker { 2 | constructor () { 3 | this.obj = {}; 4 | this.arr = []; 5 | } 6 | 7 | createBinaryTree(maxDepth, str = ' ') { 8 | const obj = { 9 | _name: str, 10 | _deps: [`${str}0`.trim(), `${str}1`.trim()], 11 | Number: Number.parseInt(str.split('').reverse().join(''), 2), 12 | }; 13 | 14 | this.obj[str] = obj; 15 | this.arr.push(obj); 16 | 17 | if (str.length === maxDepth) { 18 | delete obj._deps; 19 | return; 20 | } 21 | 22 | this.createBinaryTree(maxDepth, obj._deps[0]); 23 | this.createBinaryTree(maxDepth, obj._deps[1]); 24 | }; 25 | } 26 | 27 | module.exports = BinaryTreeMaker; 28 | -------------------------------------------------------------------------------- /.github/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | _Please explain the changes you made here._ 4 | _Does this close any currently open issues?_ 5 | 6 | ## Test Plan 7 | 8 | _Include any relevant screenshots_ 9 | 10 | ```sh 11 | Copy test output here. you can just paste the short output here with this command: 12 | yarn test-short 13 | ``` 14 | 15 | 16 | 17 | ## Verify 18 | - [ ] [Individual Contributor License Agreement (CLA)](https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1) signed 19 | - [ ] Added to changelog 20 | - [ ] Bumped version 21 | - [ ] Once PR is approved: bundled and committed a new version of DependenTree with `yarn build-plain` 22 | 23 | *Once merged, a Square team member will publish the latest version to npm* -------------------------------------------------------------------------------- /webpack/common.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | const copyright = ` 4 | Copyright 2022 Square Inc. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | `; 18 | 19 | module.exports = { 20 | entry: './src/index.js', 21 | optimization: { 22 | usedExports: true, 23 | }, 24 | output: { 25 | library: { 26 | name: 'DependenTree', 27 | export: 'default', 28 | type: 'umd', 29 | }, 30 | globalObject: 'this', 31 | }, 32 | plugins: [new webpack.BannerPlugin(copyright)], 33 | }; 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI - Node Unit & End to End Tests 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'yarn' 29 | - name: Install Packages 30 | run: yarn 31 | - name: Lint 32 | run: yarn lint 33 | - name: Bundle Test Data 34 | run: yarn test-init 35 | - name: Run Tests 36 | run: yarn test 37 | -------------------------------------------------------------------------------- /tests/__snapshots__/mouse.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Mouse _mousemove Sets tooltip HTML 1`] = ` 4 | "" 12 | `; 13 | 14 | exports[`Mouse _mousemove Sets tooltip style 1`] = `"position: fixed; visibility: hidden; background-color: white; border: 1px solid; border-radius: 5px; padding: 10px;"`; 15 | 16 | exports[`Mouse _mousemove enableTooltip option to false does not show tooltip 1`] = `"
"`; 17 | 18 | exports[`Mouse _mousemove enableTooltipKey option to false does not show key 1`] = ` 19 | "" 27 | `; 28 | -------------------------------------------------------------------------------- /tests/__snapshots__/options.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Options changing options impacts visual output default tootip styles 1`] = ` 4 | "
" 12 | `; 13 | 14 | exports[`Options changing options impacts visual output modified options tootip styles 1`] = ` 15 | "
" 23 | `; 24 | -------------------------------------------------------------------------------- /src/validate.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | 20 | export function validate() { 21 | if (!this.dependenciesAdded) { 22 | throw new Error( 23 | 'Cannot validate entities before entities have been added. Pass data to addEntities first.', 24 | ); 25 | } 26 | 27 | this._deleteClones(); 28 | 29 | let noCycles; 30 | try { 31 | JSON.stringify(this.upstream); 32 | noCycles = true; 33 | } catch { 34 | noCycles = false; 35 | } 36 | 37 | return { 38 | noDuplicateDependencies: this.dupDeps.length === 0, 39 | noMissingEntities: this.missingEntities.length === 0, 40 | duplicateDependencies: this.dupDeps, 41 | missingEntities: this.missingEntities, 42 | noCycles, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /tests/update.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | 3 | const pageURL = 'http://127.0.0.1:8081/index.html'; 4 | 5 | 6 | // Not much to test with update. Just ensure clicking on nodes 7 | // triggers update which changes the tree 8 | // Note that nodes that are not visible will still show up 9 | // in the snapshots 10 | describe('Update', () => { 11 | let one; 12 | let two; 13 | let three; 14 | 15 | beforeAll(async () => { 16 | const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 17 | const page = await browser.newPage(); 18 | await page.goto(pageURL); 19 | ({ one, two, three } = await page.evaluate(() => { 20 | const tree = new DependenTree('div#tree'); 21 | tree.addEntities(royals, { animationDuration: 0 }); 22 | tree.setTree('Elizabeth II', 'downstream'); 23 | 24 | const one = tree.svg.node().innerHTML; 25 | 26 | // clicking on Charles 27 | tree.svg.select('g:nth-of-type(3) > circle').dispatch('click'); 28 | const two = tree.svg.node().innerHTML; 29 | 30 | // clicking on Elizabeth II 31 | tree.svg.select('circle').dispatch('click'); 32 | const three = tree.svg.node().innerHTML; 33 | 34 | return { one, two, three }; 35 | })); 36 | 37 | await page.close(); 38 | await browser.close(); 39 | }); 40 | 41 | test('default tree', () => { 42 | expect(one).toMatchSnapshot(); 43 | }); 44 | 45 | test('open node', () => { 46 | expect(two).toMatchSnapshot(); 47 | }); 48 | 49 | test('close root', () => { 50 | expect(three).toMatchSnapshot(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | 20 | class DependenTree { 21 | constructor(elementSelectorString, userOptions) { 22 | this._constructor(elementSelectorString, userOptions); 23 | 24 | // default class properties, declared here to ensure these are new 25 | // objects in memory every time a new class instance is created 26 | this.nodeId = 0; 27 | this.upstream = {}; 28 | this.downstream = {}; 29 | this.missingEntities = []; 30 | this.dupDeps = []; 31 | this.keysMemo = {}; 32 | this.clones = []; 33 | } 34 | }; 35 | 36 | Object.assign( 37 | DependenTree.prototype, 38 | require('./clone'), 39 | require('./constructor'), 40 | require('./add-entities'), 41 | require('./helpers'), 42 | require('./mouse'), 43 | require('./options'), 44 | require('./text'), 45 | require('./tree-helpers'), 46 | require('./set-tree'), 47 | require('./update'), 48 | require('./validate'), 49 | ); 50 | 51 | export default DependenTree; 52 | -------------------------------------------------------------------------------- /src/set-tree.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | 20 | import * as d3 from 'd3'; 21 | 22 | // adds tree by choosing a key and upstream or downstream 23 | export function setTree(key, direction = 'upstream') { 24 | if (direction !== 'upstream' && direction !== 'downstream') { 25 | throw new Error( 26 | `The second argument must be either "upstream" or "downstream". Instead received "${direction}".` 27 | ); 28 | } 29 | 30 | const rootObj = this[direction][key]; 31 | if (!rootObj) { 32 | throw new Error(`The entity "${key}" is not found.`); 33 | } 34 | 35 | this.direction = direction; 36 | this.removeTree(); 37 | this._cloneNodes(this[direction][key]); 38 | 39 | // appends svg and group to page 40 | this.svg = this.container.append('svg'); 41 | this.gLink = this.svg.append('g'); 42 | 43 | this.svg.style('overflow', 'visible'); 44 | 45 | // declares a tree layout and assigns the size 46 | // the second array element should be horizontalSpaceBetweenNodes 47 | // but we calculate this x position in ._update because we need to 48 | // invert it for the upstream trees (right to left trees) 49 | this.treeMap = d3.tree().nodeSize([this.options.verticalSpaceBetweenNodes, 0]); 50 | 51 | // specifies the entity in the graph we are selecting 52 | // and that _deps is where to find the children 53 | this.root = d3.hierarchy(this[direction][key], d => d._deps); 54 | this.root.x0 = 0 55 | this.root.y0 = 0; 56 | 57 | // Starts the tree closed. Without this, 58 | // all nodes will start completely expanded 59 | this.collapseAll(true); 60 | this._update(this.root); 61 | 62 | this._setTooltip(); 63 | 64 | // moves the tree into view when switching 65 | // between upstream and downstream 66 | this.passedContainerEl.scrollLeft = this.direction === 'upstream' ? this.width : 0; 67 | } 68 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | 20 | import * as d3 from 'd3'; 21 | 22 | export function getEntityList(key = null, value = null) { 23 | if (key !== null && key !== undefined && typeof key !== 'string') { 24 | throw new Error( 25 | `The first argument (key) must be a string or undefined. Instead, a value of "${key}" was received with type "${typeof key}".` 26 | ); 27 | } 28 | 29 | if (value !== null && value !== undefined && typeof value !== 'string') { 30 | throw new Error( 31 | `The second argument (value) must be a string or undefined. Instead, a value of "${value}" was received with type "${typeof value}".` 32 | ); 33 | } 34 | 35 | const memoKey = `${key}---${value}`; 36 | if (this.keysMemo[memoKey]) { 37 | return this.keysMemo[memoKey]; 38 | } 39 | 40 | let list = []; 41 | 42 | if (key === null || value === null) { 43 | list = Object.keys(this.upstream); 44 | } else { 45 | const entities = this.upstream; 46 | for (const name in entities) { 47 | if (entities[name][key] === value) { 48 | list.push(name); 49 | } 50 | } 51 | } 52 | 53 | this.keysMemo[memoKey] = list; 54 | return list; 55 | } 56 | 57 | export function _setTooltip() { 58 | this.tooltip = d3 59 | .select(this.elementSelectorString) 60 | .append('div') 61 | .style('position', 'fixed') 62 | .style('visibility', 'hidden'); 63 | 64 | for (const key in this.options.tooltipStyleObj) { 65 | this.tooltip.style(key, this.options.tooltipStyleObj[key]); 66 | } 67 | } 68 | 69 | export function _styleObjToStyleStr(obj) { 70 | let str = ''; 71 | for (const key in obj) { 72 | str += `${key}:${obj[key]};`; 73 | } 74 | return str; 75 | } 76 | 77 | export function _filterScriptInjection(input) { 78 | if (typeof input !== 'string') {return input;} 79 | return input.replace(//g, '>') 80 | } 81 | -------------------------------------------------------------------------------- /tests/validate.test.js: -------------------------------------------------------------------------------- 1 | const DependenTree = require('./page/main'); 2 | const { royals, rps } = require('./page/testData'); 3 | 4 | 5 | describe('Validate', () => { 6 | test('throws an error if entities have not been added', () => { 7 | const target = 'Cannot validate entities before entities have been added. Pass data to addEntities first.'; 8 | 9 | const tree = new DependenTree('body'); 10 | let message; 11 | try { 12 | tree.validate(); 13 | } catch (e) { 14 | message = e.message 15 | } 16 | expect(message).toBe(target); 17 | }); 18 | 19 | 20 | describe('Simple clean data is all "valid"', () => { 21 | const tree = new DependenTree('body'); 22 | tree.addEntities(royals); 23 | const { 24 | noDuplicateDependencies, 25 | noMissingEntities, 26 | duplicateDependencies, 27 | missingEntities, 28 | noCycles, 29 | } = tree.validate(); 30 | 31 | test('noDuplicateDependencies', () => { 32 | expect(noDuplicateDependencies).toBe(true); 33 | }); 34 | 35 | test('noMissingEntities', () => { 36 | expect(noMissingEntities).toBe(true); 37 | }); 38 | 39 | test('duplicateDependencies', () => { 40 | expect(duplicateDependencies.length).toBe(0); 41 | }); 42 | 43 | test('missingEntities', () => { 44 | expect(missingEntities.length).toBe(0); 45 | }); 46 | 47 | test('noCycles', () => { 48 | expect(noCycles).toBe(true); 49 | }); 50 | }); 51 | 52 | describe('Cyclic malformed data is all "not valid"', () => { 53 | rps.rock._deps.push('paper'); 54 | rps.rock._deps.push('paa'); 55 | rps.scissors._deps.push('guu'); 56 | rps.paper._deps.push('choki'); 57 | 58 | const tree = new DependenTree('body'); 59 | tree.addEntities(rps); 60 | const { 61 | noDuplicateDependencies, 62 | noMissingEntities, 63 | duplicateDependencies, 64 | missingEntities, 65 | noCycles, 66 | } = tree.validate(); 67 | 68 | test('noDuplicateDependencies', () => { 69 | expect(noDuplicateDependencies).toBe(false); 70 | }); 71 | 72 | test('noMissingEntities', () => { 73 | expect(noMissingEntities).toBe(false); 74 | }); 75 | 76 | test('duplicateDependencies', () => { 77 | expect(duplicateDependencies).toEqual(expect.arrayContaining(['rock -> paper'])); 78 | }); 79 | 80 | test('missingEntities', () => { 81 | expect(missingEntities).toEqual(expect.arrayContaining(['paa', 'guu', 'choki'])); 82 | }); 83 | 84 | test('noCycles', () => { 85 | expect(noCycles).toBe(false); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@square/dependentree", 3 | "version": "1.0.2", 4 | "description": "A performant visualization library built on top of D3.js which easily converts dependency lists into an interactive tree UI", 5 | "main": "src/index.js", 6 | "exports": { 7 | "import": "./src/index.js", 8 | "require": "./dist/dependentree.js" 9 | }, 10 | "author": "Amogh Kambale ", 11 | "license": "Apache-2.0", 12 | "repository": "git@github.com:square/dependentree.git", 13 | "sideEffects": false, 14 | "scripts": { 15 | "start": "yarn serve & yarn build-watch", 16 | "serve": "live-server dev", 17 | "lint": "eslint --cache .", 18 | "lint-fix": "yarn lint --fix", 19 | "dev-init": "mkdir dev/ & yarn dev-copy-html & yarn build-dev-data", 20 | "dev-copy-html": "cp data/index.html dev/index.html", 21 | "build-dev-data": "npx webpack --config webpack/dev-data.js", 22 | "build-dev": "npx webpack --config webpack/dev-lib.js", 23 | "build-watch": "npx webpack --config webpack/dev-lib.js --watch", 24 | "test": "yarn build-test && concurrently --kill-others --success first \"live-server --no-browser --port=8081 tests/page\" \"jest --verbose\"", 25 | "test-short": "yarn build-test && concurrently --kill-others --success first \"live-server --no-browser --port=8081 tests/page\" \"jest\"", 26 | "test-init": "mkdir tests/page/ & yarn test-copy-html && yarn build-test-data", 27 | "test-copy-html": "cp data/index.html tests/page/index.html", 28 | "build-test-data": "npx webpack --config webpack/test-data.js", 29 | "build-test": "npx webpack --config webpack/test-lib.js", 30 | "serve-test": "live-server --no-browser --port=8081 tests/page", 31 | "build": "yarn build-min && yarn build-es5 && yarn build-plain", 32 | "build-min": "npx webpack --config webpack/prod.min.js", 33 | "build-es5": "npx webpack --config webpack/prod.es5.min.js", 34 | "build-plain": "npx webpack --config webpack/prod.max.js" 35 | }, 36 | "devDependencies": { 37 | "concurrently": "^6.5.1", 38 | "eslint": "^7.18.0", 39 | "eslint-plugin-square": "^17.0.0", 40 | "husky": "^4.3.8", 41 | "jest": "^26.6.3", 42 | "lint-staged": "^10.5.3", 43 | "live-server": "^1.2.1", 44 | "puppeteer": "^13.0.1", 45 | "webpack": "^5.4.0", 46 | "webpack-cli": "^4.2.0", 47 | "webpack-merge": "^4.2.2" 48 | }, 49 | "dependencies": { 50 | "d3": "^6.2.0" 51 | }, 52 | "husky": { 53 | "hooks": { 54 | "pre-commit": "lint-staged" 55 | } 56 | }, 57 | "lint-staged": { 58 | "*.js": "eslint --fix", 59 | "*.hbs": "ember-template-lint", 60 | "*.scss": "stylelint --fix" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/constructor.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | import * as d3 from 'd3'; 20 | 21 | // takes a W3C Selector String 22 | export function _constructor(elementSelectorString, userOptions = {}) { 23 | 24 | this.elementSelectorString = elementSelectorString; 25 | this.passedContainer = d3.select(elementSelectorString); 26 | this.passedContainerEl = this.passedContainer.node(); 27 | 28 | if (!this.passedContainerEl) { 29 | throw new Error( 30 | `An element could not be selected from the given selector string "${elementSelectorString}". Please refer to https://www.w3.org/TR/selectors-api/ and ensure the element is on the page.`, 31 | ); 32 | } 33 | 34 | if (userOptions.constructor.name !== 'Object') { 35 | throw new Error( 36 | `Argument options is not of type Object. Instead received a value of "${userOptions}". Please pass an empty object if you do not want to specify options.`, 37 | ); 38 | } 39 | 40 | // We make one mutation to the passed container 41 | this.passedContainerEl.style.overflow = 'auto'; 42 | 43 | // Another container element is made to be put inside of the passed container 44 | this.containerDiv = document.createElement('div'); 45 | this.passedContainerEl.appendChild(this.containerDiv); 46 | this.container = d3.select(`${elementSelectorString} > div`); 47 | 48 | 49 | this._setOptions(userOptions); 50 | 51 | 52 | // sets container width depending on user options 53 | // passed in and original container element size 54 | const passedContainerWidth = this.passedContainerEl.getBoundingClientRect().width; 55 | const { marginLeft, marginRight } = this.options; 56 | 57 | if (this.options.containerWidthInPx !== null) { 58 | this.width = this.options.containerWidthInPx + marginLeft + marginRight; 59 | } else { 60 | this.width = passedContainerWidth * this.options.containerWidthMultiplier + marginLeft + marginRight; 61 | } 62 | 63 | // sets container width to size. The SVG element needs a 64 | // a set width size to scale the tree diagram correctly 65 | this.containerDiv.style.width = `${this.width}px`; 66 | } 67 | -------------------------------------------------------------------------------- /tests/__snapshots__/set-tree.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Tree Null Inputs SVG 1`] = `"Elizabeth II"`; 4 | 5 | exports[`Tree Valid Inputs SVG 1`] = ` 6 | "Elizabeth IICharlesAnneAndrewEdward" 19 | `; 20 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | const defaultOptions = { 20 | // behavior options 21 | animationDuration: 750, 22 | maxDepth: 25, 23 | enableTooltip: true, 24 | enableTooltipKey: true, 25 | modifyEntityName: null, 26 | textClick: null, 27 | maxDepthMessage: null, 28 | missingEntityMessage: null, 29 | cyclicDependencyMessage: null, 30 | 31 | // appearance options 32 | containerWidthMultiplier: 4, 33 | containerWidthInPx: null, 34 | marginTop: 60, 35 | marginRight: 120, 36 | marginBottom: 200, 37 | marginLeft: 120 , 38 | parentNodeTextOrientation: 'left', 39 | childNodeTextOrientation: 'right', 40 | textOffset: 13, 41 | textStyleFont: '12px sans-serif', 42 | textStyleColor: 'black', 43 | circleStrokeColor: 'steelblue', 44 | circleStrokeWidth: 3, 45 | circleSize: 10, 46 | linkStrokeColor: '#dddddd', 47 | linkStrokeWidth: 2, 48 | closedNodeCircleColor: 'lightsteelblue', 49 | openNodeCircleColor: 'white', 50 | cyclicNodeColor: '#FF4242', 51 | missingNodeColor: '#E8F086', 52 | maxDepthNodeColor: '#A691AE', 53 | horizontalSpaceBetweenNodes: 180, 54 | verticalSpaceBetweenNodes: 30, 55 | wrapNodeName: true, 56 | splitStr: null, 57 | tooltipItemStyleObj: { 58 | 'font-family': 'sans-serif', 59 | 'font-size': '12px', 60 | }, 61 | tooltipColonStr: ': ', 62 | tooltipKeyStyleObj: { 'font-weight': 'bold' }, 63 | tooltipColonStyleObj: { 'font-weight': 'bold' }, 64 | tooltipValueStyleObj: {}, 65 | tooltipStyleObj: { 66 | 'background-color': 'white', 67 | border: 'solid', 68 | 'border-width': '1px', 69 | 'border-radius': '5px', 70 | padding: '10px', 71 | }, 72 | }; 73 | 74 | 75 | export function _setOptions(userOptions) { 76 | for (const key in userOptions) { 77 | const opt = userOptions[key]; 78 | if (typeof opt === 'string' && opt.includes('<') && opt.includes('>')) { 79 | throw new Error('Characters not allowed: "<" and ">" are not permitted as options to prevent script injection.') 80 | } 81 | } 82 | 83 | this.options = { 84 | ...defaultOptions, 85 | ...userOptions, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/mouse.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | export function _entityHasProps({data}) { 20 | let count = 0; 21 | for (const key in data) { 22 | if (key[0] !== '_') { 23 | count++; 24 | } 25 | } 26 | return count > 0; 27 | } 28 | 29 | export function _mousemove(event, d) { 30 | if (this._entityHasProps(d)) { 31 | this.tooltip.style('visibility', 'visible'); 32 | 33 | this.tooltip 34 | .style('top', `${event.clientY + 10}px`) 35 | .style('left', `${event.clientX + 10}px`) 36 | } 37 | } 38 | 39 | export function _mouseout() { 40 | this.tooltip.style('visibility', 'hidden'); 41 | } 42 | 43 | export function _mouseover(event, d) { 44 | const { 45 | enableTooltipKey, 46 | tooltipColonStr, 47 | tooltipKeyStyleObj, 48 | tooltipColonStyleObj, 49 | tooltipValueStyleObj, 50 | tooltipItemStyleObj, 51 | } = this.options; 52 | 53 | const tooltipStyleStr = this._styleObjToStyleStr(tooltipItemStyleObj); 54 | const tooltipKeyStyleStr = this._styleObjToStyleStr(tooltipKeyStyleObj); 55 | const tooltipColonStyleStr = this._styleObjToStyleStr(tooltipColonStyleObj); 56 | const tooltipValueStyleStr = this._styleObjToStyleStr(tooltipValueStyleObj); 57 | 58 | let str = "'; 87 | 88 | this.tooltip.html(str); 89 | } 90 | -------------------------------------------------------------------------------- /src/text.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | 20 | import * as d3 from 'd3'; 21 | 22 | 23 | // function responsible for wrapping a node's text 24 | // this is done by calculating the width of the text 25 | // and slicing the text into different tspan 26 | // elements that are stacked vertically. 27 | export function _wrap(selection, width, splitStr) { 28 | selection.each(function () { 29 | const text = d3.select(this); 30 | 31 | let useSplitStr = false; 32 | let str = ''; 33 | if (splitStr && typeof splitStr === 'string') { 34 | str = splitStr; 35 | useSplitStr = true; 36 | } else if (text.text().includes(' ')) { 37 | str = ' '; 38 | } else { 39 | str = ''; 40 | } 41 | 42 | const words = text.text().split(str).reverse(); 43 | const x = text.attr('x'); 44 | const y = text.attr('y'); 45 | let word; 46 | let line = []; 47 | let tspan = text 48 | .text(null) 49 | .append('tspan') 50 | .attr('x', x) 51 | .attr('y', y); 52 | 53 | while ((word = words.pop())) { 54 | line.push(word); 55 | tspan.text(line.join(str)); 56 | 57 | if (tspan.node().getComputedTextLength() > width) { 58 | line.pop(); 59 | let joined = line.join(str); 60 | if (useSplitStr) { 61 | joined += splitStr; 62 | } 63 | 64 | // Edge case where text does not have split string. 65 | // This prevents an empty tspan from being added 66 | if (joined === str) { continue; } 67 | 68 | tspan.text(joined); 69 | line = [word]; 70 | tspan = text 71 | .append('tspan') 72 | .attr('x', x) 73 | .attr('y', y) 74 | .attr('dy', '1em') 75 | .text(word); 76 | } 77 | } 78 | }); 79 | } 80 | 81 | // Get text direction refers to the direction 82 | // for a downstream tree, which is understandably 83 | // a bit confusing considering our default tree is 84 | // upstream. This is the case because the default 85 | // tidy tree diagram is left to right, for this 86 | // library that means downstream 87 | export function _getTextDirection(leftOrRight) { 88 | let orientation = 'start'; 89 | let offset = this.options.textOffset; 90 | 91 | if (leftOrRight === 'left') { 92 | orientation = 'end'; 93 | offset = -offset; 94 | }; 95 | 96 | if (this.direction === 'upstream') { 97 | orientation = orientation === 'end' ? 'start' : 'end'; 98 | offset = -offset; 99 | } 100 | 101 | return { orientation, offset } 102 | } 103 | -------------------------------------------------------------------------------- /data/royals.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | _name: 'Elizabeth II', 4 | Title: 'Queen', 5 | }, 6 | { 7 | _name: 'Phillip', 8 | Title: 'Duke of Edinburgh', 9 | }, 10 | { 11 | _name: 'Charles', 12 | _deps: ['Phillip', 'Elizabeth II'], 13 | Title: 'Prince of Wales', 14 | _shortTitle: 'Prince', 15 | }, 16 | { 17 | _name: 'Diana', 18 | Title: 'Princess of Wales', 19 | _shortTitle: 'Princess', 20 | }, 21 | { 22 | _name: 'William', 23 | _deps: ['Diana', 'Charles'], 24 | Title: 'Prince, Duke of Cambridge', 25 | _shortTitle: 'Prince', 26 | }, 27 | { 28 | _name: 'Catherine', 29 | Title: 'Duchess of Cambridge', 30 | }, 31 | { 32 | _name: 'George', 33 | _deps: ['Catherine', 'William'], 34 | _shortTitle: 'Prince', 35 | }, 36 | { 37 | _name: 'Charlotte', 38 | _deps: ['Catherine', 'William'], 39 | _shortTitle: 'Princess', 40 | }, 41 | { 42 | _name: 'Louis', 43 | _deps: ['Catherine', 'William'], 44 | _shortTitle: 'Prince', 45 | }, 46 | { 47 | _name: 'Harry', 48 | _deps: ['Diana', 'Charles'], 49 | Title: 'Prince, Duke of Sussex', 50 | _shortTitle: 'Prince', 51 | }, 52 | { 53 | _name: 'Meghan Markle', 54 | Title: 'Duchess of Sussex', 55 | }, 56 | { 57 | _name: 'Archie Harrison Mountbatten-Windsor', 58 | _deps: ['Meghan Markle', 'Harry'], 59 | }, 60 | { 61 | _name: 'Anne', 62 | _deps: ['Phillip', 'Elizabeth II'], 63 | Title: 'Princess Royal', 64 | _shortTitle: 'Princess', 65 | }, 66 | { 67 | _name: 'Mark Phillips', 68 | Title: 'Captain', 69 | }, 70 | { 71 | _name: 'Peter Phillips', 72 | _deps: ['Mark Phillips','Anne'], 73 | }, 74 | { 75 | _name: 'Autumn Kelly', 76 | }, 77 | { 78 | _name: 'Savannah Phillips', 79 | _deps: ['Autumn Kelly','Peter Phillips'] 80 | }, 81 | { 82 | _name: 'Isla Phillips', 83 | _deps: ['Autumn Kelly','Peter Phillips'] 84 | }, 85 | { 86 | _name: 'Zara Tindall', 87 | _deps: ['Mark Phillips', 'Anne'], 88 | }, 89 | { 90 | _name: 'Mike Tindall', 91 | }, 92 | { 93 | _name: 'Mia Grace Tindall', 94 | _deps: ['Mike Tindall','Zara Tindall'], 95 | }, 96 | { 97 | _name: 'Lena Elizabeth Tindall', 98 | _deps: ['Mike Tindall','Zara Tindall'], 99 | }, 100 | { 101 | _name: 'Andrew', 102 | _deps: ['Phillip', 'Elizabeth II'], 103 | Title: 'Prince, Duke of York', 104 | _shortTitle: 'Prince', 105 | }, 106 | { 107 | _name: 'Sarah', 108 | Title: 'Duchess of York', 109 | }, 110 | { 111 | _name: 'Beatrice', 112 | _deps: ['Sarah', 'Andrew'], 113 | }, 114 | { 115 | _name: 'Eugenie', 116 | _deps: ['Sarah', 'Andrew'], 117 | }, 118 | { 119 | _name: 'Edward', 120 | _deps: ['Phillip', 'Elizabeth II'], 121 | Title: 'Prince, Earl of Wessex', 122 | _shortTitle: 'Prince', 123 | }, 124 | { 125 | _name: 'Sophie Rhys-Jones', 126 | Title: 'Countess of Wessex', 127 | }, 128 | { 129 | _name: 'James', 130 | Title: 'Viscount Severn', 131 | _deps: ['Edward', 'Sophie Rhys-Jones'], 132 | }, 133 | { 134 | _name: 'Louise Windsor', 135 | Title: 'Lady', 136 | _deps: ['Edward', 'Sophie Rhys-Jones'], 137 | }, 138 | ]; 139 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@squareup.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/tree-helpers.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | // Expanding and collapsing the tree works by swapping 20 | // children with _children and vice versa. _children is 21 | // where collapsed children are stored. After the swap is made 22 | // _update is called on the node which will generate the 23 | // visual changed. 24 | 25 | export function collapseAll(secondLevel = false) { 26 | if (!this.root) { return; } 27 | if (!this.root.children) { return; } 28 | if (this.root.children.length === 0) { return; } 29 | 30 | if (secondLevel) { 31 | // keeps the root's children open 32 | this.root.children.forEach(this._collapse.bind(this)); 33 | } else { 34 | this._collapse(this.root); 35 | } 36 | this._update(this.root); 37 | } 38 | 39 | export function expandAll(levelsOfNodes = Infinity) { 40 | if (typeof levelsOfNodes !== 'number') { 41 | throw new TypeError('Arguments passed into expandAll must be of type number or undefined.'); 42 | } 43 | 44 | if (levelsOfNodes > 5) { 45 | console.warn( 46 | 'expandAll is not recommended for large dependency trees. This may cause layout trashing.', 47 | ); 48 | } 49 | 50 | if (!this.root) { return; } 51 | if (this.root._children) { 52 | this.root.children = this.root._children; 53 | this.root._children = null; 54 | } 55 | const children = this.root.children; 56 | this._update(this.root); 57 | if (children) { 58 | children.forEach(child => this._delayExpand(child, levelsOfNodes - 1)); 59 | } 60 | } 61 | 62 | export function removeTree() { 63 | if (this.svg) { 64 | this._deleteClones(this.root); 65 | this.svg.remove(); 66 | this.tooltip.remove(); 67 | } 68 | } 69 | 70 | export function _delayExpand(node, levelsOfNodes) { 71 | if (!node || levelsOfNodes === 1) { return; } 72 | setTimeout(() => { 73 | if (!node) { return; } 74 | if (node._children) { 75 | node.children = node._children; 76 | node._children = null; 77 | } 78 | 79 | this._update(node); 80 | if (node.children) { 81 | node.children.forEach(child => this._delayExpand(child, levelsOfNodes - 1)); 82 | } 83 | }, this.options.animationDuration + 100); 84 | } 85 | 86 | export function _collapse(node) { 87 | if (!node) { return; } 88 | if (node.children) { 89 | node._children = node.children; 90 | node._children.forEach(this._collapse.bind(this)); 91 | node.children = null; 92 | } 93 | } 94 | 95 | // Toggle dependencies on click. 96 | export function _click(event, d) { 97 | if (d.children) { 98 | d._children = d.children; 99 | d.children = null; 100 | } else { 101 | d.children = d._children; 102 | d._children = null; 103 | } 104 | this._update(d); 105 | } 106 | 107 | // Creates a curved (diagonal) path from parent to the child nodes 108 | export function _diagonal(s, d) { 109 | return ` 110 | M ${s.y} ${s.x} 111 | C ${(s.y + d.y) / 2} ${s.x}, ${(s.y + d.y) / 2} ${d.x}, ${d.y} ${d.x} 112 | `; 113 | } 114 | -------------------------------------------------------------------------------- /tests/tree-helpers.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const DependenTree = require('./page/main'); 3 | 4 | const pageURL = 'http://127.0.0.1:8081/index.html'; 5 | 6 | 7 | // NOTE: No tests for _delayExpand or _click 8 | 9 | describe('Tree Helpers', () => { 10 | // Not much to test with expand collapse 11 | // Best we can do is capture the structure of the tree 12 | describe('Expand Collapse Remove', () => { 13 | let one; 14 | let two; 15 | let three; 16 | let four; 17 | let svgOnPage; 18 | let svgNotOnPage; 19 | 20 | beforeAll(async () => { 21 | const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 22 | const page = await browser.newPage(); 23 | await page.goto(pageURL); 24 | 25 | ({ one, two, three, four, svgOnPage, svgNotOnPage } = await page.evaluate(() => { 26 | const tree = new DependenTree('div#tree'); 27 | tree.addEntities(royals, { animationDuration: 0 }); 28 | tree.setTree('Elizabeth II', 'downstream'); 29 | 30 | const one = tree.svg.node().innerHTML; 31 | 32 | tree.expandAll(); 33 | const two = tree.svg.node().innerHTML; 34 | 35 | tree.collapseAll(); 36 | const three = tree.svg.node().innerHTML; 37 | 38 | tree.expandAll(3); 39 | const four = tree.svg.node().innerHTML; 40 | 41 | const svgOnPage = Boolean(document.querySelector('div#tree > div > svg')); 42 | tree.removeTree(); 43 | const svgNotOnPage = Boolean(document.querySelector('body > svg')); 44 | 45 | return { one, two, three, four, svgOnPage, svgNotOnPage }; 46 | })); 47 | 48 | await page.close(); 49 | await browser.close(); 50 | }); 51 | 52 | test('default tree', () => { 53 | expect(one).toMatchSnapshot(); 54 | }); 55 | 56 | test('expand Infinity', () => { 57 | expect(two).toMatchSnapshot(); 58 | }); 59 | 60 | test('collapse all', () => { 61 | expect(three).toMatchSnapshot(); 62 | }); 63 | 64 | test('expand 3', () => { 65 | expect(four).toMatchSnapshot(); 66 | }); 67 | 68 | test('tree on page', () => { 69 | expect(svgOnPage).toBe(true); 70 | }); 71 | 72 | test('tree removed from page', () => { 73 | expect(svgNotOnPage).toBe(false); 74 | }); 75 | }); 76 | 77 | describe('_collapse', () => { 78 | test('recursively collapses', () => { 79 | const node = { children: [{ children: [{ children: [] }] }] }; 80 | const target = { _children: [{ _children: [{ _children: [] }] }] }; 81 | 82 | DependenTree.prototype._collapse(node); 83 | 84 | expect(node).toMatchObject(target); 85 | }); 86 | 87 | test('no action to node without children', () => { 88 | const node = { _children: [{}] } 89 | const target = { _children: [{}] }; 90 | 91 | DependenTree.prototype._collapse(node); 92 | 93 | expect(node).toMatchObject(target); 94 | }); 95 | 96 | test('secondLevel argument set to true keeps children of the root', () => { 97 | const node = { children: [{ children: [{ children: [] }] }] }; 98 | const target = { _children: [{ _children: [{ children: null }] }] }; 99 | 100 | DependenTree.prototype._collapse(node); 101 | 102 | expect(node).toMatchObject(target); 103 | }); 104 | }); 105 | 106 | describe('_diagonal', () => { 107 | test('Passed in numbers produce expected output', () => { 108 | const str = DependenTree.prototype._diagonal({x: 1, y: 2}, {x: 3, y: 4}) 109 | 110 | expect(str).toMatchSnapshot(); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /data/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DependenTree 7 | 24 | 25 | 26 |
27 | 28 | 29 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /tests/constructor.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const DependenTree = require('./page/main'); 3 | 4 | const pageURL = 'http://127.0.0.1:8081/index.html'; 5 | 6 | 7 | describe('Constructor', () => { 8 | describe('Tree initializes when', () => { 9 | test('element is found', () => { 10 | const tree = new DependenTree('body'); 11 | expect(tree).toBeInstanceOf(DependenTree); 12 | }); 13 | 14 | test('options is an object', () => { 15 | const tree = new DependenTree('body', {}); 16 | expect(tree).toBeInstanceOf(DependenTree); 17 | }); 18 | 19 | test('options is an object with properties', () => { 20 | const options = { 21 | maxDepth: 25, 22 | containerWidthMultiplier: 1, 23 | containerHeight: 1.3, 24 | } 25 | 26 | const tree = new DependenTree('body', options); 27 | expect(tree).toBeInstanceOf(DependenTree); 28 | }); 29 | }); 30 | 31 | describe('throws error when', () => { 32 | test('element is not found', () => { 33 | const expectedMessage = 'An element could not be selected from the given selector string ".foo". Please refer to https://www.w3.org/TR/selectors-api/ and ensure the element is on the page.'; 34 | 35 | let message; 36 | try { 37 | tree = new DependenTree('.foo'); 38 | } catch(e) { 39 | message = e.message; 40 | } 41 | expect(message).toBe(expectedMessage); 42 | }); 43 | 44 | 45 | test('options is not an object', () => { 46 | const expectedMessage = 'Argument options is not of type Object. Instead received a value of "true". Please pass an empty object if you do not want to specify options.'; 47 | 48 | let tree; 49 | let message; 50 | try { 51 | tree = new DependenTree('body', true); 52 | } catch(e) { 53 | message = e.message; 54 | } 55 | expect(message).toBe(expectedMessage); 56 | }); 57 | }); 58 | 59 | describe('Sets default', () => { 60 | let tree; 61 | let browser; 62 | 63 | beforeEach(() => { 64 | tree?.removeTree(); 65 | }) 66 | 67 | beforeAll(async () => { 68 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 69 | }) 70 | 71 | afterAll(() => { 72 | browser.close(); 73 | }) 74 | 75 | describe('container width in px', () => { 76 | let rect; 77 | beforeAll(async () => { 78 | const page = await browser.newPage(); 79 | await page.goto(pageURL) 80 | 81 | rect = await page.evaluate(() => { 82 | const options = { containerWidthInPx: 200, containerHeightInPx: 800 } 83 | const tree = new DependenTree('div#tree', options); 84 | tree.addEntities(royals); 85 | tree.setTree('Elizabeth II', 'downstream'); 86 | 87 | const { height, width } = tree.svg.node().getBoundingClientRect(); 88 | return { height, width }; 89 | }); 90 | 91 | await page.close(); 92 | expect(rect.width).toBe(440); 93 | }); 94 | }); 95 | 96 | test('container width with multiplier', () => { 97 | let rect; 98 | beforeAll(async () => { 99 | const page = await browser.newPage(); 100 | await page.goto(pageURL) 101 | 102 | rect = await page.evaluate(() => { 103 | const options = { containerWidthMultiplier: 2, containerHeight: 2 } 104 | const tree = new DependenTree('div#tree', options); 105 | tree.addEntities(royals); 106 | tree.setTree('Elizabeth II', 'downstream'); 107 | 108 | const { height, width } = tree.svg.node().getBoundingClientRect(); 109 | return { height, width }; 110 | }); 111 | 112 | await page.close(); 113 | 114 | expect(rect.width).toBe(2640); 115 | }); 116 | }); 117 | }); 118 | 119 | describe('Default tree values are empty', () => { 120 | let tree; 121 | beforeAll(() => { 122 | tree = new DependenTree('body'); 123 | }); 124 | 125 | test('nodeId', () => { 126 | expect(tree.nodeId).toBe(0); 127 | }); 128 | 129 | test('upstream', () => { 130 | expect({}).toMatchObject(tree.upstream); 131 | }); 132 | 133 | test('downstream', () => { 134 | expect({}).toMatchObject(tree.downstream); 135 | }); 136 | 137 | test('missingEntities', () => { 138 | expect(Array.isArray(tree.missingEntities)).toBe(true); 139 | expect(tree.missingEntities.length).toBe(0); 140 | }); 141 | 142 | test('duplicateDependencies', () => { 143 | expect(Array.isArray(tree.dupDeps)).toBe(true); 144 | expect(tree.dupDeps.length).toBe(0); 145 | }); 146 | 147 | test('keysMemo', () => { 148 | expect({}).toMatchObject(tree.keysMemo); 149 | }); 150 | 151 | test('clones', () => { 152 | expect(Array.isArray(tree.clones)).toBe(true); 153 | expect(tree.clones.length).toBe(0); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/clone.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | // this function conducts a dfs on the graph to identify cycles and 20 | // to cut off nodes that reach the max depth. Without this function 21 | // large graphs cause the page to crash. Nodes that are both max 22 | // depth and cyclic are labeled as cyclic. Nodes that are both 23 | // missing entities and max depth are labeled as missing. 24 | export function _cloneNodes(node, depth = 1, path = [node._name]) { 25 | if (!node._deps || node._deps.length === 0) { 26 | return; 27 | } 28 | node.__visited = true; 29 | 30 | for (let i = 0; i < node._deps.length; i++) { 31 | const child = node._deps[i]; 32 | path.push(child._name); 33 | 34 | // if this node has already been seen on this path before 35 | // we have identified a cycle. This is a cyclic node 36 | if (child.__visited) { 37 | const cyclicPaths = path.join(' → ') 38 | 39 | // if the child is already a clone 40 | if (child._isClone) { 41 | // and this path is not already included 42 | if (!child['Cyclic Dependency Paths'].includes(cyclicPaths)) { 43 | // add the path with a break 44 | child['Cyclic Dependency Paths'] += `
${cyclicPaths}`; 45 | } 46 | } else { 47 | // otherwise, clone and create the path for the first time 48 | this._cloneNodeCyclic(node, i, child, cyclicPaths); 49 | } 50 | // if the child is not a clone but has reached max depth 51 | } else if (depth >= this.options.maxDepth) { 52 | // and it is not a missing entity 53 | if (child._missing) { continue; } 54 | 55 | // and it does not have children 56 | if (!child._deps || child._deps.length === 0) { continue; } 57 | 58 | // clone it and mark it as max depth 59 | this._cloneNodeMaxDepth(node, i, child); 60 | 61 | // Otherwise, continue the recursion 62 | } else { 63 | this._cloneNodes( 64 | child, 65 | depth + 1, 66 | [...path], 67 | ); 68 | } 69 | } 70 | 71 | // remove the visited label as we don't want to hit the 72 | // same node on a different path and think it's a cycle 73 | delete node.__visited; 74 | } 75 | 76 | export function _cloneNodeCyclic(node, i, child, cyclicPaths) { 77 | const { cyclicDependencyMessage } = this.options; 78 | let message; 79 | if (typeof cyclicDependencyMessage === 'string') { 80 | message = cyclicDependencyMessage; 81 | } else if (typeof cyclicDependencyMessage === 'function') { 82 | message = cyclicDependencyMessage(child); 83 | } else { 84 | message = 'This entity depends on another entity that has already been displayed up the branch. No more entities will be displayed here to prevent an infinite loop.'; 85 | } 86 | 87 | const additionalProperties = { 88 | _isClone: true, 89 | _cyclic: true, 90 | 'Automated Note': message, 91 | 'Cyclic Dependency Paths': cyclicPaths, 92 | }; 93 | 94 | this._cloneNode(node, i, child, additionalProperties); 95 | } 96 | 97 | export function _cloneNodeMaxDepth(node, i, child) { 98 | const { maxDepthMessage } = this.options; 99 | let message; 100 | if (typeof maxDepthMessage === 'string') { 101 | message = maxDepthMessage; 102 | } else if (typeof maxDepthMessage === 'function') { 103 | message = maxDepthMessage(child); 104 | } else { 105 | message = `Maximum depth of ${this.options.maxDepth} entities reached. This entity has additional children, but they cannot be displayed. Set this entity as the root to view additional dependencies.` 106 | } 107 | 108 | const additionalProperties = { 109 | _isClone: true, 110 | _maxDepth: true, 111 | 'Automated Note': message, 112 | }; 113 | 114 | this._cloneNode(node, i, child, additionalProperties); 115 | } 116 | 117 | export function _cloneNode(node, i, child, additionalProperties) { 118 | const clone = this._createNodeCopy(child, additionalProperties); 119 | this.clones.push([node, i, child]); 120 | node._deps[i] = clone; 121 | } 122 | 123 | export function _createNodeCopy(node, additionalProperties = {}) { 124 | const obj = { 125 | ...node, 126 | ...additionalProperties, 127 | }; 128 | delete obj._deps; 129 | return obj; 130 | } 131 | 132 | export function _deleteClones() { 133 | let arr = this.clones.pop(); 134 | while (arr) { 135 | const [parent, index, child] = arr; 136 | parent._deps[index] = child; 137 | arr = this.clones.pop(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/helpers.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const DependenTree = require('./page/main'); 3 | const { royals, rps } = require('./page/testData'); 4 | 5 | const pageURL = 'http://127.0.0.1:8081/index.html'; 6 | 7 | const royalsStr = JSON.stringify(royals); 8 | 9 | describe('Helpers', () => { 10 | let browser; 11 | 12 | beforeAll(async () => { 13 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 14 | }); 15 | 16 | afterAll(() => { 17 | browser.close(); 18 | }); 19 | 20 | describe('addEntities', () => { 21 | test('sets dependenciesAdded to true', () => { 22 | const tree = new DependenTree('body'); 23 | tree.addEntities([{ _name: 'foo' }]); 24 | 25 | expect(tree.dependenciesAdded).toBe(true); 26 | }); 27 | 28 | test("can't be added twice", () => { 29 | const expected = 'Entities have already been added. Create a new instance of DependenTree if you need to display other data.' 30 | let message; 31 | 32 | const tree = new DependenTree('body'); 33 | tree.addEntities([{ _name: 'foo' }]); 34 | try { 35 | tree.addEntities([{ _name: 'foo' }]); 36 | } catch (e) { 37 | message = e.message; 38 | } 39 | expect(message).toBe(expected); 40 | }); 41 | }); 42 | 43 | describe('getEntityList', () => { 44 | let tree; 45 | const princesses = ['Diana', 'Charlotte', 'Anne']; 46 | 47 | beforeAll(() => { 48 | tree = new DependenTree('body'); 49 | tree.addEntities(royals); 50 | }) 51 | 52 | test('method returns appropriate filtered list', () => { 53 | const list = tree.getEntityList('_shortTitle', 'Princess'); 54 | expect(list).toEqual(expect.arrayContaining(princesses)); 55 | }); 56 | 57 | test('filtered list is memoized', () => { 58 | const key = '_shortTitle---Princess'; 59 | const list = tree.keysMemo[key]; 60 | 61 | expect(list).toEqual(expect.arrayContaining(princesses)); 62 | }); 63 | 64 | test('all entities are fetched when no key-value is passed', () => { 65 | const tree = new DependenTree('body'); 66 | tree.addEntities(rps); 67 | const list = tree.getEntityList(); 68 | 69 | expect(list).toEqual(expect.arrayContaining(['rock', 'paper', 'scissors'])); 70 | }); 71 | 72 | test('incorrectly typed key throws error', () => { 73 | const target = 'The first argument (key) must be a string or undefined. Instead, a value of "true" was received with type "boolean".'; 74 | 75 | const tree = new DependenTree('body'); 76 | tree.addEntities(JSON.parse(royalsStr)); 77 | 78 | let message; 79 | try { 80 | tree.getEntityList(true, 'value'); 81 | } catch (e) { 82 | message = e.message; 83 | } 84 | 85 | expect(message).toBe(target); 86 | }); 87 | 88 | test('incorrectly typed value throws error', () => { 89 | const target = 'The second argument (value) must be a string or undefined. Instead, a value of "true" was received with type "boolean".'; 90 | 91 | const tree = new DependenTree('body'); 92 | tree.addEntities(JSON.parse(royalsStr)); 93 | 94 | let message; 95 | try { 96 | tree.getEntityList('key', true); 97 | } catch (e) { 98 | message = e.message; 99 | } 100 | 101 | expect(message).toBe(target); 102 | }); 103 | }); 104 | 105 | describe('_setTooltip', () => { 106 | const targetStr = 'body'; 107 | 108 | test('tool tip element on page', async() => { 109 | const page = await browser.newPage(); 110 | await page.goto(pageURL); 111 | 112 | const elementsEqual = await page.evaluate(targetStr => { 113 | const tree = new DependenTree(targetStr); 114 | tree.addEntities(royals); 115 | tree.setTree('Elizabeth II'); 116 | 117 | // triggers tooltip to be populated with ul for query below 118 | const node = tree.svg.selectAll('g'); 119 | node.dispatch('mouseover'); 120 | node.dispatch('mousemove'); 121 | 122 | const nodeInDOM = document.querySelector(`${targetStr} > div > ul`).parentElement; 123 | const nodeInClass = tree.tooltip.node(); 124 | return nodeInClass === nodeInDOM; 125 | }, targetStr); 126 | 127 | await page.close(); 128 | 129 | expect(elementsEqual).toBe(true); 130 | }); 131 | 132 | test('tool tip element in class', async() => { 133 | const page = await browser.newPage(); 134 | await page.goto(pageURL) 135 | 136 | const toolTipStyle = await page.evaluate(targetStr => { 137 | const tree = new DependenTree(targetStr); 138 | tree.addEntities(royals); 139 | tree.setTree('Elizabeth II'); 140 | const { position, visibility } = tree.tooltip.node().style; 141 | return { position, visibility }; 142 | }, targetStr); 143 | 144 | await page.close(); 145 | 146 | expect(toolTipStyle.position).toBe('fixed'); 147 | expect(toolTipStyle.visibility).toBe('hidden'); 148 | }); 149 | }); 150 | 151 | describe('_styleObjToStyleStr', () => { 152 | test('converts style object to string', () => { 153 | const input = { 154 | 'font-family': 'sans-serif', 155 | 'font-size': '12px', 156 | } 157 | const styleStr = DependenTree.prototype._styleObjToStyleStr(input); 158 | expect(styleStr).toBe('font-family:sans-serif;font-size:12px;'); 159 | }); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /tests/mouse.test.js: -------------------------------------------------------------------------------- 1 | const { interpolate } = require('d3'); 2 | const puppeteer = require('puppeteer'); 3 | const DependenTree = require('./page/main'); 4 | const { royals } = require('./page/testData'); 5 | 6 | const pageURL = 'http://127.0.0.1:8081/index.html'; 7 | 8 | describe('Mouse', () => { 9 | let browser; 10 | let page; 11 | 12 | beforeAll(async () => { 13 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 14 | page = await browser.newPage(); 15 | await page.goto(pageURL); 16 | await page.evaluate(() => { 17 | window.tree = new DependenTree('div#tree'); 18 | window.tree.addEntities(royals); 19 | window.tree.setTree('Elizabeth II'); 20 | // triggers hover event on Elizabeth II 21 | window.tree.svg.select('svg > g:nth-of-type(2)').dispatch('mousemove'); 22 | }); 23 | }); 24 | 25 | afterAll(async () => { 26 | await page.close(); 27 | browser.close(); 28 | }); 29 | 30 | describe('_entityHasProps', () => { 31 | const obj = { data: { _name: 'foo', Title: 'Prince' } }; 32 | 33 | test('true case', () => { 34 | const bool = DependenTree.prototype._entityHasProps(obj); 35 | expect(bool).toBe(true); 36 | }); 37 | 38 | test('false case', () => { 39 | delete obj.data.Title; 40 | const bool = DependenTree.prototype._entityHasProps(obj) 41 | expect(bool).toBe(false); 42 | }); 43 | }); 44 | 45 | describe('_mouseover & _mouseout', () => { 46 | test('_mouseover shows visibility', async () => { 47 | const visibility = await page.evaluate(() => { 48 | window.tree.svg.selectAll('g').dispatch('mouseover'); 49 | return tree.tooltip.node().style.visibility; 50 | }); 51 | 52 | expect(visibility).toBe('visible'); 53 | }); 54 | 55 | test('_mouseout hides visibility', async () => { 56 | const visibility = await page.evaluate(() => { 57 | window.tree.svg.selectAll('g').dispatch('mouseout'); 58 | return window.tree.tooltip.node().style.visibility; 59 | }); 60 | 61 | expect(visibility).toBe('hidden'); 62 | }); 63 | }); 64 | 65 | // these target strings are verbose 66 | // opting to use snapshots instead for this reason 67 | describe('_mousemove', () => { 68 | test('Sets tooltip HTML', async () => { 69 | const innerHTML = await page.evaluate(() => { 70 | return window.tree.tooltip.node().innerHTML; 71 | }); 72 | 73 | expect(innerHTML).toMatchSnapshot(); 74 | }); 75 | 76 | test('Sets tooltip style', async () => { 77 | const cssText = await page.evaluate(() => { 78 | return window.tree.tooltip.node().style.cssText; 79 | }); 80 | 81 | expect(cssText).toMatchSnapshot(); 82 | }); 83 | 84 | test('enableTooltipKey option to false does not show key', async () => { 85 | const page2 = await browser.newPage(); 86 | await page2.goto(pageURL); 87 | 88 | const innerHTML = await page2.evaluate(() => { 89 | const tree = new DependenTree('div#tree', { enableTooltipKey: false }); 90 | tree.addEntities(royals); 91 | tree.setTree('Elizabeth II'); 92 | // triggers hover event on Elizabeth II 93 | const node = tree.svg.select('svg > g:nth-of-type(2)'); 94 | node.dispatch('mouseover'); 95 | node.dispatch('mousemove'); 96 | 97 | return tree.tooltip.node().innerHTML; 98 | }); 99 | 100 | await page2.close(); 101 | 102 | expect(innerHTML).toMatchSnapshot(); 103 | }); 104 | 105 | test('enableTooltip option to false does not show tooltip', async () => { 106 | const page2 = await browser.newPage(); 107 | await page2.goto(pageURL); 108 | 109 | const outerHTML = await page2.evaluate(() => { 110 | const tree = new DependenTree('div#tree', { enableTooltip: false }); 111 | tree.addEntities(royals); 112 | tree.setTree('Elizabeth II'); 113 | // triggers hover event on Elizabeth II 114 | tree.svg.select('svg > g:nth-of-type(2)').dispatch('mousemove'); 115 | return tree.tooltip.node().outerHTML; 116 | }); 117 | 118 | await page2.close(); 119 | 120 | expect(outerHTML).toMatchSnapshot(); 121 | }); 122 | }); 123 | 124 | describe('catches script injection', () => { 125 | let browser; 126 | let page; 127 | let results; 128 | 129 | beforeAll(async () => { 130 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 131 | page = await browser.newPage(); 132 | await page.goto(pageURL); 133 | results = await page.evaluate(() => { 134 | royals[0].scriptInjection = ''; 135 | royals[0][''] = true; 136 | window.tree = new DependenTree('div#tree'); 137 | window.tree.addEntities(royals); 138 | window.tree.setTree('Elizabeth II'); 139 | // triggers hover event on Liz 140 | const node = tree.svg.select('svg > g:nth-of-type(2)'); 141 | node.dispatch('mouseover'); 142 | node.dispatch('mousemove'); 143 | 144 | return [ 145 | Boolean(document.getElementById('bad-script')), 146 | Boolean(document.getElementById('worse-script')) 147 | ]; 148 | }); 149 | }); 150 | 151 | afterAll(async () => { 152 | await page.close(); 153 | browser.close(); 154 | }); 155 | 156 | test('catches key strings', () => { 157 | expect(results[0]).toBe(false); 158 | }); 159 | 160 | test('catches value strings', () => { 161 | expect(results[1]).toBe(false); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - [Contributing](#contributing) 4 | - [Sign the CLA](#sign-the-cla) 5 | - [Guidance](#guidance) 6 | - [One issue or bug per Pull Request](#one-issue-or-bug-per-pull-request) 7 | - [Issues before features](#issues-before-features) 8 | - [Backwards compatibility](#backwards-compatibility) 9 | - [Forwards compatibility](#forwards-compatibility) 10 | - [Making changes](#making-changes) 11 | - [Setup](#setup) 12 | - [Modifying tree behavior](#modifying-tree-behavior) 13 | - [Working with test data](#working-with-test-data) 14 | - [Test data](#test-data) 15 | - [Style Guide / Linting](#style-guide--linting) 16 | - [Testing](#testing) 17 | - [Setup](#setup-1) 18 | - [All Tests](#all-tests) 19 | - [Individual Tests](#individual-tests) 20 | - [Build versions of the library](#build-versions-of-the-library) 21 | 22 | ## Sign the CLA 23 | 24 | All contributors to your PR must sign our [Individual Contributor License Agreement (CLA)](https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1). The CLA is a short form that ensures that you are eligible to contribute. 25 | 26 | 27 | ## Guidance 28 | 29 | ### One issue or bug per Pull Request 30 | 31 | Keep your Pull Requests small. Small PRs are easier to reason about which makes them significantly more likely to get merged. 32 | 33 | 34 | ### Issues before features 35 | 36 | If you want to add a feature, please file an [Issue](../../issues) first. An Issue gives us the opportunity to discuss the requirements and implications of a feature with you before you start writing code. 37 | 38 | 39 | ### Backwards compatibility 40 | 41 | Respect the minimum deployment target. If you are adding code that uses new APIs, make sure to prevent older clients from crashing or misbehaving. Our CI runs against our minimum deployment targets, so you will not get a green build unless your code is backwards compatible. 42 | 43 | 44 | ### Forwards compatibility 45 | 46 | Please do not write new code using deprecated APIs. 47 | 48 | 49 | ## Making changes 50 | 51 | ### Setup 52 | 53 | Develop with live reloading. Make changes in `src/` and see code changes reflected in the `dev/` folder which is served to http://127.0.0.1:8080/. 54 | 55 | ```bash 56 | yarn install 57 | 58 | yarn dev-init 59 | 60 | yarn start 61 | ``` 62 | 63 | Note that `yarn dev-init` copies over test data from `data/` and overwrites any changes you have made to these files. 64 | 65 | 66 | ### Modifying tree behavior 67 | 68 | DependenTree is a visualization library and it's often best to test tree behavior manually as you make source code changes. To do so, make changes in `dev/index.html`. 69 | 70 | 71 | ### Working with test data 72 | 73 | Test data is found in the `data/` folder. These files are copied into `dev/testData.js` and `tests/page/testData.js`. If you need to modify any test data as you develop, please do so in `dev/testData.js` as we do not want to impact `tests/`. Note that changes made to `dev/testData.js` will be overwritten by running `yarn dev-init` again. 74 | 75 | Changes to test data or further additions to test data will be considered in pull requests with good reason. 76 | 77 | 78 | ### Test data 79 | 80 | | Data | Description | 81 | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | 82 | | royals | Data which produces the current british royal family tree. This data is useful as entities in this test data have a number of additional fields. | 83 | | rock-paper-scissors | Data which includes a cycle. | 84 | | binary | a class that produces a binary tree of varying size. Useful for testing scale. | 85 | | | `const bt = new BinaryTreeMaker(); bt.createBinaryTree(30); tree.addEntities(bt.obj);` | 86 | | linked-list | a class that produces a linked list of varying size. Useful for testing max-depth. | 87 | | | `const ll = new LinkedListMaker(); ll.createLinkedList(30); tree.addEntities(ll.obj);` | 88 | 89 | 90 | ### Style Guide / Linting 91 | The files in `src/` follow the [Square JavaScript style guide](https://github.com/square/eslint-plugin-square). Pre-commit hooks will prevent commits unless all lint rules are met. 92 | 93 | ```bash 94 | yarn install 95 | 96 | yarn lint 97 | 98 | or 99 | 100 | yarn lint-fix 101 | ``` 102 | 103 | 104 | ## Testing 105 | 106 | Tests use a mix of Jest's jsdom and puppeteer. The jsdom is used for basic unit tests. Puppeteer is used when a real DOM is required. All tests that require `DependenTree.prototype.setTree` need to execute on a real DOM as real pixel hights and lengths are needed to make calculations on SVG element size. 107 | 108 | With a visualization library, it is difficult to test every aspect of the tree layout. We use Jest [Snapshots](https://jestjs.io/docs/snapshot-testing) when ensuring the structure of the DOM or other verbose outputs remain consistent. 109 | 110 | 111 | ### Setup 112 | 113 | `test-init` bundles DependenTree from `src/` and copies over test and html files from `data/` into `tests/page/`. The files in `test/page/` should not be modified. 114 | 115 | ```bash 116 | yarn install 117 | 118 | yarn test-init 119 | ``` 120 | 121 | ### All Tests 122 | 123 | ```bash 124 | yarn test 125 | ``` 126 | 127 | This will launch a server which will serve `tests/page`. 128 | 129 | ### Individual Tests 130 | 131 | Launch the server and webpack bundler separately and then individually run each test suite 132 | 133 | ```bash 134 | yarn serve-test 135 | 136 | yarn build-test --watch 137 | 138 | jest tests/clone.test.js 139 | ``` 140 | 141 | It's also a good idea to run `yarn start` concurrently as this will allow you to see live changes to the source code if you need to edit or debug it when working on tests. 142 | 143 | 144 | ## Build versions of the library 145 | | Command | Description | 146 | | ---------------- | -------------------------------------------------------------------------------- | 147 | | yarn build-min | minified production build in `dist/` | 148 | | yarn build-es5 | minified ES5 production build in `dist/` | 149 | | yarn build-plain | production build in `dist/` | 150 | | yarn build | build all production versions above in `dist/` | 151 | | yarn build-dev | builds a development version in `dev/` | 152 | | yarn build-watch | continually builds a development version in `dev/` on each code change in `src/` | 153 | -------------------------------------------------------------------------------- /tests/set-tree.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | 3 | const pageURL = 'http://127.0.0.1:8081/index.html'; 4 | 5 | 6 | // Note that every test has it's own puppeteer page 7 | // This is so the jest snapshots stay consistent 8 | // Otherwise there are minor changes between each test 9 | describe('Tree', () => { 10 | let browser; 11 | 12 | beforeAll(async () => { 13 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 14 | }); 15 | 16 | afterAll(() => { 17 | browser.close(); 18 | }); 19 | 20 | describe('Valid Inputs', () => { 21 | test('key', async () => { 22 | const key = 'Elizabeth II'; 23 | 24 | const page = await browser.newPage(); 25 | await page.goto(pageURL); 26 | const rootName = await page.evaluate(key => { 27 | const tree = new DependenTree('div#tree'); 28 | tree.addEntities(royals); 29 | tree.setTree(key); 30 | return tree.root.data._name; 31 | }, key); 32 | 33 | await page.close(); 34 | 35 | expect(rootName).toBe(key); 36 | }); 37 | 38 | test('direction', async () => { 39 | const expected = 'downstream' 40 | 41 | const page = await browser.newPage(); 42 | await page.goto(pageURL); 43 | 44 | const direction = await page.evaluate(direction => { 45 | const tree = new DependenTree('div#tree'); 46 | tree.addEntities(royals); 47 | tree.setTree('Elizabeth II', direction); 48 | return tree.direction; 49 | }, expected); 50 | 51 | await page.close(); 52 | 53 | expect(direction).toBe(expected); 54 | }); 55 | 56 | test('SVG', async () => { 57 | const direction = 'downstream' 58 | 59 | const page = await browser.newPage(); 60 | await page.goto(pageURL); 61 | 62 | const innerHTML = await page.evaluate(direction => { 63 | const tree = new DependenTree('div#tree'); 64 | tree.addEntities(royals); 65 | tree.setTree('Elizabeth II', direction); 66 | return tree.svg.node().innerHTML; 67 | }, direction); 68 | 69 | expect(innerHTML).toMatchSnapshot(); 70 | }); 71 | }); 72 | 73 | describe('Null Inputs', () => { 74 | let browser; 75 | 76 | beforeAll(async () => { 77 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 78 | }); 79 | 80 | afterAll(() => { 81 | browser.close(); 82 | }); 83 | 84 | test('key', async () => { 85 | const target = 'The entity "null" is not found.'; 86 | const key = null; 87 | 88 | const page = await browser.newPage(); 89 | await page.goto(pageURL); 90 | 91 | const message = await page.evaluate(key => { 92 | const tree = new DependenTree('div#tree'); 93 | tree.addEntities(royals); 94 | let message; 95 | try { 96 | tree.setTree(key); 97 | } catch (e) { 98 | message = e.message 99 | } 100 | return message; 101 | }, key); 102 | 103 | await page.close(); 104 | 105 | expect(message).toBe(target); 106 | }); 107 | 108 | test('direction', async () => { 109 | const undef = undefined; 110 | 111 | const page = await browser.newPage(); 112 | await page.goto(pageURL); 113 | 114 | const direction = await page.evaluate(directionArg => { 115 | const tree = new DependenTree('div#tree'); 116 | tree.addEntities(royals); 117 | tree.setTree('Elizabeth II', directionArg); 118 | return tree.direction; 119 | }, undef); 120 | 121 | await page.close(); 122 | 123 | expect(direction).toBe('upstream'); 124 | }); 125 | 126 | test('SVG', async () => { 127 | const direction = undefined; 128 | 129 | const page = await browser.newPage(); 130 | await page.goto(pageURL); 131 | 132 | const innerHTML = await page.evaluate(direction => { 133 | const tree = new DependenTree('div#tree'); 134 | tree.addEntities(royals); 135 | try { 136 | tree.setTree('Elizabeth II', direction); 137 | } catch { 138 | // 139 | } 140 | return tree.svg.node().innerHTML; 141 | }, direction); 142 | 143 | expect(innerHTML).toMatchSnapshot(); 144 | }); 145 | }); 146 | 147 | describe('Invalid Inputs', () => { 148 | test('key', async () => { 149 | const key = 'some string'; 150 | 151 | const page = await browser.newPage(); 152 | await page.goto(pageURL); 153 | const rootName = await page.evaluate(key => { 154 | const tree = new DependenTree('div#tree'); 155 | tree.addEntities(royals); 156 | try { 157 | tree.setTree(key); 158 | } catch { 159 | // 160 | } 161 | return tree.root; 162 | }, key); 163 | 164 | await page.close(); 165 | 166 | expect(rootName).toBeUndefined(); 167 | }); 168 | 169 | test('direction', async () => { 170 | const target = 'The second argument must be either "upstream" or "downstream". Instead received "some string".'; 171 | const direction = 'some string'; 172 | 173 | const page = await browser.newPage(); 174 | await page.goto(pageURL); 175 | 176 | const message = await page.evaluate(direction => { 177 | const tree = new DependenTree('div#tree'); 178 | tree.addEntities(royals); 179 | let message; 180 | try { 181 | tree.setTree('Elizabeth II', direction); 182 | } catch (e) { 183 | message = e.message 184 | } 185 | return message; 186 | }, direction); 187 | 188 | await page.close(); 189 | 190 | expect(target).toBe(message); 191 | }); 192 | 193 | test('SVG', async () => { 194 | const direction = 'some string'; 195 | 196 | const page = await browser.newPage(); 197 | await page.goto(pageURL); 198 | 199 | const innerHTML = await page.evaluate(() => { 200 | const tree = new DependenTree('div#tree'); 201 | tree.addEntities(royals); 202 | try { 203 | tree.setTree('Elizabeth II', direction); 204 | } catch { 205 | // 206 | } 207 | return tree.svg; 208 | }); 209 | 210 | expect(innerHTML).toBeUndefined(); 211 | }); 212 | }); 213 | describe('Scroll', () => { 214 | test('right on upstream', async () => { 215 | const direction = 'upstream'; 216 | 217 | const page = await browser.newPage(); 218 | await page.goto(pageURL); 219 | 220 | const scrollLeft = await page.evaluate(direction => { 221 | const tree = new DependenTree('div#tree'); 222 | tree.addEntities(royals); 223 | tree.setTree('Elizabeth II', direction); 224 | return tree.passedContainerEl.scrollLeft; 225 | 226 | }, direction); 227 | 228 | await page.close(); 229 | 230 | expect(scrollLeft).toBe(3840); 231 | }); 232 | 233 | test('left on downstream', async () => { 234 | const direction = 'downstream'; 235 | 236 | const page = await browser.newPage(); 237 | await page.goto(pageURL); 238 | 239 | const scrollLeft = await page.evaluate(direction => { 240 | const tree = new DependenTree('div#tree'); 241 | tree.addEntities(royals); 242 | tree.setTree('Elizabeth II', direction); 243 | return tree.passedContainerEl.scrollLeft 244 | 245 | }, direction); 246 | 247 | await page.close(); 248 | 249 | expect(scrollLeft).toBe(0); 250 | }); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /tests/text.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const DependenTree = require('./page/main'); 3 | 4 | const pageURL = 'http://127.0.0.1:8081/index.html'; 5 | 6 | describe('Text', () => { 7 | describe('_wrap', () => { 8 | let browser; 9 | const longStr = 'very long string that must be wrapped'; 10 | beforeAll(async () => { 11 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 12 | }); 13 | 14 | afterAll(() => { 15 | browser.close(); 16 | }); 17 | 18 | describe('string with spaces', () => { 19 | test('long string is wrapped', async () => { 20 | const data = [ 21 | { _name: longStr } 22 | ]; 23 | 24 | const page = await browser.newPage(); 25 | await page.goto(pageURL) 26 | 27 | const numLinesOfText = await page.evaluate((data) => { 28 | const tree = new DependenTree('div#tree'); 29 | tree.addEntities(data); 30 | tree.setTree(data[0]._name); 31 | return document.querySelector('text').childElementCount; 32 | }, data); 33 | 34 | await page.close(); 35 | 36 | expect(numLinesOfText).toBe(2); 37 | }); 38 | 39 | test('short string is not wrapped', async () => { 40 | const data = [ 41 | { _name: 'short string' } 42 | ]; 43 | 44 | const page = await browser.newPage(); 45 | await page.goto(pageURL) 46 | 47 | const numLinesOfText = await page.evaluate((data) => { 48 | const tree = new DependenTree('div#tree'); 49 | tree.addEntities(data); 50 | tree.setTree(data[0]._name); 51 | return document.querySelector('text').childElementCount; 52 | }, data); 53 | 54 | await page.close(); 55 | 56 | expect(numLinesOfText).toBe(1); 57 | }); 58 | }); 59 | 60 | describe('string with no spaces', () => { 61 | test('long string is wrapped', async () => { 62 | const data = [ 63 | { _name: longStr.split(' ').join('') } 64 | ]; 65 | 66 | const page = await browser.newPage(); 67 | await page.goto(pageURL) 68 | 69 | const numLinesOfText = await page.evaluate((data) => { 70 | const tree = new DependenTree('div#tree'); 71 | tree.addEntities(data); 72 | tree.setTree(data[0]._name); 73 | return document.querySelector('text').childElementCount; 74 | }, data); 75 | 76 | await page.close(); 77 | 78 | expect(numLinesOfText).toBe(2); 79 | }); 80 | 81 | test('short string is not wrapped', async () => { 82 | const data = [ 83 | { _name: 'shortstring' } 84 | ]; 85 | 86 | const page = await browser.newPage(); 87 | await page.goto(pageURL) 88 | 89 | const numLinesOfText = await page.evaluate((data) => { 90 | const tree = new DependenTree('div#tree'); 91 | tree.addEntities(data); 92 | tree.setTree(data[0]._name); 93 | return document.querySelector('text').childElementCount; 94 | }, data); 95 | 96 | await page.close(); 97 | 98 | expect(numLinesOfText).toBe(1); 99 | }); 100 | }); 101 | 102 | describe('splitStr option', () => { 103 | test('long string with target string is wrapped', async () => { 104 | const splitStr = '_'; 105 | const data = [ 106 | { _name: longStr.split(' ').join(splitStr) } 107 | ]; 108 | 109 | const page = await browser.newPage(); 110 | await page.goto(pageURL) 111 | 112 | const numLinesOfText = await page.evaluate((data, splitStr) => { 113 | const tree = new DependenTree('div#tree', { splitStr }); 114 | tree.addEntities(data); 115 | tree.setTree(data[0]._name); 116 | return document.querySelector('text').childElementCount; 117 | }, data, splitStr); 118 | 119 | await page.close(); 120 | 121 | expect(numLinesOfText).toBe(2); 122 | }); 123 | 124 | test('long string with spaces is not wrapped', async () => { 125 | const splitStr = '_'; 126 | const data = [ 127 | { _name: longStr } 128 | ]; 129 | 130 | const page = await browser.newPage(); 131 | await page.goto(pageURL) 132 | 133 | const numLinesOfText = await page.evaluate((data, splitStr) => { 134 | const tree = new DependenTree('div#tree', { splitStr }); 135 | tree.addEntities(data); 136 | tree.setTree(data[0]._name); 137 | return document.querySelector('text').childElementCount; 138 | }, data, splitStr); 139 | 140 | await page.close(); 141 | 142 | expect(numLinesOfText).toBe(1); 143 | }); 144 | }); 145 | 146 | describe('wrapNodeName option', () => { 147 | test('long string is not wrapped', async () => { 148 | const data = [ 149 | { _name: longStr } 150 | ]; 151 | 152 | const page = await browser.newPage(); 153 | await page.goto(pageURL) 154 | 155 | const innerHTML = await page.evaluate((data) => { 156 | const tree = new DependenTree('div#tree', { wrapNodeName: false }); 157 | tree.addEntities(data); 158 | tree.setTree(data[0]._name); 159 | // text element will have no element children if wrap is disabled 160 | return document.querySelector('text').innerHTML; 161 | }, data); 162 | 163 | await page.close(); 164 | 165 | expect(innerHTML).toBe(longStr); 166 | }); 167 | }); 168 | }); 169 | 170 | describe('_getTextDirection', () => { 171 | describe('downstream', () => { 172 | describe('left', () => { 173 | test('orientation end', () => { 174 | const tree = new DependenTree('body'); 175 | tree.direction = 'downstream'; 176 | const { orientation } = tree._getTextDirection('left'); 177 | expect(orientation).toBe('end'); 178 | }); 179 | 180 | test('offset -13', () => { 181 | const tree = new DependenTree('body'); 182 | tree.direction = 'downstream'; 183 | const { offset } = tree._getTextDirection('left'); 184 | expect(offset).toBe(-13) 185 | }); 186 | }); 187 | 188 | describe('right', () => { 189 | test('orientation start', () => { 190 | const tree = new DependenTree('body'); 191 | tree.direction = 'downstream'; 192 | const { orientation } = tree._getTextDirection('right'); 193 | expect(orientation).toBe('start') 194 | }); 195 | 196 | test('offset 13', () => { 197 | const tree = new DependenTree('body'); 198 | tree.direction = 'downstream'; 199 | const { offset } = tree._getTextDirection('right'); 200 | expect(offset).toBe(13) 201 | }); 202 | }); 203 | }); 204 | 205 | describe('upstream', () => { 206 | describe('left', () => { 207 | test('orientation end', () => { 208 | const tree = new DependenTree('body'); 209 | tree.direction = 'upstream'; 210 | const { orientation } = tree._getTextDirection('left'); 211 | expect(orientation).toBe('start'); 212 | }); 213 | 214 | test('offset -13', () => { 215 | const tree = new DependenTree('body'); 216 | tree.direction = 'upstream'; 217 | const { offset } = tree._getTextDirection('left'); 218 | expect(offset).toBe(13) 219 | }); 220 | }); 221 | 222 | describe('right', () => { 223 | test('orientation start', () => { 224 | const tree = new DependenTree('body'); 225 | tree.direction = 'upstream'; 226 | const { orientation } = tree._getTextDirection('right'); 227 | expect(orientation).toBe('end') 228 | }); 229 | 230 | test('offset 13', () => { 231 | const tree = new DependenTree('body'); 232 | tree.direction = 'upstream'; 233 | const { offset } = tree._getTextDirection('right'); 234 | expect(offset).toBe(-13) 235 | }); 236 | }); 237 | }); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /src/add-entities.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | export function addEntities(entities) { 20 | if (this.dependenciesAdded) { 21 | throw new Error('Entities have already been added. Create a new instance of DependenTree if you need to display other data.'); 22 | } 23 | this._populateUpstream(entities); 24 | this._setPointersForUpstreamAndPopulateDownstream(); 25 | this._setPointersForDownstream(); 26 | this.dependenciesAdded = true; 27 | } 28 | 29 | // takes data and validates that each entity is valid. 30 | // converts the array data format to the object data format 31 | // if needed. This creates the initial upstream structure 32 | export function _populateUpstream(data) { 33 | if (Array.isArray(data)) { 34 | // handles case where data structure is an array of objects, not keyed objects 35 | // creates keyed object structure 36 | data.forEach(obj => { 37 | this._typeCheckEntity(obj, null); 38 | 39 | if (this.upstream[obj._name]) { 40 | throw new Error( 41 | `Entity "${obj._name}" is duplicated in the input data. Ensure that every entity in the input data has a unique name.` 42 | ); 43 | } 44 | 45 | this.upstream[obj._name] = obj; 46 | }); 47 | } else { 48 | for (const key in data) { 49 | this._typeCheckEntity(data[key], key); 50 | } 51 | this.upstream = data; 52 | } 53 | } 54 | 55 | // This function goes through each _deps string in each upstream entity 56 | // and replaces it with a pointer to that entity object 57 | export function _setPointersForUpstreamAndPopulateDownstream() { 58 | // for in loop for speed 59 | for (const entityKey in this.upstream) { 60 | const upEntity = this.upstream[entityKey]; 61 | 62 | // create corresponding downstream entity if does not exist yet 63 | if (this._isNullOrUndef(this.downstream[entityKey])) { 64 | this._addNode('downstream', upEntity); 65 | } 66 | 67 | // get dependencies if are any 68 | const upDeps = upEntity._deps; 69 | if (upDeps) { 70 | for (let i = 0; i < upDeps.length; i++) { 71 | const depStr = upDeps[i]; 72 | 73 | // creates upstream objects if it does not exist yet 74 | if (this._isNullOrUndef(this.upstream[depStr])) { 75 | this._createMissingEntity(depStr); 76 | } 77 | // replaces string dependency with pointer to the corresponding entity object 78 | upDeps[i] = this.upstream[depStr]; 79 | 80 | // same entity object, but now in downstream obj 81 | let downEntity; 82 | 83 | // creates downstream entity object with entityKey depStr if it does not exist yet 84 | if (this._isNullOrUndef(this.downstream[depStr])) { 85 | downEntity = this._addNode('downstream', this.upstream[depStr]); 86 | } else { 87 | downEntity = this.downstream[depStr]; 88 | } 89 | 90 | // adds _deps array if does not exist 91 | if (this._isNullOrUndef(downEntity._deps)) { 92 | downEntity._deps = []; 93 | } 94 | 95 | const downDeps = downEntity._deps; 96 | this._warnDuplicates(downDeps, depStr, entityKey); 97 | downDeps.push(entityKey); 98 | } 99 | } 100 | } 101 | } 102 | 103 | // does the string to pointer replacement but now for downstream objects 104 | // this function is much simpler because all missing entities have been 105 | // added in the above function 106 | export function _setPointersForDownstream() { 107 | for (const entityKey in this.downstream) { 108 | const downEntity = this.downstream[entityKey]; 109 | 110 | if (downEntity._deps) { 111 | this.downstream[entityKey]._deps.forEach((depStr, i) => { 112 | this.downstream[entityKey]._deps[i] = this.downstream[depStr]; 113 | }); 114 | } 115 | } 116 | } 117 | 118 | // Note that this function only checks the values of _name and _deps 119 | // All other additional fields will be represented as strings in HTML 120 | export function _typeCheckEntity(entity, key) { 121 | if (entity.constructor.name !== 'Object') { 122 | throw new Error( 123 | `Entity${ key ? ` "${key}" ` : ' ' }is not of type Object. Instead received a value of "${entity}".` 124 | ); 125 | } 126 | 127 | const { _name, _deps } = entity; 128 | this._isValidNameStr(null, _name); 129 | 130 | // note that it's fine if _deps is undefined or null 131 | if (_deps !== undefined && _deps !== null && !Array.isArray(_deps)) { 132 | throw new Error( 133 | `"_deps" key in "${_name}" entity object is not of type array, undefined, or null. Instead received a value of "${_deps}".` 134 | ); 135 | } 136 | 137 | // checks if the _deps of this node are valid strings too 138 | if (Array.isArray(_deps)) { 139 | _deps.forEach(depName => { 140 | this._isValidNameStr(_name, depName); 141 | }); 142 | } 143 | } 144 | 145 | export function _isValidNameStr(parentStr, str) { 146 | if (this._isNullOrUndef(str) || typeof str !== 'string') { 147 | throw new Error( 148 | `"_name" key in entity object is not of type string. Instead received a value of "${str}" with a type of "${typeof str}".` 149 | ); 150 | } 151 | 152 | if (str === '') { 153 | if (parentStr) { 154 | throw new Error( 155 | `Entity "${parentStr}" was found with an element in "_deps" containing an empty string. This is considered invalid. Ensure all dependencies in _deps are valid strings.` 156 | ); 157 | } 158 | 159 | throw new Error( 160 | 'An entity was found with a "_name" key as an empty string. This is considered invalid.' 161 | ); 162 | } 163 | } 164 | 165 | export function _isNullOrUndef(ele) { 166 | return ele === null || ele === undefined; 167 | } 168 | 169 | export function _addNode(direction, node, additionalProperties) { 170 | const { _name } = node; 171 | // note that _createNodeCopy deletes _deps 172 | this[direction][_name] = this._createNodeCopy(node, additionalProperties); 173 | return this[direction][_name]; 174 | } 175 | 176 | // Note that we only create missing entities in the upstream. They will 177 | // automatically be added to downstream regardless of if they are missing in upstream 178 | export function _createMissingEntity(name) { 179 | const { missingEntityMessage } = this.options; 180 | 181 | let message; 182 | if (typeof missingEntityMessage === 'string') { 183 | message = missingEntityMessage; 184 | } else if (typeof missingEntityMessage === 'function') { 185 | message = missingEntityMessage(name); 186 | } else { 187 | message = `"${name}" was not found in the input entity list and was added by the visualization library. This entity may have additional dependencies of its own.` 188 | } 189 | 190 | this.missingEntities.push(name); 191 | return this._addNode('upstream', { _name: name, _missing: true, 'Automated Note': message }) 192 | } 193 | 194 | /* 195 | Note that we only handle checking a duplicate key when populating downstream 196 | But this is just a warning. We still set a duplicate pointer in both upstream 197 | and downstream. The resulting nodes will look like this. 198 | upstream: { _name: a, _deps: [b, c, c] } 199 | downstream: { _name: c, _deps: [a, a] } 200 | */ 201 | export function _warnDuplicates(arr, depKey, parentKey) { 202 | if (arr.includes(parentKey)) { 203 | console.warn( 204 | `Entity "${parentKey}" has duplicate dependencies entities named "${depKey}".`, 205 | ); 206 | const duplicateKey = `${parentKey} -> ${depKey}`; 207 | if (!this.dupDeps.includes(duplicateKey)) { 208 | this.dupDeps.push(duplicateKey); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /tests/__snapshots__/update.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Update close root 1`] = ` 4 | "Elizabeth IICharlesAnneAndrewEdwardWilliamHarry" 23 | `; 24 | 25 | exports[`Update default tree 1`] = ` 26 | "Elizabeth IICharlesAnneAndrewEdward" 39 | `; 40 | 41 | exports[`Update open node 1`] = ` 42 | "Elizabeth IICharlesAnneAndrewEdwardWilliamHarry" 61 | `; 62 | -------------------------------------------------------------------------------- /tests/add-entities.js: -------------------------------------------------------------------------------- 1 | const DependenTree = require('./page/main'); 2 | const { royals, rps } = require('./page/testData'); 3 | 4 | 5 | // test data needs to be cloned as objects stay consistent 6 | // across different runs of create graph. 7 | function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); } 8 | 9 | // Note that _warnDuplicates is already tested in validate 10 | 11 | describe('Create Graph', () => { 12 | describe('_populateUpstream', () => { 13 | test('valid array input produces upstream', () => { 14 | const tree = new DependenTree('body'); 15 | tree._populateUpstream(royals); 16 | 17 | expect(tree.upstream).toMatchSnapshot(); 18 | }); 19 | 20 | test('valid object input produces upstream', () => { 21 | const tree = new DependenTree('body'); 22 | tree._populateUpstream(rps); 23 | 24 | expect(tree.upstream).toMatchSnapshot(); 25 | }); 26 | 27 | test('invalid array input throws error', () => { 28 | const expected = 'Entity "Elizabeth II" is duplicated in the input data. Ensure that every entity in the input data has a unique name.'; 29 | 30 | const tree = new DependenTree('body'); 31 | const invalidRoyals = [royals[0], royals[0]]; 32 | let error = null; 33 | 34 | try { 35 | tree._populateUpstream(invalidRoyals); 36 | } catch (e) { 37 | error = e.message; 38 | } 39 | 40 | expect(error).toBe(expected); 41 | }); 42 | }); 43 | 44 | // this._populateUpstream(dependencies); 45 | // this._setPointersForUpstreamAndPopulateDownstream(); 46 | // this._setPointersForDownstream(); 47 | 48 | describe('_setPointersForUpstreamAndPopulateDownstream', () => { 49 | test('produces expected upstream object', () => { 50 | const royalsClone = deepClone(royals); 51 | const tree = new DependenTree('body'); 52 | tree._populateUpstream(royalsClone); 53 | tree._setPointersForUpstreamAndPopulateDownstream(); 54 | expect(tree.upstream).toMatchSnapshot(); 55 | }); 56 | 57 | test('produces expected downstream object', () => { 58 | const royalsClone = deepClone(royals); 59 | const tree = new DependenTree('body'); 60 | tree._populateUpstream(royalsClone); 61 | tree._setPointersForUpstreamAndPopulateDownstream(); 62 | expect(tree.downstream).toMatchSnapshot(); 63 | }); 64 | }); 65 | 66 | describe('_setPointersForDownstream', () => { 67 | test('produces expected downstream object royals', () => { 68 | const royalsClone = deepClone(royals); 69 | const tree = new DependenTree('body'); 70 | tree._populateUpstream(royalsClone); 71 | tree._setPointersForUpstreamAndPopulateDownstream(); 72 | tree._setPointersForDownstream(); 73 | expect(tree.downstream).toMatchSnapshot(); 74 | }); 75 | 76 | test('produces expected downstream object rps', () => { 77 | const rpsClone = deepClone(rps); 78 | const tree = new DependenTree('body'); 79 | tree._populateUpstream(rpsClone); 80 | tree._setPointersForUpstreamAndPopulateDownstream(); 81 | tree._setPointersForDownstream(); 82 | expect(tree.downstream).toMatchSnapshot(); 83 | }); 84 | }); 85 | 86 | 87 | describe('_typeCheckEntity', () => { 88 | test('catches not object error', () => { 89 | const expected = 'Entity "foo" is not of type Object. Instead received a value of "true".' 90 | let error = null; 91 | try { 92 | DependenTree.prototype._typeCheckEntity(true, 'foo'); 93 | } catch (e) { 94 | error = e.message; 95 | } 96 | expect(error).toBe(expected); 97 | }); 98 | 99 | test('catches when _deps is not undefined, null, or array', () => { 100 | const expected = '"_deps" key in "foo" entity object is not of type array, undefined, or null. Instead received a value of "true".'; 101 | let error = null; 102 | try { 103 | DependenTree.prototype._typeCheckEntity({ _name: 'foo', _deps: true }, 'foo'); 104 | } catch (e) { 105 | error = e.message; 106 | } 107 | expect(error).toBe(expected); 108 | }); 109 | }); 110 | 111 | describe('_isValidNameStr', () => { 112 | test('catches non string name error', () => { 113 | const expected = '"_name" key in entity object is not of type string. Instead received a value of "true" with a type of "boolean".' 114 | 115 | let error = null; 116 | try { 117 | DependenTree.prototype._isValidNameStr(null, true); 118 | } catch (e) { 119 | error = e.message; 120 | } 121 | expect(error).toBe(expected); 122 | }); 123 | 124 | test('throws error', () => { 125 | const expected = 'An entity was found with a "_name" key as an empty string. This is considered invalid.'; 126 | let error = null; 127 | try { 128 | DependenTree.prototype._isValidNameStr(null, ''); 129 | } catch (e) { 130 | error = e.message; 131 | } 132 | expect(error).toBe(expected); 133 | }); 134 | 135 | test('throws parent error', () => { 136 | const expected = 'Entity "parent" was found with an element in "_deps" containing an empty string. This is considered invalid. Ensure all dependencies in _deps are valid strings.'; 137 | let error = null; 138 | try { 139 | DependenTree.prototype._isValidNameStr('parent', ''); 140 | } catch (e) { 141 | error = e.message; 142 | } 143 | expect(error).toBe(expected); 144 | }); 145 | 146 | test('does not throw error with valid input', () => { 147 | let error = null; 148 | try { 149 | DependenTree.prototype._isValidNameStr('parent', 'child'); 150 | } catch (e) { 151 | error = e.message; 152 | } 153 | expect(error).toBeNull(); 154 | }); 155 | }); 156 | 157 | describe('_isNullOrUndef', () => { 158 | test('null', () => { 159 | const result = DependenTree.prototype._isNullOrUndef(null); 160 | expect(result).toBe(true); 161 | }); 162 | 163 | test('undefined', () => { 164 | const result = DependenTree.prototype._isNullOrUndef(); 165 | expect(result).toBe(true); 166 | }); 167 | 168 | test('other falsy value is not', () => { 169 | const result = DependenTree.prototype._isNullOrUndef(''); 170 | expect(result).toBe(false); 171 | }); 172 | 173 | test('truthy value is not', () => { 174 | const result = DependenTree.prototype._isNullOrUndef({}); 175 | expect(result).toBe(false); 176 | }); 177 | }); 178 | 179 | 180 | describe('_addNode', () => { 181 | test('adds to upstream', () => { 182 | const tree = new DependenTree('body'); 183 | tree._addNode('upstream', { _name: 'foo' }); 184 | expect({ foo: { _name: 'foo' } }).toMatchObject(tree.upstream); 185 | }); 186 | 187 | test('adds to downstream', () => { 188 | const tree = new DependenTree('body'); 189 | tree._addNode('downstream', { _name: 'foo' }); 190 | expect({ foo: { _name: 'foo' } }).toMatchObject(tree.downstream); 191 | }); 192 | 193 | test('adds additional properties', () => { 194 | const tree = new DependenTree('body'); 195 | tree._addNode('upstream', { _name: 'foo' }, { baz: 'bar'}); 196 | expect({ foo: { _name: 'foo', baz: 'bar' } }).toMatchObject(tree.upstream); 197 | }); 198 | }); 199 | 200 | describe('_createMissingEntity', () => { 201 | test('adds missing entity to array', () => { 202 | const tree = new DependenTree('body', { missingEntityMessage: 'foo' }); 203 | tree._createMissingEntity('foo', 'upstream'); 204 | 205 | expect(tree.missingEntities).toEqual(expect.arrayContaining(['foo'])); 206 | }); 207 | 208 | test('missing entity is found in up/downstream', () => { 209 | const expected = { foo: { _name: 'foo', _missing: true, 'Automated Note': 'foo' } }; 210 | const tree = new DependenTree('body', { missingEntityMessage: 'foo' }); 211 | tree._createMissingEntity('foo', 'upstream'); 212 | 213 | expect(expected).toMatchObject(tree.upstream); 214 | }); 215 | 216 | test('missingEntityMessage option as string matches automated note', () => { 217 | const missingEntityMessage = 'foo'; 218 | const tree = new DependenTree('body', { missingEntityMessage }); 219 | const note = tree._createMissingEntity('foo', 'upstream')['Automated Note']; 220 | 221 | expect(note).toBe(missingEntityMessage); 222 | }); 223 | 224 | test('missingEntityMessage option as function changes automated note', () => { 225 | function missingEntityMessage(arg) { return `this is ${arg}`; } 226 | const tree = new DependenTree('body', { missingEntityMessage }); 227 | const note = tree._createMissingEntity('foo', 'upstream')['Automated Note']; 228 | 229 | expect(note).toBe('this is foo'); 230 | }); 231 | 232 | test('missingEntityMessage option default', () => { 233 | const expected = '"foo" was not found in the input entity list and was added by the visualization library. This entity have additional dependencies of its own.'; 234 | const tree = new DependenTree('body'); 235 | const note = tree._createMissingEntity('foo', 'upstream')['Automated Note']; 236 | 237 | expect(note).toBe(expected); 238 | }); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /src/update.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright 2022 Square Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | 19 | // source refers to the ancestor node that this node is 20 | // currently entering from 21 | export function _update(source) { 22 | const { 23 | animationDuration, 24 | parentNodeTextOrientation, 25 | childNodeTextOrientation, 26 | openNodeCircleColor, 27 | closedNodeCircleColor, 28 | maxDepthNodeColor, 29 | cyclicNodeColor, 30 | missingNodeColor, 31 | horizontalSpaceBetweenNodes, 32 | textStyleColor, 33 | textStyleFont, 34 | circleStrokeColor, 35 | circleStrokeWidth, 36 | circleSize, 37 | linkStrokeColor, 38 | linkStrokeWidth, 39 | wrapNodeName, 40 | enableTooltip, 41 | modifyEntityName, 42 | textClick, 43 | marginTop, 44 | marginBottom, 45 | marginLeft, 46 | marginRight 47 | } = this.options; 48 | 49 | const boundClick = this._click.bind(this); 50 | this.treeData = this.treeMap(this.root); 51 | 52 | // Tree data descendants gets all the nodes that are visible on the page. 53 | // specifically the data objects, not the actual node elements. If the 54 | // node element has not previously been on the page yet, the data object 55 | // won't exist on this list. It's important to note that none of these 56 | // are SVGs yet, just objects in memory. Each node has a _children, 57 | // .children, and a data.children. The former two are specific to the 58 | // visual node object, the latter is a part of the data object (that stays 59 | // static). These objects are the same. Even on each new treeData 60 | // variable, the objects are consistent. The object structure is as follows 61 | // node = { entityDataObj, x, y, 0x, 0y ..otherCoordinatesAndVisualData } 62 | 63 | // This code below computes the layout. 64 | const nodes = this.treeData.descendants(); 65 | const links = this.treeData.descendants().slice(1); 66 | 67 | // Normalize for fixed-depth. 68 | nodes.forEach(d => { 69 | const fixedDepth = d.depth * horizontalSpaceBetweenNodes; 70 | // by inverting the y coordinate, we create our upstream (left to right) tree 71 | d.y = this.direction === 'upstream' ? this.width - fixedDepth : fixedDepth; 72 | if (!d.dx) { d.dx = source.x0; } 73 | if (!d.dy) { d.dy = source.y0; } 74 | }); 75 | 76 | // calculates the height of the tree based on the position 77 | // of the leftmost and rightmost nodes 78 | let nodeLeft = this.root; 79 | let nodeRight = this.root; 80 | this.root.eachBefore(node => { 81 | if (node.x < nodeLeft.x) {nodeLeft = node;} 82 | if (node.x > nodeRight.x) {nodeRight = node;} 83 | }); 84 | const height = nodeRight.x - nodeLeft.x + marginTop + marginBottom; 85 | 86 | const transition = this.svg.transition() 87 | .duration(animationDuration) 88 | // dynamically sets the height of the svg based on how many nodes there are to display 89 | .attr('viewBox', [-marginLeft, nodeLeft.x - marginTop, this.width + marginLeft + marginRight, height]) 90 | .tween('resize', window.ResizeObserver ? null : () => () => this.svg.dispatch('toggle')); 91 | 92 | 93 | // ****************** Nodes section *************************** 94 | 95 | // Update the nodes... 96 | const node = this.svg 97 | .selectAll('g.node') 98 | .data(nodes, d => d.id || (d.id = ++this.nodeId)); 99 | 100 | // Enter any new nodes at the source's previous position. 101 | const nodeEnter = node 102 | .enter() 103 | .append('g') 104 | .attr('class', 'node') 105 | .attr('transform', () => `translate(${source.y0},${source.x0})`) 106 | .attr('cursor', 'pointer'); 107 | 108 | // Add Circle for the nodes 109 | nodeEnter 110 | .append('circle') 111 | .on('click', boundClick) 112 | .attr('r', 1e-6) 113 | .style('stroke', d => { 114 | // abnormal nodes don't have a circle border 115 | // so we just fill this color with the same 116 | // color creating a solid dot 117 | if (d.data['Automated Note']) { 118 | if (d.data._maxDepth) { 119 | return maxDepthNodeColor; 120 | } else if (d.data._cyclic) { 121 | return cyclicNodeColor; 122 | } else if (d.data._missing) { 123 | return missingNodeColor; 124 | } 125 | } 126 | return circleStrokeColor; 127 | }) 128 | .style('stroke-width', `${circleStrokeWidth}px`) 129 | .style('fill', d => { 130 | if (d.data['Automated Note']) { 131 | if (d.data._maxDepth) { 132 | return maxDepthNodeColor; 133 | } else if (d.data._cyclic) { 134 | return cyclicNodeColor; 135 | } else if (d.data._missing) { 136 | return missingNodeColor; 137 | } 138 | return circleStrokeColor; 139 | } 140 | return d._children ? closedNodeCircleColor : openNodeCircleColor; 141 | }); 142 | 143 | // handle user options orientation 144 | const parent = this._getTextDirection(parentNodeTextOrientation); 145 | const child = this._getTextDirection(childNodeTextOrientation); 146 | 147 | const text = nodeEnter 148 | .append('text') 149 | .attr('dy', '.35em') 150 | .attr('x', d => 151 | d.children || d._children ? parent.offset : child.offset, 152 | ) 153 | .attr('text-anchor', d => 154 | d.children || d._children ? parent.orientation : child.orientation, 155 | ) 156 | .attr('fill', textStyleColor) 157 | .text(d => this._filterScriptInjection(modifyEntityName ? modifyEntityName(d.data) : d.data._name)) 158 | .style('fill-opacity', 1e-6) 159 | .style('font', textStyleFont) 160 | .on('click', boundClick); 161 | 162 | if (textClick) { 163 | text.on('click', (event, node) => textClick(event, node.data)); 164 | } 165 | 166 | if (wrapNodeName) { 167 | text.call(this._wrap, horizontalSpaceBetweenNodes * 0.75, this.options.splitStr); 168 | } 169 | 170 | const nodeUpdate = nodeEnter.merge(node); 171 | 172 | nodeUpdate 173 | .transition(transition) 174 | .duration(animationDuration) 175 | .attr('transform', d => `translate(${d.y}, ${d.x})`); 176 | 177 | nodeUpdate 178 | .select('circle') 179 | .attr('r', circleSize) 180 | .style('fill', d => { 181 | if (d.data['Automated Note']) { 182 | if (d.data._maxDepth) { 183 | return maxDepthNodeColor; 184 | } else if (d.data._cyclic) { 185 | return cyclicNodeColor; 186 | } else if (d.data._missing) { 187 | return missingNodeColor; 188 | } else { 189 | return circleStrokeColor; 190 | } 191 | } 192 | return d._children ? closedNodeCircleColor : openNodeCircleColor; 193 | }); 194 | 195 | nodeUpdate.select('text').style('fill-opacity', 1) 196 | 197 | if (enableTooltip) { 198 | nodeUpdate 199 | .on('mouseover', this._mouseover.bind(this)) 200 | .on('mousemove', this._mousemove.bind(this)) 201 | .on('mouseout', this._mouseout.bind(this)); 202 | } 203 | 204 | const nodeExit = node 205 | .exit() 206 | .transition(transition) 207 | .duration(animationDuration) 208 | .attr('transform', () => `translate(${source.y},${source.x})`) 209 | .remove(); 210 | 211 | // On exit reduce the node circles size to 0 212 | nodeExit.select('circle').attr('r', 1e-6); 213 | 214 | // On exit reduce the opacity of text labels 215 | nodeExit.select('text').style('fill-opacity', 1e-6); 216 | 217 | 218 | // ****************** links section *************************** 219 | 220 | // Update the links... 221 | const link = this.svg.selectAll('path').data(links, d => d.id); 222 | 223 | // Enter any new links at the source's previous position. 224 | const linkEnter = link 225 | .enter() 226 | .insert('path', 'g') 227 | .attr('d', d => { 228 | const o = { x: source.x0, y: source.y0 }; 229 | return this._diagonal(o, o); 230 | }) 231 | .style('fill', 'none') 232 | .style('stroke', linkStrokeColor) 233 | .style('stroke-width', `${linkStrokeWidth}px`); 234 | 235 | const linkUpdate = linkEnter.merge(link); 236 | 237 | // Transition back to the parent element position 238 | linkUpdate 239 | .transition(transition) 240 | .duration(animationDuration) 241 | .attr('d', d => { 242 | return this._diagonal(d, d.parent); 243 | }); 244 | 245 | // Remove any exiting links 246 | link 247 | .exit() 248 | .transition(transition) 249 | .duration(animationDuration) 250 | .attr('d', d => { 251 | const o = { x: source.x, y: source.y }; 252 | return this._diagonal(o, o); 253 | }) 254 | .remove(); 255 | 256 | 257 | // Store the old positions of each node for transition. 258 | nodes.forEach(d => { 259 | d.x0 = d.x; 260 | d.y0 = d.y; 261 | }); 262 | } 263 | -------------------------------------------------------------------------------- /tests/__snapshots__/tree-helpers.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Tree Helpers _diagonal Passed in numbers produce expected output 1`] = ` 4 | " 5 | M 2 1 6 | C 3 1, 3 3, 4 3 7 | " 8 | `; 9 | 10 | exports[`Tree Helpers Expand Collapse Remove collapse all 1`] = ` 11 | "Elizabeth IICharlesAnneAndrewEdward" 24 | `; 25 | 26 | exports[`Tree Helpers Expand Collapse Remove default tree 1`] = ` 27 | "Elizabeth IICharlesAnneAndrewEdward" 40 | `; 41 | 42 | exports[`Tree Helpers Expand Collapse Remove expand 3 1`] = ` 43 | "Elizabeth IICharlesAnneAndrewEdward" 56 | `; 57 | 58 | exports[`Tree Helpers Expand Collapse Remove expand Infinity 1`] = ` 59 | "Elizabeth IICharlesAnneAndrewEdward" 72 | `; 73 | -------------------------------------------------------------------------------- /tests/clone.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const DependenTree = require('./page/main'); 3 | 4 | const pageURL = 'http://127.0.0.1:8081/index.html'; 5 | 6 | 7 | describe('Clone', () => { 8 | let browser; 9 | beforeAll(async () => { 10 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 11 | }); 12 | 13 | afterAll(async () => { 14 | await browser.close(); 15 | }); 16 | 17 | describe('Cyclic', () => { 18 | let clones; 19 | let upstream; 20 | let page; 21 | beforeAll(async () => { 22 | page = await browser.newPage(); 23 | await page.goto(pageURL); 24 | 25 | ([clones, upstream] = await page.evaluate(() => { 26 | window.tree = new DependenTree('div#tree'); 27 | window.tree.addEntities(rps); 28 | window.tree.setTree('rock'); 29 | return [tree.clones, tree.upstream]; 30 | })); 31 | }); 32 | 33 | afterAll(async () => { 34 | await page.close(); 35 | }); 36 | 37 | test('catches cycles', () => { 38 | expect(clones.length).toBe(1); 39 | }); 40 | 41 | test('cloned node is rock', () => { 42 | const child = clones[0][2]; 43 | expect(child._name).toBe('rock'); 44 | }); 45 | 46 | test('cloned node is identified as clone', () => { 47 | const rockClone = upstream.scissors._deps[0]; 48 | expect(rockClone._isClone).toBe(true); 49 | }); 50 | 51 | test('cloned rock node is not the same as original rock node', () => { 52 | const rockClone = upstream.scissors._deps[0]; 53 | const { rock } = upstream; 54 | expect(rockClone).not.toBe(rock); 55 | }); 56 | 57 | test('"Cyclic Dependency Paths" sets correctly', () => { 58 | const paths = upstream.scissors._deps[0]['Cyclic Dependency Paths']; 59 | expect(paths).toBe('rock → paper → scissors → rock'); 60 | }); 61 | 62 | test('"Automated Note" sets correctly', () => { 63 | const note = upstream.scissors._deps[0]['Automated Note']; 64 | expect(note).toBe( 65 | 'This entity depends on another entity that has already been displayed up the branch. No more entities will be displayed here to prevent an infinite loop.' 66 | ); 67 | }); 68 | 69 | test('cyclicDependencyMessage option as string changes "Automated Note"', async () => { 70 | const page2 = await browser.newPage(); 71 | await page2.goto(pageURL); 72 | 73 | const expectedNote = 'This note is passed in as an option'; 74 | 75 | const note = await page2.evaluate((expectedNote) => { 76 | const tree = new DependenTree('div#tree', { cyclicDependencyMessage: expectedNote }); 77 | tree.addEntities(rps); 78 | tree.setTree('rock'); 79 | return tree.upstream.scissors._deps[0]['Automated Note']; 80 | }, expectedNote); 81 | 82 | page2.close(); 83 | 84 | expect(note).toBe(expectedNote); 85 | }); 86 | 87 | test('cyclicDependencyMessage option as function changes "Automated Note"', async () => { 88 | const page2 = await browser.newPage(); 89 | await page2.goto(pageURL); 90 | 91 | const expectedNote = 'This note is passed in as an option'; 92 | 93 | const note = await page2.evaluate((expectedNote) => { 94 | const tree = new DependenTree('div#tree', { cyclicDependencyMessage: () => expectedNote }); 95 | tree.addEntities(rps); 96 | tree.setTree('rock'); 97 | return tree.upstream.scissors._deps[0]['Automated Note']; 98 | }, expectedNote); 99 | 100 | page2.close(); 101 | 102 | expect(note).toBe(expectedNote); 103 | }); 104 | 105 | // upstream will be undefined because puppeteer 106 | // can't return a circular structure 107 | test('deleting clones makes a circular structure', async () => { 108 | const newUpstream = await page.evaluate(() => { 109 | window.tree._deleteClones(); 110 | return tree.upstream; 111 | }); 112 | expect(newUpstream).toBeUndefined(); 113 | }); 114 | }); 115 | 116 | describe('Max Depth', () => { 117 | const maxDepth = 1; 118 | let clones; 119 | let upstream; 120 | let page; 121 | beforeAll(async () => { 122 | page = await browser.newPage(); 123 | await page.goto(pageURL); 124 | 125 | ([clones, upstream] = await page.evaluate((maxDepth) => { 126 | window.tree = new DependenTree('div#tree', { maxDepth }); 127 | window.tree.addEntities(rps); 128 | window.tree.setTree('rock'); 129 | return [tree.clones, tree.upstream]; 130 | }, maxDepth)); 131 | }); 132 | 133 | afterAll(async () => { 134 | await page.close(); 135 | }); 136 | 137 | test('catches max depth', () => { 138 | expect(clones.length).toBe(1); 139 | }); 140 | 141 | test('cloned node is paper', () => { 142 | const child = clones[0][2]; 143 | expect(child._name).toBe('paper'); 144 | }); 145 | 146 | test('cloned paper node is not the same as original paper node', () => { 147 | const paperClone = upstream.rock._deps[0]; 148 | const { paper } = upstream; 149 | expect(paperClone).not.toBe(paper); 150 | }); 151 | 152 | test('"Automated Note" sets correctly', () => { 153 | const note = upstream.rock._deps[0]['Automated Note']; 154 | expect(note).toBe( 155 | `Maximum depth of ${maxDepth} entities reached. This entity has additional children, but they cannot be displayed. Set this entity as the root to view additional dependencies.` 156 | ); 157 | }); 158 | 159 | test('maxDepthMessage option as string changes "Automated Note"', async () => { 160 | const page2 = await browser.newPage(); 161 | await page2.goto(pageURL); 162 | 163 | const expectedNote = 'This note is passed in as an option'; 164 | 165 | const note = await page2.evaluate((maxDepth, expectedNote) => { 166 | const tree = new DependenTree('div#tree', { maxDepth, maxDepthMessage: expectedNote }); 167 | tree.addEntities(rps); 168 | tree.setTree('rock'); 169 | return tree.upstream.rock._deps[0]['Automated Note']; 170 | }, maxDepth, expectedNote); 171 | 172 | page2.close(); 173 | 174 | expect(note).toBe(expectedNote); 175 | }); 176 | 177 | test('maxDepthMessage option as function changes "Automated Note"', async () => { 178 | const page2 = await browser.newPage(); 179 | await page2.goto(pageURL); 180 | 181 | const expectedNote = 'This note is passed in as an option'; 182 | 183 | const note = await page2.evaluate((maxDepth, expectedNote) => { 184 | const tree = new DependenTree('div#tree', { maxDepth, maxDepthMessage: () => expectedNote }); 185 | tree.addEntities(rps); 186 | tree.setTree('rock'); 187 | return tree.upstream.rock._deps[0]['Automated Note']; 188 | }, maxDepth, expectedNote); 189 | 190 | page2.close(); 191 | 192 | expect(note).toBe(expectedNote); 193 | }); 194 | 195 | test('deleting clones brings back upstream children', async () => { 196 | // only non-clones have a _deps property; 197 | const depsExist = await page.evaluate(() => { 198 | window.tree._deleteClones(); 199 | return Boolean(tree.upstream.rock._deps[0]._deps); 200 | }); 201 | expect(depsExist).toBe(true); 202 | }); 203 | }); 204 | 205 | describe('Error message hierarchy', () => { 206 | test('missing entity > max depth', async () => { 207 | const page2 = await browser.newPage(); 208 | await page2.goto(pageURL); 209 | 210 | const maxDepth = 1; 211 | const data = [{_name: 'a', _deps: ['b', 'c']}, { _name: 'b', _deps: ['d'] }]; 212 | 213 | const [b, c] = await page2.evaluate((maxDepth, data) => { 214 | const tree = new DependenTree('div#tree', { maxDepth }); 215 | tree.addEntities(data); 216 | tree.setTree('a'); 217 | return [ 218 | tree.upstream.a._deps[0]._maxDepth, 219 | tree.upstream.c._missing 220 | ]; 221 | }, maxDepth, data); 222 | 223 | await page2.close(); 224 | 225 | // b is not missing, so we get get _maxDepth 226 | expect(b).toBe(true); 227 | 228 | // c is missing so _missing takes precedence over _maxDepth 229 | expect(c).toBe(true); 230 | }); 231 | 232 | test('cycle > max depth', async () => { 233 | const page2 = await browser.newPage(); 234 | await page2.goto(pageURL); 235 | 236 | const maxDepth = 1; 237 | const data = [{_name: 'a', _deps: ['b', 'a']}, { _name: 'b', _deps: ['d'] }]; 238 | 239 | const [b, a] = await page2.evaluate((maxDepth, data) => { 240 | const tree = new DependenTree('div#tree', { maxDepth }); 241 | tree.addEntities(data); 242 | tree.setTree('a'); 243 | return [ 244 | tree.upstream.a._deps[0]._maxDepth, 245 | tree.upstream.a._deps[1]._cyclic, 246 | ]; 247 | }, maxDepth, data); 248 | 249 | await page2.close(); 250 | 251 | // a is cycle and should have _cyclic which takes precedence 252 | expect(a).toBe(true); 253 | 254 | // b is at max depth but not a cycle so it should have _maxDepth 255 | expect(b).toBe(true); 256 | }); 257 | 258 | test('entity without _deps does not have _maxDepth', async () => { 259 | const page2 = await browser.newPage(); 260 | await page2.goto(pageURL); 261 | 262 | const maxDepth = 1; 263 | const data = [{ _name: 'a', _deps: ['b'] }, { _name: 'b' }]; 264 | 265 | const _maxDepth = await page2.evaluate((maxDepth, data) => { 266 | const tree = new DependenTree('div#tree', { maxDepth }); 267 | tree.addEntities(data); 268 | tree.setTree('a'); 269 | return tree.upstream.a._deps[0]._maxDepth; 270 | }, maxDepth, data); 271 | 272 | await page2.close(); 273 | 274 | expect(_maxDepth).toBeUndefined(); 275 | }); 276 | }); 277 | 278 | describe('Utils', () => { 279 | test('_cloneNode', () => { 280 | const child = { _name: 'bar', _deps: [1, 2, 3] }; 281 | const node = { _name: 'foo', _deps: [child] }; 282 | const index = 0; 283 | 284 | const tree = new DependenTree('body'); 285 | tree._cloneNode(node, index, child); 286 | 287 | const clonedChild = node._deps[index]; 288 | 289 | expect(child).not.toBe(clonedChild); 290 | expect(child._name).toBe('bar'); 291 | }); 292 | 293 | test('_createNodeCopy', () => { 294 | const expected = { _name: 'foo', bar: 'baz' } 295 | 296 | const node = { _name: 'foo', _deps: [] }; 297 | const additionalProperties = { bar: 'baz' }; 298 | const result = DependenTree.prototype._createNodeCopy(node, additionalProperties); 299 | 300 | expect(result).toMatchObject(expected); 301 | }); 302 | }); 303 | }); 304 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/options.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const DependenTree = require('./page/main'); 3 | 4 | const pageURL = 'http://127.0.0.1:8081/index.html'; 5 | 6 | 7 | const defaultOpts = { 8 | // behavior options 9 | animationDuration: 750, 10 | maxDepth: 25, 11 | enableTooltip: true, 12 | enableTooltipKey: true, 13 | modifyEntityName: null, 14 | textClick: null, 15 | maxDepthMessage: null, 16 | missingEntityMessage: null, 17 | cyclicDependencyMessage: null, 18 | 19 | // appearance options 20 | containerWidthMultiplier: 4, 21 | containerWidthInPx: null, 22 | marginTop: 60, 23 | marginRight: 120, 24 | marginBottom: 200, 25 | marginLeft: 120 , 26 | parentNodeTextOrientation: 'left', 27 | childNodeTextOrientation: 'right', 28 | textOffset: 13, 29 | textStyleFont: '12px sans-serif', 30 | textStyleColor: 'black', 31 | circleStrokeColor: 'steelblue', 32 | circleStrokeWidth: 3, 33 | circleSize: 10, 34 | linkStrokeColor: '#dddddd', 35 | linkStrokeWidth: 2, 36 | closedNodeCircleColor: 'lightsteelblue', 37 | openNodeCircleColor: 'white', 38 | cyclicNodeColor: '#FF4242', 39 | missingNodeColor: '#E8F086', 40 | maxDepthNodeColor: '#A691AE', 41 | horizontalSpaceBetweenNodes: 180, 42 | verticalSpaceBetweenNodes: 30, 43 | wrapNodeName: true, 44 | splitStr: null, 45 | tooltipItemStyleObj: { 46 | 'font-family': 'sans-serif', 47 | 'font-size': '12px', 48 | }, 49 | tooltipColonStr: ': ', 50 | tooltipKeyStyleObj: { 'font-weight': 'bold' }, 51 | tooltipColonStyleObj: { 'font-weight': 'bold' }, 52 | tooltipValueStyleObj: {}, 53 | tooltipStyleObj: { 54 | 'background-color': 'white', 55 | border: 'solid', 56 | 'border-width': '1px', 57 | 'border-radius': '5px', 58 | padding: '10px', 59 | }, 60 | }; 61 | 62 | // testing most options. Some of these are tested elsewhere 63 | // so modifying them here is not necessary. those are specified in comments 64 | const modifiedOpts = { 65 | // containerWidthMultiplier: 4, → see constructor.test.js 66 | // containerWidthInPx: null, → see constructor.test.js 67 | // containerHeight: 1, → see constructor.test.js 68 | // containerHeightInPx: null, → see constructor.test.js 69 | margin: { right: 10, bottom: 10, left: 10 }, // note that top is omitted here 70 | // animationDuration: 500, → animation duration is difficult to test, so this is omitted 71 | parentNodeTextOrientation: 'right', 72 | childNodeTextOrientation: 'left', 73 | textOffset: 10, 74 | textStyleFont: '10px sans-serif', 75 | textStyleColor: 'green', 76 | circleStrokeColor: 'green', 77 | circleStrokeWidth: 10, 78 | circleSize: 20, 79 | linkStrokeColor: 'green', 80 | linkStrokeWidth: 5, 81 | closedNodeCircleColor: 'blue', 82 | openNodeCircleColor: 'purple', 83 | cyclicNodeColor: 'green', 84 | missingNodeColor: 'green', 85 | maxDepthNodeColor: 'green', 86 | horizontalSpaceBetweenNodes: 200, 87 | verticalSpaceBetweenNodes: 100, 88 | // maxDepth: 25, → see clone.test.js 89 | // wrapNodeName: true, → see text.test.js 90 | // splitStr: null, → see text.test.js 91 | // enableTooltip: true, → see mouse.test.js 92 | // enableTooltipKey: true, → see mouse.test.js 93 | tooltipItemStyleObj: { 94 | 'font-family': 'Times New Roman', 95 | 'font-size': '20px', 96 | }, 97 | tooltipColonStr: ' - ', 98 | tooltipKeyStyleObj: { 'font-weight': 'normal' }, 99 | tooltipColonStyleObj: { 'font-weight': 'normal' }, 100 | tooltipValueStyleObj: { 'font-weight': 'bold' }, 101 | tooltipStyleObj: { 102 | 'background-color': 'green', 103 | border: 'dotted', 104 | 'border-width': '10px', 105 | 'border-radius': '10px', 106 | padding: '20px', 107 | }, 108 | // modifyEntityName: null, → tested separately at the bottom 109 | // textClick: null, → tested separately at the bottom 110 | // maxDepthMessage: null, → see clone.test.js 111 | // missingEntityMessage: null, → see create-graph.test.js 112 | // cyclicDependencyMessage: null, → see clone.test.js 113 | }; 114 | 115 | describe('Options', () => { 116 | let browser; 117 | beforeAll(async () => { 118 | browser = await puppeteer.launch({ args: ['--no-sandbox'] }); 119 | }); 120 | 121 | afterAll(async () => { 122 | await browser.close(); 123 | }); 124 | 125 | describe('_setOptions', () => { 126 | const combinedOpts = { 127 | ...defaultOpts, 128 | ...modifiedOpts 129 | }; 130 | 131 | test('default option', () => { 132 | const tree = new DependenTree('body'); 133 | expect(tree.options).toMatchObject(defaultOpts); 134 | }); 135 | 136 | test('passed options updated, but others left default', () => { 137 | const tree = new DependenTree('body', modifiedOpts); 138 | expect(tree.options).toMatchObject(combinedOpts); 139 | }); 140 | }); 141 | 142 | describe('changing options impacts visual output', () => { 143 | describe('default', () => { 144 | let page; 145 | beforeAll(async () => { 146 | page = await browser.newPage(); 147 | await page.goto(pageURL); 148 | await page.evaluate(() => { 149 | const tree = new DependenTree('div#tree', { animationDuration: 0 }); 150 | tree.addEntities(royals); 151 | tree.setTree('Elizabeth II', 'downstream'); 152 | 153 | // A few misc properties we're saving before expanding the tree 154 | window.fillColor = document.querySelector('g:nth-of-type(4)> circle').style.fill; 155 | const node = tree.svg.select('svg > g:nth-of-type(3)'); 156 | node.dispatch('mouseover'); 157 | node.dispatch('mousemove'); 158 | window.svgHTML = tree.tooltip.node().outerHTML; 159 | 160 | tree.expandAll(); 161 | }); 162 | 163 | await page.waitForTimeout(1000); 164 | }); 165 | 166 | afterAll(async () => { 167 | await page.close(); 168 | }); 169 | 170 | test('parentNodeTextOrientation', async () => { 171 | const x = await page.evaluate(() => { 172 | return document.querySelector('text').x.animVal[0].value; 173 | }); 174 | 175 | expect(x).toBeLessThan(0); 176 | }); 177 | 178 | test('childNodeTextOrientation', async () => { 179 | const x = await page.evaluate(() => { 180 | return document.querySelector('g:nth-of-type(20) > text').x.animVal[0].value; 181 | }); 182 | 183 | expect(x).toBeGreaterThan(0); 184 | }); 185 | 186 | test('textOffset', async () => { 187 | const x = await page.evaluate(() => { 188 | return document.querySelector('text').x.animVal[0].value; 189 | }); 190 | 191 | expect(Math.abs(x)).toBe(defaultOpts.textOffset); 192 | }); 193 | 194 | test('textStyleFont', async () => { 195 | const font = await page.evaluate(() => { 196 | return document.querySelector('text').style.font; 197 | }); 198 | 199 | expect(font).toBe(defaultOpts.textStyleFont); 200 | }); 201 | 202 | test('textStyleColor', async () => { 203 | const color = await page.evaluate(() => { 204 | return document.querySelector('text').getAttribute('fill'); 205 | }); 206 | 207 | expect(color).toBe(defaultOpts.textStyleColor); 208 | }); 209 | 210 | test('circleStrokeColor', async () => { 211 | const color = await page.evaluate(() => { 212 | return document.querySelector('circle').style.stroke; 213 | }); 214 | 215 | expect(color).toBe(defaultOpts.circleStrokeColor); 216 | }); 217 | 218 | test('circleStrokeWidth', async () => { 219 | const color = await page.evaluate(() => { 220 | return document.querySelector('circle').style.strokeWidth; 221 | }); 222 | 223 | expect(color).toBe(`${defaultOpts.circleStrokeWidth}px`); 224 | }); 225 | 226 | test('circleSize', async () => { 227 | const r = await page.evaluate(() => { 228 | return document.querySelector('circle').r.animVal.value; 229 | }); 230 | 231 | expect(r).toBe(defaultOpts.circleSize); 232 | }); 233 | 234 | test('linkStrokeColor', async () => { 235 | const color = await page.evaluate(() => { 236 | return document.querySelector('path').style.stroke; 237 | }); 238 | 239 | expect(color).toBe('rgb(221, 221, 221)'); 240 | }); 241 | 242 | test('linkStrokeWidth', async () => { 243 | const width = await page.evaluate(() => { 244 | return document.querySelector('path').style['strokeWidth']; 245 | }); 246 | 247 | expect(width).toBe(`${defaultOpts.linkStrokeWidth}px`); 248 | }); 249 | 250 | test('closedNodeCircleColor', async () => { 251 | const color = await page.evaluate(() => window.fillColor); 252 | 253 | expect(color).toBe(defaultOpts.closedNodeCircleColor); 254 | }); 255 | 256 | test('openNodeCircleColor', async () => { 257 | const color = await page.evaluate(() => { 258 | return document.querySelector('circle').style.fill 259 | }); 260 | 261 | expect(color).toBe(defaultOpts.openNodeCircleColor); 262 | }); 263 | 264 | test('horizontalSpaceBetweenNodes', async () => { 265 | const e = await page.evaluate(() => { 266 | return document.querySelector('g:nth-of-type(3)').transform.baseVal.consolidate().matrix.e 267 | }); 268 | 269 | expect(e).toBe(defaultOpts.horizontalSpaceBetweenNodes); 270 | }); 271 | 272 | // Its a little too cumbersome to test every tooltip style option 273 | // Settling for snapshotting the HTML output 274 | test('tootip styles', async () => { 275 | const outerHTML = await page.evaluate(() => window.svgHTML); 276 | 277 | expect(outerHTML).toMatchSnapshot(); 278 | }); 279 | 280 | describe('other colors', () => { 281 | let page; 282 | beforeAll(async () => { 283 | page = await browser.newPage(); 284 | await page.goto(pageURL); 285 | await page.evaluate(() => { 286 | // modify royals slightly so we get different node colors 287 | const james = royals[28]; 288 | james._deps.push('Harry'); 289 | james._deps.push('foo'); 290 | royals[26]._deps.push('James'); 291 | 292 | const tree = new DependenTree('div#tree', { maxDepth: 2, animationDuration: 0 }); 293 | tree.addEntities(royals); 294 | tree.setTree('James'); 295 | tree.expandAll(); 296 | }); 297 | 298 | await page.waitForTimeout(1200); 299 | }); 300 | 301 | afterAll(async () => { 302 | await page.close(); 303 | }); 304 | 305 | test('missingNodeColor', async () => { 306 | const color = await page.evaluate(() => { 307 | return document.querySelector('g:nth-of-type(6) > circle').style.fill; 308 | }); 309 | 310 | expect(color).toBe('rgb(232, 240, 134)'); 311 | }); 312 | 313 | test('cyclicNodeColor', async () => { 314 | const color = await page.evaluate(() => { 315 | return document.querySelector('g:nth-of-type(9) > circle').style.fill; 316 | }); 317 | 318 | expect(color).toBe('rgb(255, 66, 66)'); 319 | }); 320 | 321 | 322 | test('maxDepthNodeColor', async () => { 323 | const color = await page.evaluate(() => { 324 | return document.querySelector('g:nth-of-type(11) > circle').style.fill; 325 | }); 326 | 327 | expect(color).toBe('rgb(166, 145, 174)'); 328 | }); 329 | }); 330 | }); 331 | 332 | describe('modified options', () => { 333 | let page; 334 | beforeAll(async () => { 335 | page = await browser.newPage(); 336 | await page.goto(pageURL); 337 | await page.evaluate(modifiedOpts => { 338 | const tree = new DependenTree('div#tree', { ...modifiedOpts, animationDuration: 0 }); 339 | tree.addEntities(royals); 340 | tree.setTree('Elizabeth II', 'downstream'); 341 | 342 | // A few misc properties we're saving before expanding the tree 343 | window.fillColor = document.querySelector('g:nth-of-type(4)> circle').style.fill; 344 | const node = tree.svg.select('svg > g:nth-of-type(3)'); 345 | node.dispatch('mouseover'); 346 | node.dispatch('mousemove'); 347 | window.svgHTML = tree.tooltip.node().outerHTML; 348 | 349 | tree.expandAll(); 350 | }, modifiedOpts); 351 | 352 | await page.waitForTimeout(1000); 353 | }); 354 | 355 | afterAll(async () => { 356 | await page.close(); 357 | }); 358 | 359 | test('parentNodeTextOrientation', async () => { 360 | const x = await page.evaluate(() => { 361 | return document.querySelector('text').x.animVal[0].value; 362 | }); 363 | 364 | expect(x).toBeGreaterThan(0); 365 | }); 366 | 367 | test('childNodeTextOrientation', async () => { 368 | const x = await page.evaluate(() => { 369 | return document.querySelector('g:nth-of-type(20) > text').x.animVal[0].value; 370 | }); 371 | 372 | expect(x).toBeLessThan(0); 373 | }); 374 | 375 | test('textOffset', async () => { 376 | const x = await page.evaluate(() => { 377 | return document.querySelector('text').x.animVal[0].value; 378 | }); 379 | 380 | expect(Math.abs(x)).toBe(modifiedOpts.textOffset) 381 | }); 382 | 383 | test('textStyleFont', async () => { 384 | const font = await page.evaluate(() => { 385 | return document.querySelector('text').style.font; 386 | }); 387 | 388 | expect(font).toBe(modifiedOpts.textStyleFont); 389 | }); 390 | 391 | test('textStyleColor', async () => { 392 | const color = await page.evaluate(() => { 393 | return document.querySelector('text').getAttribute('fill') 394 | }); 395 | 396 | expect(color).toBe(modifiedOpts.textStyleColor); 397 | }); 398 | 399 | test('circleStrokeColor', async () => { 400 | const color = await page.evaluate(() => { 401 | return document.querySelector('circle').style.stroke; 402 | }); 403 | 404 | expect(color).toBe(modifiedOpts.circleStrokeColor); 405 | }); 406 | 407 | test('circleStrokeWidth', async () => { 408 | const color = await page.evaluate(() => { 409 | return document.querySelector('circle').style.strokeWidth; 410 | }); 411 | 412 | expect(color).toBe(`${modifiedOpts.circleStrokeWidth}px`); 413 | }); 414 | 415 | test('circleSize', async () => { 416 | const r = await page.evaluate(() => { 417 | return document.querySelector('circle').r.animVal.value; 418 | }); 419 | 420 | expect(r).toBe(modifiedOpts.circleSize); 421 | }); 422 | 423 | test('linkStrokeColor', async () => { 424 | const color = await page.evaluate(() => { 425 | return document.querySelector('path').style.stroke; 426 | }); 427 | 428 | expect(color).toBe(modifiedOpts.linkStrokeColor); 429 | }); 430 | 431 | test('linkStrokeWidth', async () => { 432 | const width = await page.evaluate(() => { 433 | return document.querySelector('path').style['strokeWidth']; 434 | }); 435 | 436 | expect(width).toBe(`${modifiedOpts.linkStrokeWidth}px`); 437 | }); 438 | 439 | test('closedNodeCircleColor', async () => { 440 | const color = await page.evaluate(() => window.fillColor); 441 | 442 | expect(color).toBe(modifiedOpts.closedNodeCircleColor); 443 | }); 444 | 445 | test('openNodeCircleColor', async () => { 446 | const color = await page.evaluate(() => { 447 | return document.querySelector('circle').style.fill 448 | }); 449 | 450 | expect(color).toBe(modifiedOpts.openNodeCircleColor); 451 | }); 452 | 453 | test('horizontalSpaceBetweenNodes', async () => { 454 | const e = await page.evaluate(() => { 455 | return document.querySelector('g:nth-of-type(3)').transform.baseVal.consolidate().matrix.e 456 | }); 457 | 458 | expect(e).toBe(modifiedOpts.horizontalSpaceBetweenNodes); 459 | }); 460 | 461 | // Its a little too cumbersome to test every tooltip style option 462 | // Settling for snapshotting the HTML output 463 | test('tootip styles', async () => { 464 | const outerHTML = await page.evaluate(() => window.svgHTML); 465 | 466 | expect(outerHTML).toMatchSnapshot(); 467 | }); 468 | 469 | describe('other colors', () => { 470 | let page; 471 | beforeAll(async () => { 472 | page = await browser.newPage(); 473 | await page.goto(pageURL); 474 | await page.evaluate(modifiedOpts => { 475 | // modify royals slightly so we get different node colors 476 | const james = royals[28]; 477 | james._deps.push('Harry'); 478 | james._deps.push('foo'); 479 | royals[26]._deps.push('James'); 480 | 481 | const tree = new DependenTree( 482 | 'body', 483 | { ...modifiedOpts, maxDepth: 2, animationDuration: 0 } 484 | ); 485 | tree.addEntities(royals); 486 | tree.setTree('James'); 487 | tree.expandAll(); 488 | }, modifiedOpts); 489 | 490 | await page.waitForTimeout(2000); 491 | }); 492 | 493 | afterAll(async () => { 494 | await page.close(); 495 | }); 496 | 497 | test('missingNodeColor', async () => { 498 | const color = await page.evaluate(() => { 499 | return document.querySelector('g:nth-of-type(6) > circle').style.fill; 500 | }); 501 | 502 | expect(color).toBe(modifiedOpts.cyclicNodeColor); 503 | }); 504 | 505 | test('cyclicNodeColor', async () => { 506 | const color = await page.evaluate(() => { 507 | return document.querySelector('g:nth-of-type(9) > circle').style.fill; 508 | }); 509 | 510 | expect(color).toBe(modifiedOpts.cyclicNodeColor); 511 | }); 512 | 513 | 514 | test('maxDepthNodeColor', async () => { 515 | const color = await page.evaluate(() => { 516 | return document.querySelector('g:nth-of-type(11) > circle').style.fill; 517 | }); 518 | 519 | expect(color).toBe(modifiedOpts.maxDepthNodeColor); 520 | }); 521 | }); 522 | }); 523 | }); 524 | 525 | describe('modifyEntityName & textClick', () => { 526 | describe('passed as functions', () => { 527 | let page; 528 | beforeAll(async () => { 529 | 530 | page = await browser.newPage(); 531 | await page.goto(pageURL); 532 | 533 | await page.evaluate(() => { 534 | function modifyEntityName(data) { return `${data._name} foo`; }; 535 | function textClick() { window.clicked = true; }; 536 | window.tree = new DependenTree( 537 | 'body', 538 | { animationDuration: 0, modifyEntityName, textClick }, 539 | ); 540 | window.tree.addEntities(royals); 541 | window.tree.setTree('Elizabeth II', 'downstream'); 542 | }); 543 | }); 544 | 545 | afterAll(async () => { 546 | await page.close(); 547 | }); 548 | 549 | test('modifyEntityName', async () => { 550 | const innerHTML = await page.evaluate(() => { 551 | return document.querySelector('tspan').innerHTML; 552 | }); 553 | 554 | expect(innerHTML).toBe('Elizabeth II foo'); 555 | }); 556 | 557 | test('textClick', async () => { 558 | const clicked = await page.evaluate(() => { 559 | window.tree.svg.select('text').dispatch('click'); 560 | return window.clicked; 561 | }); 562 | 563 | expect(clicked).toBe(true); 564 | }); 565 | 566 | test('with textClick function, clicking on text does not expand/collapse node', async () => { 567 | const [before, after] = await page.evaluate(() => { 568 | const before = document.querySelector('circle').style.fill; 569 | window.tree.svg.select('text').dispatch('click'); 570 | const after = document.querySelector('circle').style.fill; 571 | return [before, after] 572 | }); 573 | 574 | expect(before).toBe(after); 575 | }); 576 | }); 577 | 578 | describe('default value of null', () => { 579 | let page; 580 | beforeAll(async () => { 581 | page = await browser.newPage(); 582 | await page.goto(pageURL); 583 | 584 | await page.evaluate(() => { 585 | window.tree = new DependenTree('div#tree',{ animationDuration: 0 }); 586 | window.tree.addEntities(royals); 587 | window.tree.setTree('Elizabeth II', 'downstream'); 588 | }); 589 | }); 590 | 591 | afterAll(async () => { 592 | await page.close(); 593 | }); 594 | 595 | test('modifyEntityName', async () => { 596 | const innerHTML = await page.evaluate(() => { 597 | return document.querySelector('tspan').innerHTML; 598 | }); 599 | 600 | expect(innerHTML).toBe('Elizabeth II'); 601 | }); 602 | 603 | test('textClick', async () => { 604 | const clicked = await page.evaluate(() => { 605 | window.tree.svg.select('text').dispatch('click'); 606 | return window.clicked; 607 | }); 608 | 609 | expect(clicked).toBeUndefined(); 610 | }); 611 | 612 | test('without textClick function, clicking on text expands/collapses node', async () => { 613 | const [before, after] = await page.evaluate(() => { 614 | const before = document.querySelector('circle').style.fill; 615 | window.tree.svg.select('text').dispatch('click'); 616 | const after = document.querySelector('circle').style.fill; 617 | return [before, after]; 618 | }); 619 | 620 | expect(before).not.toBe(after); 621 | }); 622 | }); 623 | }); 624 | 625 | describe('catches script injection', () => { 626 | test('in options', () => { 627 | let treeInitialized; 628 | try { 629 | new DependenTree('body', { maxDepthMessage: '' }); 630 | treeInitialized = true; 631 | } catch { 632 | treeInitialized = false; 633 | } 634 | expect(treeInitialized).toBe(false); 635 | }); 636 | }) 637 | }); 638 | --------------------------------------------------------------------------------