├── .nvmrc ├── .npmrc ├── .gitattributes ├── __mocks__ ├── styles.mock.js └── icon.mock.js ├── .eslintignore ├── example.gif ├── .huskyrc ├── flow-typed ├── d3_v4.x.x.js ├── dagre_v0.x.x.js ├── react-ace_v6.x.x.js ├── styles.js └── brace_v0.x.x.js ├── css-module.js ├── src ├── utilities │ ├── layout-engine │ │ ├── layout-engine-types.js │ │ ├── none.js │ │ ├── layout-engine-config.js │ │ ├── layout-engine.js │ │ ├── snap-to-grid.js │ │ ├── vertical-tree.js │ │ └── horizontal-tree.js │ ├── transformers │ │ ├── transformer.js │ │ └── bwdl-transformer.js │ └── graph-util.js ├── examples │ ├── bwdl-editable │ │ ├── bwdl-editable.scss │ │ ├── bwdl-config.js │ │ ├── bwdl-example-data.js │ │ └── index.js │ ├── app.js │ ├── bwdl │ │ ├── bwdl.scss │ │ ├── bwdl-node-form.js │ │ ├── bwdl-config.js │ │ ├── bwdl-example-data.js │ │ └── index.js │ ├── index.html │ ├── app.scss │ ├── sidebar.js │ └── graph-config.js ├── components │ ├── circle.js │ ├── dropshadow-filter.js │ ├── background-pattern.js │ ├── background.js │ ├── arrowhead-marker.js │ ├── defs.js │ ├── graph-view-props.js │ ├── graph-controls.js │ ├── node-text.js │ └── node.js ├── index.js └── styles │ └── main.scss ├── jest-setup.js ├── run_local.sh ├── .lintstagedrc ├── .npmignore ├── .gitignore ├── .babelrc ├── react-digraph.iml ├── .flowconfig ├── tools └── analyzeBundle.js ├── __tests__ ├── utilities │ ├── layout-engine │ │ ├── none.test.js │ │ ├── layout-engine-config.test.js │ │ ├── layout-engine.test.js │ │ ├── snap-to-grid.test.js │ │ └── vertical-tree.test.js │ └── transformers │ │ ├── transformer.test.js │ │ └── bwdl-transformer.test.js ├── index.test.js └── components │ ├── circle.test.js │ ├── background-pattern.test.js │ ├── dropshadow-filter.test.js │ ├── background.test.js │ ├── arrowhead-marker.test.js │ ├── defs.test.js │ ├── node-text.test.js │ ├── graph-controls.test.js │ ├── graph-util.test.js │ └── node.test.js ├── .vscode └── settings.json ├── server ├── package.json └── server.js ├── LICENSE ├── .eslintrc ├── CHANGELOG.md ├── webpack.prod.js ├── webpack.config.js ├── package.json ├── typings └── index.d.ts └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.16.3 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.com/" 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json -diff 2 | dist/* -diff -------------------------------------------------------------------------------- /__mocks__/styles.mock.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | dist/* 3 | flow-typed/* 4 | coverage/* -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuttle-hq/taskgraph/HEAD/example.gif -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /flow-typed/d3_v4.x.x.js: -------------------------------------------------------------------------------- 1 | declare module 'd3' { 2 | declare module.exports: any; 3 | } 4 | -------------------------------------------------------------------------------- /flow-typed/dagre_v0.x.x.js: -------------------------------------------------------------------------------- 1 | declare module 'dagre' { 2 | declare module.exports: any; 3 | } 4 | -------------------------------------------------------------------------------- /css-module.js: -------------------------------------------------------------------------------- 1 | declare module CSSModule { 2 | declare var exports: { [key: string]: string }; 3 | } 4 | -------------------------------------------------------------------------------- /flow-typed/react-ace_v6.x.x.js: -------------------------------------------------------------------------------- 1 | declare module 'react-ace' { 2 | declare module.exports: any; 3 | } 4 | -------------------------------------------------------------------------------- /flow-typed/styles.js: -------------------------------------------------------------------------------- 1 | declare module '../styles/main.scss' { 2 | declare module.exports: any; 3 | } 4 | -------------------------------------------------------------------------------- /src/utilities/layout-engine/layout-engine-types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type LayoutEngineType = 4 | | 'None' 5 | | 'SnapToGrid' 6 | | 'VerticalTree' 7 | | 'HorizontalTree'; 8 | -------------------------------------------------------------------------------- /flow-typed/brace_v0.x.x.js: -------------------------------------------------------------------------------- 1 | declare module 'brace/mode/json' { 2 | declare module.exports: any; 3 | } 4 | 5 | declare module 'brace/theme/monokai' { 6 | declare module.exports: any; 7 | } 8 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | const Enzyme = require('enzyme'); 2 | const EnzymeAdapter = require('enzyme-adapter-react-16'); 3 | 4 | // Setup enzyme's react adapter 5 | Enzyme.configure({ adapter: new EnzymeAdapter() }); 6 | -------------------------------------------------------------------------------- /run_local.sh: -------------------------------------------------------------------------------- 1 | mkdir -p ~/.taskgraph 2 | docker run -d -p 27017:27017 -v ~/.taskgraph/data:/data/db mongo 3 | rm /tmp/server-output.log 4 | nohup node server/server.js > /tmp/server-output.log & 5 | npm run serve 6 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "linters": { 3 | "src/**/*.js": [ 4 | "eslint --fix", 5 | "git add" 6 | ], 7 | "*.json": [ 8 | "prettier --write", 9 | "git add" 10 | ] 11 | }, 12 | "ignore": [] 13 | } 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | src/ 3 | coverage/ 4 | dist/test/ 5 | dist/examples/ 6 | example.gif 7 | __tests__ 8 | __mocks__ 9 | webpack.* 10 | jest-* 11 | yarn.lock 12 | dist/bwdl-* 13 | dist/css.* 14 | dist/app.* 15 | dist/example* 16 | dist/graph* 17 | dist/index* 18 | dist/sidebar* 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | dist/hot 4 | dist/ 5 | 6 | # temporary jenkins files 7 | test/test.js.tap 8 | jshint.xml 9 | jshint-proper.xml 10 | 11 | core 12 | package-lock.json 13 | yarn.lock 14 | npm-debug.log 15 | 16 | # WebStorm 17 | .idea 18 | 19 | .DS_Store 20 | *.log 21 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-object-assign", 4 | "transform-es2015-destructuring", 5 | "transform-object-rest-spread", 6 | "transform-class-properties" 7 | ], 8 | "presets": [ 9 | "flow", 10 | "react", 11 | "es2015", 12 | "stage-2" 13 | ], 14 | "compact" : true 15 | } 16 | -------------------------------------------------------------------------------- /react-digraph.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [include] 2 | ./src 3 | 4 | [ignore] 5 | .*/dist/.* 6 | .*/__tests__/.* 7 | .*/__mocks__/.* 8 | /node_modules/.* 9 | 10 | [libs] 11 | /flow-typed/ 12 | 13 | [options] 14 | module.name_mapper='^~/\(.*\)$' -> '/src/\1' 15 | module.name_mapper='.+\(.s?css\)' -> 'CSSModule' 16 | module.name_mapper='components' -> '/components' 17 | 18 | [version] 19 | -------------------------------------------------------------------------------- /tools/analyzeBundle.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 3 | import config from '../webpack.prod'; 4 | 5 | config.plugins.push(new BundleAnalyzerPlugin()); 6 | process.env.NODE_ENV = 'production'; 7 | 8 | const compiler = webpack(config); 9 | 10 | compiler.run((error, stats) => { 11 | if (error) { 12 | throw new Error(error); 13 | } 14 | 15 | console.log(stats); 16 | }); 17 | -------------------------------------------------------------------------------- /__tests__/utilities/layout-engine/none.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | 4 | import None from '../../../src/utilities/layout-engine/none'; 5 | 6 | describe('None', () => { 7 | const output = null; 8 | 9 | describe('class', () => { 10 | it('is defined', () => { 11 | expect(None).toBeDefined(); 12 | }); 13 | 14 | it('instantiates', () => { 15 | const blah = new None(); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "flow.useNPMPackagedFlow": true, 3 | "javascript.validate.enable": false, 4 | "typescript.validate.enable": false, 5 | "search.exclude": { 6 | "**/flow-typed": true, 7 | "**/node_modules": true, 8 | "**/bower_components": true, 9 | "**/dist*": true 10 | }, 11 | "prettier.eslintIntegration": true, 12 | "eslint.enable": true, 13 | "workbench.colorCustomizations": {}, 14 | "editor.tabSize": 2 15 | } 16 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taskgraph-server", 3 | "version": "1.0.0", 4 | "description": "The backend for taskgraph", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "npm start", 8 | "start": "node server.js" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "body-parser": "^1.19.0", 14 | "cors": "^2.8.5", 15 | "express": "^4.17.1", 16 | "mongoose": "^5.8.10" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import GV, { 2 | Edge, 3 | Node, 4 | GraphUtils, 5 | BwdlTransformer, 6 | GraphView, 7 | } from '../src/'; 8 | 9 | describe('Imports', () => { 10 | it('has all of the exports', () => { 11 | expect(GV).toBeDefined(); 12 | expect(Edge).toBeDefined(); 13 | expect(Node).toBeDefined(); 14 | expect(GraphUtils).toBeDefined(); 15 | expect(BwdlTransformer).toBeDefined(); 16 | expect(GV).toEqual(GraphView); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /__mocks__/icon.mock.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | ''; 3 | -------------------------------------------------------------------------------- /__tests__/utilities/layout-engine/layout-engine-config.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | 4 | import { LayoutEngines } from '../../../src/utilities/layout-engine/layout-engine-config'; 5 | 6 | describe('LayoutEngineConfig', () => { 7 | const output = null; 8 | 9 | describe('class', () => { 10 | it('is defined', () => { 11 | expect(LayoutEngines).toBeDefined(); 12 | expect(LayoutEngines.None).toBeDefined(); 13 | expect(LayoutEngines.SnapToGrid).toBeDefined(); 14 | expect(LayoutEngines.VerticalTree).toBeDefined(); 15 | expect(LayoutEngines.HorizontalTree).toBeDefined(); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/examples/bwdl-editable/bwdl-editable.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright(c) 2018 Uber Technologies, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | #bwdl-editable-graph { 18 | height: 100%; 19 | width: 100%; 20 | display: flex; 21 | } 22 | -------------------------------------------------------------------------------- /src/utilities/layout-engine/none.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import LayoutEngine, { type IPosition } from './layout-engine'; 18 | 19 | class None extends LayoutEngine { 20 | calculatePosition(node: IPosition) { 21 | return node; 22 | } 23 | } 24 | 25 | export default None; 26 | -------------------------------------------------------------------------------- /__tests__/components/circle.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import Circle from '../../src/components/circle'; 8 | 9 | describe('Circle component', () => { 10 | let output = null; 11 | 12 | beforeEach(() => { 13 | output = shallow(); 14 | }); 15 | 16 | describe('render method', () => { 17 | it('renders without props', () => { 18 | expect(output.props().className).toEqual('circle'); 19 | expect(output.props().cx).toEqual(18); 20 | expect(output.props().cy).toEqual(18); 21 | expect(output.props().r).toEqual(2); 22 | }); 23 | 24 | it('renders with props', () => { 25 | output.setProps({ 26 | gridDotSize: 3, 27 | gridSpacing: 10, 28 | }); 29 | expect(output.props().className).toEqual('circle'); 30 | expect(output.props().cx).toEqual(5); 31 | expect(output.props().cy).toEqual(5); 32 | expect(output.props().r).toEqual(3); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /__tests__/utilities/transformers/transformer.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | import Transformer from '../../../src/utilities/transformers/transformer'; 5 | 6 | describe('Transformer', () => { 7 | const output = null; 8 | 9 | describe('class', () => { 10 | it('is defined', () => { 11 | expect(Transformer).toBeDefined(); 12 | }); 13 | }); 14 | 15 | describe('transform method', () => { 16 | it('returns a default response', () => { 17 | const expected = { 18 | edges: [], 19 | nodes: [], 20 | }; 21 | const result = Transformer.transform(); 22 | 23 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 24 | }); 25 | }); 26 | 27 | describe('revert method', () => { 28 | it('mocks out the revert method', () => { 29 | const expected = { 30 | edges: [], 31 | nodes: [], 32 | }; 33 | const result = Transformer.revert(expected); 34 | 35 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/examples/app.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright(c) 2018 Uber Technologies, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import * as React from 'react'; 18 | import * as ReactDOM from 'react-dom'; 19 | 20 | import Graph from './graph'; 21 | 22 | import './app.scss'; 23 | 24 | class App extends React.Component { 25 | render() { 26 | return ; 27 | } 28 | } 29 | 30 | if (typeof window !== 'undefined') { 31 | window.onload = () => { 32 | ReactDOM.render(, document.getElementById('content')); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /__tests__/utilities/layout-engine/layout-engine.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | import LayoutEngine from '../../../src/utilities/layout-engine/layout-engine'; 5 | 6 | describe('LayoutEngine', () => { 7 | const output = null; 8 | 9 | describe('class', () => { 10 | it('is defined', () => { 11 | expect(LayoutEngine).toBeDefined(); 12 | }); 13 | }); 14 | 15 | describe('calculatePosition method', () => { 16 | it('returns the node with no changes', () => { 17 | const layoutEngine = new LayoutEngine(); 18 | const position = { x: 1, y: 2 }; 19 | const newPosition = layoutEngine.calculatePosition(position); 20 | 21 | expect(newPosition).toEqual(position); 22 | }); 23 | }); 24 | 25 | describe('getPositionForNode method', () => { 26 | it('does not modify the node', () => { 27 | const layoutEngine = new LayoutEngine(); 28 | const node = { x: 1, y: 2 }; 29 | const newPosition = layoutEngine.getPositionForNode(node); 30 | 31 | expect(newPosition).toEqual(node); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/utilities/layout-engine/layout-engine-config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import None from './none'; 19 | import SnapToGrid from './snap-to-grid'; 20 | import VerticalTree from './vertical-tree'; 21 | import HorizontalTree from './horizontal-tree'; 22 | 23 | export type LayoutEngine = None | SnapToGrid | VerticalTree | HorizontalTree; 24 | 25 | export const LayoutEngines = { 26 | None, 27 | SnapToGrid, 28 | VerticalTree, 29 | HorizontalTree, 30 | }; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Uber Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/examples/bwdl/bwdl.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright(c) 2018 Uber Technologies, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | #bwdl-graph { 18 | height: 100%; 19 | width: 100%; 20 | display: flex; 21 | } 22 | 23 | .selected-node-container { 24 | padding: 10px; 25 | 26 | .node-property { 27 | margin-bottom: 10px; 28 | 29 | .choices { 30 | padding-left: 10px; 31 | margin-bottom: 10px; 32 | 33 | .and-object { 34 | padding-left: 10px; 35 | margin-bottom: 5px; 36 | } 37 | } 38 | 39 | .node-sub-property { 40 | padding-left: 10px; 41 | } 42 | } 43 | 44 | label { 45 | font-weight: bold; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /__tests__/components/background-pattern.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import BackgroundPattern from '../../src/components/background-pattern'; 8 | 9 | describe('BackgroundPattern component', () => { 10 | let output = null; 11 | 12 | beforeEach(() => { 13 | output = shallow(); 14 | }); 15 | 16 | describe('render method', () => { 17 | it('renders without props', () => { 18 | expect(output.props().id).toEqual('grid'); 19 | expect(output.props().width).toEqual(undefined); 20 | expect(output.props().height).toEqual(undefined); 21 | expect(output.props().patternUnits).toEqual('userSpaceOnUse'); 22 | }); 23 | 24 | it('renders with props', () => { 25 | output.setProps({ 26 | gridDotSize: 3, 27 | gridSpacing: 10, 28 | }); 29 | expect(output.props().width).toEqual(10); 30 | expect(output.props().height).toEqual(10); 31 | expect(output.children().length).toEqual(1); 32 | const circleProps = output 33 | .children() 34 | .first() 35 | .props(); 36 | 37 | expect(circleProps.gridSpacing).toEqual(10); 38 | expect(circleProps.gridDotSize).toEqual(3); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/examples/index.html: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | taskgraph - home 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/circle.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | 20 | type ICircleProps = { 21 | gridSpacing?: number, 22 | gridDotSize?: number, 23 | }; 24 | 25 | class Circle extends React.Component { 26 | static defaultProps = { 27 | gridDotSize: 2, 28 | gridSpacing: 36, 29 | }; 30 | 31 | render() { 32 | const { gridSpacing, gridDotSize } = this.props; 33 | 34 | return ( 35 | 41 | ); 42 | } 43 | } 44 | 45 | export default Circle; 46 | -------------------------------------------------------------------------------- /src/components/dropshadow-filter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | 20 | class DropshadowFilter extends React.Component { 21 | render() { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | } 37 | 38 | export default DropshadowFilter; 39 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import GV from './components/graph-view'; 19 | import { type LayoutEngine as LayoutEngineConfigTypes } from './utilities/layout-engine/layout-engine-config'; 20 | import type { IEdge } from './components/edge'; 21 | import type { INode } from './components/node'; 22 | 23 | export { default as Edge } from './components/edge'; 24 | export type IEdgeType = IEdge; 25 | export { default as GraphUtils } from './utilities/graph-util'; 26 | export { default as Node } from './components/node'; 27 | export type INodeType = INode; 28 | export { default as BwdlTransformer } from './utilities/transformers/bwdl-transformer'; 29 | export { GV as GraphView }; 30 | export type LayoutEngineType = LayoutEngineConfigTypes; 31 | export default GV; 32 | -------------------------------------------------------------------------------- /src/components/background-pattern.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | import Circle from './circle'; 20 | 21 | type IBackgroundPatternProps = { 22 | gridSpacing?: number, 23 | gridDotSize?: number, 24 | }; 25 | 26 | class BackgroundPattern extends React.Component { 27 | render() { 28 | const { gridSpacing, gridDotSize } = this.props; 29 | 30 | return ( 31 | 38 | 39 | 40 | ); 41 | } 42 | } 43 | 44 | export default BackgroundPattern; 45 | -------------------------------------------------------------------------------- /__tests__/components/dropshadow-filter.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import DropshadowFilter from '../../src/components/dropshadow-filter'; 8 | 9 | describe('DropshadowFilter component', () => { 10 | let output = null; 11 | 12 | beforeEach(() => { 13 | output = shallow(); 14 | }); 15 | 16 | describe('render method', () => { 17 | it('renders', () => { 18 | expect(output.props().id).toEqual('dropshadow'); 19 | expect(output.props().height).toEqual('130%'); 20 | 21 | const feGaussianBlur = output.find('feGaussianBlur'); 22 | 23 | expect(feGaussianBlur.props().in).toEqual('SourceAlpha'); 24 | expect(feGaussianBlur.props().stdDeviation).toEqual('3'); 25 | 26 | const feOffset = output.find('feOffset'); 27 | 28 | expect(feOffset.props().dx).toEqual('2'); 29 | expect(feOffset.props().dy).toEqual('2'); 30 | expect(feOffset.props().result).toEqual('offsetblur'); 31 | 32 | const feFuncA = output.find('feComponentTransfer>feFuncA'); 33 | 34 | expect(feFuncA.props().type).toEqual('linear'); 35 | expect(feFuncA.props().slope).toEqual('0.1'); 36 | 37 | const feMergeNode = output.find('feMerge>feMergeNode'); 38 | 39 | expect(feMergeNode.length).toEqual(2); 40 | expect(feMergeNode.last().props().in).toEqual('SourceGraphic'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/utilities/transformers/transformer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import { type IEdge } from '../../components/edge'; 19 | import { type INode } from '../../components/node'; 20 | 21 | export type IGraphInput = { 22 | nodes: INode[], 23 | edges: IEdge[], 24 | }; 25 | 26 | export default class Transformer { 27 | /** 28 | * Converts an input from the specified type to IGraphInput type. 29 | * @param input 30 | * @returns IGraphInput 31 | */ 32 | static transform(input: any): IGraphInput { 33 | return { 34 | edges: [], 35 | nodes: [], 36 | }; 37 | } 38 | 39 | /** 40 | * Converts a graphInput to the specified transformer type. 41 | * @param graphInput 42 | * @returns any 43 | */ 44 | static revert(graphInput: IGraphInput): any { 45 | return graphInput; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/background.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | 20 | type IBackgroundProps = { 21 | gridSize?: number, 22 | backgroundFillId?: string, 23 | renderBackground?: (gridSize?: number) => any, 24 | }; 25 | 26 | class Background extends React.Component { 27 | static defaultProps = { 28 | backgroundFillId: '#grid', 29 | gridSize: 40960, 30 | }; 31 | 32 | render() { 33 | const { gridSize, backgroundFillId, renderBackground } = this.props; 34 | 35 | if (renderBackground != null) { 36 | return renderBackground(gridSize); 37 | } 38 | 39 | return ( 40 | 48 | ); 49 | } 50 | } 51 | 52 | export default Background; 53 | -------------------------------------------------------------------------------- /__tests__/components/background.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import Background from '../../src/components/background'; 8 | 9 | describe('Background component', () => { 10 | let output; 11 | let instance; 12 | 13 | beforeEach(() => { 14 | output = shallow(); 15 | instance = output.instance(); 16 | }); 17 | 18 | describe('render method', () => { 19 | it('renders without props', () => { 20 | expect(output.props().className).toEqual('background'); 21 | expect(output.props().x).toEqual(-10240); 22 | expect(output.props().y).toEqual(-10240); 23 | expect(output.props().width).toEqual(40960); 24 | expect(output.props().height).toEqual(40960); 25 | expect(output.props().fill).toEqual('url(#grid)'); 26 | }); 27 | 28 | it('renders with props', () => { 29 | output.setProps({ 30 | backgroundFillId: '#test', 31 | gridSize: 400, 32 | }); 33 | expect(output.props().x).toEqual(-100); 34 | expect(output.props().y).toEqual(-100); 35 | expect(output.props().width).toEqual(400); 36 | expect(output.props().height).toEqual(400); 37 | expect(output.props().fill).toEqual('url(#test)'); 38 | }); 39 | }); 40 | 41 | describe('renderBackground method', () => { 42 | it('uses the renderBackground callback', () => { 43 | const renderBackground = jest.fn().mockReturnValue('test'); 44 | 45 | output.setProps({ 46 | gridSize: 1000, 47 | renderBackground, 48 | }); 49 | 50 | expect(renderBackground).toHaveBeenCalledWith(1000); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/arrowhead-marker.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | 20 | type IArrowheadMarkerProps = { 21 | edgeArrowSize?: number, 22 | }; 23 | 24 | class ArrowheadMarker extends React.Component { 25 | static defaultProps = { 26 | edgeArrowSize: 8, 27 | }; 28 | 29 | render() { 30 | const { edgeArrowSize } = this.props; 31 | 32 | if (!edgeArrowSize && edgeArrowSize !== 0) { 33 | return null; 34 | } 35 | 36 | return ( 37 | 46 | 51 | 52 | ); 53 | } 54 | } 55 | 56 | export default ArrowheadMarker; 57 | -------------------------------------------------------------------------------- /src/utilities/layout-engine/layout-engine.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | // import { type IGraphViewProps } from '../../components/graph-view'; 19 | import { type IGraphViewProps } from '../../components/graph-view-props'; 20 | import { type INode } from '../../components/node'; 21 | 22 | export type IPosition = { 23 | x: number, 24 | y: number, 25 | [key: string]: any, 26 | }; 27 | 28 | export default class LayoutEngine { 29 | graphViewProps: IGraphViewProps; 30 | constructor(graphViewProps: IGraphViewProps) { 31 | this.graphViewProps = graphViewProps; 32 | } 33 | 34 | calculatePosition(node: IPosition) { 35 | return node; 36 | } 37 | 38 | adjustNodes(nodes: INode[], nodesMap?: any): INode[] { 39 | let node = null; 40 | 41 | for (let i = 0; i < nodes.length; i++) { 42 | node = nodes[i]; 43 | const position = this.calculatePosition({ 44 | x: node.x || 0, 45 | y: node.y || 0, 46 | }); 47 | 48 | node.x = position.x; 49 | node.y = position.y; 50 | } 51 | 52 | return nodes; 53 | } 54 | 55 | getPositionForNode(node: IPosition): IPosition { 56 | return this.calculatePosition(node); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /__tests__/components/arrowhead-marker.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import ArrowheadMarker from '../../src/components/arrowhead-marker'; 8 | 9 | describe('ArrowheadMarker component', () => { 10 | let output = null; 11 | 12 | beforeEach(() => { 13 | output = shallow(); 14 | }); 15 | 16 | describe('render method', () => { 17 | it('renders without props', () => { 18 | expect(output.props().id).toEqual('end-arrow'); 19 | expect(output.props().viewBox).toEqual('0 -4 8 8'); 20 | expect(output.props().refX).toEqual('4'); 21 | expect(output.props().markerWidth).toEqual('8'); 22 | expect(output.props().markerHeight).toEqual('8'); 23 | 24 | expect(output.children().length).toEqual(1); 25 | const arrowPathProps = output 26 | .children() 27 | .first() 28 | .props(); 29 | 30 | expect(arrowPathProps.className).toEqual('arrow'); 31 | expect(arrowPathProps.d).toEqual('M0,-4L8,0L0,4'); 32 | }); 33 | 34 | it('renders with props', () => { 35 | output.setProps({ 36 | edgeArrowSize: 3, 37 | }); 38 | expect(output.props().viewBox).toEqual('0 -1.5 3 3'); 39 | expect(output.props().refX).toEqual('1.5'); 40 | expect(output.props().markerWidth).toEqual('3'); 41 | expect(output.props().markerHeight).toEqual('3'); 42 | 43 | const arrowPathProps = output 44 | .children() 45 | .first() 46 | .props(); 47 | 48 | expect(arrowPathProps.d).toEqual('M0,-1.5L3,0L0,1.5'); 49 | }); 50 | 51 | it('renders without an edgeArrowSize', () => { 52 | output.setProps({ 53 | edgeArrowSize: null, 54 | }); 55 | 56 | expect(output.getElement()).toBeNull(); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "allowImportExportEverywhere": false, 6 | "codeFrame": false 7 | }, 8 | "plugins": [ 9 | "flowtype", 10 | "jest" 11 | ], 12 | "env": { 13 | "browser": true, 14 | "jest/globals": true, 15 | "node": true 16 | }, 17 | "extends": ["plugin:prettier/recommended", "eslint-config-fusion"], 18 | "rules": { 19 | "prettier/prettier": [ 20 | "error", 21 | { 22 | "bracketSpacing": true, 23 | "singleQuote": true, 24 | "trailingComma": "es5", 25 | "jsxBracketSameLine": false 26 | } 27 | ], 28 | "react/no-deprecated": ["warn"], 29 | "flowtype/space-after-type-colon": ["off"], 30 | "curly": ["error", "all"], 31 | "no-var": "error", 32 | "prefer-const": "error", 33 | "no-use-before-define": "error", 34 | "object-curly-spacing": ["error", "always"], 35 | "space-before-blocks": "error", 36 | "no-console": ["error", { "allow": ["warn", "error"]}], 37 | "padding-line-between-statements": [ 38 | "error", 39 | { "blankLine": "always", "prev": "*", "next": "return" }, 40 | { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, 41 | { 42 | "blankLine": "any", 43 | "prev": ["const", "let", "var"], 44 | "next": ["const", "let", "var"] 45 | }, 46 | { "blankLine": "always", "prev": "directive", "next": "*" }, 47 | { "blankLine": "any", "prev": "directive", "next": "directive" }, 48 | { "blankLine": "always", "prev": "*", "next": "if" }, 49 | { "blankLine": "always", "prev": "if", "next": "*" }, 50 | { "blankLine": "always", "prev": "*", "next": "function" } 51 | ], 52 | "no-useless-constructor": "error" 53 | }, 54 | "settings": { 55 | "flowtype": { 56 | "onlyFilesWithFlowAnnotation": true 57 | }, 58 | "react": { 59 | "version": "detect" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const bodyParser = require('body-parser'); 4 | const mongoose = require('mongoose'); 5 | 6 | 7 | const Schema = mongoose.Schema; 8 | 9 | // ===== Express Setup ===== 10 | 11 | const app = express(); 12 | const port = 3001; 13 | 14 | app.use(cors()); 15 | app.use(bodyParser.json()); 16 | 17 | // ===== Mongo/ose Setup ===== 18 | mongoose.Promise = global.Promise; 19 | mongoose.connect('mongodb://localhost:27017/taskgraph').then(r => { 20 | console.log('MongoDB connected'); 21 | }); 22 | const GraphSchema = new Schema({}, {strict: false}); 23 | const GraphModel = mongoose.model('Graph', GraphSchema); 24 | 25 | // ===== Endpoints Setup ===== 26 | 27 | app.post('/state', (req, res) => { 28 | let graph = req.body; 29 | const timeStampedGraph = { 30 | time: Date.now(), 31 | graph: graph, 32 | }; 33 | const thing = new GraphModel(timeStampedGraph); 34 | 35 | thing 36 | .save() 37 | .then(r => { 38 | res.sendStatus(200); 39 | }) 40 | .catch(err => { 41 | // 422 is Unprocessed Entity, so the POST failed 42 | res.sendStatus(422); 43 | }); 44 | }); 45 | app.get('/state', (req, res) => { 46 | 47 | GraphModel.findOne() 48 | .sort('-time') 49 | .exec(function (err, model) { 50 | if (err) { 51 | console.err(err); 52 | res.sendStatus(500); 53 | } else { 54 | // Hacky hacky hack because I don't want to define a schema yet 55 | if (model == null) { 56 | res.sendStatus(404); 57 | } else { 58 | let graph = JSON.parse(JSON.stringify(model)).graph; 59 | res.json(graph); 60 | } 61 | } 62 | }); 63 | }); 64 | 65 | app.listen(port, () => 66 | console.log(`TaskGraph server listening on port ${port}!`) 67 | ); 68 | -------------------------------------------------------------------------------- /__tests__/utilities/layout-engine/snap-to-grid.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | 4 | import SnapToGrid from '../../../src/utilities/layout-engine/snap-to-grid'; 5 | 6 | describe('SnapToGrid', () => { 7 | const output = null; 8 | 9 | describe('class', () => { 10 | it('is defined', () => { 11 | expect(SnapToGrid).toBeDefined(); 12 | }); 13 | 14 | it('instantiates', () => { 15 | const snapToGrid = new SnapToGrid(); 16 | }); 17 | }); 18 | 19 | describe('calculatePosition method', () => { 20 | it('adjusts the node position to be centered on a grid space', () => { 21 | const snapToGrid = new SnapToGrid({ 22 | gridSpacing: 10, 23 | }); 24 | const newPosition = snapToGrid.calculatePosition({ x: 9, y: 8 }); 25 | const expected = { x: 5, y: 5 }; 26 | 27 | expect(JSON.stringify(newPosition)).toEqual(JSON.stringify(expected)); 28 | }); 29 | 30 | it('uses the default grid spacing', () => { 31 | const snapToGrid = new SnapToGrid({}); 32 | const newPosition = snapToGrid.calculatePosition({ x: 9, y: 8 }); 33 | const expected = { x: 5, y: 5 }; 34 | 35 | expect(JSON.stringify(newPosition)).toEqual(JSON.stringify(expected)); 36 | }); 37 | 38 | it('defaults the x and y to 0 when they are not present', () => { 39 | const snapToGrid = new SnapToGrid({ 40 | gridSpacing: 10, 41 | }); 42 | const newPosition = snapToGrid.calculatePosition({}); 43 | const expected = { x: 0, y: 0 }; 44 | 45 | expect(JSON.stringify(newPosition)).toEqual(JSON.stringify(expected)); 46 | }); 47 | 48 | it('moves the positions in the reverse direction', () => { 49 | const snapToGrid = new SnapToGrid({ 50 | gridSpacing: 10, 51 | }); 52 | const newPosition = snapToGrid.calculatePosition({ x: 11, y: 11 }); 53 | const expected = { x: 15, y: 15 }; 54 | 55 | expect(JSON.stringify(newPosition)).toEqual(JSON.stringify(expected)); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/utilities/layout-engine/snap-to-grid.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import LayoutEngine, { type IPosition } from './layout-engine'; 19 | 20 | class SnapToGrid extends LayoutEngine { 21 | calculatePosition(node: IPosition) { 22 | const { x, y } = node; 23 | let { gridSpacing } = this.graphViewProps; 24 | 25 | gridSpacing = gridSpacing || 10; 26 | const gridOffset = gridSpacing / 2; 27 | 28 | let newX = x || 0; 29 | let newY = y || 0; 30 | 31 | if (x && (x - gridOffset) % gridSpacing !== 0) { 32 | // Add (gridSpacing / 2) to account for the dot rendering. 33 | // Now the center of the node is on a dot. 34 | let multiplier = 1; 35 | 36 | if ((x - gridOffset) % gridSpacing < gridOffset) { 37 | multiplier = -1; 38 | } 39 | 40 | newX = 41 | gridSpacing * Math.round(x / gridSpacing) + gridOffset * multiplier; 42 | } 43 | 44 | if (y && (y - gridOffset) % gridSpacing !== 0) { 45 | // Add (gridSpacing / 2) to account for the dot rendering. 46 | // Now the center of the node is on a dot. 47 | let multiplier = 1; 48 | 49 | if ((y - gridOffset) % gridSpacing < gridOffset) { 50 | multiplier = -1; 51 | } 52 | 53 | newY = 54 | gridSpacing * Math.round(y / gridSpacing) + gridOffset * multiplier; 55 | } 56 | 57 | return { 58 | x: newX, 59 | y: newY, 60 | }; 61 | } 62 | } 63 | 64 | export default SnapToGrid; 65 | -------------------------------------------------------------------------------- /__tests__/components/defs.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import ArrowheadMarker from '../../src/components/arrowhead-marker'; 8 | import BackgroundPattern from '../../src/components/background-pattern'; 9 | import Defs from '../../src/components/defs'; 10 | import DropshadowFilter from '../../src/components/dropshadow-filter'; 11 | 12 | describe('Circle component', () => { 13 | let output; 14 | let nodeTypes; 15 | let nodeSubtypes; 16 | let edgeTypes; 17 | 18 | beforeEach(() => { 19 | nodeTypes = { 20 | testType: { 21 | shape: , 22 | }, 23 | }; 24 | nodeSubtypes = { 25 | testSubtype: { 26 | shape: , 27 | }, 28 | }; 29 | edgeTypes = { 30 | testEdgeType: { 31 | shape: , 32 | }, 33 | }; 34 | 35 | output = shallow( 36 | 41 | ); 42 | }); 43 | 44 | describe('render method', () => { 45 | it('renders without optional props', () => { 46 | expect(output.find('circle').length).toEqual(1); 47 | expect(output.find('rect').length).toEqual(1); 48 | expect(output.find('path').length).toEqual(1); 49 | expect(output.find(ArrowheadMarker).length).toEqual(1); 50 | expect(output.find(ArrowheadMarker).props().edgeArrowSize).toEqual(8); 51 | expect(output.find(BackgroundPattern).length).toEqual(1); 52 | expect(output.find(DropshadowFilter).length).toEqual(1); 53 | }); 54 | 55 | it('renders with optional props', () => { 56 | output.setProps({ 57 | edgeArrowSize: 4, 58 | gridDotSize: 3, 59 | gridSpacing: 10, 60 | }); 61 | expect(output.find(ArrowheadMarker).props().edgeArrowSize).toEqual(4); 62 | expect(output.find(BackgroundPattern).props().gridSpacing).toEqual(10); 63 | expect(output.find(BackgroundPattern).props().gridDotSize).toEqual(3); 64 | }); 65 | 66 | it('uses the renderDefs prop callback', () => { 67 | output.setProps({ 68 | renderDefs: () => { 69 | return ; 70 | }, 71 | }); 72 | 73 | expect(output.find('ellipse').length).toEqual(1); 74 | expect(output.find('ellipse').props().id).toEqual('renderDefsEllipse'); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | **v6.6.0** - January 8, 2020 2 | - PR #184 - Horizontal tree (@chiayenhung) - https://github.com/uber/react-digraph/pull/184 3 | - PR #185 - new props README for HorizontalTree (@chiayenhung) - https://github.com/uber/react-digraph/pull/185 4 | 5 | **v6.5.0** - August 7, 2019 6 | - PR #148 - Asynchronous Fast rendering 7 | - PR #157 - Added data-intersect-ignore property to node SVG to prevent intersection calculaton from selecting the wrong element. 8 | - PR #159 - Resolved #158 - Add tooltip for edges 9 | 10 | **v6.4.0** - July 11, 2019 11 | - PR #145 - Fix 'Cannot read property 'getBBox' of null' error when remounting (@ksnyder9801) - https://github.com/uber/react-digraph/pull/145 12 | - PR #139 - Added eslint rules and fixed code. (@ajbogh) - https://github.com/uber/react-digraph/pull/139 13 | - PR #136 - Add horizontal layotu engine (@wfriebel) - https://github.com/uber/react-digraph/pull/136 14 | - PR #134 - Resolved #114 - maxTitleChars property is not being used (@thesuperhomie) - https://github.com/uber/react-digraph/pull/134 15 | - PR #131 - Fix expected params in handleDragEnd test (@ksnyder9801) - https://github.com/uber/react-digraph/pull/131 16 | 17 | 18 | **v6.3.0** - May 21, 2019 19 | - PR #130 - Added code to return the d3 event on node selection (@ajbogh) - https://github.com/uber/react-digraph/pull/130 20 | - PR #129 - Add panToNode/panToEdge imperative methods (@ksnyder9801) - https://github.com/uber/react-digraph/pull/129 21 | 22 | **v6.2.0** - Feb 26, 2019 23 | - PR #99 - Avoid creating orphan edges (@iamsoorena) - https://github.com/uber/react-digraph/pull/99 24 | - PR #109 - Only import the expand icon (@rileyhilliard) - https://github.com/uber/react-digraph/pull/109 25 | - PR #107 - Adding webpack-build-analyzer to react-digraph (@rileyhilliard) - https://github.com/uber/react-digraph/pull/107 26 | 27 | **v6.0.0** - Jan 7, 2019 - Added mouse event to onCreateNode callback - Contributor: iamsoorena - https://github.com/uber/react-digraph/pull/98 28 | 29 | **v5.1.0** - Nov 5, 2018 - Refactor of several APIs to fix race condition caused by using array indices to reference nodes rather than node IDs. Race condition would occur when a service would rewrite the array asynchronously, causing the indices to change. This would cause any node movement or edge changes to break. 30 | 31 | **v5.0.6** - Oct 30, 2018 - First official release of v5.0 code. Please note that v4.x code is still present and being worked on, but it eventually be deprecated. Issues should include a version number to indicate to the developer which branch to work on. PRs should be targeted toward the correct branch (master is v5+, v4.x.x is older code). 32 | -------------------------------------------------------------------------------- /src/utilities/layout-engine/vertical-tree.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as dagre from 'dagre'; 19 | import { type INode } from '../../components/node'; 20 | import SnapToGrid from './snap-to-grid'; 21 | 22 | class VerticalTree extends SnapToGrid { 23 | adjustNodes(nodes: INode[], nodesMap?: any): INode[] { 24 | const { 25 | nodeKey, 26 | nodeSize, 27 | nodeHeight, 28 | nodeWidth, 29 | nodeSpacingMultiplier, 30 | } = this.graphViewProps; 31 | const g = new dagre.graphlib.Graph(); 32 | 33 | g.setGraph({}); 34 | g.setDefaultEdgeLabel(() => ({})); 35 | 36 | const spacing = nodeSpacingMultiplier || 1.5; 37 | const size = (nodeSize || 1) * spacing; 38 | let height; 39 | let width; 40 | 41 | if (nodeHeight) { 42 | height = nodeHeight * spacing; 43 | } 44 | 45 | if (nodeWidth) { 46 | width = nodeWidth * spacing; 47 | } 48 | 49 | nodes.forEach(node => { 50 | if (!nodesMap) { 51 | return; 52 | } 53 | 54 | const nodeId = node[nodeKey]; 55 | const nodeKeyId = `key-${nodeId}`; 56 | const nodesMapNode = nodesMap[nodeKeyId]; 57 | 58 | // prevent disconnected nodes from being part of the graph 59 | if ( 60 | nodesMapNode.incomingEdges.length === 0 && 61 | nodesMapNode.outgoingEdges.length === 0 62 | ) { 63 | return; 64 | } 65 | 66 | g.setNode(nodeKeyId, { width: width || size, height: height || size }); 67 | nodesMapNode.outgoingEdges.forEach(edge => { 68 | g.setEdge(nodeKeyId, `key-${edge.target}`); 69 | }); 70 | }); 71 | 72 | dagre.layout(g); 73 | 74 | g.nodes().forEach(gNodeId => { 75 | const nodesMapNode = nodesMap[gNodeId]; 76 | 77 | // gNode is the dagre representation 78 | const gNode = g.node(gNodeId); 79 | 80 | nodesMapNode.node.x = gNode.x; 81 | nodesMapNode.node.y = gNode.y; 82 | }); 83 | 84 | return nodes; 85 | } 86 | } 87 | 88 | export default VerticalTree; 89 | -------------------------------------------------------------------------------- /src/utilities/transformers/bwdl-transformer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import { type IEdge } from '../../components/edge'; 19 | import { type INode } from '../../components/node'; 20 | import Transformer, { type IGraphInput } from './transformer'; 21 | 22 | export default class BwdlTransformer extends Transformer { 23 | static transform(input: any) { 24 | if (!input.States) { 25 | return { 26 | edges: [], 27 | nodes: [], 28 | }; 29 | } 30 | 31 | const nodeNames = Object.keys(input.States); 32 | 33 | const nodes: INode[] = []; 34 | const edges: IEdge[] = []; 35 | 36 | nodeNames.forEach(name => { 37 | const currentNode = input.States[name]; 38 | 39 | if (!currentNode) { 40 | return; 41 | } 42 | 43 | const nodeToAdd: INode = { 44 | title: name, 45 | type: currentNode.Type, 46 | x: currentNode.x || 0, 47 | y: currentNode.y || 0, 48 | }; 49 | 50 | if (name === input.StartAt) { 51 | nodes.unshift(nodeToAdd); 52 | } else { 53 | nodes.push(nodeToAdd); 54 | } 55 | 56 | // create edges 57 | if (currentNode.Type === 'Choice') { 58 | // multiple edges 59 | currentNode.Choices.forEach(choice => { 60 | if (input.States[choice.Next]) { 61 | edges.push({ 62 | source: name, 63 | target: choice.Next, 64 | }); 65 | } 66 | }); 67 | 68 | // Choice nodes carry both a Choices list and an optional Default value which is not part of the list. 69 | if (currentNode.Default) { 70 | edges.push({ 71 | source: name, 72 | target: currentNode.Default, 73 | }); 74 | } 75 | } else if (currentNode.Next) { 76 | if (input.States[currentNode.Next]) { 77 | // single edge 78 | edges.push({ 79 | source: name, 80 | target: currentNode.Next, 81 | }); 82 | } 83 | } 84 | }); 85 | 86 | return { 87 | edges, 88 | nodes, 89 | }; 90 | } 91 | 92 | static revert(graphInput: IGraphInput) { 93 | return graphInput; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /__tests__/components/node-text.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import NodeText from '../../src/components/node-text'; 8 | 9 | describe('NodeText component', () => { 10 | let output = null; 11 | let nodeData; 12 | let nodeTypes; 13 | 14 | beforeEach(() => { 15 | nodeData = { 16 | title: 'Test', 17 | type: 'fake', 18 | }; 19 | nodeTypes = { 20 | fake: { 21 | typeText: 'Fake', 22 | }, 23 | }; 24 | output = shallow( 25 | 26 | ); 27 | }); 28 | 29 | describe('render method', () => { 30 | it('renders', () => { 31 | expect(output.props().className).toEqual('node-text'); 32 | const tspan = output.find('tspan'); 33 | 34 | expect(tspan.at(0).text()).toEqual('Fake'); 35 | expect(tspan.at(1).text()).toEqual('Test'); 36 | expect(tspan.at(1).props().x).toEqual(0); 37 | expect(tspan.at(1).props().dy).toEqual(18); 38 | const title = output.find('title'); 39 | 40 | expect(title.at(0).text()).toEqual('Test'); 41 | }); 42 | 43 | it('renders as selected', () => { 44 | output.setProps({ 45 | isSelected: true, 46 | }); 47 | expect(output.props().className).toEqual('node-text selected'); 48 | }); 49 | 50 | it('does not render a title element when there is no title', () => { 51 | nodeData.title = null; 52 | output.setProps({ 53 | nodeData, 54 | }); 55 | const tspan = output.find('tspan'); 56 | const title = output.find('title'); 57 | 58 | expect(tspan.length).toEqual(1); 59 | expect(title.length).toEqual(0); 60 | }); 61 | 62 | it('truncates node title characters when maxTitleChars is supplied', () => { 63 | output.setProps({ 64 | maxTitleChars: 2, 65 | }); 66 | const tspan = output.find('tspan'); 67 | 68 | expect(tspan.at(1).text()).toEqual('Te'); 69 | }); 70 | }); 71 | 72 | describe('getTypeText method', () => { 73 | it('returns the node typeText', () => { 74 | const result = output.instance().getTypeText(nodeData, nodeTypes); 75 | 76 | expect(result).toEqual('Fake'); 77 | }); 78 | 79 | it('returns the emptyNode typeText', () => { 80 | nodeData.type = 'notFound'; 81 | nodeTypes.emptyNode = { 82 | typeText: 'Empty', 83 | }; 84 | const result = output.instance().getTypeText(nodeData, nodeTypes); 85 | 86 | expect(result).toEqual('Empty'); 87 | }); 88 | 89 | it('returns null when the type is not available and there is no emptyNode', () => { 90 | nodeData.type = 'notFound'; 91 | const result = output.instance().getTypeText(nodeData, nodeTypes); 92 | 93 | expect(result).toEqual(null); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/components/defs.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | import ArrowheadMarker from './arrowhead-marker'; 20 | import BackgroundPattern from './background-pattern'; 21 | import DropshadowFilter from './dropshadow-filter'; 22 | 23 | type IDefsProps = { 24 | gridSpacing?: number, 25 | gridDotSize?: number, 26 | edgeArrowSize?: number, 27 | nodeTypes: any, // TODO: define nodeTypes, nodeSubtypes, and edgeTypes. Must have shape and shapeId! 28 | nodeSubtypes: any, 29 | edgeTypes: any, 30 | renderDefs?: () => any | null, 31 | }; 32 | 33 | type IDefsState = { 34 | graphConfigDefs: any, 35 | }; 36 | 37 | class Defs extends React.Component { 38 | static defaultProps = { 39 | gridDotSize: 2, 40 | renderDefs: () => null, 41 | }; 42 | 43 | static getDerivedStateFromProps(nextProps: any, prevState: any) { 44 | const graphConfigDefs = []; 45 | 46 | Defs.processGraphConfigDefs(nextProps.nodeTypes, graphConfigDefs); 47 | Defs.processGraphConfigDefs(nextProps.nodeSubtypes, graphConfigDefs); 48 | Defs.processGraphConfigDefs(nextProps.edgeTypes, graphConfigDefs); 49 | 50 | return { 51 | graphConfigDefs, 52 | }; 53 | } 54 | 55 | static processGraphConfigDefs(typesObj: any, graphConfigDefs: any) { 56 | Object.keys(typesObj).forEach(type => { 57 | const safeId = typesObj[type].shapeId 58 | ? typesObj[type].shapeId.replace('#', '') 59 | : 'graphdef'; 60 | 61 | graphConfigDefs.push( 62 | React.cloneElement(typesObj[type].shape, { 63 | key: `${safeId}-${graphConfigDefs.length + 1}`, 64 | }) 65 | ); 66 | }); 67 | } 68 | 69 | constructor(props: IDefsProps) { 70 | super(props); 71 | this.state = { 72 | graphConfigDefs: [], 73 | }; 74 | } 75 | 76 | render() { 77 | const { edgeArrowSize, gridSpacing, gridDotSize } = this.props; 78 | 79 | return ( 80 | 81 | {this.state.graphConfigDefs} 82 | 83 | 84 | 85 | 89 | 90 | 91 | 92 | {this.props.renderDefs && this.props.renderDefs()} 93 | 94 | ); 95 | } 96 | } 97 | 98 | export default Defs; 99 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'production', 7 | devtool: 'source-map', 8 | context: __dirname + '/src', 9 | entry: { 10 | main: './index.js', 11 | css: './styles/main.scss', 12 | }, 13 | 14 | output: { 15 | filename: '[name].min.js', 16 | chunkFilename: '[name].min.js', 17 | path: __dirname + '/dist', 18 | publicPath: '/dist/', 19 | library: 'ReactDigraph', 20 | libraryTarget: 'commonjs2', 21 | }, 22 | 23 | resolve: { 24 | // Add '.ts' and '.tsx' as resolvable extensions. 25 | extensions: ['.ts', '.tsx', '.js', '.json'], 26 | }, 27 | 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.jsx?$/, 32 | exclude: /(node_modules|bower_components)/, 33 | enforce: 'pre', 34 | loader: 'eslint-loader', 35 | options: { 36 | configFile: '.eslintrc', 37 | }, 38 | }, 39 | { 40 | test: /\.jsx?$/, 41 | // Don't exclude the node_modules directory, otherwise the error is: 42 | // [21:23:59] GulpUglifyError: unable to minify JavaScript 43 | // Caused by: SyntaxError: Unexpected token: name (e) 44 | // exclude: /(node_modules|bower_components)/, 45 | use: [ 46 | { 47 | loader: 'babel-loader', 48 | options: { 49 | babelrc: true, 50 | }, 51 | }, 52 | ], 53 | }, 54 | // All files with a '.ts' or '.tsx' extension will be handled by 55 | // 'awesome-typescript-loader'. 56 | { 57 | test: /\.tsx?$/, 58 | loader: 'awesome-typescript-loader', 59 | }, 60 | 61 | // All scss files 62 | { 63 | test: /\.s?css$/, 64 | use: [ 65 | { 66 | loader: 'style-loader', // creates style nodes from JS strings 67 | }, 68 | { 69 | loader: 'css-loader', // translates CSS into CommonJS 70 | }, 71 | { 72 | loader: 'sass-loader', // compiles Sass to CSS 73 | }, 74 | ], 75 | }, 76 | { 77 | test: /\.svg$/, 78 | loader: 'svg-inline-loader', 79 | }, 80 | ], 81 | }, 82 | 83 | plugins: [ 84 | new UglifyJSPlugin({ 85 | sourceMap: true, 86 | }), 87 | new webpack.DefinePlugin({ 88 | 'process.env.NODE_ENV': JSON.stringify('production'), 89 | }), 90 | ], 91 | 92 | externals: { 93 | // TODO: figure out how to deal with externals 94 | react: { 95 | amd: 'react', 96 | root: 'react', 97 | global: 'React', 98 | commonjs: 'react', 99 | commonjs2: 'react', 100 | }, 101 | 'react-dom': { 102 | amd: 'react-dom', 103 | root: 'react-dom', 104 | global: 'ReactDOM', 105 | commonjs: 'react-dom', 106 | commonjs2: 'react-dom', 107 | }, 108 | tslib: 'tslib', 109 | }, 110 | }; 111 | -------------------------------------------------------------------------------- /src/components/graph-view-props.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import { type LayoutEngineType } from '../utilities/layout-engine/layout-engine-types'; 19 | import { type IEdge } from './edge'; 20 | import { type INode } from './node'; 21 | 22 | export type IBBox = { 23 | x: number, 24 | y: number, 25 | width: number, 26 | height: number, 27 | }; 28 | 29 | export type IGraphViewProps = { 30 | backgroundFillId?: string, 31 | edges: any[], 32 | edgeArrowSize?: number, 33 | edgeHandleSize?: number, 34 | edgeTypes: any, 35 | gridDotSize?: number, 36 | gridSize?: number, 37 | gridSpacing?: number, 38 | layoutEngineType?: LayoutEngineType, 39 | maxTitleChars?: number, 40 | maxZoom?: number, 41 | minZoom?: number, 42 | nodeKey: string, 43 | nodes: any[], 44 | nodeSize?: number, 45 | nodeHeight?: number, 46 | nodeWidth?: number, 47 | nodeSpacingMultiplier?: number, 48 | nodeSubtypes: any, 49 | nodeTypes: any, 50 | readOnly?: boolean, 51 | selected: any, 52 | showGraphControls?: boolean, 53 | zoomDelay?: number, 54 | zoomDur?: number, 55 | canCreateEdge?: (startNode?: INode, endNode?: INode) => boolean, 56 | canDeleteEdge?: (selected: any) => boolean, 57 | canDeleteNode?: (selected: any) => boolean, 58 | onBackgroundClick?: (x: number, y: number, event: any) => void, 59 | onCopySelected?: () => void, 60 | onCreateEdge: (sourceNode: INode, targetNode: INode) => void, 61 | onCreateNode: (x: number, y: number, event: any) => void, 62 | onDeleteEdge: (selectedEdge: IEdge, edges: IEdge[]) => void, 63 | onDeleteNode: (selected: any, nodeId: string, nodes: any[]) => void, 64 | onPasteSelected?: () => void, 65 | onSelectEdge: (selectedEdge: IEdge) => void, 66 | onSelectNode: (node: INode | null, event: any) => void, 67 | onSwapEdge: (sourceNode: INode, targetNode: INode, edge: IEdge) => void, 68 | onUndo?: () => void, 69 | onUpdateNode: (node: INode) => void, 70 | renderBackground?: (gridSize?: number) => any, 71 | renderDefs?: () => any, 72 | renderNode?: ( 73 | nodeRef: any, 74 | data: any, 75 | id: string, 76 | selected: boolean, 77 | hovered: boolean 78 | ) => any, 79 | afterRenderEdge?: ( 80 | id: string, 81 | element: any, 82 | edge: IEdge, 83 | edgeContainer: any, 84 | isEdgeSelected: boolean 85 | ) => void, 86 | renderNodeText?: (data: any, id: string | number, isSelected: boolean) => any, 87 | rotateEdgeHandle?: boolean, 88 | centerNodeOnMove?: boolean, 89 | initialBBox: IBBox, 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/graph-controls.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | Zoom slider and zoom to fit controls for GraphView 20 | */ 21 | 22 | import React from 'react'; 23 | import Parse from 'html-react-parser'; 24 | import faExpand from '@fortawesome/fontawesome-free/svgs/solid/expand.svg'; 25 | 26 | const steps = 100; // Slider steps 27 | const parsedIcon = Parse(faExpand); // parse SVG once 28 | const ExpandIcon = () => parsedIcon; // convert SVG to react component 29 | 30 | type IGraphControlProps = { 31 | maxZoom?: number, 32 | minZoom?: number, 33 | zoomLevel: number, 34 | zoomToFit: (event: SyntheticMouseEvent) => void, 35 | modifyZoom: (delta: number) => boolean, 36 | }; 37 | 38 | class GraphControls extends React.Component { 39 | static defaultProps = { 40 | maxZoom: 1.5, 41 | minZoom: 0.15, 42 | }; 43 | 44 | // Convert slider val (0-steps) to original zoom value range 45 | sliderToZoom(val: number) { 46 | const { minZoom, maxZoom } = this.props; 47 | 48 | return (val * ((maxZoom || 0) - (minZoom || 0))) / steps + (minZoom || 0); 49 | } 50 | 51 | // Convert zoom val (minZoom-maxZoom) to slider range 52 | zoomToSlider(val: number) { 53 | const { minZoom, maxZoom } = this.props; 54 | 55 | return ((val - (minZoom || 0)) * steps) / ((maxZoom || 0) - (minZoom || 0)); 56 | } 57 | 58 | // Modify current zoom of graph-view 59 | zoom = (e: any) => { 60 | const { minZoom, maxZoom } = this.props; 61 | const sliderVal = e.target.value; 62 | const zoomLevelNext = this.sliderToZoom(sliderVal); 63 | const delta = zoomLevelNext - this.props.zoomLevel; 64 | 65 | if (zoomLevelNext <= (maxZoom || 0) && zoomLevelNext >= (minZoom || 0)) { 66 | this.props.modifyZoom(delta); 67 | } 68 | }; 69 | 70 | render() { 71 | return ( 72 |
73 |
74 | - 75 | 84 | + 85 |
86 | 93 |
94 | ); 95 | } 96 | } 97 | 98 | export default GraphControls; 99 | -------------------------------------------------------------------------------- /__tests__/components/graph-controls.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | 5 | import { shallow } from 'enzyme'; 6 | 7 | import GraphControls from '../../src/components/graph-controls'; 8 | 9 | describe('GraphControls component', () => { 10 | let output = null; 11 | let zoomToFit; 12 | let modifyZoom; 13 | 14 | beforeEach(() => { 15 | zoomToFit = jest.fn(); 16 | modifyZoom = jest.fn(); 17 | output = shallow( 18 | 23 | ); 24 | }); 25 | 26 | describe('render method', () => { 27 | it('renders', () => { 28 | expect(output.props().className).toEqual('graph-controls'); 29 | expect( 30 | output 31 | .children() 32 | .first() 33 | .props().className 34 | ).toEqual('slider-wrapper'); 35 | 36 | const rangeInput = output.find('input.slider'); 37 | 38 | expect(rangeInput.length).toEqual(1); 39 | expect(rangeInput.props().type).toEqual('range'); 40 | expect(rangeInput.props().min).toEqual(0); 41 | expect(rangeInput.props().max).toEqual(100); 42 | expect(rangeInput.props().value).toEqual(-11.11111111111111); 43 | expect(rangeInput.props().step).toEqual('1'); 44 | }); 45 | 46 | it('renders with a custom min and max zoom', () => { 47 | output.setProps({ 48 | maxZoom: 0.9, 49 | minZoom: 0, 50 | }); 51 | const rangeInput = output.find('input.slider'); 52 | 53 | expect(rangeInput.props().min).toEqual(0); 54 | expect(rangeInput.props().max).toEqual(100); 55 | expect(rangeInput.props().value).toEqual(0); 56 | }); 57 | 58 | it('zooms on change', () => { 59 | const rangeInput = output.find('input'); 60 | 61 | rangeInput.simulate('change', { 62 | target: { 63 | value: 55, 64 | }, 65 | }); 66 | expect(modifyZoom).toHaveBeenCalledWith(0.8925000000000001); 67 | }); 68 | }); 69 | 70 | describe('zoom method', () => { 71 | it('calls modifyZoom callback with the new zoom delta', () => { 72 | output.instance().zoom({ 73 | target: { 74 | value: 55, 75 | }, 76 | }); 77 | expect(modifyZoom).toHaveBeenCalledWith(0.8925000000000001); 78 | }); 79 | 80 | it('does not call modifyZoom callback when the zoom level is greater than max', () => { 81 | output.instance().zoom({ 82 | target: { 83 | value: 101, 84 | }, 85 | }); 86 | expect(modifyZoom).not.toHaveBeenCalled(); 87 | }); 88 | 89 | it('does not call modifyZoom callback when the zoom level is less than min', () => { 90 | output.instance().zoom({ 91 | target: { 92 | value: -1, 93 | }, 94 | }); 95 | expect(modifyZoom).not.toHaveBeenCalled(); 96 | }); 97 | }); 98 | 99 | describe('zoomToSlider method', () => { 100 | it('converts a value to a decimal-based slider position', () => { 101 | const result = output.instance().zoomToSlider(10); 102 | 103 | expect(result).toEqual(729.6296296296296); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/examples/app.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright(c) 2018 Uber Technologies, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | html, body { 18 | font-family: sans-serif; 19 | font-size: 12px; 20 | margin: 0px; 21 | } 22 | 23 | button { 24 | margin-right: 10px; 25 | } 26 | 27 | #graph { 28 | height: 100%; 29 | width: 100%; 30 | display: flex; 31 | } 32 | 33 | .total-nodes { 34 | margin-right: 10px; 35 | } 36 | 37 | .app-header { 38 | border-bottom: 1px solid black; 39 | background: #f9f9f9; 40 | 41 | > nav { 42 | height: 25px; 43 | > a { 44 | border-right: 1px solid black; 45 | line-height: 25px; 46 | min-width: 150px; 47 | padding: 10px; 48 | 49 | &.active { 50 | background: #333; 51 | color: white; 52 | } 53 | } 54 | } 55 | } 56 | 57 | .graph-header { 58 | border-bottom: 1px solid black; 59 | position: fixed; 60 | width: 100%; 61 | background-color: #fff; 62 | padding: 10px; 63 | 64 | .layout-engine, .pan-list { 65 | display: inline-block; 66 | > span { 67 | margin-right: 5px; 68 | } 69 | } 70 | } 71 | 72 | .sidebar { 73 | background-color: white; 74 | display: flex; 75 | 76 | &.left, 77 | &.right { 78 | height: 100%; 79 | width: 50%; 80 | flex: 1; 81 | 82 | .sidebar-main-container { 83 | &.closed { 84 | width: 0 !important; 85 | } 86 | } 87 | 88 | .sidebar-toggle-bar { 89 | width: 10px; 90 | height: 100%; 91 | border-right: 1px solid black; 92 | border-left: 1px solid black; 93 | } 94 | } 95 | &.right { 96 | flex-direction: row-reverse; 97 | } 98 | 99 | &.up, 100 | &.down { 101 | width: 100%; 102 | 103 | .sidebar-main-container { 104 | &.closed { 105 | height: 0 !important; 106 | } 107 | } 108 | 109 | .sidebar-toggle-bar { 110 | height: 10px; 111 | width: 100%; 112 | border-top: 1px solid black; 113 | border-bottom: 1px solid black; 114 | } 115 | } 116 | 117 | &.up { 118 | flex-direction: column; 119 | } 120 | &.down { 121 | flex-direction: column-reverse; 122 | } 123 | 124 | .sidebar-main-container { 125 | flex: 1; 126 | transition: height, width 0.5s; 127 | 128 | &.closed { 129 | overflow: hidden; 130 | } 131 | } 132 | 133 | .sidebar-toggle-bar { 134 | background-color: white; 135 | display: flex; 136 | align-items: center; 137 | justify-content: center; 138 | color: black; 139 | } 140 | 141 | .sidebar-text-area { 142 | width: 100%; 143 | height: 100%; 144 | border: 0; 145 | padding: 10px; 146 | font-size: 1.2em; 147 | } 148 | } 149 | 150 | .graph-container { 151 | position: relative; 152 | flex: 1; 153 | } 154 | 155 | @import './bwdl/bwdl.scss'; 156 | @import './bwdl-editable/bwdl-editable.scss'; 157 | -------------------------------------------------------------------------------- /src/components/node-text.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | import GraphUtils from '../utilities/graph-util'; 20 | import { type INode } from './node'; 21 | import {Status} from '../components/node'; 22 | 23 | type INodeTextProps = { 24 | data: INode, 25 | nodeTypes: any, // TODO: create a nodeTypes interface 26 | isSelected: boolean, 27 | maxTitleChars: number, 28 | }; 29 | 30 | class NodeText extends React.Component { 31 | getTypeText(data: INode, nodeTypes: any) { 32 | if (data.type && nodeTypes[data.type]) { 33 | return nodeTypes[data.type].typeText; 34 | } else if (nodeTypes.emptyNode) { 35 | return nodeTypes.emptyNode.typeText; 36 | } else { 37 | return null; 38 | } 39 | } 40 | 41 | completionDateIn(days, status) { 42 | let today = new Date(); 43 | let endDate = "", count = 0; 44 | if(days === 0 || status === Status.done) return today; 45 | while(count < days) { 46 | endDate = new Date(today.setDate(today.getDate() + 1)); 47 | if (endDate.getDay() !== 0 && endDate.getDay() !== 6) { 48 | //Date.getDay() gives weekday starting from 0(Sunday) to 6(Saturday) 49 | count++; 50 | } 51 | } 52 | return endDate; 53 | } 54 | 55 | render() { 56 | const { data, nodeTypes, isSelected, maxTitleChars } = this.props; 57 | const lineOffset = 18; 58 | const title = data.title; 59 | const description = data.description; 60 | const timeEstimate = data.timeEstimate; 61 | const completionDate = this.completionDateIn(data.totalTimeEstimate, data.status); 62 | let formatted_date = completionDate.getFullYear() + "-" + (completionDate.getMonth() + 1) + "-" + completionDate.getDate() 63 | 64 | const className = GraphUtils.classNames('node-text', { 65 | selected: isSelected, 66 | }); 67 | 68 | return ( 69 | 70 | {!!title && {title}} 71 | {title && ( 72 | 73 | {title.length > maxTitleChars 74 | ? description.substr(0, maxTitleChars) 75 | : description} 76 | 77 | )} 78 | 79 | {title && ( 80 | 81 | Completion Date: {formatted_date} 82 | 83 | )} 84 | 85 | 86 | {title && timeEstimate > 0 && ( 87 | 88 | Time: {timeEstimate} days 89 | 90 | )} 91 | 92 | 93 | ); 94 | } 95 | } 96 | 97 | export default NodeText; 98 | -------------------------------------------------------------------------------- /src/utilities/layout-engine/horizontal-tree.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as dagre from 'dagre'; 19 | import { type INode } from '../../components/node'; 20 | import SnapToGrid from './snap-to-grid'; 21 | 22 | class HorizontalTree extends SnapToGrid { 23 | adjustNodes(nodes: INode[], nodesMap?: any): INode[] { 24 | const { 25 | nodeKey, 26 | nodeSize, 27 | nodeHeight, 28 | nodeWidth, 29 | nodeSizeOverridesAllowed, 30 | nodeSpacingMultiplier, 31 | nodeLocationOverrides, 32 | graphConfig, 33 | } = this.graphViewProps; 34 | const spacing = nodeSpacingMultiplier || 1.5; 35 | const size = (nodeSize || 1) * spacing; 36 | const g = new dagre.graphlib.Graph(); 37 | const height = nodeHeight ? nodeHeight * spacing : size; 38 | const width = nodeWidth ? nodeWidth * spacing : size; 39 | 40 | g.setGraph( 41 | Object.assign( 42 | { 43 | rankdir: 'LR', 44 | }, 45 | graphConfig 46 | ) 47 | ); 48 | g.setDefaultEdgeLabel(() => ({})); 49 | 50 | nodes.forEach(node => { 51 | if (!nodesMap) { 52 | return; 53 | } 54 | 55 | const { 56 | sizeOverrides: { width: widthOverride, height: heightOverride } = {}, 57 | } = node; 58 | 59 | const nodeId = node[nodeKey]; 60 | const nodeKeyId = `key-${nodeId}`; 61 | const nodesMapNode = nodesMap[nodeKeyId]; 62 | 63 | // prevent disconnected nodes from being part of the graph 64 | if ( 65 | nodesMapNode.incomingEdges.length === 0 && 66 | nodesMapNode.outgoingEdges.length === 0 67 | ) { 68 | return; 69 | } 70 | 71 | g.setNode(nodeKeyId, { 72 | width: 73 | nodeSizeOverridesAllowed && widthOverride ? widthOverride : width, 74 | height: 75 | nodeSizeOverridesAllowed && heightOverride ? heightOverride : height, 76 | }); 77 | nodesMapNode.outgoingEdges.forEach(edge => { 78 | g.setEdge(nodeKeyId, `key-${edge.target}`); 79 | }); 80 | }); 81 | 82 | dagre.layout(g); 83 | 84 | if (nodeLocationOverrides) { 85 | for (const gNodeId in nodeLocationOverrides) { 86 | if (nodeLocationOverrides.hasOwnProperty(gNodeId)) { 87 | const nodeKeyId = `key-${gNodeId}`; 88 | const gNode = g.node(nodeKeyId); 89 | const locationOverride = nodeLocationOverrides[gNodeId]; 90 | 91 | if (gNode && locationOverride) { 92 | gNode.x = locationOverride.x; 93 | gNode.y = locationOverride.y; 94 | } 95 | } 96 | } 97 | } 98 | 99 | g.nodes().forEach(gNodeId => { 100 | const nodesMapNode = nodesMap[gNodeId]; 101 | 102 | // gNode is the dagre representation 103 | const gNode = g.node(gNodeId); 104 | 105 | nodesMapNode.node.x = gNode.x; 106 | nodesMapNode.node.y = gNode.y; 107 | }); 108 | 109 | return nodes; 110 | } 111 | } 112 | 113 | export default HorizontalTree; 114 | -------------------------------------------------------------------------------- /src/examples/sidebar.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | import GraphUtils from '../utilities/graph-util'; 20 | 21 | type ISidebarProps = { 22 | children: any, 23 | direction: 'left' | 'right' | 'up' | 'down', 24 | size: number | string, 25 | }; 26 | 27 | type ISidebarState = { 28 | sidebarClass?: string | null, 29 | }; 30 | 31 | const sidebarClass = { 32 | CLOSED: 'closed', 33 | OPEN: 'open', 34 | }; 35 | 36 | const directionOpposites = { 37 | down: 'up', 38 | left: 'right', 39 | right: 'left', 40 | up: 'down', 41 | }; 42 | 43 | export default class Sidebar extends React.Component< 44 | ISidebarProps, 45 | ISidebarState 46 | > { 47 | static defaultProps = { 48 | direction: 'left', 49 | size: '130px', 50 | }; 51 | 52 | constructor(props: ISidebarProps) { 53 | super(props); 54 | this.state = { 55 | sidebarClass: sidebarClass.OPEN, 56 | }; 57 | } 58 | 59 | toggleContainer = () => { 60 | const originalValue = this.state.sidebarClass; 61 | let newValue = sidebarClass.CLOSED; 62 | 63 | if (originalValue === newValue) { 64 | newValue = sidebarClass.OPEN; 65 | } 66 | 67 | this.setState({ 68 | sidebarClass: newValue, 69 | }); 70 | }; 71 | 72 | getContainerClasses(): string { 73 | const classes = ['sidebar-main-container']; 74 | 75 | classes.push(this.state.sidebarClass || ''); 76 | 77 | return GraphUtils.classNames(classes); 78 | } 79 | 80 | getContainerStyle(size: number | string, direction: string) { 81 | if (direction === 'up' || direction === 'down') { 82 | return { height: `${size}`, maxHeight: `${size}` }; 83 | } 84 | 85 | return { width: `${size}`, maxWidth: `${size}` }; 86 | } 87 | 88 | getArrowIconClasses(direction: string): string { 89 | const classes = ['icon']; 90 | 91 | if (this.state.sidebarClass === sidebarClass.CLOSED) { 92 | classes.push(`icon_${directionOpposites[direction]}-arrow`); 93 | } else { 94 | classes.push(`icon_${direction}-arrow`); 95 | } 96 | 97 | return GraphUtils.classNames(classes); 98 | } 99 | 100 | renderToggleBar(direction: string) { 101 | return ( 102 |
103 | 104 |
105 | ); 106 | } 107 | 108 | render() { 109 | const { children, direction, size } = this.props; 110 | const sidebarClassName = GraphUtils.classNames('sidebar', direction); 111 | 112 | return ( 113 |
114 |
118 | {children} 119 |
120 |
121 | 122 |
123 |
124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /__tests__/utilities/layout-engine/vertical-tree.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | 4 | import VerticalTree from '../../../src/utilities/layout-engine/vertical-tree'; 5 | 6 | describe('VerticalTree', () => { 7 | const output = null; 8 | 9 | describe('class', () => { 10 | it('is defined', () => { 11 | expect(VerticalTree).toBeDefined(); 12 | }); 13 | 14 | it('instantiates', () => { 15 | const verticalTree = new VerticalTree(); 16 | }); 17 | }); 18 | 19 | describe('calculatePosition method', () => { 20 | it('adjusts the node position to be centered on a grid space', () => { 21 | const verticalTree = new VerticalTree({ 22 | nodeKey: 'id', 23 | nodeSize: 10, 24 | }); 25 | const nodes = [ 26 | { id: 'test', x: 9, y: 8 }, 27 | { id: 'test1', x: 4, y: 7 }, 28 | ]; 29 | const nodesMap = { 30 | 'key-test': { 31 | incomingEdges: [], 32 | outgoingEdges: [ 33 | { 34 | source: 'test', 35 | target: 'test1', 36 | }, 37 | ], 38 | node: nodes[0], 39 | }, 40 | 'key-test1': { 41 | incomingEdges: [ 42 | { 43 | source: 'test', 44 | target: 'test1', 45 | }, 46 | ], 47 | outgoingEdges: [], 48 | node: nodes[0], 49 | }, 50 | }; 51 | const newNodes = verticalTree.adjustNodes(nodes, nodesMap); 52 | const expected = [ 53 | { id: 'test', x: 7.5, y: 72.5 }, 54 | { id: 'test1', x: 4, y: 7 }, 55 | ]; 56 | 57 | expect(JSON.stringify(newNodes)).toEqual(JSON.stringify(expected)); 58 | }); 59 | 60 | it('does nothing when there is no nodeMap', () => { 61 | const verticalTree = new VerticalTree({ 62 | nodeKey: 'id', 63 | nodeSize: 10, 64 | }); 65 | const nodes = [{ id: 'test', x: 9, y: 8 }]; 66 | const newNodes = verticalTree.adjustNodes(nodes); 67 | const expected = [{ id: 'test', x: 9, y: 8 }]; 68 | 69 | expect(JSON.stringify(newNodes)).toEqual(JSON.stringify(expected)); 70 | }); 71 | 72 | it('does nothing on disconnected nodes', () => { 73 | const verticalTree = new VerticalTree({ 74 | nodeKey: 'id', 75 | nodeSize: 10, 76 | }); 77 | const nodes = [{ id: 'test', x: 9, y: 8 }]; 78 | const nodesMap = { 79 | 'key-test': { 80 | incomingEdges: [], 81 | outgoingEdges: [], 82 | node: nodes[0], 83 | }, 84 | }; 85 | const newNodes = verticalTree.adjustNodes(nodes, nodesMap); 86 | const expected = [{ id: 'test', x: 9, y: 8 }]; 87 | 88 | expect(JSON.stringify(newNodes)).toEqual(JSON.stringify(expected)); 89 | }); 90 | 91 | it('uses a default nodeSize', () => { 92 | const verticalTree = new VerticalTree({ 93 | nodeKey: 'id', 94 | }); 95 | const nodes = [ 96 | { id: 'test', x: 9, y: 8 }, 97 | { id: 'test1', x: 4, y: 7 }, 98 | ]; 99 | const nodesMap = { 100 | 'key-test': { 101 | incomingEdges: [], 102 | outgoingEdges: [ 103 | { 104 | source: 'test', 105 | target: 'test1', 106 | }, 107 | ], 108 | node: nodes[0], 109 | }, 110 | 'key-test1': { 111 | incomingEdges: [ 112 | { 113 | source: 'test', 114 | target: 'test1', 115 | }, 116 | ], 117 | outgoingEdges: [], 118 | node: nodes[0], 119 | }, 120 | }; 121 | 122 | const newNodes = verticalTree.adjustNodes(nodes, nodesMap); 123 | const expected = [ 124 | { id: 'test', x: 0.75, y: 52.25 }, 125 | { id: 'test1', x: 4, y: 7 }, 126 | ]; 127 | 128 | expect(JSON.stringify(newNodes)).toEqual(JSON.stringify(expected)); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | context: __dirname + '/src', 8 | entry: { 9 | main: './index.js', 10 | example: './examples/app.js', 11 | exampleCss: './examples/app.scss', 12 | css: './styles/main.scss', 13 | }, 14 | 15 | // cheap is not the best for quality, but it speeds up the build. the downside 16 | // is the console doesn't report the correct line numbers, but the filenames are 17 | // correct and the links work. 18 | devtool: 'cheap-eval-source-map', //'source-map', 19 | 20 | output: { 21 | filename: '[name].js', 22 | sourceMapFilename: '[name].js.map', 23 | chunkFilename: '[name].js', 24 | hotUpdateChunkFilename: 'hot/hot-update.js', 25 | hotUpdateMainFilename: 'hot/hot-update.json', 26 | path: __dirname + '/dist', 27 | publicPath: '/dist/', 28 | library: 'ReactDigraph', 29 | libraryTarget: 'var', //'commonjs2' // , 30 | // libraryExport: 'default' 31 | }, 32 | 33 | devServer: { 34 | contentBase: [ 35 | path.join(__dirname, 'src', 'examples'), 36 | path.join(__dirname, 'dist'), 37 | ], 38 | compress: true, 39 | port: 9000, 40 | watchContentBase: true, 41 | }, 42 | 43 | resolve: { 44 | // Add '.ts' and '.tsx' as resolvable extensions. 45 | extensions: ['.ts', '.tsx', '.js', '.json'], 46 | }, 47 | 48 | module: { 49 | rules: [ 50 | { 51 | test: /\.jsx?$/, 52 | exclude: /(node_modules|bower_components)/, 53 | enforce: 'pre', 54 | loader: 'eslint-loader', 55 | options: { 56 | configFile: '.eslintrc', 57 | }, 58 | }, 59 | { 60 | test: /\.jsx?$/, 61 | exclude: /(node_modules|bower_components)/, 62 | use: [ 63 | { 64 | loader: 'babel-loader', 65 | options: { 66 | babelrc: true, 67 | // presets: ['es2015'] 68 | }, 69 | }, 70 | // Need to run the react preset first to strip flow annotations 71 | // { 72 | // loader: 'babel-loader', 73 | // options: { 74 | // babelrc: false, 75 | // presets: ['react'], 76 | // plugins: ['transform-class-properties'] 77 | // } 78 | // } 79 | ], 80 | }, 81 | // All files with a '.ts' or '.tsx' extension will be handled by 82 | // 'awesome-typescript-loader'. 83 | { 84 | test: /\.tsx?$/, 85 | loader: 'awesome-typescript-loader', 86 | }, 87 | 88 | // All output '.js' files will have any sourcemaps re-processed by 89 | // 'source-map-loader'. 90 | { 91 | enforce: 'pre', 92 | test: /\.js$/, 93 | loader: 'source-map-loader', 94 | }, 95 | 96 | // All scss files 97 | { 98 | test: /\.s?css$/, 99 | use: [ 100 | { 101 | loader: 'style-loader', // creates style nodes from JS strings 102 | }, 103 | { 104 | loader: 'css-loader', // translates CSS into CommonJS 105 | }, 106 | { 107 | loader: 'sass-loader', // compiles Sass to CSS 108 | }, 109 | ], 110 | }, 111 | { 112 | test: /\.svg$/, 113 | loader: 'svg-inline-loader', 114 | }, 115 | ], 116 | }, 117 | 118 | plugins: [ 119 | new webpack.WatchIgnorePlugin([/\.d\.ts$/]), 120 | new webpack.HotModuleReplacementPlugin(), 121 | new CopyWebpackPlugin([ 122 | { 123 | from: './examples/**/index.html', 124 | to: 'index.html', 125 | }, 126 | { 127 | from: './examples/**/*.js', 128 | to: '[name].js', 129 | }, 130 | ]), 131 | ], 132 | 133 | externals: { 134 | // TODO: figure out how to deal with externals 135 | // react: 'React', 136 | // 'react-dom': 'ReactDOM', 137 | // d3: 'd3', 138 | tslib: 'tslib', 139 | }, 140 | }; 141 | -------------------------------------------------------------------------------- /src/examples/bwdl/bwdl-node-form.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | 20 | type IBwdlNodeFormProps = { 21 | bwdlNode: any, 22 | bwdlNodeKey: string, 23 | nextChoices: string[], 24 | }; 25 | 26 | class BwdlNodeForm extends React.Component { 27 | renderNextOptions(value: any) { 28 | const { nextChoices } = this.props; 29 | 30 | // This function is defined and used locally to avoid tslint's jsx-no-lambda error. 31 | // It requires the local value variable, so it cannot be defined in the class. 32 | const handleChange = (event: any) => { 33 | event.target.value = value; 34 | }; 35 | 36 | return ( 37 | 42 | ); 43 | } 44 | 45 | renderAndObjectArray(andObjectArray: any[]) { 46 | return andObjectArray.map((andObject, index) => { 47 | return ( 48 |
49 | {Object.keys(andObject).map(key => { 50 | return ( 51 |
52 | {this.renderKey(key, andObject[key])} 53 |
54 | ); 55 | })} 56 |
57 | ); 58 | }); 59 | } 60 | 61 | renderChoicesOptions(value: any) { 62 | return value.map((choice, index) => { 63 | return ( 64 |
65 | {Object.keys(choice).map(choiceOption => { 66 | if (choiceOption === 'Next') { 67 | // "Next" option 68 | return ( 69 |
70 | {' '} 71 | {this.renderNextOptions(choice[choiceOption])} 72 |
73 | ); 74 | } else if (Array.isArray(choice[choiceOption])) { 75 | // "And" array 76 | return ( 77 |
78 | {' '} 79 | {this.renderAndObjectArray(choice[choiceOption])} 80 |
81 | ); 82 | } 83 | 84 | // text option 85 | return ( 86 |
87 | {choice[choiceOption]} 88 |
89 | ); 90 | })} 91 |
92 | ); 93 | }); 94 | } 95 | 96 | renderKey(key: string, value: any) { 97 | if (key === 'Next') { 98 | return this.renderNextOptions(value); 99 | } else if (key === 'Choices') { 100 | return this.renderChoicesOptions(value); 101 | } else if ( 102 | typeof value === 'string' || 103 | typeof value === 'number' || 104 | typeof value === 'boolean' 105 | ) { 106 | return value; 107 | } else if (typeof value === 'object' && !Array.isArray(value)) { 108 | return Object.keys(value).map(valueKey => { 109 | return ( 110 |
111 | {' '} 112 | {this.renderKey(valueKey, value[valueKey])} 113 |
114 | ); 115 | }); 116 | } 117 | 118 | return
{JSON.stringify(value, null, 2)}
; 119 | } 120 | 121 | render() { 122 | const { bwdlNode, bwdlNodeKey } = this.props; 123 | 124 | return ( 125 |
126 |

{bwdlNodeKey}

127 | {Object.keys(bwdlNode).map(key => { 128 | return ( 129 |
130 | {this.renderKey(key, bwdlNode[key])} 131 |
132 | ); 133 | })} 134 |
135 | ); 136 | } 137 | } 138 | 139 | export default BwdlNodeForm; 140 | -------------------------------------------------------------------------------- /src/examples/bwdl-editable/bwdl-config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | Example config for GraphView component 20 | */ 21 | import * as React from 'react'; 22 | 23 | export const NODE_KEY = 'title'; // Key used to identify nodes 24 | 25 | // These keys are arbitrary (but must match the config) 26 | // However, GraphView renders text differently for empty types 27 | // so this has to be passed in if that behavior is desired. 28 | export const EMPTY_TYPE = 'empty'; // Empty node type 29 | export const CHOICE_TYPE = 'Choice'; 30 | export const TASK_TYPE = 'Task'; 31 | export const PASS_TYPE = 'Pass'; 32 | export const WAIT_TYPE = 'Wait'; 33 | export const TERMINATOR_TYPE = 'Terminator'; 34 | export const SPECIAL_CHILD_SUBTYPE = 'specialChild'; 35 | export const EMPTY_EDGE_TYPE = 'emptyEdge'; 36 | export const SPECIAL_EDGE_TYPE = 'specialEdge'; 37 | 38 | export const nodeTypes = [ 39 | EMPTY_TYPE, 40 | CHOICE_TYPE, 41 | TASK_TYPE, 42 | PASS_TYPE, 43 | WAIT_TYPE, 44 | TERMINATOR_TYPE, 45 | ]; 46 | export const edgeTypes = [EMPTY_EDGE_TYPE, SPECIAL_EDGE_TYPE]; 47 | 48 | const EmptyShape = ( 49 | 50 | 51 | 52 | ); 53 | 54 | const ChoiceShape = ( 55 | 56 | 57 | 58 | ); 59 | 60 | const TaskShape = ( 61 | 62 | 63 | 64 | ); 65 | 66 | const PassShape = ( 67 | 68 | 69 | 70 | ); 71 | 72 | const WaitShape = ( 73 | 74 | 75 | 76 | ); 77 | 78 | const TerminatorShape = ( 79 | 80 | 87 | 88 | ); 89 | 90 | const SpecialChildShape = ( 91 | 92 | 93 | 94 | ); 95 | 96 | const EmptyEdgeShape = ( 97 | 98 | 99 | 100 | ); 101 | 102 | const SpecialEdgeShape = ( 103 | 104 | 112 | 113 | ); 114 | 115 | export default { 116 | EdgeTypes: { 117 | emptyEdge: { 118 | shape: EmptyEdgeShape, 119 | shapeId: '#emptyEdge', 120 | }, 121 | specialEdge: { 122 | shape: SpecialEdgeShape, 123 | shapeId: '#specialEdge', 124 | }, 125 | }, 126 | NodeSubtypes: { 127 | specialChild: { 128 | shape: SpecialChildShape, 129 | shapeId: '#specialChild', 130 | }, 131 | }, 132 | NodeTypes: { 133 | Choice: { 134 | shape: ChoiceShape, 135 | shapeId: '#choice', 136 | typeText: 'Choice', 137 | }, 138 | emptyNode: { 139 | shape: EmptyShape, 140 | shapeId: '#empty', 141 | typeText: 'None', 142 | }, 143 | Pass: { 144 | shape: PassShape, 145 | shapeId: '#pass', 146 | typeText: 'Pass', 147 | }, 148 | Task: { 149 | shape: TaskShape, 150 | shapeId: '#task', 151 | typeText: 'Task', 152 | }, 153 | Terminator: { 154 | shape: TerminatorShape, 155 | shapeId: '#terminator', 156 | typeText: 'Terminator', 157 | }, 158 | Wait: { 159 | shape: WaitShape, 160 | shapeId: '#wait', 161 | typeText: 'Wait', 162 | }, 163 | }, 164 | }; 165 | -------------------------------------------------------------------------------- /src/examples/bwdl/bwdl-config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | Example config for GraphView component 20 | */ 21 | import * as React from 'react'; 22 | 23 | export const NODE_KEY = 'title'; // Key used to identify nodes 24 | 25 | // These keys are arbitrary (but must match the config) 26 | // However, GraphView renders text differently for empty types 27 | // so this has to be passed in if that behavior is desired. 28 | export const EMPTY_TYPE = 'empty'; // Empty node type 29 | export const CHOICE_TYPE = 'Choice'; 30 | export const TASK_TYPE = 'Task'; 31 | export const PASS_TYPE = 'Pass'; 32 | export const WAIT_TYPE = 'Wait'; 33 | export const TERMINATOR_TYPE = 'Terminator'; 34 | export const SPECIAL_CHILD_SUBTYPE = 'specialChild'; 35 | export const EMPTY_EDGE_TYPE = 'emptyEdge'; 36 | export const SPECIAL_EDGE_TYPE = 'specialEdge'; 37 | 38 | export const nodeTypes = [ 39 | EMPTY_TYPE, 40 | CHOICE_TYPE, 41 | TASK_TYPE, 42 | PASS_TYPE, 43 | WAIT_TYPE, 44 | TERMINATOR_TYPE, 45 | ]; 46 | export const edgeTypes = [EMPTY_EDGE_TYPE, SPECIAL_EDGE_TYPE]; 47 | 48 | const EmptyShape = ( 49 | 50 | 51 | 52 | ); 53 | 54 | const ChoiceShape = ( 55 | 56 | 57 | 58 | ); 59 | 60 | const TaskShape = ( 61 | 62 | 69 | 70 | ); 71 | 72 | const PassShape = ( 73 | 74 | 75 | 76 | ); 77 | 78 | const WaitShape = ( 79 | 80 | 81 | 82 | ); 83 | 84 | const TerminatorShape = ( 85 | 86 | 93 | 94 | ); 95 | 96 | const SpecialChildShape = ( 97 | 98 | 99 | 100 | ); 101 | 102 | const EmptyEdgeShape = ( 103 | 104 | 105 | 106 | ); 107 | 108 | const SpecialEdgeShape = ( 109 | 110 | 118 | 119 | ); 120 | 121 | export default { 122 | EdgeTypes: { 123 | emptyEdge: { 124 | shape: EmptyEdgeShape, 125 | shapeId: '#emptyEdge', 126 | }, 127 | specialEdge: { 128 | shape: SpecialEdgeShape, 129 | shapeId: '#specialEdge', 130 | }, 131 | }, 132 | NodeSubtypes: { 133 | specialChild: { 134 | shape: SpecialChildShape, 135 | shapeId: '#specialChild', 136 | }, 137 | }, 138 | NodeTypes: { 139 | Choice: { 140 | shape: ChoiceShape, 141 | shapeId: '#choice', 142 | typeText: 'Choice', 143 | }, 144 | emptyNode: { 145 | shape: EmptyShape, 146 | shapeId: '#empty', 147 | typeText: 'None', 148 | }, 149 | Pass: { 150 | shape: PassShape, 151 | shapeId: '#pass', 152 | typeText: 'Pass', 153 | }, 154 | Task: { 155 | shape: TaskShape, 156 | shapeId: '#task', 157 | typeText: 'Task', 158 | }, 159 | Terminator: { 160 | shape: TerminatorShape, 161 | shapeId: '#terminator', 162 | typeText: 'Terminator', 163 | }, 164 | Wait: { 165 | shape: WaitShape, 166 | shapeId: '#wait', 167 | typeText: 'Wait', 168 | }, 169 | }, 170 | }; 171 | -------------------------------------------------------------------------------- /src/examples/graph-config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | Example config for GraphView component 20 | */ 21 | import * as React from 'react'; 22 | 23 | export const NODE_KEY = 'id'; // Key used to identify nodes 24 | 25 | // These keys are arbitrary (but must match the config) 26 | // However, GraphView renders text differently for empty types 27 | // so this has to be passed in if that behavior is desired. 28 | export const EMPTY_TYPE = 'customEmpty'; // Empty node type 29 | export const POLY_TYPE = 'poly'; 30 | export const SPECIAL_TYPE = 'special'; 31 | export const SKINNY_TYPE = 'skinny'; 32 | export const SPECIAL_CHILD_SUBTYPE = 'specialChild'; 33 | export const EMPTY_EDGE_TYPE = 'emptyEdge'; 34 | export const SPECIAL_EDGE_TYPE = 'specialEdge'; 35 | export const COMPLEX_CIRCLE_TYPE = 'complexCircle'; 36 | 37 | export const nodeTypes = [EMPTY_TYPE, POLY_TYPE, SPECIAL_TYPE, SKINNY_TYPE]; 38 | export const edgeTypes = [EMPTY_EDGE_TYPE, SPECIAL_EDGE_TYPE]; 39 | 40 | const EmptyNodeShape = ( 41 | 42 | 43 | 44 | ); 45 | 46 | const CustomEmptyShape = ( 47 | 48 | 49 | 50 | ); 51 | 52 | const SpecialShape = ( 53 | 54 | 55 | 56 | ); 57 | 58 | const PolyShape = ( 59 | 60 | 61 | 62 | ); 63 | 64 | const ComplexCircleShape = ( 65 | 66 | 67 | 68 | 72 | 73 | ); 74 | 75 | const SkinnyShape = ( 76 | 77 | 78 | 79 | ); 80 | 81 | const SpecialChildShape = ( 82 | 83 | 90 | 91 | ); 92 | 93 | const EmptyEdgeShape = ( 94 | 95 | 96 | 97 | ); 98 | 99 | const SpecialEdgeShape = ( 100 | 101 | 109 | 110 | ); 111 | 112 | export default { 113 | EdgeTypes: { 114 | emptyEdge: { 115 | shape: EmptyEdgeShape, 116 | shapeId: '#emptyEdge', 117 | }, 118 | specialEdge: { 119 | shape: SpecialEdgeShape, 120 | shapeId: '#specialEdge', 121 | }, 122 | }, 123 | NodeSubtypes: { 124 | specialChild: { 125 | shape: SpecialChildShape, 126 | shapeId: '#specialChild', 127 | }, 128 | }, 129 | NodeTypes: { 130 | emptyNode: { 131 | shape: EmptyNodeShape, 132 | shapeId: '#emptyNode', 133 | typeText: 'None', 134 | }, 135 | empty: { 136 | shape: CustomEmptyShape, 137 | shapeId: '#empty', 138 | typeText: 'None', 139 | }, 140 | special: { 141 | shape: SpecialShape, 142 | shapeId: '#special', 143 | typeText: 'Special', 144 | }, 145 | skinny: { 146 | shape: SkinnyShape, 147 | shapeId: '#skinny', 148 | typeText: 'Skinny', 149 | }, 150 | poly: { 151 | shape: PolyShape, 152 | shapeId: '#poly', 153 | typeText: 'Poly', 154 | }, 155 | complexCircle: { 156 | shape: ComplexCircleShape, 157 | shapeId: '#complexCircle', 158 | typeText: '#complexCircle', 159 | }, 160 | }, 161 | }; 162 | -------------------------------------------------------------------------------- /src/utilities/graph-util.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import { type IEdge } from '../components/edge'; 19 | import { type INode } from '../components/node'; 20 | import fastDeepEqual from 'fast-deep-equal'; 21 | 22 | export type INodeMapNode = { 23 | node: INode, 24 | originalArrIndex: number, 25 | incomingEdges: IEdge[], 26 | outgoingEdges: IEdge[], 27 | parents: INode[], 28 | children: INode[], 29 | }; 30 | 31 | class GraphUtils { 32 | static getNodesMap(nodes: any, key: string) { 33 | const map = {}; 34 | const arr = Object.keys(nodes).map(key => nodes[key]); 35 | let item = null; 36 | 37 | for (let i = 0; i < arr.length; i++) { 38 | item = arr[i]; 39 | map[`key-${item[key]}`] = { 40 | children: [], 41 | incomingEdges: [], 42 | node: item, 43 | originalArrIndex: i, 44 | outgoingEdges: [], 45 | parents: [], 46 | }; 47 | } 48 | 49 | return map; 50 | } 51 | 52 | static getEdgesMap(arr: IEdge[]) { 53 | const map = {}; 54 | let item = null; 55 | 56 | for (let i = 0; i < arr.length; i++) { 57 | item = arr[i]; 58 | 59 | if (!item.target) { 60 | continue; 61 | } 62 | 63 | map[`${item.source || ''}_${item.target}`] = { 64 | edge: item, 65 | originalArrIndex: i, 66 | }; 67 | } 68 | 69 | return map; 70 | } 71 | 72 | static linkNodesAndEdges(nodesMap: any, edges: IEdge[]) { 73 | let nodeMapSourceNode = null; 74 | let nodeMapTargetNode = null; 75 | let edge = null; 76 | 77 | for (let i = 0; i < edges.length; i++) { 78 | edge = edges[i]; 79 | 80 | if (!edge.target) { 81 | continue; 82 | } 83 | 84 | nodeMapSourceNode = nodesMap[`key-${edge.source || ''}`]; 85 | nodeMapTargetNode = nodesMap[`key-${edge.target}`]; 86 | 87 | // avoid an orphaned edge 88 | if (nodeMapSourceNode && nodeMapTargetNode) { 89 | nodeMapSourceNode.outgoingEdges.push(edge); 90 | nodeMapTargetNode.incomingEdges.push(edge); 91 | nodeMapSourceNode.children.push(nodeMapTargetNode); 92 | nodeMapTargetNode.parents.push(nodeMapSourceNode); 93 | } 94 | } 95 | } 96 | 97 | static removeElementFromDom(id: string) { 98 | const container = document.getElementById(id); 99 | 100 | if (container && container.parentNode) { 101 | container.parentNode.removeChild(container); 102 | 103 | return true; 104 | } 105 | 106 | return false; 107 | } 108 | 109 | static findParent(element: any, selector: string) { 110 | if (element && element.matches && element.matches(selector)) { 111 | return element; 112 | } else if (element && element.parentNode) { 113 | return GraphUtils.findParent(element.parentNode, selector); 114 | } 115 | 116 | return null; 117 | } 118 | 119 | static classNames(...args: any[]) { 120 | let className = ''; 121 | 122 | for (const arg of args) { 123 | if (typeof arg === 'string' || typeof arg === 'number') { 124 | className += ` ${arg}`; 125 | } else if ( 126 | typeof arg === 'object' && 127 | !Array.isArray(arg) && 128 | arg !== null 129 | ) { 130 | Object.keys(arg).forEach(key => { 131 | if (arg[key]) { 132 | className += ` ${key}`; 133 | } 134 | }); 135 | } else if (Array.isArray(arg)) { 136 | className += ` ${arg.join(' ')}`; 137 | } 138 | } 139 | 140 | return className.trim(); 141 | } 142 | 143 | static yieldingLoop(count, chunksize, callback, finished) { 144 | let i = 0; 145 | 146 | (function chunk() { 147 | const end = Math.min(i + chunksize, count); 148 | 149 | for (; i < end; ++i) { 150 | callback.call(null, i); 151 | } 152 | 153 | if (i < count) { 154 | setTimeout(chunk, 0); 155 | } else { 156 | finished && finished.call(null); 157 | } 158 | })(); 159 | } 160 | 161 | // retained for backwards compatibility 162 | static hasNodeShallowChanged(prevNode: INode, newNode: INode) { 163 | return !this.isEqual(prevNode, newNode); 164 | } 165 | 166 | static isEqual(prevNode: any, newNode: any) { 167 | return fastDeepEqual(prevNode, newNode); 168 | } 169 | } 170 | 171 | export default GraphUtils; 172 | -------------------------------------------------------------------------------- /__tests__/utilities/transformers/bwdl-transformer.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | import BwdlTransformer from '../../../src/utilities/transformers/bwdl-transformer'; 5 | 6 | describe('BwdlTransformer', () => { 7 | const output = null; 8 | 9 | describe('class', () => { 10 | it('is defined', () => { 11 | expect(BwdlTransformer).toBeDefined(); 12 | }); 13 | }); 14 | 15 | describe('transform static method', () => { 16 | it('returns a default response when the input has no states', () => { 17 | const input = {}; 18 | const expected = { 19 | edges: [], 20 | nodes: [], 21 | }; 22 | const result = BwdlTransformer.transform(input); 23 | 24 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 25 | }); 26 | 27 | it('returns a default node edge array for a single State node', () => { 28 | const input = { 29 | StartAt: 'test', 30 | States: { 31 | test: {}, 32 | }, 33 | }; 34 | const expected = { 35 | edges: [], 36 | nodes: [ 37 | { 38 | title: 'test', 39 | x: 0, 40 | y: 0, 41 | }, 42 | ], 43 | }; 44 | const result = BwdlTransformer.transform(input); 45 | 46 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 47 | }); 48 | 49 | it('handles Choice nodes', () => { 50 | const input = { 51 | StartAt: 'test', 52 | States: { 53 | test: { 54 | Type: 'Choice', 55 | Choices: [ 56 | { 57 | Next: 'test2', 58 | }, 59 | ], 60 | }, 61 | test2: {}, 62 | }, 63 | }; 64 | const expected = { 65 | edges: [ 66 | { 67 | source: 'test', 68 | target: 'test2', 69 | }, 70 | ], 71 | nodes: [ 72 | { 73 | title: 'test', 74 | type: 'Choice', 75 | x: 0, 76 | y: 0, 77 | }, 78 | { 79 | title: 'test2', 80 | x: 0, 81 | y: 0, 82 | }, 83 | ], 84 | }; 85 | const result = BwdlTransformer.transform(input); 86 | 87 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 88 | }); 89 | 90 | it('handles a Choice node with a Default value', () => { 91 | const input = { 92 | StartAt: 'test', 93 | States: { 94 | test: { 95 | Type: 'Choice', 96 | Choices: [], 97 | Default: 'test2', 98 | }, 99 | test2: {}, 100 | }, 101 | }; 102 | const expected = { 103 | edges: [ 104 | { 105 | source: 'test', 106 | target: 'test2', 107 | }, 108 | ], 109 | nodes: [ 110 | { 111 | title: 'test', 112 | type: 'Choice', 113 | x: 0, 114 | y: 0, 115 | }, 116 | { 117 | title: 'test2', 118 | x: 0, 119 | y: 0, 120 | }, 121 | ], 122 | }; 123 | const result = BwdlTransformer.transform(input); 124 | 125 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 126 | }); 127 | 128 | it('handles a regular node with a Next property', () => { 129 | const input = { 130 | StartAt: 'test', 131 | States: { 132 | test: { 133 | Type: 'Default', 134 | Next: 'test2', 135 | }, 136 | test2: {}, 137 | }, 138 | }; 139 | const expected = { 140 | edges: [ 141 | { 142 | source: 'test', 143 | target: 'test2', 144 | }, 145 | ], 146 | nodes: [ 147 | { 148 | title: 'test', 149 | type: 'Default', 150 | x: 0, 151 | y: 0, 152 | }, 153 | { 154 | title: 'test2', 155 | x: 0, 156 | y: 0, 157 | }, 158 | ], 159 | }; 160 | const result = BwdlTransformer.transform(input); 161 | 162 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 163 | }); 164 | 165 | it('returns a default set when does not have a current node', () => { 166 | const input = { 167 | StartAt: 'test', 168 | States: {}, 169 | }; 170 | const expected = { 171 | edges: [], 172 | nodes: [], 173 | }; 174 | const result = BwdlTransformer.transform(input); 175 | 176 | expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); 177 | }); 178 | }); 179 | 180 | describe('revert static method', () => { 181 | it('returns the input', () => { 182 | const input = { 183 | test: true, 184 | }; 185 | const result = BwdlTransformer.revert(input); 186 | 187 | expect(JSON.stringify(result)).toEqual(JSON.stringify(input)); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /src/examples/bwdl/bwdl-example-data.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | export default { 19 | ExampleSource: 20 | 'https://code.uberinternal.com/file/data/aioyv5yrrs3dadbmxlap/PHID-FILE-v36jeiyn4y3gphtdwjsm/1.json', 21 | Name: 'Colombo_Intercity_Driver_dispatch', 22 | Comment: 23 | 'Send SMS message to drivers accept dispatch for Colombo intercity trip', 24 | Version: 1, 25 | Domain: '//Autobots', 26 | Id: '//Autobots/ColomboIntercityDriverDispatch', 27 | StartAt: 'Init', 28 | AllowReentry: true, 29 | States: { 30 | Init: { 31 | Type: 'Terminator', 32 | Resource: 'kafka://hp_demand_job-assigned', 33 | ResultPath: '$.event', 34 | Next: 'Check City and Vehicle View', 35 | }, 36 | 'Check City and Vehicle View': { 37 | Type: 'Choice', 38 | InputPath: '$.event', 39 | Choices: [ 40 | { 41 | And: [ 42 | { 43 | Variable: '$.region.id', 44 | NumberEquals: 478, 45 | }, 46 | { 47 | Variable: '$.vehicleViewId', 48 | NumberEquals: 20006733, 49 | }, 50 | ], 51 | Next: 'SMS for Dispatch accepted', 52 | }, 53 | { 54 | And: [ 55 | { 56 | Variable: '$.region.id', 57 | NumberEquals: 999, 58 | }, 59 | ], 60 | Next: 'SMS for Dispatch denied', 61 | }, 62 | ], 63 | }, 64 | 'Check Other City': { 65 | Type: 'Choice', 66 | InputPath: '$.event', 67 | Choices: [ 68 | { 69 | And: [ 70 | { 71 | Variable: '$.region.id', 72 | NumberEquals: 478, 73 | }, 74 | ], 75 | Next: 'Wait for six hours', 76 | }, 77 | { 78 | And: [ 79 | { 80 | Variable: '$.region.id', 81 | NumberEquals: 999, 82 | }, 83 | ], 84 | Next: 'Wait for twenty four hours', 85 | }, 86 | ], 87 | }, 88 | 'SMS for Dispatch accepted': { 89 | Type: 'Pass', 90 | InputPath: '$.event', 91 | Result: { 92 | expirationMinutes: 60, 93 | fromUserUUID: '71af5aea-9eaa-45a1-9825-2c124030b063', 94 | toUserUUID: 'Eval($.supplyUUID)', 95 | getSMSReply: false, 96 | message: 97 | 'Hithawath Partner, Oba labegena athi mema trip eka UberGALLE trip ekaki, Karunakara rider wa amatha drop location eka confirm karaganna. Sthuthi', 98 | messageType: 'SEND_SMS', 99 | priority: 1, 100 | actionUUID: 'd259c34d-457a-411e-8c93-6edd63a7ddc6', 101 | }, 102 | ResultPath: '$.actionParam', 103 | Next: 'Send SMS', 104 | }, 105 | 'SMS for Dispatch denied': { 106 | Type: 'Pass', 107 | InputPath: '$.event', 108 | Result: { 109 | expirationMinutes: 60, 110 | fromUserUUID: '71af5aea-9eaa-45a1-9825-2c124030b063', 111 | toUserUUID: 'Eval($.supplyUUID)', 112 | getSMSReply: false, 113 | message: 114 | 'Hithawath Partner, Oba labegena athi mema trip eka UberGALLE trip ekaki, Karunakara rider wa amatha drop location eka confirm karaganna. Sthuthi', 115 | messageType: 'SEND_SMS', 116 | priority: 1, 117 | actionUUID: 'd259c34d-457a-411e-8c93-6edd63a7ddc6', 118 | }, 119 | ResultPath: '$.actionParam', 120 | Next: 'Send SMS', 121 | }, 122 | 'Send SMS': { 123 | Type: 'Task', 124 | InputPath: '$.actionParam', 125 | Resource: 'uns://sjc1/sjc1-prod01/us1/cleopatra/Cleopatra::sendSMS', 126 | InputSchema: { 127 | '$.expirationMinutes': 'int', 128 | '$.toUserUUID': 'string', 129 | '$.fromUserUUID': 'string', 130 | '$.getSMSReply': 'bool', 131 | '$.message': 'string', 132 | '$.messageType': 'string', 133 | '$.priority': 'int', 134 | '$.actionUUID': 'string', 135 | }, 136 | OutputSchema: { 137 | '$.fraudDriverUUIDs[*]': 'string', 138 | }, 139 | Next: 'Check Other City', 140 | }, 141 | 'Wait for six hours': { 142 | Type: 'Wait', 143 | Seconds: 21600, 144 | Next: 'Exit', 145 | }, 146 | 'Wait for twenty four hours': { 147 | Type: 'Wait', 148 | Seconds: 86400, 149 | Next: 'Exit', 150 | }, 151 | Exit: { 152 | Type: 'Terminator', 153 | End: true, 154 | }, 155 | }, 156 | }; 157 | -------------------------------------------------------------------------------- /src/examples/bwdl-editable/bwdl-example-data.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | export default { 19 | ExampleSource: 20 | 'https://code.uberinternal.com/file/data/aioyv5yrrs3dadbmxlap/PHID-FILE-v36jeiyn4y3gphtdwjsm/1.json', 21 | Name: 'Colombo_Intercity_Driver_dispatch', 22 | Comment: 23 | 'Send SMS message to drivers accept dispatch for Colombo intercity trip', 24 | Version: 1, 25 | Domain: '//Autobots', 26 | Id: '//Autobots/ColomboIntercityDriverDispatch', 27 | StartAt: 'Init', 28 | AllowReentry: true, 29 | States: { 30 | Init: { 31 | Type: 'Terminator', 32 | Resource: 'kafka://hp_demand_job-assigned', 33 | ResultPath: '$.event', 34 | Next: 'Check City and Vehicle View', 35 | }, 36 | 'Check City and Vehicle View': { 37 | Type: 'Choice', 38 | InputPath: '$.event', 39 | Choices: [ 40 | { 41 | And: [ 42 | { 43 | Variable: '$.region.id', 44 | NumberEquals: 478, 45 | }, 46 | { 47 | Variable: '$.vehicleViewId', 48 | NumberEquals: 20006733, 49 | }, 50 | ], 51 | Next: 'SMS for Dispatch accepted', 52 | }, 53 | { 54 | And: [ 55 | { 56 | Variable: '$.region.id', 57 | NumberEquals: 999, 58 | }, 59 | ], 60 | Next: 'SMS for Dispatch denied', 61 | }, 62 | ], 63 | }, 64 | 'Check Other City': { 65 | Type: 'Choice', 66 | InputPath: '$.event', 67 | Choices: [ 68 | { 69 | And: [ 70 | { 71 | Variable: '$.region.id', 72 | NumberEquals: 478, 73 | }, 74 | ], 75 | Next: 'Wait for six hours', 76 | }, 77 | { 78 | And: [ 79 | { 80 | Variable: '$.region.id', 81 | NumberEquals: 999, 82 | }, 83 | ], 84 | Next: 'Wait for twenty four hours', 85 | }, 86 | ], 87 | }, 88 | 'SMS for Dispatch accepted': { 89 | Type: 'Pass', 90 | InputPath: '$.event', 91 | Result: { 92 | expirationMinutes: 60, 93 | fromUserUUID: '71af5aea-9eaa-45a1-9825-2c124030b063', 94 | toUserUUID: 'Eval($.supplyUUID)', 95 | getSMSReply: false, 96 | message: 97 | 'Hithawath Partner, Oba labegena athi mema trip eka UberGALLE trip ekaki, Karunakara rider wa amatha drop location eka confirm karaganna. Sthuthi', 98 | messageType: 'SEND_SMS', 99 | priority: 1, 100 | actionUUID: 'd259c34d-457a-411e-8c93-6edd63a7ddc6', 101 | }, 102 | ResultPath: '$.actionParam', 103 | Next: 'Send SMS', 104 | }, 105 | 'SMS for Dispatch denied': { 106 | Type: 'Pass', 107 | InputPath: '$.event', 108 | Result: { 109 | expirationMinutes: 60, 110 | fromUserUUID: '71af5aea-9eaa-45a1-9825-2c124030b063', 111 | toUserUUID: 'Eval($.supplyUUID)', 112 | getSMSReply: false, 113 | message: 114 | 'Hithawath Partner, Oba labegena athi mema trip eka UberGALLE trip ekaki, Karunakara rider wa amatha drop location eka confirm karaganna. Sthuthi', 115 | messageType: 'SEND_SMS', 116 | priority: 1, 117 | actionUUID: 'd259c34d-457a-411e-8c93-6edd63a7ddc6', 118 | }, 119 | ResultPath: '$.actionParam', 120 | Next: 'Send SMS', 121 | }, 122 | 'Send SMS': { 123 | Type: 'Task', 124 | InputPath: '$.actionParam', 125 | Resource: 'uns://sjc1/sjc1-prod01/us1/cleopatra/Cleopatra::sendSMS', 126 | InputSchema: { 127 | '$.expirationMinutes': 'int', 128 | '$.toUserUUID': 'string', 129 | '$.fromUserUUID': 'string', 130 | '$.getSMSReply': 'bool', 131 | '$.message': 'string', 132 | '$.messageType': 'string', 133 | '$.priority': 'int', 134 | '$.actionUUID': 'string', 135 | }, 136 | OutputSchema: { 137 | '$.fraudDriverUUIDs[*]': 'string', 138 | }, 139 | Next: 'Check Other City', 140 | }, 141 | 'Wait for six hours': { 142 | Type: 'Wait', 143 | Seconds: 21600, 144 | Next: 'Exit', 145 | }, 146 | 'Wait for twenty four hours': { 147 | Type: 'Wait', 148 | Seconds: 86400, 149 | Next: 'Exit', 150 | }, 151 | Exit: { 152 | Type: 'Terminator', 153 | End: true, 154 | }, 155 | }, 156 | }; 157 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-digraph", 3 | "description": "directed graph react component", 4 | "version": "6.6.3", 5 | "keywords": [ 6 | "uber-library", 7 | "babel", 8 | "es6", 9 | "d3", 10 | "react", 11 | "graph", 12 | "digraph" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/uber/react-digraph.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/uber/react-digraph/issues/new", 20 | "email": "antonb@uber.com" 21 | }, 22 | "engines": { 23 | "node": ">= 0.10.0" 24 | }, 25 | "license": "MIT", 26 | "main": "dist/main.min.js", 27 | "types": "./typings/index.d.ts", 28 | "peerDependencies": { 29 | "react": "^16.4.1", 30 | "react-dom": "^16.4.1" 31 | }, 32 | "dependencies": { 33 | "d3": "^5.7.0", 34 | "dagre": "^0.8.2", 35 | "fast-deep-equal": "^2.0.1", 36 | "html-react-parser": "^0.6.1", 37 | "kld-affine": "2.0.4", 38 | "kld-intersections": "^0.4.3", 39 | "line-intersect": "^2.1.1", 40 | "svg-intersections": "^0.4.0" 41 | }, 42 | "devDependencies": { 43 | "@fortawesome/fontawesome-free": "^5.7.2", 44 | "babel-cli": "^6.6.5", 45 | "babel-core": "^6.26.0", 46 | "babel-eslint": "^10.0.1", 47 | "babel-jest": "^23.6.0", 48 | "babel-loader": "^7.1.5", 49 | "babel-plugin-react": "^1.0.0", 50 | "babel-plugin-transform-es2015-destructuring": "^6.23.0", 51 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 52 | "babel-plugin-transform-object-assign": "^6.8.0", 53 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 54 | "babel-preset-env": "^1.7.0", 55 | "babel-preset-es2015": "^6.24.1", 56 | "babel-preset-react": "^6.24.1", 57 | "babel-preset-stage-2": "^6.24.1", 58 | "brace": "^0.11.1", 59 | "browserify": "^14.4.0", 60 | "copy-webpack-plugin": "^4.5.2", 61 | "css-loader": "^1.0.0", 62 | "enzyme": "3.8.0", 63 | "enzyme-adapter-react-16": "^1.10.0", 64 | "eslint": "^5.2.0", 65 | "eslint-config-fusion": "^6.0.0", 66 | "eslint-config-prettier": "^4.3.0", 67 | "eslint-loader": "^2.1.0", 68 | "eslint-plugin-cup": "^2.0.1", 69 | "eslint-plugin-es6-recommended": "^0.1.2", 70 | "eslint-plugin-flowtype": "^2.50.0", 71 | "eslint-plugin-import": "^2.14.0", 72 | "eslint-plugin-import-order": "^2.1.4", 73 | "eslint-plugin-jest": "^22.6.4", 74 | "eslint-plugin-jsx-a11y": "^6.1.1", 75 | "eslint-plugin-prettier": "^3.1.0", 76 | "eslint-plugin-react": "^7.5.1", 77 | "eslint-plugin-react-hooks": "^1.6.0", 78 | "flow-bin": "^0.86.0", 79 | "husky": "^2.4.0", 80 | "jest": "^22.4.3", 81 | "jsdom": "^11.12.0", 82 | "lint-staged": "^8.2.0", 83 | "live-server": "^1.2.0", 84 | "node-sass": "^4.9.2", 85 | "npm-run-all": "^4.1.3", 86 | "opn-cli": "3.1.0", 87 | "prettier": "^1.12.0", 88 | "prop-types": "^15.6.0", 89 | "react": "^16.4.2", 90 | "react-ace": "^6.1.4", 91 | "react-dom": "^16.4.2", 92 | "react-router-dom": "^4.3.1", 93 | "rimraf": "^2.6.2", 94 | "sass-loader": "^7.0.3", 95 | "source-map-loader": "^0.2.3", 96 | "style-loader": "^0.23.1", 97 | "svg-inline-loader": "^0.8.0", 98 | "uglifyjs-webpack-plugin": "^2.0.1", 99 | "webpack": "^4.26.1", 100 | "webpack-bundle-analyzer": "3.6.0", 101 | "webpack-cli": "^3.1.2" 102 | }, 103 | "scripts": { 104 | "build": "webpack", 105 | "build:prod": "webpack --config webpack.prod.js", 106 | "clean": "rimraf ./dist", 107 | "watch": "webpack --watch", 108 | "build-css": "node-sass --include-path scss src/styles/main.scss dist/styles/main.css && node-sass --include-path scss src/examples/app.scss dist/examples/app.css", 109 | "cover": "npm run test", 110 | "example": "npm run serve", 111 | "flow": "flow .", 112 | "jenkins-install": "npm install", 113 | "jenkins-jshint": "npm run lint -- --o=jshint.xml --f=checkstyle", 114 | "jenkins-test": "npm run jenkins-jshint && npm run test", 115 | "live-server": "live-server ./dist --entry-file=./index.html", 116 | "live-serve": "npm-run-all --parallel watch live-server", 117 | "lint": "eslint src", 118 | "lint-fix": "eslint --fix src", 119 | "precommit": "lint-staged && npm run test", 120 | "prefast-test": "npm run prepublish", 121 | "prepublish": "npm run package", 122 | "serve": "npm run live-serve", 123 | "test": "jest", 124 | "test:debug": "node --inspect node_modules/.bin/jest --watch --runInBand", 125 | "view-cover": "npm run cover -- --report=html && opn ./coverage/index.html", 126 | "package": "npm-run-all clean lint test build build:prod", 127 | "analyze-bundle": "babel-node ./tools/analyzeBundle.js" 128 | }, 129 | "jest": { 130 | "testURL": "http://localhost", 131 | "moduleFileExtensions": [ 132 | "ts", 133 | "tsx", 134 | "js" 135 | ], 136 | "transformIgnorePatterns": [ 137 | "node_modules" 138 | ], 139 | "testMatch": [ 140 | "**/__tests__/**/*.+(ts|tsx|js)" 141 | ], 142 | "collectCoverage": true, 143 | "coverageDirectory": "/coverage", 144 | "collectCoverageFrom": [ 145 | "**/src/**/*.{js,ts,tsx}", 146 | "!**/node_modules/**", 147 | "!**/vendor/**", 148 | "!**/*.d.ts", 149 | "!**/examples/**" 150 | ], 151 | "coverageReporters": [ 152 | "json", 153 | "lcov", 154 | "text", 155 | "html", 156 | "cobertura" 157 | ], 158 | "setupTestFrameworkScriptFile": "/jest-setup.js", 159 | "moduleNameMapper": { 160 | "\\.(scss)$": "/__mocks__/styles.mock.js", 161 | "@fortawesome/fontawesome-free/svgs/solid/expand.svg": "/__mocks__/icon.mock.js" 162 | } 163 | }, 164 | "publishConfig": { 165 | "registry": "https://registry.npmjs.org" 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright(c) 2018 Uber Technologies, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | $primary-color: dodgerblue; 18 | $light-color: white; 19 | $dark-color: black; 20 | $light-grey: lightgrey; 21 | $task-done-color: lightgreen; 22 | $task-in-progress-color: lightblue; 23 | 24 | $background-color: #f9f9f9; 25 | 26 | .view-wrapper { 27 | height: 100%; 28 | width: 100%; 29 | margin: 0; 30 | display: flex; 31 | box-shadow: none; 32 | background: $background-color; 33 | transition: opacity 0.167s; 34 | opacity: 1; 35 | outline: none; 36 | user-select: none; 37 | 38 | > .graph { 39 | align-content: stretch; 40 | flex: 1; 41 | width: 100%; 42 | height: 100%; 43 | } 44 | 45 | .node-todo { 46 | .shape { 47 | > use.node-todo { 48 | color: $primary-color; 49 | stroke: $dark-color; 50 | fill: $light-color; 51 | filter: url(#dropshadow); 52 | stroke-width: 0.5px; 53 | cursor: pointer; 54 | user-select: none; 55 | 56 | &.hovered { 57 | stroke: $primary-color; 58 | } 59 | &.selected { 60 | color: $light-color; 61 | stroke: $primary-color; 62 | stroke-width: 1px; 63 | fill: $primary-color; 64 | } 65 | } 66 | } 67 | 68 | .node-text { 69 | fill: $dark-color; 70 | cursor: pointer; 71 | user-select: none; 72 | &.selected { 73 | fill: $light-color; 74 | stroke: $light-color; 75 | } 76 | } 77 | } 78 | 79 | .node-in-progress { 80 | .shape { 81 | > use.node-in-progress { 82 | color: $primary-color; 83 | stroke: $dark-color; 84 | fill: $task-in-progress-color; 85 | filter: url(#dropshadow); 86 | stroke-width: 0.5px; 87 | cursor: pointer; 88 | user-select: none; 89 | 90 | &.hovered { 91 | stroke: $primary-color; 92 | } 93 | &.selected { 94 | color: $light-color; 95 | stroke: $primary-color; 96 | stroke-width: 1px; 97 | fill: $primary-color; 98 | } 99 | } 100 | } 101 | 102 | .node-text { 103 | fill: $dark-color; 104 | cursor: pointer; 105 | user-select: none; 106 | &.selected { 107 | fill: $light-color; 108 | stroke: $light-color; 109 | } 110 | } 111 | } 112 | 113 | .node-done { 114 | .shape { 115 | > use.node-done { 116 | color: $primary-color; 117 | stroke: $dark-color; 118 | fill: $task-done-color; 119 | filter: url(#dropshadow); 120 | stroke-width: 0.5px; 121 | cursor: pointer; 122 | user-select: none; 123 | 124 | &.hovered { 125 | stroke: $primary-color; 126 | } 127 | &.selected { 128 | color: $light-color; 129 | stroke: $primary-color; 130 | stroke-width: 1px; 131 | fill: $primary-color; 132 | } 133 | } 134 | } 135 | 136 | .node-text { 137 | fill: $dark-color; 138 | cursor: pointer; 139 | user-select: none; 140 | &.selected { 141 | fill: $light-color; 142 | stroke: $light-color; 143 | } 144 | } 145 | } 146 | 147 | .edge { 148 | color: $light-color; 149 | stroke: $primary-color; 150 | stroke-width: 2px; 151 | marker-end: url(#end-arrow); 152 | cursor: pointer; 153 | 154 | .edge-text { 155 | stroke-width: 0.5px; 156 | fill: $primary-color; 157 | stroke: $primary-color; 158 | 159 | cursor: pointer; 160 | user-select: none; 161 | } 162 | 163 | &.selected { 164 | color: $primary-color; 165 | stroke: $primary-color; 166 | 167 | .edge-text { 168 | fill: $light-color; 169 | stroke: $light-color; 170 | } 171 | } 172 | 173 | 174 | } 175 | 176 | .edge-mouse-handler { 177 | stroke: black; 178 | opacity: 0; 179 | color: transparent; 180 | stroke-width: 15px; 181 | cursor: pointer; 182 | pointer-events: all; 183 | } 184 | 185 | .arrow { 186 | fill: $primary-color; 187 | } 188 | 189 | .graph-controls { 190 | position: absolute; 191 | bottom: 30px; 192 | left: 15px; 193 | z-index: 100; 194 | display: grid; 195 | grid-template-columns: auto auto; 196 | grid-gap: 15px; 197 | align-items: center; 198 | user-select: none; 199 | 200 | > .slider-wrapper { 201 | background-color: white; 202 | color: $primary-color; 203 | border: solid 1px lightgray; 204 | padding: 6.5px; 205 | border-radius: 2px; 206 | 207 | > span { 208 | display: inline-block; 209 | vertical-align: top; 210 | margin-top: 4px; 211 | } 212 | 213 | > .slider { 214 | position: relative; 215 | margin-left: 4px; 216 | margin-right: 4px; 217 | cursor: pointer; 218 | } 219 | } 220 | 221 | > .slider-button { 222 | background-color: white; 223 | fill: $primary-color; 224 | border: solid 1px lightgray; 225 | outline: none; 226 | width: 31px; 227 | height: 31px; 228 | border-radius: 2px; 229 | cursor: pointer; 230 | margin: 0; 231 | } 232 | } 233 | 234 | .circle { 235 | fill: $light-grey; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/examples/bwdl/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | import AceEditor from 'react-ace'; 20 | import 'brace/mode/json'; 21 | import 'brace/theme/monokai'; 22 | import { type IEdge } from '../../components/edge'; 23 | import GraphView from '../../components/graph-view'; 24 | import { type INode } from '../../components/node'; 25 | import { type LayoutEngineType } from '../../utilities/layout-engine/layout-engine-types'; 26 | import BwdlTransformer from '../../utilities/transformers/bwdl-transformer'; 27 | import Sidebar from '../sidebar'; 28 | import GraphConfig, { NODE_KEY } from './bwdl-config'; // Configures node/edge types 29 | import bwdlExample from './bwdl-example-data'; 30 | import BwdlNodeForm from './bwdl-node-form'; 31 | 32 | type IBwdlState = { 33 | nodes: INode[], 34 | edges: IEdge[], 35 | selected: INode | IEdge | null, 36 | layoutEngineType: LayoutEngineType, 37 | bwdlText: string, 38 | bwdlJson: any, 39 | copiedNode: any, 40 | selectedBwdlNode: any, 41 | }; 42 | 43 | class Bwdl extends React.Component<{}, IBwdlState> { 44 | GraphView: GraphView | null; 45 | 46 | constructor(props: any) { 47 | super(props); 48 | 49 | const transformed = BwdlTransformer.transform(bwdlExample); 50 | 51 | this.state = { 52 | bwdlJson: bwdlExample, 53 | bwdlText: JSON.stringify(bwdlExample, null, 2), 54 | copiedNode: null, 55 | edges: transformed.edges, 56 | layoutEngineType: 'VerticalTree', 57 | nodes: transformed.nodes, 58 | selected: null, 59 | selectedBwdlNode: null, 60 | }; 61 | } 62 | 63 | updateBwdl = () => { 64 | const transformed = BwdlTransformer.transform(this.state.bwdlJson); 65 | 66 | this.setState({ 67 | edges: transformed.edges, 68 | nodes: transformed.nodes, 69 | }); 70 | }; 71 | 72 | handleTextAreaChange = (value: string, event: any) => { 73 | let input = null; 74 | const bwdlText = value; 75 | 76 | this.setState({ 77 | bwdlText, 78 | }); 79 | 80 | try { 81 | input = JSON.parse(bwdlText); 82 | } catch (e) { 83 | return; 84 | } 85 | 86 | this.setState({ 87 | bwdlJson: input, 88 | }); 89 | 90 | this.updateBwdl(); 91 | }; 92 | 93 | onSelectNode = (node: INode | null) => { 94 | this.setState({ 95 | selected: node, 96 | selectedBwdlNode: node ? this.state.bwdlJson.States[node.title] : null, 97 | }); 98 | }; 99 | 100 | onCreateNode = () => { 101 | return; 102 | }; 103 | onUpdateNode = () => { 104 | return; 105 | }; 106 | onDeleteNode = () => { 107 | return; 108 | }; 109 | onSelectEdge = () => { 110 | return; 111 | }; 112 | onCreateEdge = () => { 113 | return; 114 | }; 115 | onSwapEdge = () => { 116 | return; 117 | }; 118 | onDeleteEdge = () => { 119 | return; 120 | }; 121 | 122 | renderLeftSidebar() { 123 | return ( 124 | 125 |
126 | 144 |
145 |
146 | ); 147 | } 148 | 149 | renderRightSidebar() { 150 | if (!this.state.selected) { 151 | return null; 152 | } 153 | 154 | return ( 155 | 156 |
157 | 162 |
163 |
164 | ); 165 | } 166 | 167 | renderGraph() { 168 | const { nodes, edges, selected } = this.state; 169 | const { NodeTypes, NodeSubtypes, EdgeTypes } = GraphConfig; 170 | 171 | return ( 172 | (this.GraphView = el)} 174 | nodeKey={NODE_KEY} 175 | readOnly={true} 176 | nodes={nodes} 177 | edges={edges} 178 | selected={selected} 179 | nodeTypes={NodeTypes} 180 | nodeSubtypes={NodeSubtypes} 181 | edgeTypes={EdgeTypes} 182 | onSelectNode={this.onSelectNode} 183 | onCreateNode={this.onCreateNode} 184 | onUpdateNode={this.onUpdateNode} 185 | onDeleteNode={this.onDeleteNode} 186 | onSelectEdge={this.onSelectEdge} 187 | onCreateEdge={this.onCreateEdge} 188 | onSwapEdge={this.onSwapEdge} 189 | onDeleteEdge={this.onDeleteEdge} 190 | layoutEngineType={this.state.layoutEngineType} 191 | /> 192 | ); 193 | } 194 | 195 | render() { 196 | return ( 197 |
198 | {this.renderLeftSidebar()} 199 |
{this.renderGraph()}
200 | {this.state.selected && this.renderRightSidebar()} 201 |
202 | ); 203 | } 204 | } 205 | 206 | export default Bwdl; 207 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright(c) 2018 Uber Technologies, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | declare module 'react-digraph' { 18 | export type INode = { 19 | title: string; 20 | x?: number | null; 21 | y?: number | null; 22 | type?: string; 23 | subtype?: string | null; 24 | [key: string]: any; 25 | }; 26 | 27 | export type IPoint = { 28 | x: number; 29 | y: number; 30 | }; 31 | 32 | export type IBBox = { 33 | x: number, 34 | y: number, 35 | width: number, 36 | height: number, 37 | }; 38 | 39 | export type INodeProps = { 40 | data: INode; 41 | id: string; 42 | nodeTypes: any; // TODO: make a nodeTypes interface 43 | nodeSubtypes: any; // TODO: make a nodeSubtypes interface 44 | opacity?: number; 45 | nodeKey: string; 46 | nodeSize?: number; 47 | onNodeMouseEnter: (event: any, data: any, hovered: boolean) => void; 48 | onNodeMouseLeave: (event: any, data: any) => void; 49 | onNodeMove: (point: IPoint, id: string, shiftKey: boolean) => void; 50 | onNodeSelected: (data: any, id: string, shiftKey: boolean) => void; 51 | onNodeUpdate: (point: IPoint, id: string, shiftKey: boolean) => void; 52 | renderNode?: ( 53 | nodeRef: any, 54 | data: any, 55 | id: string, 56 | selected: boolean, 57 | hovered: boolean 58 | ) => any; 59 | renderNodeText?: ( 60 | data: any, 61 | id: string | number, 62 | isSelected: boolean 63 | ) => any; 64 | isSelected: boolean; 65 | layoutEngine?: any; 66 | viewWrapperElem: HTMLDivElement; 67 | }; 68 | 69 | export const Node: React.ComponentClass; 70 | 71 | export type IEdge = { 72 | source: string; 73 | target: string; 74 | type?: string; 75 | handleText?: string; 76 | handleTooltipText?: string; 77 | [key: string]: any; 78 | }; 79 | 80 | export type ITargetPosition = { 81 | x: number; 82 | y: number; 83 | }; 84 | 85 | export type IEdgeProps = { 86 | data: IEdge; 87 | edgeTypes: any; // TODO: create an edgeTypes interface 88 | edgeHandleSize?: number; 89 | nodeSize?: number; 90 | sourceNode: INode | null; 91 | targetNode: INode | ITargetPosition; 92 | isSelected: boolean; 93 | nodeKey: string; 94 | viewWrapperElem: HTMLDivElement; 95 | }; 96 | 97 | export const Edge: React.Component; 98 | 99 | export type IGraphViewProps = { 100 | backgroundFillId?: string; 101 | edges: any[]; 102 | edgeArrowSize?: number; 103 | edgeHandleSize?: number; 104 | edgeTypes: any; 105 | gridDotSize?: number; 106 | gridSize?: number; 107 | gridSpacing?: number; 108 | layoutEngineType?: LayoutEngineType; 109 | maxTitleChars?: number; 110 | maxZoom?: number; 111 | minZoom?: number; 112 | nodeKey: string; 113 | nodes: any[]; 114 | nodeSize?: number; 115 | nodeHeight?: number, 116 | nodeWidth?: number, 117 | nodeSpacingMultiplier?: number, 118 | nodeSubtypes: any; 119 | nodeTypes: any; 120 | readOnly?: boolean; 121 | selected: any; 122 | showGraphControls?: boolean; 123 | zoomDelay?: number; 124 | zoomDur?: number; 125 | canCreateEdge?: (startNode?: INode, endNode?: INode) => boolean; 126 | canDeleteEdge?: (selected: any) => boolean; 127 | canDeleteNode?: (selected: any) => boolean; 128 | onBackgroundClick?: (x: number, y: number, event: any) => void, 129 | onCopySelected?: () => void; 130 | onCreateEdge: (sourceNode: INode, targetNode: INode) => void; 131 | onCreateNode: (x: number, y: number, event: any) => void; 132 | onDeleteEdge: (selectedEdge: IEdge, edges: IEdge[]) => void; 133 | onDeleteNode: (selected: any, nodeId: string, nodes: any[]) => void; 134 | onPasteSelected?: () => void; 135 | onSelectEdge: (selectedEdge: IEdge) => void; 136 | onSelectNode: (node: INode | null) => void; 137 | onSwapEdge: (sourceNode: INode, targetNode: INode, edge: IEdge) => void; 138 | onUndo?: () => void; 139 | onUpdateNode: (node: INode) => void; 140 | renderBackground?: (gridSize?: number) => any; 141 | renderDefs?: () => any; 142 | renderNode?: ( 143 | nodeRef: any, 144 | data: any, 145 | id: string, 146 | selected: boolean, 147 | hovered: boolean 148 | ) => any; 149 | afterRenderEdge?: ( 150 | id: string, 151 | element: any, 152 | edge: IEdge, 153 | edgeContainer: any, 154 | isEdgeSelected: boolean 155 | ) => void; 156 | renderNodeText?: ( 157 | data: any, 158 | id: string | number, 159 | isSelected: boolean 160 | ) => any; 161 | rotateEdgeHandle?: boolean; 162 | centerNodeOnMove?: boolean; 163 | initialBBox: IBBox; 164 | }; 165 | 166 | export type IGraphInput = { 167 | nodes: INode[]; 168 | edges: IEdge[]; 169 | }; 170 | 171 | export class BwdlTransformer extends Transformer {} 172 | 173 | export class Transformer { 174 | /** 175 | * Converts an input from the specified type to IGraphInput type. 176 | * @param input 177 | * @returns IGraphInput 178 | */ 179 | static transform(input: any): IGraphInput; 180 | 181 | /** 182 | * Converts a graphInput to the specified transformer type. 183 | * @param graphInput 184 | * @returns any 185 | */ 186 | static revert(graphInput: IGraphInput): any; 187 | } 188 | 189 | export type LayoutEngineType = 'None' | 'SnapToGrid' | 'VerticalTree'; 190 | 191 | export const GraphView: React.ComponentClass; 192 | export type INodeMapNode = { 193 | node: INode; 194 | originalArrIndex: number; 195 | incomingEdges: IEdge[]; 196 | outgoingEdges: IEdge[]; 197 | parents: INode[]; 198 | children: INode[]; 199 | }; 200 | 201 | type ObjectMap = { [key: string]: T }; 202 | 203 | export type NodesMap = ObjectMap; 204 | 205 | export type EdgesMap = ObjectMap; 206 | 207 | export interface IEdgeMapNode { 208 | edge: IEdge; 209 | originalArrIndex: number; 210 | } 211 | 212 | export type Element = any; 213 | 214 | export class GraphUtils { 215 | static getNodesMap(arr: INode[], key: string): NodesMap; 216 | 217 | static getEdgesMap(arr: IEdge[]): EdgesMap; 218 | 219 | static linkNodesAndEdges(nodesMap: NodesMap, edges: IEdge[]): void; 220 | 221 | static removeElementFromDom(id: string): boolean; 222 | 223 | static findParent(element: Element, selector: string): Element | null; 224 | 225 | static classNames(...args: any[]): string; 226 | 227 | static yieldingLoop( 228 | count: number, 229 | chunksize: number, 230 | callback: (i: number) => void, 231 | finished?: () => void 232 | ): void; 233 | 234 | static hasNodeShallowChanged(prevNode: INode, newNode: INode): boolean; 235 | 236 | static isEqual(prevNode: any, newNode: any): boolean; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /__tests__/components/graph-util.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import GraphUtils from '../../src/utilities/graph-util'; 4 | 5 | describe('GraphUtils class', () => { 6 | describe('getNodesMap method', () => { 7 | it('converts an array of nodes to a hash map', () => { 8 | const nodes = [ 9 | { 10 | id: 'foo', 11 | name: 'bar', 12 | }, 13 | ]; 14 | const nodesMap = GraphUtils.getNodesMap(nodes, 'id'); 15 | 16 | expect(JSON.stringify(nodesMap)).toEqual( 17 | JSON.stringify({ 18 | 'key-foo': { 19 | children: [], 20 | incomingEdges: [], 21 | node: nodes[0], 22 | originalArrIndex: 0, 23 | outgoingEdges: [], 24 | parents: [], 25 | }, 26 | }) 27 | ); 28 | }); 29 | }); 30 | 31 | describe('getEdgesMap method', () => { 32 | it('converts an array of edges to a hash map', () => { 33 | const edges = [ 34 | { 35 | source: 'foo', 36 | target: 'bar', 37 | }, 38 | ]; 39 | const edgesMap = GraphUtils.getEdgesMap(edges); 40 | 41 | expect(JSON.stringify(edgesMap)).toEqual( 42 | JSON.stringify({ 43 | foo_bar: { 44 | edge: edges[0], 45 | originalArrIndex: 0, 46 | }, 47 | }) 48 | ); 49 | }); 50 | }); 51 | 52 | describe('linkNodesAndEdges method', () => { 53 | let nodesMap; 54 | 55 | beforeEach(() => { 56 | nodesMap = { 57 | 'key-bar': { 58 | children: [], 59 | incomingEdges: [], 60 | node: { id: 'bar' }, 61 | originalArrIndex: 0, 62 | outgoingEdges: [], 63 | parents: [], 64 | }, 65 | 'key-foo': { 66 | children: [], 67 | incomingEdges: [], 68 | node: { id: 'foo' }, 69 | originalArrIndex: 0, 70 | outgoingEdges: [], 71 | parents: [], 72 | }, 73 | }; 74 | }); 75 | 76 | it('fills in various properties of a nodeMapNode', () => { 77 | const edges = [ 78 | { 79 | source: 'foo', 80 | target: 'bar', 81 | }, 82 | ]; 83 | 84 | GraphUtils.linkNodesAndEdges(nodesMap, edges); 85 | 86 | expect(nodesMap['key-bar'].incomingEdges.length).toEqual(1); 87 | expect(nodesMap['key-bar'].incomingEdges[0]).toEqual(edges[0]); 88 | expect(nodesMap['key-foo'].outgoingEdges.length).toEqual(1); 89 | expect(nodesMap['key-foo'].outgoingEdges[0]).toEqual(edges[0]); 90 | expect(nodesMap['key-foo'].children.length).toEqual(1); 91 | expect(nodesMap['key-foo'].children[0]).toEqual(nodesMap['key-bar']); 92 | expect(nodesMap['key-bar'].parents.length).toEqual(1); 93 | expect(nodesMap['key-bar'].parents[0]).toEqual(nodesMap['key-foo']); 94 | }); 95 | 96 | it('does not modify nodes if there is no matching target', () => { 97 | const edges = [ 98 | { 99 | source: 'foo', 100 | target: 'fake', 101 | }, 102 | ]; 103 | 104 | GraphUtils.linkNodesAndEdges(nodesMap, edges); 105 | 106 | expect(nodesMap['key-foo'].outgoingEdges.length).toEqual(0); 107 | expect(nodesMap['key-foo'].children.length).toEqual(0); 108 | }); 109 | 110 | it('does not modify nodes if there is no matching source', () => { 111 | const edges = [ 112 | { 113 | source: 'fake', 114 | target: 'bar', 115 | }, 116 | ]; 117 | 118 | GraphUtils.linkNodesAndEdges(nodesMap, edges); 119 | 120 | expect(nodesMap['key-bar'].incomingEdges.length).toEqual(0); 121 | expect(nodesMap['key-bar'].parents.length).toEqual(0); 122 | }); 123 | }); 124 | 125 | describe('removeElementFromDom method', () => { 126 | it('removes an element using an id', () => { 127 | const fakeElement = { 128 | parentNode: { 129 | removeChild: jest.fn(), 130 | }, 131 | }; 132 | 133 | jest.spyOn(document, 'getElementById').mockReturnValue(fakeElement); 134 | const result = GraphUtils.removeElementFromDom('fake'); 135 | 136 | expect(fakeElement.parentNode.removeChild).toHaveBeenCalledWith( 137 | fakeElement 138 | ); 139 | expect(result).toEqual(true); 140 | }); 141 | 142 | it("does nothing when it can't find the element", () => { 143 | jest.spyOn(document, 'getElementById').mockReturnValue(undefined); 144 | const result = GraphUtils.removeElementFromDom('fake'); 145 | 146 | expect(result).toEqual(false); 147 | }); 148 | }); 149 | 150 | describe('findParent method', () => { 151 | it('returns the element if an element matches a selector', () => { 152 | const element = { 153 | matches: jest.fn().mockReturnValue(true), 154 | }; 155 | const parent = GraphUtils.findParent(element, 'fake'); 156 | 157 | expect(parent).toEqual(element); 158 | }); 159 | 160 | it('returns the parent if an element contains a parentNode property', () => { 161 | const element = { 162 | parentNode: { 163 | matches: jest.fn().mockReturnValue(true), 164 | }, 165 | }; 166 | const parent = GraphUtils.findParent(element, 'fake'); 167 | 168 | expect(parent).toEqual(element.parentNode); 169 | }); 170 | 171 | it('returns null when there is no match', () => { 172 | const element = { 173 | parentNode: { 174 | matches: jest.fn().mockReturnValue(false), 175 | }, 176 | }; 177 | const parent = GraphUtils.findParent(element, 'fake'); 178 | 179 | expect(parent).toEqual(null); 180 | }); 181 | }); 182 | 183 | describe('classNames static method', () => { 184 | it('handles multiple string-based arguments', () => { 185 | const result = GraphUtils.classNames('test', 'hello'); 186 | 187 | expect(result).toEqual('test hello'); 188 | }); 189 | 190 | it('handles a string and an array', () => { 191 | const result = GraphUtils.classNames('test', ['hello', 'world']); 192 | 193 | expect(result).toEqual('test hello world'); 194 | }); 195 | 196 | it('handles a string and object', () => { 197 | const result = GraphUtils.classNames('test', { 198 | hello: true, 199 | world: false, 200 | }); 201 | 202 | expect(result).toEqual('test hello'); 203 | }); 204 | }); 205 | 206 | describe('hasNodeShallowChanged', () => { 207 | it('calls isEqual', () => { 208 | jest.spyOn(GraphUtils, 'isEqual'); 209 | const node1 = { x: 0, y: 1 }; 210 | const node2 = { x: 0, y: 1 }; 211 | 212 | GraphUtils.hasNodeShallowChanged(node1, node2); 213 | 214 | expect(GraphUtils.isEqual).toHaveBeenCalled(); 215 | }); 216 | 217 | it('does not find differences in 2 objects', () => { 218 | const node1 = { x: 0, y: 1 }; 219 | const node2 = { x: 0, y: 1 }; 220 | const changed = GraphUtils.hasNodeShallowChanged(node1, node2); 221 | 222 | expect(changed).toEqual(false); 223 | }); 224 | }); 225 | 226 | describe('isEqual', () => { 227 | it('finds differences in 2 objects', () => { 228 | const node1 = { x: 0, y: 1 }; 229 | const node2 = { x: 1, y: 2 }; 230 | const changed = GraphUtils.hasNodeShallowChanged(node1, node2); 231 | 232 | expect(changed).toEqual(true); 233 | }); 234 | 235 | it('does not find differences in 2 objects', () => { 236 | const node1 = { x: 0, y: 1 }; 237 | const node2 = { x: 0, y: 1 }; 238 | const changed = GraphUtils.hasNodeShallowChanged(node1, node2); 239 | 240 | expect(changed).toEqual(false); 241 | }); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /src/examples/bwdl-editable/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as React from 'react'; 19 | import AceEditor from 'react-ace'; 20 | import 'brace/mode/json'; 21 | import 'brace/theme/monokai'; 22 | import { type IEdge } from '../../components/edge'; 23 | import GraphView from '../../components/graph-view'; 24 | import { type INode } from '../../components/node'; 25 | import { type LayoutEngineType } from '../../utilities/layout-engine/layout-engine-types'; 26 | import BwdlTransformer from '../../utilities/transformers/bwdl-transformer'; 27 | import Sidebar from '../sidebar'; 28 | import GraphConfig, { EMPTY_TYPE, NODE_KEY } from './bwdl-config'; // Configures node/edge types 29 | import bwdlExample from './bwdl-example-data'; 30 | 31 | type IBwdlState = { 32 | nodes: INode[], 33 | edges: IEdge[], 34 | selected: INode | IEdge | null, 35 | layoutEngineType: LayoutEngineType, 36 | bwdlText: string, 37 | bwdlJson: any, 38 | copiedNode: any, 39 | }; 40 | 41 | class BwdlEditable extends React.Component<{}, IBwdlState> { 42 | GraphView: GraphView | null; 43 | 44 | constructor(props: any) { 45 | super(props); 46 | 47 | const transformed = BwdlTransformer.transform(bwdlExample); 48 | 49 | this.state = { 50 | bwdlJson: bwdlExample, 51 | bwdlText: JSON.stringify(bwdlExample, null, 2), 52 | copiedNode: null, 53 | edges: transformed.edges, 54 | layoutEngineType: 'VerticalTree', 55 | nodes: transformed.nodes, 56 | selected: null, 57 | }; 58 | } 59 | 60 | linkEdge(sourceNode: INode, targetNode: INode, edge?: IEdge) { 61 | const newBwdlJson = { 62 | ...this.state.bwdlJson, 63 | }; 64 | const sourceNodeBwdl = newBwdlJson.States[sourceNode.title]; 65 | 66 | if (sourceNodeBwdl.Type === 'Choice') { 67 | const newChoice = { 68 | Next: targetNode.title, 69 | }; 70 | 71 | if (sourceNodeBwdl.Choices) { 72 | // check if swapping edge 73 | let swapped = false; 74 | 75 | if (edge) { 76 | sourceNodeBwdl.Choices.forEach(choice => { 77 | if (edge && choice.Next === edge.target) { 78 | choice.Next = targetNode.title; 79 | swapped = true; 80 | } 81 | }); 82 | } 83 | 84 | if (!swapped) { 85 | sourceNodeBwdl.Choices.push(newChoice); 86 | } 87 | } else { 88 | sourceNodeBwdl.Choices = [newChoice]; 89 | } 90 | } else { 91 | sourceNodeBwdl.Next = targetNode.title; 92 | } 93 | 94 | this.setState({ 95 | bwdlJson: newBwdlJson, 96 | bwdlText: JSON.stringify(newBwdlJson, null, 2), 97 | }); 98 | this.updateBwdl(); 99 | } 100 | 101 | onSelectNode = (node: INode | null) => { 102 | this.setState({ 103 | selected: node, 104 | }); 105 | }; 106 | 107 | onCreateNode = (x: number, y: number) => { 108 | const newBwdlJson = { 109 | ...this.state.bwdlJson, 110 | }; 111 | 112 | newBwdlJson.States[`New Item ${Date.now()}`] = { 113 | Type: EMPTY_TYPE, 114 | x, 115 | y, 116 | }; 117 | this.setState({ 118 | bwdlJson: newBwdlJson, 119 | bwdlText: JSON.stringify(newBwdlJson, null, 2), 120 | }); 121 | this.updateBwdl(); 122 | }; 123 | onUpdateNode = (node: INode) => { 124 | return; 125 | }; 126 | 127 | onDeleteNode = (selected: INode, nodeId: string, nodes: any[]) => { 128 | const newBwdlJson = { 129 | ...this.state.bwdlJson, 130 | }; 131 | 132 | delete newBwdlJson.States[selected.title]; 133 | this.setState({ 134 | bwdlJson: newBwdlJson, 135 | bwdlText: JSON.stringify(newBwdlJson, null, 2), 136 | }); 137 | this.updateBwdl(); 138 | }; 139 | 140 | onSelectEdge = (edge: IEdge) => { 141 | this.setState({ 142 | selected: edge, 143 | }); 144 | }; 145 | 146 | onCreateEdge = (sourceNode: INode, targetNode: INode) => { 147 | this.linkEdge(sourceNode, targetNode); 148 | }; 149 | 150 | onSwapEdge = (sourceNode: INode, targetNode: INode, edge: IEdge) => { 151 | this.linkEdge(sourceNode, targetNode, edge); 152 | }; 153 | 154 | onDeleteEdge = (selectedEdge: IEdge, edges: IEdge[]) => { 155 | const newBwdlJson = { 156 | ...this.state.bwdlJson, 157 | }; 158 | const sourceNodeBwdl = newBwdlJson.States[selectedEdge.source]; 159 | 160 | if (sourceNodeBwdl.Choices) { 161 | sourceNodeBwdl.Choices = sourceNodeBwdl.Choices.filter(choice => { 162 | return choice.Next !== selectedEdge.target; 163 | }); 164 | } else { 165 | delete sourceNodeBwdl.Next; 166 | } 167 | 168 | this.setState({ 169 | bwdlJson: newBwdlJson, 170 | bwdlText: JSON.stringify(newBwdlJson, null, 2), 171 | }); 172 | this.updateBwdl(); 173 | }; 174 | 175 | onUndo() { 176 | alert('Undo is not supported yet.'); 177 | } 178 | 179 | onCopySelected = () => { 180 | const { selected, bwdlJson } = this.state; 181 | 182 | if (!selected) { 183 | return; 184 | } 185 | 186 | const original = bwdlJson.States[selected.title]; 187 | const newItem = JSON.parse(JSON.stringify(original)); 188 | 189 | this.setState({ 190 | copiedNode: newItem, 191 | }); 192 | }; 193 | 194 | onPasteSelected = () => { 195 | const { copiedNode, bwdlJson } = this.state; 196 | 197 | bwdlJson.States[`New Item ${Date.now()}`] = copiedNode; 198 | 199 | const newBwdlJson = { 200 | ...bwdlJson, 201 | }; 202 | 203 | this.setState({ 204 | bwdlJson: newBwdlJson, 205 | bwdlText: JSON.stringify(newBwdlJson, null, 2), 206 | }); 207 | this.updateBwdl(); 208 | }; 209 | 210 | updateBwdl = () => { 211 | const transformed = BwdlTransformer.transform(this.state.bwdlJson); 212 | 213 | this.setState({ 214 | edges: transformed.edges, 215 | nodes: transformed.nodes, 216 | }); 217 | }; 218 | 219 | handleTextAreaChange = (value: string, event: any) => { 220 | let input = null; 221 | const bwdlText = value; 222 | 223 | this.setState({ 224 | bwdlText, 225 | }); 226 | 227 | try { 228 | input = JSON.parse(bwdlText); 229 | } catch (e) { 230 | return; 231 | } 232 | 233 | this.setState({ 234 | bwdlJson: input, 235 | }); 236 | 237 | this.updateBwdl(); 238 | }; 239 | 240 | renderSidebar() { 241 | return ( 242 | 243 |
244 | 262 |
263 |
264 | ); 265 | } 266 | 267 | renderGraph() { 268 | const { nodes, edges, selected } = this.state; 269 | const { NodeTypes, NodeSubtypes, EdgeTypes } = GraphConfig; 270 | 271 | return ( 272 | (this.GraphView = el)} 274 | nodeKey={NODE_KEY} 275 | nodes={nodes} 276 | edges={edges} 277 | selected={selected} 278 | nodeTypes={NodeTypes} 279 | nodeSubtypes={NodeSubtypes} 280 | edgeTypes={EdgeTypes} 281 | onSelectNode={this.onSelectNode} 282 | onCreateNode={this.onCreateNode} 283 | onUpdateNode={this.onUpdateNode} 284 | onDeleteNode={this.onDeleteNode} 285 | onSelectEdge={this.onSelectEdge} 286 | onCreateEdge={this.onCreateEdge} 287 | onSwapEdge={this.onSwapEdge} 288 | onDeleteEdge={this.onDeleteEdge} 289 | onUndo={this.onUndo} 290 | onCopySelected={this.onCopySelected} 291 | onPasteSelected={this.onPasteSelected} 292 | layoutEngineType={this.state.layoutEngineType} 293 | /> 294 | ); 295 | } 296 | 297 | render() { 298 | return ( 299 |
300 | {this.renderSidebar()} 301 |
{this.renderGraph()}
302 |
303 | ); 304 | } 305 | } 306 | 307 | export default BwdlEditable; 308 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to react-digraph 3 | 4 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 5 | 6 | The following is a set of guidelines for contributing to react-digraph, which are hosted in the [Uber Organization](https://github.com/uber) on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. 7 | 8 | ## Code of Conduct 9 | 10 | We ask that everyone participating in this project be respectful, non-discriminatory, and maintain decorum and diplomacy. We are here to code and contribute to the world at large, and that means we must respect all individuals. We also respect discussion and differing opinions, but please remember to keep those opinions civil and based on the technology and code, never personalized. We also ask everyone participating to learn and have fun! 11 | 12 | ## How Can I Contribute? 13 | 14 | ### Reporting Bugs 15 | 16 | Bug reports are essential to keeping react-digraph stable. 17 | 18 | First, [go to the issues tab](https://github.com/uber/react-digraph/issues) and make sure to search for your issue in case it has already been answered. Be sure to check [Closed issues](https://github.com/uber/react-digraph/issues?q=is%3Aissue+is%3Aclosed) as well. 19 | 20 | If the issue is not already present, then click the [New Issue](https://github.com/uber/react-digraph/issues/new/choose) button. You will be presented with some options, either you can create a bug report or a feature request. 21 | 22 | When creating [a new bug report](https://github.com/uber/react-digraph/issues/new?template=bug_report.md) you will notice some instructions, such as description, reproduction steps (aka repro steps), expected behavior, screenshots, OS type, browser, and version (which refers to your react-digraph version), and some additional context. Please fill in as much as possible, but not all of it is required. The more information we have the better we can help. 23 | 24 | Sometimes it's necessary to create an example demo for the developers. We recommend [plnkr.co](https://plnkr.co/), [jsfiddle](http://jsfiddle.net/), or [codepen.io](https://codepen.io/). We ask that you limit your example to the bare minimum amount of code which reproduces your issue. You can also [create a Gist in Github](https://gist.github.com/), which will allow us to see the code but we won't be able to run it. 25 | 26 | ### Requesting Features 27 | 28 | Feature requests help drive the development of our project. Since this project is also driven by Uber goals, as it's under the Uber umbrella, some features may be added by internal teams. Hopefully all developers create feature requests in Github in order to make the public aware of the design decisions, but unfortunately sometimes that is missed. 29 | 30 | If you think react-digraph needs a new feature, please create a new Feature request by [going to the issues tab](https://github.com/uber/react-digraph/issues). Again, make sure to search for your issue in case it has already been answered. Be sure to check [Closed issues](https://github.com/uber/react-digraph/issues?q=is%3Aissue+is%3Aclosed) as well. 31 | 32 | If the issue is not already present, then click the [New Issue](https://github.com/uber/react-digraph/issues/new/choose) button. You will be presented with some options, either you can create a bug report or a feature request. 33 | 34 | When creating [a new feature request](https://github.com/uber/react-digraph/issues/new?template=feature_request.md) you will notice some instructions, such as relation to a problem, solution you'd like, alternatives, and some additional context. Please fill in as much as possible, but not all of it is required. The more information we have the better we can help. 35 | 36 | ### Your First Code Contribution 37 | 38 | #### Setup 39 | 40 | In order to work on react-digraph you will need to fork the project to your user account in Github. Navigate to [react-digraph's main page](https://github.com/uber/react-digraph), then press the **Fork** button in the upper right. If the fork is successful you should see the URL and project name switch from "**uber/react-digraph**" to "**yourusername/react-digraph**". 41 | 42 | 43 | First you will need to download the project and install the project dependencies. These instructions are based on [using a remote upstream repository](https://medium.com/sweetmeat/how-to-keep-a-downstream-git-repository-current-with-upstream-repository-changes-10b76fad6d97). 44 | 45 | ```bash 46 | git clone git@github.com:yourusername/react-digraph.git 47 | cd react-digraph 48 | git remote add upstream git@github.com:uber/react-digraph.git # adds the parent repository as 'upstream' 49 | git fetch upstream 50 | npm install 51 | ``` 52 | 53 | #### Creating a working branch 54 | 55 | Ideally, all work should be done on a working branch. This branch is then referenced when creating a Pull Request (PR). 56 | 57 | First, you must rebase your own master on upstream's master. 58 | 59 | ```bash 60 | git fetch upstream 61 | git checkout master 62 | git rebase upstream/master 63 | ``` 64 | 65 | ```bash 66 | git checkout -b my_new_feature # use any naming convention you want 67 | ``` 68 | 69 | Some people like to reference the issue number if their pull request is related to a bug or feature request. When doing so you should make sure your commit tells Github that you've fixed the issue in reference. 70 | 71 | ```bash 72 | git checkout -b 71-fix-click-issue # use any naming convention you want 73 | # make changes 74 | git add . 75 | git commit -m "Resolved #71" 76 | ``` 77 | 78 | #### Using the example site 79 | 80 | react-digraph includes a simple example site. Every time the webpage is refreshed the data will reset. We would love more examples, so feel free to add more pages or modifications to suit your use cases. 81 | 82 | The site should automatically open in the browser, and upon making changes to the code it should automatically refresh the page. 83 | 84 | ```bash 85 | npm run example 86 | ``` 87 | 88 | #### Linking to react-digraph 89 | 90 | By using npm linking you can point your website or project to a local version of react-digraph. Then you can make changes within react-digraph and, after a restart of your app, you can see the changes in your website. 91 | 92 | Clone the website using the instructions above. Then use the following commands. 93 | 94 | ```bash 95 | cd react-digraph 96 | npm link 97 | 98 | cd /path/to/your/project 99 | npm link react-digraph 100 | ``` 101 | 102 | **Note:** Once you've linked a package within your project, you cannot run `npm install react-digraph` without breaking the link. If you break the link you should run `npm link react-digraph` again within your project directory. Your project's `package.json` file may be modified by npm when linking packages, be careful when submitting your code to a repository. 103 | 104 | Now that the project is linked to your local react-digraph you may modify react-digraph and see the changes in your project. 105 | 106 | ```bash 107 | # make modifications to react-digraph then run 108 | cd react-digraph # make sure you're in the react-digraph directory 109 | npm run package # this runs the linter, tests, and builds a production distribution file 110 | ``` 111 | 112 | Now you may stop your project's server and restart it to see the changes in your project. 113 | 114 | #### Creating tests 115 | 116 | Please make sure to test all of your code. We would prefer 100% code coverage. All tests are located in the `__tests__` folder, and all mocks in the `__mocks__` folder. 117 | 118 | Tests are created using [Jest](https://jestjs.io/) and [Enzyme](https://github.com/airbnb/enzyme). See the documentation on those projects for help. Use the existing examples in `__tests__` to see the structure and other examples. 119 | 120 | Test file and folder structure is as follows: 121 | 122 | ``` 123 | __tests__ 124 | - components 125 | my-component.test.js 126 | - utilities 127 | - layout-engine 128 | snap-to-grid.test.js 129 | ``` 130 | 131 | The components under the `__tests__` folder should match the folder structure in `src`. 132 | If you are more comfortable creating E2E tests, please create a `__tests__/e2e` folder and place them there. 133 | 134 | 135 | #### Committing code 136 | 137 | We ask that you limit the number of commits to a reasonable amount. If you're comfortable with [squashing your commits](https://github.com/todotxt/todo.txt-android/wiki/Squash-All-Commits-Related-to-a-Single-Issue-into-a-Single-Commit), please do that, otherwise you should be careful with how many commits you are making. 138 | 139 | ``` 140 | git add . #add your changes 141 | git commit -m "Resolved #71" 142 | git push origin 71-fix-click-issue # use whatever branch name you're on 143 | ``` 144 | 145 | Navigate to Github and select your new branch. 146 | 147 | Press the "New pull request" button. 148 | 149 | You should see a comparison with base: `master` with `yourusername/react-digraph` compare: `71-fix-click-issue`. 150 | 151 | **Note:** If you performed a `git checkout -b` based on the react-digraph `v4.x.x` branch, then change base: `master` to `v4.x.x` instead. 152 | 153 | ## Testing 154 | 155 | As mentioned before, react-digraph uses Jest and Enzyme. Tests are located in the `__tests__` folder. 156 | 157 | To run the tests run `npm run test`. 158 | 159 | # NPM Package Maintainers Only! 160 | 161 | ## Creating a new version 162 | 163 | **Checkout master and pull updates** 164 | 165 | ``` 166 | git checkout master 167 | git pull 168 | ``` 169 | 170 | **Create a new version** 171 | 172 | Create a new version using `npm version [major|minor|patch]` depending on the version type. `major` for major breaking changes, `minor` for non-breaking backwards-compatible changes that introduce new features or improvements, `patch` for bugfixes. 173 | 174 | ``` 175 | npm version minor 176 | npm publish 177 | ``` 178 | 179 | **Push updates** 180 | 181 | ``` 182 | git push origin master 183 | git push origin --tags 184 | ``` -------------------------------------------------------------------------------- /src/components/node.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* 3 | Copyright(c) 2018 Uber Technologies, 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 | import * as d3 from 'd3'; 19 | import * as React from 'react'; 20 | // This works in Typescript but causes an import loop for Flowtype. We'll just use `any` below. 21 | // import { type LayoutEngine } from '../utilities/layout-engine/layout-engine-config'; 22 | import Edge from './edge'; 23 | import GraphUtils from '../utilities/graph-util'; 24 | import NodeText from './node-text'; 25 | 26 | export type IPoint = { 27 | x: number, 28 | y: number, 29 | }; 30 | 31 | export type INode = { 32 | title: string, 33 | description: string, 34 | timeEstimate: number, 35 | status: string, 36 | totalTimeEstimate?: number | null, 37 | x?: number | null, 38 | y?: number | null, 39 | type?: string | null, 40 | subtype?: string | null, 41 | [key: string]: any, 42 | }; 43 | 44 | export const Status = { 45 | todo: "todo", 46 | inProgress: "in-progress", 47 | done: "done" 48 | }; 49 | 50 | type INodeProps = { 51 | data: INode, 52 | id: string, 53 | nodeTypes: any, // TODO: make a nodeTypes interface 54 | nodeSubtypes: any, // TODO: make a nodeSubtypes interface 55 | opacity?: number, 56 | nodeKey: string, 57 | nodeSize?: number, 58 | onNodeMouseEnter: (event: any, data: any, hovered: boolean) => void, 59 | onNodeMouseLeave: (event: any, data: any) => void, 60 | onNodeMove: (point: IPoint, id: string, shiftKey: boolean) => void, 61 | onNodeSelected: ( 62 | data: any, 63 | id: string, 64 | shiftKey: boolean, 65 | event?: any 66 | ) => void, 67 | onNodeUpdate: (point: IPoint, id: string, shiftKey: boolean) => void, 68 | renderNode?: ( 69 | nodeRef: any, 70 | data: any, 71 | id: string, 72 | selected: boolean, 73 | hovered: boolean 74 | ) => any, 75 | renderNodeText?: (data: any, id: string | number, isSelected: boolean) => any, 76 | isSelected: boolean, 77 | layoutEngine?: any, 78 | viewWrapperElem: HTMLDivElement, 79 | centerNodeOnMove: boolean, 80 | maxTitleChars: number, 81 | }; 82 | 83 | type INodeState = { 84 | hovered: boolean, 85 | x: number, 86 | y: number, 87 | selected: boolean, 88 | mouseDown: boolean, 89 | drawingEdge: boolean, 90 | pointerOffset: ?{ x: number, y: number }, 91 | }; 92 | 93 | class Node extends React.Component { 94 | static defaultProps = { 95 | isSelected: false, 96 | nodeSize: 200, 97 | maxTitleChars: 30, 98 | onNodeMouseEnter: () => { 99 | return; 100 | }, 101 | onNodeMouseLeave: () => { 102 | return; 103 | }, 104 | onNodeMove: () => { 105 | return; 106 | }, 107 | onNodeSelected: () => { 108 | return; 109 | }, 110 | onNodeUpdate: () => { 111 | return; 112 | }, 113 | centerNodeOnMove: true, 114 | }; 115 | 116 | static getDerivedStateFromProps( 117 | nextProps: INodeProps, 118 | prevState: INodeState 119 | ) { 120 | return { 121 | selected: nextProps.isSelected, 122 | x: nextProps.data.x, 123 | y: nextProps.data.y, 124 | }; 125 | } 126 | 127 | nodeRef: any; 128 | oldSibling: any; 129 | 130 | constructor(props: INodeProps) { 131 | super(props); 132 | 133 | this.state = { 134 | drawingEdge: false, 135 | hovered: false, 136 | mouseDown: false, 137 | selected: false, 138 | x: props.data.x || 0, 139 | y: props.data.y || 0, 140 | pointerOffset: null, 141 | }; 142 | 143 | this.nodeRef = React.createRef(); 144 | } 145 | 146 | componentDidMount() { 147 | const dragFunction = d3 148 | .drag() 149 | .on('drag', () => { 150 | this.handleMouseMove(d3.event); 151 | }) 152 | .on('start', this.handleDragStart) 153 | .on('end', () => { 154 | this.handleDragEnd(d3.event); 155 | }); 156 | 157 | d3.select(this.nodeRef.current) 158 | .on('mouseout', this.handleMouseOut) 159 | .call(dragFunction); 160 | } 161 | 162 | handleMouseMove = (event: any) => { 163 | const mouseButtonDown = event.sourceEvent.buttons === 1; 164 | const shiftKey = event.sourceEvent.shiftKey; 165 | const { 166 | nodeSize, 167 | layoutEngine, 168 | nodeKey, 169 | viewWrapperElem, 170 | data, 171 | } = this.props; 172 | const {pointerOffset} = this.state; 173 | 174 | if (!mouseButtonDown) { 175 | return; 176 | } 177 | 178 | // While the mouse is down, this function handles all mouse movement 179 | const newState = { 180 | x: event.x, 181 | y: event.y, 182 | pointerOffset, 183 | }; 184 | 185 | if (!this.props.centerNodeOnMove) { 186 | newState.pointerOffset = pointerOffset || { 187 | x: event.x - (data.x || 0), 188 | y: event.y - (data.y || 0), 189 | }; 190 | newState.x -= newState.pointerOffset.x; 191 | newState.y -= newState.pointerOffset.y; 192 | } 193 | 194 | if (shiftKey) { 195 | this.setState({drawingEdge: true}); 196 | // draw edge 197 | // undo the target offset subtraction done by Edge 198 | const off = Edge.calculateOffset( 199 | nodeSize, 200 | this.props.data, 201 | newState, 202 | nodeKey, 203 | true, 204 | viewWrapperElem 205 | ); 206 | 207 | newState.x += off.xOff; 208 | newState.y += off.yOff; 209 | // now tell the graph that we're actually drawing an edge 210 | } else if (!this.state.drawingEdge && layoutEngine) { 211 | // move node using the layout engine 212 | Object.assign(newState, layoutEngine.getPositionForNode(newState)); 213 | } 214 | 215 | this.setState(newState); 216 | this.props.onNodeMove(newState, this.props.data[nodeKey], shiftKey); 217 | }; 218 | 219 | handleDragStart = () => { 220 | if (!this.nodeRef.current) { 221 | return; 222 | } 223 | 224 | if (!this.oldSibling) { 225 | this.oldSibling = this.nodeRef.current.parentElement.nextSibling; 226 | } 227 | 228 | // Moves child to the end of the element stack to re-arrange the z-index 229 | this.nodeRef.current.parentElement.parentElement.appendChild( 230 | this.nodeRef.current.parentElement 231 | ); 232 | }; 233 | 234 | handleDragEnd = (event: any) => { 235 | if (!this.nodeRef.current) { 236 | return; 237 | } 238 | 239 | const {x, y, drawingEdge} = this.state; 240 | const {data, nodeKey, onNodeSelected, onNodeUpdate} = this.props; 241 | const {sourceEvent} = event; 242 | 243 | this.setState({ 244 | mouseDown: false, 245 | drawingEdge: false, 246 | pointerOffset: null, 247 | }); 248 | 249 | if (this.oldSibling && this.oldSibling.parentElement) { 250 | this.oldSibling.parentElement.insertBefore( 251 | this.nodeRef.current.parentElement, 252 | this.oldSibling 253 | ); 254 | } 255 | 256 | const shiftKey = sourceEvent.shiftKey; 257 | 258 | onNodeUpdate({x, y}, data[nodeKey], shiftKey || drawingEdge); 259 | 260 | onNodeSelected(data, data[nodeKey], shiftKey || drawingEdge, sourceEvent); 261 | }; 262 | 263 | handleMouseOver = (event: any) => { 264 | // Detect if mouse is already down and do nothing. 265 | let hovered = false; 266 | 267 | if (event && event.buttons !== 1) { 268 | hovered = true; 269 | this.setState({hovered}); 270 | } 271 | 272 | this.props.onNodeMouseEnter(event, this.props.data, hovered); 273 | }; 274 | 275 | handleMouseOut = (event: any) => { 276 | // Detect if mouse is already down and do nothing. Sometimes the system lags on 277 | // drag and we don't want the mouseOut to fire while the user is moving the 278 | // node around 279 | 280 | this.setState({hovered: false}); 281 | this.props.onNodeMouseLeave(event, this.props.data); 282 | }; 283 | 284 | static getNodeTypeXlinkHref(data: INode, nodeTypes: any) { 285 | if (data.type && nodeTypes[data.type]) { 286 | return nodeTypes[data.type].shapeId; 287 | } else if (nodeTypes.emptyNode) { 288 | return nodeTypes.emptyNode.shapeId; 289 | } 290 | 291 | return null; 292 | } 293 | 294 | static getNodeSubtypeXlinkHref(data: INode, nodeSubtypes?: any) { 295 | if (data.subtype && nodeSubtypes && nodeSubtypes[data.subtype]) { 296 | return nodeSubtypes[data.subtype].shapeId; 297 | } else if (nodeSubtypes && nodeSubtypes.emptyNode) { 298 | return nodeSubtypes.emptyNode.shapeId; 299 | } 300 | 301 | return null; 302 | } 303 | 304 | renderShape(nodeClassName) { 305 | const {renderNode, data, nodeTypes, nodeSubtypes, nodeKey} = this.props; 306 | const {hovered, selected} = this.state; 307 | const props = { 308 | height: this.props.nodeSize || 0, 309 | width: this.props.nodeSize || 0, 310 | }; 311 | const nodeShapeContainerClassName = GraphUtils.classNames('shape'); 312 | const nodeTypeXlinkHref = Node.getNodeTypeXlinkHref(data, nodeTypes) || ''; 313 | // get width and height defined on def element 314 | const defSvgNodeElement: any = nodeTypeXlinkHref 315 | ? document.querySelector(`defs>${nodeTypeXlinkHref}`) 316 | : null; 317 | 318 | const nodeWidthAttr = defSvgNodeElement 319 | ? defSvgNodeElement.getAttribute('width') 320 | : 0; 321 | const nodeHeightAttr = defSvgNodeElement 322 | ? defSvgNodeElement.getAttribute('height') 323 | : 0; 324 | 325 | props.width = nodeWidthAttr ? parseInt(nodeWidthAttr, 10) : props.width; 326 | props.height = nodeHeightAttr ? parseInt(nodeHeightAttr, 10) : props.height; 327 | 328 | if (renderNode) { 329 | // Originally: graphView, domNode, datum, index, elements. 330 | return renderNode(this.nodeRef, data, data[nodeKey], selected, hovered); 331 | } else { 332 | return ( 333 | 334 | 342 | 343 | ); 344 | } 345 | } 346 | 347 | renderText() { 348 | const { 349 | data, 350 | id, 351 | nodeTypes, 352 | renderNodeText, 353 | isSelected, 354 | maxTitleChars, 355 | } = this.props; 356 | 357 | if (renderNodeText) { 358 | return renderNodeText(data, id, isSelected); 359 | } 360 | 361 | return ( 362 | 368 | ); 369 | } 370 | 371 | render() { 372 | const {x, y, hovered, selected} = this.state; 373 | const {opacity, id, data} = this.props; 374 | const className = GraphUtils.classNames('node-' + data.status, data.type, { 375 | hovered, 376 | selected, 377 | }); 378 | 379 | console.log(className) 380 | 381 | 382 | return ( 383 | 393 | {this.renderShape(className)} 394 | {this.renderText()} 395 | 396 | ); 397 | } 398 | } 399 | 400 | export default Node; 401 | -------------------------------------------------------------------------------- /__tests__/components/node.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as React from 'react'; 4 | import { shallow, ShallowWrapper } from 'enzyme'; 5 | import Node from '../../src/components/node'; 6 | import NodeText from '../../src/components/node-text'; 7 | 8 | // jest.mock('d3'); 9 | 10 | describe('Node component', () => { 11 | let output = null; 12 | let nodeData; 13 | let nodeTypes; 14 | let nodeSubtypes; 15 | let onNodeMouseEnter; 16 | let onNodeMouseLeave; 17 | let onNodeMove; 18 | let onNodeSelected; 19 | let onNodeUpdate; 20 | 21 | beforeEach(() => { 22 | nodeData = { 23 | uuid: '1', 24 | title: 'Test', 25 | type: 'emptyNode', 26 | x: 5, 27 | y: 10, 28 | }; 29 | 30 | nodeTypes = { 31 | emptyNode: { 32 | shapeId: '#test', 33 | }, 34 | }; 35 | 36 | nodeSubtypes = {}; 37 | 38 | onNodeMouseEnter = jest.fn(); 39 | onNodeMouseLeave = jest.fn(); 40 | onNodeMove = jest.fn(); 41 | onNodeSelected = jest.fn(); 42 | onNodeUpdate = jest.fn(); 43 | 44 | jest.spyOn(document, 'querySelector').mockReturnValue({ 45 | getAttribute: jest.fn().mockReturnValue(100), 46 | getBoundingClientRect: jest.fn().mockReturnValue({ 47 | width: 0, 48 | height: 0, 49 | }), 50 | }); 51 | 52 | // this gets around d3 being readonly, we need to customize the event object 53 | // const globalEvent = { 54 | // sourceEvent: {}, 55 | // }; 56 | 57 | output = shallow( 58 | 73 | ); 74 | 75 | // Object.defineProperty(d3, 'event', { 76 | // get: () => { 77 | // return globalEvent; 78 | // }, 79 | // set: event => { 80 | // globalEvent = event; 81 | // }, 82 | // }); 83 | }); 84 | 85 | describe('render method', () => { 86 | it('renders', () => { 87 | expect(output.props().className).toEqual('node emptyNode'); 88 | expect(output.props().transform).toEqual('translate(5, 10)'); 89 | 90 | const nodeShape = output.find('.shape > use'); 91 | 92 | expect(nodeShape.props().className).toEqual('node'); 93 | expect(nodeShape.props().x).toEqual(-50); 94 | expect(nodeShape.props().y).toEqual(-50); 95 | expect(nodeShape.props().width).toEqual(100); 96 | expect(nodeShape.props().height).toEqual(100); 97 | expect(nodeShape.props().xlinkHref).toEqual('#test'); 98 | 99 | const nodeText = output.find(NodeText); 100 | 101 | expect(nodeText.length).toEqual(1); 102 | }); 103 | 104 | it('calls handleMouseOver', () => { 105 | const event = { 106 | test: true, 107 | }; 108 | 109 | output 110 | .find('g.node') 111 | .props() 112 | .onMouseOver(event); 113 | expect(onNodeMouseEnter).toHaveBeenCalledWith(event, nodeData, true); 114 | }); 115 | 116 | it('calls handleMouseOut', () => { 117 | const event = { 118 | test: true, 119 | }; 120 | 121 | output.setState({ 122 | hovered: true, 123 | }); 124 | output 125 | .find('g.node') 126 | .props() 127 | .onMouseOut(event); 128 | expect(onNodeMouseLeave).toHaveBeenCalledWith(event, nodeData); 129 | expect(output.state().hovered).toEqual(false); 130 | }); 131 | }); 132 | 133 | describe('renderText method', () => { 134 | let renderNodeText; 135 | 136 | beforeEach(() => { 137 | renderNodeText = jest.fn().mockReturnValue('success'); 138 | }); 139 | 140 | it('calls the renderNodeText callback', () => { 141 | output.setProps({ 142 | renderNodeText, 143 | }); 144 | 145 | const result = output.instance().renderText(); 146 | 147 | expect(renderNodeText).toHaveBeenCalledWith(nodeData, 'test-node', false); 148 | expect(result).toEqual('success'); 149 | }); 150 | 151 | it('creates its own NodeText element', () => { 152 | const result = output.instance().renderText(); 153 | 154 | expect(renderNodeText).not.toHaveBeenCalled(); 155 | expect(result.type.prototype.constructor.name).toEqual('NodeText'); 156 | }); 157 | }); 158 | 159 | describe('renderShape method', () => { 160 | let renderNode; 161 | 162 | beforeEach(() => { 163 | renderNode = jest.fn().mockReturnValue('success'); 164 | }); 165 | 166 | it('calls the renderNode callback', () => { 167 | output.setProps({ 168 | renderNode, 169 | }); 170 | 171 | const result = output.instance().renderShape(); 172 | 173 | expect(renderNode).toHaveBeenCalledWith( 174 | output.instance().nodeRef, 175 | nodeData, 176 | '1', 177 | false, 178 | false 179 | ); 180 | expect(result).toEqual('success'); 181 | }); 182 | 183 | it('returns a node shape without a subtype', () => { 184 | const result: ShallowWrapper = shallow( 185 | output.instance().renderShape() 186 | ); 187 | 188 | expect(renderNode).not.toHaveBeenCalledWith(); 189 | expect(result.props().className).toEqual('shape'); 190 | expect(result.props().height).toEqual(100); 191 | expect(result.props().width).toEqual(100); 192 | 193 | const nodeShape = result.find('.node'); 194 | const nodeSubtypeShape = result.find('.subtype-shape'); 195 | 196 | expect(nodeShape.length).toEqual(1); 197 | expect(nodeSubtypeShape.length).toEqual(0); 198 | 199 | expect(nodeShape.props().className).toEqual('node'); 200 | expect(nodeShape.props().x).toEqual(-50); 201 | expect(nodeShape.props().y).toEqual(-50); 202 | expect(nodeShape.props().width).toEqual(100); 203 | expect(nodeShape.props().height).toEqual(100); 204 | expect(nodeShape.props().xlinkHref).toEqual('#test'); 205 | }); 206 | 207 | it('returns a node shape with a subtype', () => { 208 | nodeData.subtype = 'fake'; 209 | nodeSubtypes.fake = { 210 | shapeId: '#blah', 211 | }; 212 | output.setProps({ 213 | data: nodeData, 214 | nodeSubtypes, 215 | }); 216 | const result: ShallowWrapper = shallow( 217 | output.instance().renderShape() 218 | ); 219 | const nodeSubtypeShape = result.find('.subtype-shape'); 220 | 221 | expect(nodeSubtypeShape.length).toEqual(1); 222 | expect(nodeSubtypeShape.props().className).toEqual('subtype-shape'); 223 | expect(nodeSubtypeShape.props().x).toEqual(-50); 224 | expect(nodeSubtypeShape.props().y).toEqual(-50); 225 | expect(nodeSubtypeShape.props().width).toEqual(100); 226 | expect(nodeSubtypeShape.props().height).toEqual(100); 227 | expect(nodeSubtypeShape.props().xlinkHref).toEqual('#blah'); 228 | }); 229 | }); 230 | 231 | describe('getNodeSubtypeXlinkHref method', () => { 232 | it('returns the shapeId from the nodeSubtypes object', () => { 233 | nodeData.subtype = 'fake'; 234 | nodeSubtypes.fake = { 235 | shapeId: '#blah', 236 | }; 237 | 238 | const result = Node.getNodeSubtypeXlinkHref(nodeData, nodeSubtypes); 239 | 240 | expect(result).toEqual('#blah'); 241 | }); 242 | 243 | it('returns the emptyNode shapeId from the nodeSubtypes object', () => { 244 | nodeSubtypes.emptyNode = { 245 | shapeId: '#empty', 246 | }; 247 | 248 | const result = Node.getNodeSubtypeXlinkHref(nodeData, nodeSubtypes); 249 | 250 | expect(result).toEqual('#empty'); 251 | }); 252 | 253 | it('returns null', () => { 254 | const result = Node.getNodeSubtypeXlinkHref(nodeData, nodeSubtypes); 255 | 256 | expect(result).toEqual(null); 257 | }); 258 | }); 259 | 260 | describe('getNodeTypeXlinkHref method', () => { 261 | beforeEach(() => { 262 | nodeData.type = 'fake'; 263 | }); 264 | 265 | it('returns the shapeId from the nodeTypes object', () => { 266 | nodeTypes.fake = { 267 | shapeId: '#blah', 268 | }; 269 | 270 | const result = Node.getNodeTypeXlinkHref(nodeData, nodeTypes); 271 | 272 | expect(result).toEqual('#blah'); 273 | }); 274 | 275 | it('returns the emptyNode shapeId from the nodeTypes object', () => { 276 | nodeTypes.emptyNode = { 277 | shapeId: '#empty', 278 | }; 279 | 280 | const result = Node.getNodeTypeXlinkHref(nodeData, nodeTypes); 281 | 282 | expect(result).toEqual('#empty'); 283 | }); 284 | 285 | it('returns null', () => { 286 | delete nodeTypes.emptyNode; 287 | const result = Node.getNodeTypeXlinkHref(nodeData, nodeTypes); 288 | 289 | expect(result).toEqual(null); 290 | }); 291 | }); 292 | 293 | describe('handleMouseOut method', () => { 294 | it('sets hovered to false and calls the onNodeMouseLeave callback', () => { 295 | const event = { 296 | test: true, 297 | }; 298 | 299 | output.setState({ 300 | hovered: true, 301 | }); 302 | output.instance().handleMouseOut(event); 303 | expect(output.state().hovered).toEqual(false); 304 | expect(onNodeMouseLeave).toHaveBeenCalledWith(event, nodeData); 305 | }); 306 | }); 307 | 308 | describe('handleMouseOver method', () => { 309 | it('calls the onNodeMouseEnter callback with the mouse down', () => { 310 | // this test cares about the passed-in event 311 | const event = { 312 | buttons: 1, 313 | }; 314 | 315 | output.setState({ 316 | hovered: false, 317 | }); 318 | output.instance().handleMouseOver(event); 319 | expect(output.state().hovered).toEqual(false); 320 | expect(onNodeMouseEnter).toHaveBeenCalledWith(event, nodeData, false); 321 | }); 322 | 323 | it('sets hovered to true when the mouse is not down', () => { 324 | const event = { 325 | buttons: 0, 326 | }; 327 | 328 | output.setState({ 329 | hovered: false, 330 | }); 331 | output.instance().handleMouseOver(event); 332 | expect(output.state().hovered).toEqual(true); 333 | expect(onNodeMouseEnter).toHaveBeenCalledWith(event, nodeData, true); 334 | }); 335 | }); 336 | 337 | describe('handleDragEnd method', () => { 338 | it('updates and selects the node using the callbacks', () => { 339 | output.instance().nodeRef = { 340 | current: { 341 | parentElement: null, 342 | }, 343 | }; 344 | 345 | output.instance().handleDragEnd({ 346 | sourceEvent: { 347 | shiftKey: true, 348 | }, 349 | }); 350 | expect(onNodeUpdate).toHaveBeenCalledWith({ x: 5, y: 10 }, '1', true); 351 | expect(onNodeSelected).toHaveBeenCalledWith(nodeData, '1', true, { 352 | shiftKey: true, 353 | }); 354 | }); 355 | 356 | it('moves the element back to the original DOM position', () => { 357 | const insertBefore = jest.fn(); 358 | 359 | output.instance().nodeRef.current = { 360 | parentElement: 'blah', 361 | }; 362 | output.instance().oldSibling = { 363 | parentElement: { 364 | insertBefore, 365 | }, 366 | }; 367 | 368 | output.instance().handleDragEnd({ 369 | sourceEvent: { 370 | shiftKey: true, 371 | }, 372 | }); 373 | expect(insertBefore).toHaveBeenCalledWith( 374 | 'blah', 375 | output.instance().oldSibling 376 | ); 377 | }); 378 | }); 379 | 380 | describe('handleDragStart method', () => { 381 | let grandparent; 382 | let parentElement; 383 | 384 | beforeEach(() => { 385 | grandparent = { 386 | appendChild: jest.fn(), 387 | }; 388 | parentElement = { 389 | nextSibling: 'blah', 390 | parentElement: grandparent, 391 | }; 392 | output.instance().nodeRef.current = { 393 | parentElement, 394 | }; 395 | }); 396 | 397 | it('assigns an oldSibling so that the element can be put back', () => { 398 | output.instance().nodeRef.current = { 399 | parentElement, 400 | }; 401 | 402 | output.instance().handleDragStart(); 403 | 404 | expect(output.instance().oldSibling).toEqual('blah'); 405 | expect(grandparent).toEqual(grandparent); 406 | }); 407 | 408 | it('moves the element in the DOM', () => { 409 | output.instance().oldSibling = {}; 410 | output.instance().handleDragStart(); 411 | expect(grandparent).toEqual(grandparent); 412 | }); 413 | }); 414 | 415 | describe('handleMouseMove method', () => { 416 | it('calls the onNodeMove callback', () => { 417 | output.instance().handleMouseMove({ 418 | sourceEvent: { 419 | buttons: 0, 420 | }, 421 | }); 422 | expect(onNodeMove).not.toHaveBeenCalled(); 423 | }); 424 | 425 | it('calls the onNodeMove callback with the shiftKey pressed', () => { 426 | const event = { 427 | sourceEvent: { 428 | buttons: 1, 429 | shiftKey: true, 430 | }, 431 | x: 20, 432 | y: 50, 433 | }; 434 | 435 | output.instance().handleMouseMove(event); 436 | expect(onNodeMove).toHaveBeenCalledWith( 437 | { pointerOffset: null, x: 20, y: 50 }, 438 | '1', 439 | true 440 | ); 441 | }); 442 | 443 | it('calls the onNodeMove callback with the shiftKey not pressed', () => { 444 | const event = { 445 | sourceEvent: { 446 | buttons: 1, 447 | shiftKey: false, 448 | }, 449 | x: 20, 450 | y: 50, 451 | }; 452 | 453 | output.instance().handleMouseMove(event); 454 | expect(onNodeMove).toHaveBeenCalledWith( 455 | { pointerOffset: null, x: 20, y: 50 }, 456 | '1', 457 | false 458 | ); 459 | }); 460 | 461 | it('uses a layoutEngine to obtain a new position', () => { 462 | const layoutEngine = { 463 | getPositionForNode: jest.fn().mockImplementation(newState => { 464 | return { 465 | x: 100, 466 | y: 200, 467 | }; 468 | }), 469 | }; 470 | 471 | output.setProps({ 472 | layoutEngine, 473 | }); 474 | 475 | const event = { 476 | sourceEvent: { 477 | buttons: 1, 478 | shiftKey: false, 479 | }, 480 | x: 20, 481 | y: 50, 482 | }; 483 | 484 | output.instance().handleMouseMove(event); 485 | 486 | expect(onNodeMove).toHaveBeenCalledWith( 487 | { pointerOffset: null, x: 100, y: 200 }, 488 | '1', 489 | false 490 | ); 491 | }); 492 | }); 493 | }); 494 | --------------------------------------------------------------------------------