├── 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 | "
5 | -
6 | Title:
7 |
8 | Queen
9 |
10 |
11 |
"
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 | "
20 | -
21 |
22 |
23 | Queen
24 |
25 |
26 |
"
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 | "
5 | -
6 | Title:
7 |
8 | Prince of Wales
9 |
10 |
11 |
"
12 | `;
13 |
14 | exports[`Options changing options impacts visual output modified options tootip styles 1`] = `
15 | "
16 | -
17 | Title -
18 |
19 | Prince of Wales
20 |
21 |
22 |
"
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 = "";
59 |
60 | for (const key in d.data) {
61 | if (key[0] === '_') {
62 | continue;
63 | }
64 | const value = d.data[key];
65 | if (this._isNullOrUndef(value) || value === '') {
66 | continue;
67 | }
68 |
69 | const filteredKey = this._filterScriptInjection(key);
70 |
71 | const keyColon = enableTooltipKey
72 | ? `${filteredKey}${tooltipColonStr}`
73 | : '';
74 |
75 | const filteredVal = this._filterScriptInjection(value);
76 |
77 | str += `
78 | -
79 | ${keyColon}
80 |
81 | ${filteredVal}
82 |
83 |
84 | `;
85 | }
86 | 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 |
--------------------------------------------------------------------------------