├── .DS_Store ├── talend-scripts.json ├── src ├── reducers │ ├── index.ts │ ├── nodeType.reducer.ts │ ├── link.reducer.test.ts │ ├── port.reducer.test.ts │ ├── flow.reducer.test.ts │ ├── node.reducer.test.ts │ ├── flow.reducer.ts │ ├── port.reducer.ts │ └── node.reducer.ts ├── components │ ├── link │ │ ├── __snapshots__ │ │ │ ├── LinksRenderer.test.tsx.snap │ │ │ └── LinkHandle.test.tsx.snap │ │ ├── LinkHandle.test.tsx │ │ ├── LinksRenderer.component.tsx │ │ ├── LinkHandle.component.tsx │ │ ├── LinksRenderer.test.tsx │ │ └── AbstractLink.component.tsx │ ├── port │ │ ├── __snapshots__ │ │ │ ├── PortsRenderer.test.tsx.snap │ │ │ └── AbstractPort.test.tsx.snap │ │ ├── PortsRenderer.component.tsx │ │ ├── AbstractPort.test.tsx │ │ ├── PortsRenderer.test.tsx │ │ └── AbstractPort.component.tsx │ ├── node │ │ ├── __snapshots__ │ │ │ ├── AbstractNode.snapshot.test.tsx.snap │ │ │ └── NodesRenderer.test.tsx.snap │ │ ├── AbstractNode.snapshot.test.tsx │ │ ├── NodesRenderer.component.tsx │ │ ├── NodesRenderer.test.tsx │ │ ├── AbstractNode.test.tsx │ │ └── AbstractNode.component.tsx │ ├── grid │ │ ├── Grid.test.tsx │ │ ├── __snapshots__ │ │ │ └── Grid.test.tsx.snap │ │ └── Grid.component.tsx │ ├── configuration │ │ ├── LinkType.component.ts │ │ ├── NodeType.component.ts │ │ ├── PortType.component.ts │ │ └── NodeType.test.tsx │ ├── __snapshots__ │ │ ├── ZoomHandler.test.tsx.snap │ │ └── FlowDesigner.container.test.tsx.snap │ ├── ZoomHandler.test.tsx │ ├── FlowDesigner.container.test.tsx │ ├── ZoomHandler.component.tsx │ └── FlowDesigner.container.tsx ├── actions │ ├── __snapshots__ │ │ ├── flow.actions.test.ts.snap │ │ ├── port.actions.test.ts.snap │ │ ├── link.actions.test.ts.snap │ │ └── node.actions.test.ts.snap │ ├── nodeType.actions.ts │ ├── nodeType.actions.test.ts │ ├── flow.actions.test.ts │ ├── flow.actions.ts │ ├── port.actions.test.ts │ ├── port.actions.ts │ ├── link.actions.ts │ ├── link.actions.test.ts │ ├── node.actions.test.ts │ └── node.actions.ts ├── api │ ├── index.ts │ ├── throwInDev.ts │ ├── readme.md │ ├── data │ │ ├── data.ts │ │ └── data.test.ts │ ├── size │ │ ├── size.ts │ │ └── size.test.ts │ ├── position │ │ ├── position.ts │ │ └── position.test.ts │ └── link │ │ └── link.ts ├── constants │ ├── flowdesigner.proptypes.ts │ ├── flowdesigner.constants.ts │ └── flowdesigner.model.ts ├── selectors │ ├── __snapshots__ │ │ └── nodeSelectors.test.ts.snap │ ├── nodeSelectors.ts │ ├── linkSelectors.ts │ ├── portSelectors.test.ts │ ├── portSelectors.ts │ └── nodeSelectors.test.ts ├── index.ts └── customTypings │ └── index.d.ts ├── .gitignore ├── test ├── styleMock.js ├── fileMock.js └── test-setup.js ├── .npmignore ├── CHANGELOG.md ├── .changeset ├── config.json └── README.md ├── .prettierrc ├── .editorconfig ├── tsconfig.json ├── .eslintrc.js ├── licence.js ├── .github ├── workflows │ ├── ci.yml │ ├── changeset.yml │ └── update-yarn-talend.yml └── PULL_REQUEST_TEMPLATE.md ├── package.json ├── README.md └── jenkins └── veracode-scan.groovy /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Talend/react-flow-designer/HEAD/.DS_Store -------------------------------------------------------------------------------- /talend-scripts.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "@talend/scripts-preset-react-lib" 3 | } 4 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import flowDesignerReducer from './flow.reducer'; 2 | 3 | export default flowDesignerReducer; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | yarn-error.log 3 | jsconfig.json 4 | node_modules/ 5 | coverage/ 6 | lib/ 7 | docs/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /test/styleMock.js: -------------------------------------------------------------------------------- 1 | // test/styleMock.js 2 | // // Return an object to emulate css modules (if you are using them) 3 | module.exports = {}; 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules/ 3 | coverage/ 4 | src/ 5 | test/ 6 | .babelrc 7 | .eslintrc 8 | .gitignore 9 | .travis.yml 10 | webpack.config.babel.js 11 | -------------------------------------------------------------------------------- /test/fileMock.js: -------------------------------------------------------------------------------- 1 | // test/fileMock.js 2 | // Return an empty string or other mock path to emulate the url that 3 | // Webpack provides via the file-loader 4 | module.exports = ''; 5 | -------------------------------------------------------------------------------- /src/components/link/__snapshots__/LinksRenderer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 | 6 | MockLink 7 | 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /src/components/port/__snapshots__/PortsRenderer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 | 6 | MockPort 7 | 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-flow-designer 2 | 3 | ## 4.2.2 4 | 5 | ### Patch Changes 6 | 7 | - 798b4b5: re align redux dependencies and prepare auto upgrade PR 8 | 9 | ## 4.2.1 10 | 11 | ### Patch Changes 12 | 13 | - 99bd1c5: upgrade dependencies 14 | -------------------------------------------------------------------------------- /src/components/link/__snapshots__/LinkHandle.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly renders correctly 1`] = ` 4 | 7 | 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /src/components/port/__snapshots__/AbstractPort.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly renders correctly 1`] = ` 4 | 5 | 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /src/components/node/__snapshots__/AbstractNode.snapshot.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 | 9 | 10 | 11 | 12 | `; 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "restricted", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "semi": true, 7 | "useTabs": true, 8 | "arrowParens": "avoid", 9 | "overrides": [ 10 | { 11 | "files": "**/*.json", 12 | "options": { 13 | "tabWidth": 2, 14 | "useTabs": false 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/components/node/__snapshots__/NodesRenderer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 | 6 | MockNodes 7 | 8 | 9 | `; 10 | 11 | exports[` renders correctly if nested 1`] = ` 12 | 13 | 14 | MockNodes 15 | 16 | 17 | `; 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | [*.js, *.jsx, *.css, *.scss] 9 | indent_style = tab 10 | # special rule for json expecially package.json wich npm reset to space for any modification 11 | [*.json] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /src/components/grid/Grid.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import Grid from './Grid.component'; 5 | 6 | describe('Grid.component', () => { 7 | it('should render a grid by default', () => { 8 | const tree = renderer.create(); 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@talend/scripts-config-typescript/tsconfig.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "declaration": true, 6 | "allowJs": false, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "CommonJs", 10 | "typeRoots": ["src/customTypings/index.d", "node_modules/@types"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/actions/__snapshots__/flow.actions.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`setZoom if transform object is not well set return null 1`] = `null`; 4 | 5 | exports[`setZoom should generate an action with proper shape 1`] = ` 6 | Object { 7 | "transform": Object { 8 | "k": 0, 9 | "x": 0, 10 | "y": 0, 11 | }, 12 | "type": "FLOWDESIGNER.FLOW_SET_ZOOM", 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /src/actions/nodeType.actions.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import { FLOWDESIGNER_NODETYPE_SET } from '../constants/flowdesigner.constants'; 3 | 4 | /** 5 | * Ask to set a map for nodeTypes 6 | * @param {Map} nodeTypes 7 | */ 8 | export const setNodeTypes = (nodeTypes: Map) => ({ 9 | type: FLOWDESIGNER_NODETYPE_SET, 10 | nodeTypes, 11 | }); 12 | 13 | export default setNodeTypes; 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const talendEslintRcPath = require.resolve('@talend/scripts-config-eslint/.eslintrc'); 2 | 3 | module.exports = { 4 | extends: talendEslintRcPath, 5 | parser: '@typescript-eslint/parser', 6 | rules: { 7 | 'react/prop-types': 'off', 8 | 'import/no-unresolved': 'off', 9 | 'no-shadow': 'off', 10 | '@typescript-eslint/no-use-before-define': 'off', 11 | '@typescript-eslint/no-unused-vars': 'off', 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as port from './port/port'; 2 | import * as link from './link/link'; 3 | import * as node from './node/node'; 4 | import * as position from './position/position'; 5 | import * as size from './size/size'; 6 | import * as data from './data/data'; 7 | 8 | export const Port = port; 9 | export const Link = link; 10 | export const Node = node; 11 | export const Position = position; 12 | export const Size = size; 13 | export const Data = data; 14 | -------------------------------------------------------------------------------- /src/reducers/nodeType.reducer.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { FLOWDESIGNER_NODETYPE_SET } from '../constants/flowdesigner.constants'; 4 | 5 | const defaultState = Map(); 6 | const nodeTypeReducer = (state = defaultState, action: any) => { 7 | switch (action.type) { 8 | case FLOWDESIGNER_NODETYPE_SET: 9 | return state.mergeIn(['nodeTypes'], action.nodeTypes); 10 | default: 11 | return state; 12 | } 13 | }; 14 | 15 | export default nodeTypeReducer; 16 | -------------------------------------------------------------------------------- /src/actions/nodeType.actions.test.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import * as nodeTypeActions from './nodeType.actions'; 3 | 4 | describe('Check that nodeType action creators generate the right action objects', () => { 5 | it('setNodeTypes', () => { 6 | const nodeTypes = Map().set('anything', { something: true }); 7 | expect(nodeTypeActions.setNodeTypes(nodeTypes)).toEqual({ 8 | type: 'FLOWDESIGNER_NODETYPE_SET', 9 | nodeTypes, 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/configuration/LinkType.component.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import invariant from 'invariant'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | function LinkType({ type, component }: { type: string; component: React.ReactNode }) { 6 | invariant( 7 | false, 8 | ' elements are for DataFlow configuration only and should not be rendered', 9 | ); 10 | return null; 11 | } 12 | 13 | LinkType.displayName = 'LinkType'; 14 | 15 | export default LinkType; 16 | -------------------------------------------------------------------------------- /src/components/configuration/NodeType.component.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import invariant from 'invariant'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | function NodeType({ type, component }: { type: string; component: React.ReactNode }) { 6 | invariant( 7 | false, 8 | ' elements are for DataFlow configuration only and should not be rendered', 9 | ); 10 | return null; 11 | } 12 | 13 | NodeType.displayName = 'NodeType'; 14 | 15 | export default NodeType; 16 | -------------------------------------------------------------------------------- /src/components/configuration/PortType.component.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import invariant from 'invariant'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | function PortType({ type, component }: { type: string; component: React.ReactNode }) { 6 | invariant( 7 | false, 8 | ' elements are for DataFlow configuration only and should not be rendered', 9 | ); 10 | return null; 11 | } 12 | 13 | PortType.displayName = 'PortType'; 14 | 15 | export default PortType; 16 | -------------------------------------------------------------------------------- /src/components/configuration/NodeType.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import NodeType from './NodeType.component'; 4 | 5 | const mockComponent = () =>
; 6 | 7 | describe('Testing ', () => { 8 | it('should log an error if rendered', () => { 9 | expect(() => { 10 | shallow(); 11 | }).toThrowError( 12 | ' elements are for DataFlow configuration only and should not be rendered', 13 | ); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /licence.js: -------------------------------------------------------------------------------- 1 | const licenceTemplate = `============================================================================ 2 | 3 | Copyright (C) 2006-${new Date().getFullYear()} Talend Inc. - www.talend.com 4 | 5 | This source code is available under agreement available at 6 | https://github.com/Talend/react-flow-designer/blob/master/LICENSE 7 | 8 | You should have received a copy of the agreement 9 | along with this program; if not, write to Talend SA 10 | 9 rue Pages 92150 Suresnes, France 11 | 12 | ============================================================================`; 13 | 14 | module.exports = licenceTemplate; 15 | -------------------------------------------------------------------------------- /src/components/link/LinkHandle.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import LinkHandle from './LinkHandle.component'; 4 | import { PositionRecord } from '../../constants/flowdesigner.model'; 5 | 6 | const MockComponent = () => ; 7 | 8 | describe(' renders correctly', () => { 9 | it(' renders correctly', () => { 10 | const children = ; 11 | const position = new PositionRecord({ x: 10, y: 10 }); 12 | const tree = renderer 13 | .create() 14 | .toJSON(); 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/port/PortsRenderer.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import get from 'lodash/get'; 3 | 4 | import { Port } from '../../api'; 5 | import { PortRecord, PortRecordMap } from '../../customTypings/index.d'; 6 | 7 | function PortsRenderer({ ports, portTypeMap }: { ports: PortRecordMap; portTypeMap: Object }) { 8 | const renderPort = (port: PortRecord) => { 9 | const type = Port.getComponentType(port); 10 | const ConcretePort = get((portTypeMap as any)[type], 'component'); 11 | return ; 12 | }; 13 | 14 | return {ports.valueSeq().map(renderPort)}; 15 | } 16 | 17 | export default PortsRenderer; 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: React Flow Designer CI 2 | on: 3 | pull_request: 4 | branches: [master] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | name: Build and test 10 | 11 | steps: 12 | - name: Checkout sources 13 | uses: actions/checkout@v2 14 | 15 | - name: Use Node.js 14 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 14 19 | registry-url: 'https://registry.npmjs.org/' 20 | scope: '@talend' 21 | cache: 'yarn' 22 | 23 | - name: Install 24 | run: yarn install --frozen-lockfile 25 | 26 | - name: Test 27 | run: yarn test 28 | 29 | - name: Lint 30 | run: yarn lint 31 | -------------------------------------------------------------------------------- /src/actions/flow.actions.test.ts: -------------------------------------------------------------------------------- 1 | import * as flowActions from './flow.actions'; 2 | 3 | describe('Check that flowActions generate proper action objects', () => { 4 | it('addFlowElements generate proper action object', () => { 5 | expect(flowActions.addFlowElements([])).toEqual({ 6 | type: 'FLOWDESIGNER.FLOW_ADD_ELEMENTS', 7 | listOfActionCreation: [], 8 | }); 9 | }); 10 | }); 11 | 12 | describe('setZoom', () => { 13 | it('should generate an action with proper shape', () => { 14 | expect(flowActions.setZoom({ k: 0, x: 0, y: 0 })).toMatchSnapshot(); 15 | }); 16 | 17 | it('if transform object is not well set return null', () => { 18 | expect(flowActions.setZoom({ k: 'yolo' as any, x: 0, y: 0 })).toMatchSnapshot(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/constants/flowdesigner.proptypes.ts: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { recordOf } from 'react-immutable-proptypes'; 3 | 4 | export const NodeType = recordOf({ 5 | id: PropTypes.string.isRequired, 6 | position: recordOf({ 7 | x: PropTypes.number.isRequired, 8 | y: PropTypes.number.isRequired, 9 | }), 10 | }); 11 | 12 | export const PortType = recordOf({ 13 | id: PropTypes.string.isRequired, 14 | nodeId: PropTypes.string.isRequired, 15 | position: recordOf({ 16 | x: PropTypes.number.isRequired, 17 | y: PropTypes.number.isRequired, 18 | }), 19 | }); 20 | 21 | export const LinkType = recordOf({ 22 | id: PropTypes.string.isRequired, 23 | sourceId: PropTypes.string.isRequired, 24 | targetId: PropTypes.string.isRequired, 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/port/AbstractPort.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import AbstractPort from './AbstractPort.component'; 5 | import { 6 | PortRecord, 7 | PositionRecord, 8 | PortGraphicalAttributes, 9 | } from '../../constants/flowdesigner.model'; 10 | 11 | describe(' renders correctly', () => { 12 | it(' renders correctly', () => { 13 | const port = new PortRecord({ 14 | id: 'idPort', 15 | nodeId: 'nodeId', 16 | graphicalAttributes: new PortGraphicalAttributes({ 17 | position: new PositionRecord({ 18 | x: 100, 19 | y: 100, 20 | }), 21 | }), 22 | }); 23 | const tree = renderer.create().toJSON(); 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/grid/__snapshots__/Grid.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Grid.component should render a grid by default 1`] = ` 4 | 5 | 6 | 17 | 21 | 24 | 25 | 26 | 38 | 39 | `; 40 | -------------------------------------------------------------------------------- /test/test-setup.js: -------------------------------------------------------------------------------- 1 | jest.mock('react-i18next', () => ({ 2 | // this mock makes sure any components using the translate HoC receive the t function as a prop 3 | withTranslation: () => Component => { 4 | Component.defaultProps = { 5 | ...Component.defaultProps, 6 | t: (key, options) => 7 | (options.defaultValue || '').replace(/{{(\w+)}}/g, (_, k) => options[k]), 8 | }; 9 | Component.displayName = `withI18nextTranslation(${ 10 | Component.displayName || Component.name 11 | })`; 12 | return Component; 13 | }, 14 | setI18n: () => {}, 15 | getI18n: () => ({ 16 | t: (key, options) => 17 | (options.defaultValue || '').replace(/{{(\w+)}}/g, (_, k) => options[k]), 18 | }), 19 | })); 20 | 21 | jest.mock('i18next', () => ({ 22 | t: (key, options) => (options.defaultValue || '').replace(/{{(\w+)}}/g, (_, k) => options[k]), 23 | createInstance: () => {}, 24 | })); 25 | -------------------------------------------------------------------------------- /src/components/__snapshots__/ZoomHandler.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ZoomHandler renders correctly renders correctly renders correctly 1`] = ` 4 | 10 | 22 | 32 | 42 | 43 | `; 44 | -------------------------------------------------------------------------------- /src/components/__snapshots__/FlowDesigner.container.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly renders correctly 1`] = ` 4 | 7 | 8 | 15 | 19 | 25 | 29 | 33 | 34 | 35 | 38 | 39 | 40 | 41 | 42 | `; 43 | -------------------------------------------------------------------------------- /src/api/throwInDev.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Throw {message} only in dev mode 3 | * @param {string} message 4 | */ 5 | export function throwInDev(message: string | TypeError) { 6 | if (!(process.env.NODE_ENV === 'production')) { 7 | throw message; 8 | } 9 | } 10 | 11 | /** 12 | * Throw a type error 13 | * @todo for ease of use param should be an object { 14 | * expected: 'Linkrecord', 15 | * given: link, 16 | * paramName: 'link', 17 | * module: 'Link' 18 | * } 19 | * @param {string} expected - describe expected type 20 | * @param {Object} given - the given param 21 | * @param {string} module - (optionnal) module to use 22 | */ 23 | export function throwTypeError(expected: string, given: Object, module?: string | undefined) { 24 | throwInDev( 25 | new TypeError(`${expected || 'parameter'} should be a ${expected}, was given 26 | """ 27 | ${typeof given} 28 | """ 29 | ${given && given.toString()} 30 | """ 31 | ${module && `you should use ${module} module functions to create and transform ${module}`}`), 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/port/PortsRenderer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { Map } from 'immutable'; 4 | 5 | import PortsRenderer from './PortsRenderer.component'; 6 | import { PortRecord } from '../../constants/flowdesigner.model'; 7 | import { Id, PortRecord as PortRecordType } from '../../customTypings/index.d'; 8 | 9 | const MockPort = () => MockPort; 10 | 11 | describe('', () => { 12 | it('renders correctly', () => { 13 | const ports = Map().set( 14 | 'id', 15 | new PortRecord({ 16 | id: 'id', 17 | nodeId: 'nodeId', 18 | graphicalAttributes: Map({ 19 | portType: 'id', 20 | }), 21 | }), 22 | ); 23 | const portTypeMap = { 24 | id: { 25 | id: 'id', 26 | component: MockPort, 27 | }, 28 | }; 29 | const tree = renderer 30 | .create() 31 | .toJSON(); 32 | expect(tree).toMatchSnapshot(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/selectors/__snapshots__/nodeSelectors.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Testing node selectors node1 should have 7 successors 1`] = ` 4 | Immutable.Set [ 5 | "id8", 6 | "id7", 7 | "id5", 8 | "id3", 9 | "id6", 10 | "id4", 11 | "id2", 12 | ] 13 | `; 14 | 15 | exports[`Testing node selectors node1 should not have any predecessors 1`] = `Immutable.Set []`; 16 | 17 | exports[`Testing node selectors node4 should have node1, node2 as predecessors 1`] = ` 18 | Immutable.Set [ 19 | "id1", 20 | "id2", 21 | ] 22 | `; 23 | 24 | exports[`Testing node selectors node4 should have node6, node7, node8 as successors 1`] = ` 25 | Immutable.Set [ 26 | "id8", 27 | "id7", 28 | "id6", 29 | ] 30 | `; 31 | 32 | exports[`Testing node selectors node8 should have 7 predecessors 1`] = ` 33 | Immutable.Set [ 34 | "id1", 35 | "id2", 36 | "id3", 37 | "id5", 38 | "id4", 39 | "id6", 40 | "id7", 41 | ] 42 | `; 43 | 44 | exports[`Testing node selectors node8 should not have any successors 1`] = `Immutable.Set []`; 45 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **What is the problem this PR is trying to solve?** 2 | 3 | **What is the chosen solution to this problem?** 4 | 5 | **Please check if the PR fulfills these requirements** 6 | 7 | - [ ] The PR commit message follows our [guidelines](https://github.com/talend/tools/blob/master/tools-root-github/CONTRIBUTING.md#commit-message-format) 8 | - [ ] Tests for the changes have been added (for bug fixes / features) 9 | - [ ] Docs have been added / updated (for bug fixes / features) 10 | - [ ] Related design / discussions / pages, if any, are all linked or available in the PR 11 | 12 | 13 | 14 | **[ ] This PR introduces a breaking change** 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/selectors/nodeSelectors.ts: -------------------------------------------------------------------------------- 1 | import { Set } from 'immutable'; 2 | import { State, Id } from '../customTypings/index.d'; 3 | 4 | /** 5 | * @param state Map flow state 6 | * @param nodeId String 7 | * @param predecessors Set list of already determined predecessors 8 | */ 9 | export function getPredecessors(state: State, nodeId: Id, predecessors?: Set) { 10 | return state.getIn(['parents', nodeId]).reduce( 11 | (accumulator: Set, parentId: Id) => 12 | getPredecessors(state, parentId, accumulator).add(parentId), 13 | // eslint-disable-next-line new-cap 14 | predecessors || Set(), 15 | ); 16 | } 17 | 18 | /** 19 | * @param state Map flow state 20 | * @param nodeId String 21 | * @param successors Set list of already determined successors 22 | */ 23 | export function getSuccessors(state: State, nodeId: Id, successors?: Set) { 24 | return state.getIn(['childrens', nodeId]).reduce( 25 | (accumulator: Set, childrenId: Id) => 26 | getSuccessors(state, childrenId, accumulator).add(childrenId), 27 | // eslint-disable-next-line new-cap 28 | successors || Set(), 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ZoomHandler.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import ZoomHandler, { transformToString } from './ZoomHandler.component'; 5 | 6 | describe('ZoomHandler renders correctly', () => { 7 | describe(' renders correctly', () => { 8 | it(' renders correctly', () => { 9 | // given 10 | const transform = { x: 0, y: 0, k: 1 }; 11 | 12 | // when 13 | const tree = renderer 14 | .create( 15 | 16 | 17 | 18 | , 19 | ) 20 | .toJSON(); 21 | 22 | // expect 23 | expect(tree).toMatchSnapshot(); 24 | }); 25 | }); 26 | describe('transformToString', () => { 27 | // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform 28 | it('generate proper transform string', () => { 29 | // given 30 | const transform = { x: 1, y: 2, k: -3 }; 31 | 32 | // when 33 | const stringResult = transformToString(transform); 34 | 35 | // expect 36 | expect(stringResult).toEqual( 37 | `translate(${transform.x}, ${transform.y}) scale(${transform.k})`, 38 | ); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/node/AbstractNode.snapshot.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import AbstractNode from './AbstractNode.component'; 5 | import { 6 | NodeGraphicalAttributes, 7 | NodeRecord, 8 | PositionRecord, 9 | SizeRecord, 10 | } from '../../constants/flowdesigner.model'; 11 | 12 | jest.mock('d3', () => { 13 | const original = jest.requireActual('d3'); 14 | return { 15 | ...original, 16 | select() { 17 | return { data() {}, call() {} }; 18 | }, 19 | }; 20 | }); 21 | 22 | const noOp = () => {}; 23 | 24 | describe('', () => { 25 | it('renders correctly', () => { 26 | const node = new NodeRecord({ 27 | id: 'id', 28 | graphicalAttributes: new NodeGraphicalAttributes({ 29 | position: new PositionRecord({ x: 100, y: 100 }), 30 | nodeSize: new SizeRecord({ width: 125, height: 75 }), 31 | }), 32 | }); 33 | const tree = renderer 34 | .create( 35 | 41 | 42 | , 43 | ) 44 | .toJSON(); 45 | expect(tree).toMatchSnapshot(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/link/LinksRenderer.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import invariant from 'invariant'; 3 | import get from 'lodash/get'; 4 | import { LinkRecordMap, PortRecordMap, LinkRecord } from '../../customTypings/index.d'; 5 | 6 | type Props = { 7 | links: LinkRecordMap; 8 | ports: PortRecordMap; 9 | linkTypeMap: Object; 10 | }; 11 | 12 | class LinksRender extends React.Component { 13 | constructor(props: Props) { 14 | super(props); 15 | this.renderLink = this.renderLink.bind(this); 16 | } 17 | 18 | renderLink(link: LinkRecord) { 19 | const ConcreteLink = get((this.props.linkTypeMap as any)[link.getLinkType()], 'component'); 20 | const source = this.props.ports.get(link.sourceId); 21 | const target = this.props.ports.get(link.targetId); 22 | if (!ConcreteLink) { 23 | invariant( 24 | false, 25 | ` the defined link type in your graph model 26 | hasn't been mapped into the dataflow configuration, 27 | check LinkType documentation`, 28 | ); 29 | } 30 | return ; 31 | } 32 | 33 | render() { 34 | return {this.props.links.valueSeq().map(this.renderLink)}; 35 | } 36 | } 37 | 38 | export default LinksRender; 39 | -------------------------------------------------------------------------------- /src/components/FlowDesigner.container.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { Map } from 'immutable'; 4 | 5 | import { FlowDesigner } from './FlowDesigner.container'; 6 | import NodeType from './configuration/NodeType.component'; 7 | import { NodeRecord, Id, PortRecord, LinkRecord } from '../customTypings/index.d'; 8 | 9 | jest.mock('./ZoomHandler.component'); 10 | jest.mock('./grid/Grid.component', () => { 11 | return null; 12 | }); 13 | 14 | const noOp = () => {}; 15 | 16 | describe(' renders correctly', () => { 17 | it(' renders correctly', () => { 18 | const nodes = Map(); 19 | const ports = Map(); 20 | const links = Map(); 21 | const tree = renderer 22 | .create( 23 | 33 | 34 | 35 | , 36 | ) 37 | .toJSON(); 38 | expect(tree).toMatchSnapshot(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/components/grid/Grid.component.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import get from 'lodash/get'; 4 | 5 | import { GRID_SIZE } from '../../constants/flowdesigner.constants'; 6 | import { Transform } from '../../customTypings/index.d'; 7 | 8 | function Grid({ transformData }: { transformData?: Transform }) { 9 | const largeGridSize = GRID_SIZE * get(transformData, 'k', 1); 10 | return ( 11 | 12 | 13 | 24 | 25 | 26 | 27 | 28 | 36 | 37 | ); 38 | } 39 | 40 | Grid.propTypes = { 41 | transformData: PropTypes.shape({ 42 | k: PropTypes.number.isRequired, 43 | x: PropTypes.number.isRequired, 44 | y: PropTypes.number.isRequired, 45 | }), 46 | }; 47 | 48 | export default Grid; 49 | -------------------------------------------------------------------------------- /.github/workflows/changeset.yml: -------------------------------------------------------------------------------- 1 | name: Changeset (Release) 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@master 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | 19 | - name: Setup Node.js 14.x 20 | uses: actions/setup-node@master 21 | with: 22 | node-version: 14.x 23 | registry-url: 'https://registry.npmjs.org/' 24 | cache: 'yarn' 25 | 26 | - name: Install Dependencies 27 | run: yarn && yarn pre-release 28 | 29 | - name: Create Release Pull Request or Publish to npm 30 | id: changesets 31 | uses: changesets/action@master 32 | with: 33 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 34 | publish: yarn release 35 | commit: "chore: prepare release" 36 | title: "chore: prepare release" 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 40 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 41 | -------------------------------------------------------------------------------- /src/components/port/AbstractPort.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { select } from 'd3'; 3 | 4 | import { Port, Position } from '../../api'; 5 | import { PortRecord } from '../../customTypings/index.d'; 6 | 7 | type Props = { 8 | port?: PortRecord; 9 | onClick?: React.MouseEventHandler; 10 | children?: React.ReactChildren; 11 | }; 12 | 13 | class AbstractPort extends React.Component { 14 | d3Node: any; 15 | 16 | node: any; 17 | 18 | constructor(props: Props) { 19 | super(props); 20 | this.onClick = this.onClick.bind(this); 21 | } 22 | 23 | componentDidMount() { 24 | this.d3Node = select(this.node); 25 | this.d3Node.on('click', this.onClick); 26 | } 27 | 28 | shouldComponentUpdate(nextProps: Props) { 29 | return nextProps.port !== this.props.port || nextProps.children !== this.props.children; 30 | } 31 | 32 | onClick(event: any) { 33 | if (this.props.onClick) { 34 | this.props.onClick(event); 35 | } 36 | } 37 | 38 | render() { 39 | const position = Port.getPosition(this.props.port); 40 | return ( 41 | 42 | { 44 | this.node = c; 45 | }} 46 | transform={`translate(${Position.getXCoordinate( 47 | position, 48 | )},${Position.getYCoordinate(position)})`} 49 | > 50 | {this.props.children} 51 | 52 | 53 | ); 54 | } 55 | } 56 | 57 | export default AbstractPort; 58 | -------------------------------------------------------------------------------- /src/components/link/LinkHandle.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { drag, select } from 'd3'; 3 | import { PositionRecord } from '../../customTypings/index.d'; 4 | 5 | type Props = { 6 | position: PositionRecord; 7 | onDrag?: (event: any) => void; 8 | onDragEnd?: (event: any) => void; 9 | component: React.ReactElement; 10 | }; 11 | 12 | class LinkHandle extends React.Component { 13 | d3Handle: any; 14 | 15 | handle: React.ElementRef<'g'> | null; 16 | 17 | constructor(props: Props) { 18 | super(props); 19 | this.drag = this.drag.bind(this); 20 | this.dragEnd = this.dragEnd.bind(this); 21 | this.handle = null; 22 | } 23 | 24 | componentDidMount() { 25 | this.d3Handle = select(this.handle); 26 | this.d3Handle.call(drag().on('drag', this.drag).on('end', this.dragEnd)); 27 | } 28 | 29 | componentWillUnmount() { 30 | this.d3Handle.remove(); 31 | } 32 | 33 | drag(event: any) { 34 | if (this.props.onDrag) { 35 | this.props.onDrag(event); 36 | } 37 | } 38 | 39 | dragEnd(event: any) { 40 | if (this.props.onDragEnd) { 41 | this.props.onDragEnd(event); 42 | } 43 | } 44 | 45 | render() { 46 | const position = this.props.position; 47 | return ( 48 | { 50 | this.handle = c; 51 | }} 52 | transform={`translate(${position.get('x')},${position.get('y')})`} 53 | > 54 | {this.props.component} 55 | 56 | ); 57 | } 58 | } 59 | 60 | export default LinkHandle; 61 | -------------------------------------------------------------------------------- /src/actions/flow.actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FLOWDESIGNER_FLOW_ADD_ELEMENTS, 3 | FLOWDESIGNER_FLOW_RESET, 4 | FLOWDESIGNER_FLOW_LOAD, 5 | FLOWDESIGNER_FLOW_SET_ZOOM, 6 | FLOWDESIGNER_FLOW_ZOOM_IN, 7 | FLOWDESIGNER_FLOW_ZOOM_OUT, 8 | } from '../constants/flowdesigner.constants'; 9 | 10 | /** 11 | * Ask to sequentially add elements to the flow, each creation should be checked against store, 12 | * then applied via current reducers 13 | * 14 | * @params {array} listOfActionCreation 15 | */ 16 | export const addFlowElements = (listOfActionCreation: any) => ({ 17 | type: FLOWDESIGNER_FLOW_ADD_ELEMENTS, 18 | listOfActionCreation, 19 | }); 20 | 21 | /** 22 | * ask for flow reset, emptying, nodes, links, ports collections 23 | */ 24 | export const resetFlow = () => ({ 25 | type: FLOWDESIGNER_FLOW_RESET, 26 | }); 27 | 28 | /** 29 | * reset old flow, load elements for the new flow 30 | */ 31 | export const loadFlow = (listOfActionCreation: any) => ({ 32 | type: FLOWDESIGNER_FLOW_LOAD, 33 | listOfActionCreation, 34 | }); 35 | 36 | export function setZoom(transform: { k: number; x: number; y: number }) { 37 | if (!isNaN(transform.k) && !isNaN(transform.x) && !isNaN(transform.y)) { 38 | return { 39 | type: FLOWDESIGNER_FLOW_SET_ZOOM, 40 | transform, 41 | }; 42 | } 43 | return null; 44 | } 45 | 46 | export function zoomIn(scale: number) { 47 | return { 48 | type: FLOWDESIGNER_FLOW_ZOOM_IN, 49 | scale, 50 | }; 51 | } 52 | 53 | export function zoomOut(scale: number) { 54 | return { 55 | type: FLOWDESIGNER_FLOW_ZOOM_OUT, 56 | scale, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/components/node/NodesRenderer.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import invariant from 'invariant'; 3 | import get from 'lodash/get'; 4 | import { NodeRecordMap, NodeRecord, Id, Position } from '../../customTypings/index.d'; 5 | 6 | type Props = { 7 | nodes: NodeRecordMap; 8 | nodeTypeMap: Object; 9 | startMoveNodeTo: (nodeId: Id, nodePosition: string) => void; 10 | moveNodeTo: (nodeId: Id, nodePosition: Position) => void; 11 | moveNodeToEnd: (nodeId: Id, nodePosition: Position) => void; 12 | snapToGrid: boolean; 13 | }; 14 | 15 | class NodesRenderer extends React.Component { 16 | constructor(props: Props) { 17 | super(props); 18 | this.renderNode = this.renderNode.bind(this); 19 | } 20 | 21 | renderNode(node: NodeRecord) { 22 | const type = node.getNodeType(); 23 | const ConcreteComponent = get((this.props.nodeTypeMap as any)[type], 'component'); 24 | if (!ConcreteComponent) { 25 | invariant( 26 | false, 27 | ` the defined node type in your graph model hasn't been mapped into 28 | the dataflow configuration, check NodeType documentation`, 29 | ); 30 | } 31 | return ( 32 | 40 | ); 41 | } 42 | 43 | render() { 44 | return {this.props.nodes.valueSeq().map(this.renderNode)}; 45 | } 46 | } 47 | 48 | export default NodesRenderer; 49 | -------------------------------------------------------------------------------- /.github/workflows/update-yarn-talend.yml: -------------------------------------------------------------------------------- 1 | name: Yarn talend auto upgrade 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | # Every tuesday 6 | - cron: '0 13 * * TUE' 7 | 8 | jobs: 9 | upgrade: 10 | name: Upgrade yarn @talend dependencies 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@master 15 | - name: Use Node.js 14 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 14 19 | registry-url: 'https://registry.npmjs.org/' 20 | scope: '@talend' 21 | cache: 'yarn' 22 | 23 | # NODE_AUTH_TOKEN is not working with "npx" command.. 24 | - name: Upgrade talend dependencies 25 | run: | 26 | yarn 27 | yarn talend-scripts upgrade:deps 28 | yarn talend-scripts upgrade:deps --latest --dry > dependencies-latest.md 29 | git add dependencies-latest.md 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | 33 | - name: Create Pull Request 34 | uses: peter-evans/create-pull-request@v3 35 | with: 36 | assignees: tlnd-mhuchet 37 | reviewers: tlnd-mhuchet 38 | commit-message: 'chore(scripts): Upgrade dependencies' 39 | title: 'chore(scripts): Upgrade talend dependencies' 40 | branch: ci/chore/upgrade_webapp_talend_dependencies 41 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import FlowDesigner, { FlowDesigner as FlowDesignerComponent } from './components/FlowDesigner.container'; 2 | import AbstractNode from './components/node/AbstractNode.component'; 3 | import AbstractLink from './components/link/AbstractLink.component'; 4 | import AbstractPort from './components/port/AbstractPort.component'; 5 | import NodeType from './components/configuration/NodeType.component'; 6 | import LinkType from './components/configuration/LinkType.component'; 7 | import PortType from './components/configuration/PortType.component'; 8 | import flowDesignerReducer from './reducers'; 9 | import * as flowDesignerConstants from './constants/flowdesigner.constants'; 10 | import * as flowActions from './actions/flow.actions'; 11 | import * as nodeActions from './actions/node.actions'; 12 | import * as portActions from './actions/port.actions'; 13 | import * as linkActions from './actions/link.actions'; 14 | import * as portSelectors from './selectors/portSelectors'; 15 | import * as nodeSelectors from './selectors/nodeSelectors'; 16 | import * as flowPropTypes from './constants/flowdesigner.proptypes'; 17 | import * as flowModels from './constants/flowdesigner.model'; 18 | 19 | export { 20 | flowDesignerReducer, 21 | FlowDesigner, 22 | FlowDesignerComponent, 23 | AbstractNode, 24 | AbstractLink, 25 | AbstractPort, 26 | NodeType, 27 | LinkType, 28 | PortType, 29 | flowDesignerConstants, // should i share ? 30 | flowActions, 31 | nodeActions, 32 | portActions, 33 | linkActions, 34 | portSelectors, 35 | nodeSelectors, 36 | flowPropTypes, 37 | flowModels, 38 | }; 39 | -------------------------------------------------------------------------------- /src/api/readme.md: -------------------------------------------------------------------------------- 1 | # Uncertainty 2 | How uncertainty is handled in this API. 3 | 4 | ## extracting values from a given value (get*) 5 | On dev mode: 6 | - if the parameters are not fitting, throw an exception 7 | 8 | On prod mode: 9 | - if parameter are not fitting, don't throw, but return null. 10 | 11 | ```javascript 12 | // dev 13 | expect(Port.getPosition(invalidPort)).toThrow(); 14 | // throw 15 | 16 | // prod 17 | expect(Port.getPosition(invalidPort)).toBe(null); 18 | // return null 19 | ``` 20 | 21 | ## questioning the given value (is*, has*) 22 | usualy questioning return a boolean. In this case the answer should never be something else than true or false 23 | 24 | ```javascript 25 | // dev 26 | expect(Port.isPort(invalidPort)).toThrow(); 27 | // throw 28 | 29 | // prod 30 | expect(Port.isPort(invalidPort)).toBe(false); 31 | // return null 32 | ``` 33 | 34 | ### transforming the given value (set*) 35 | to avoid undefined error, in case the transformation is not applicable the original value is returned 36 | 37 | ```javascript 38 | // dev 39 | expect(Port.setId(id, invalidPort)).toThrow(); 40 | // throw 41 | 42 | // prod 43 | expect(Port.isPort(id, invalidPort)).toBe(invalidPort); 44 | // return null 45 | ``` 46 | 47 | Why null returned when expected return is not a boolean ? 48 | Because "" for string or 0 or NaN doesn't capture well the intent of providing a default neutral value for number and string, same apply to object. Undefined is not suitable to because undefined error often are unhandled error in your code or a remote code. 49 | 50 | A better solution overall would be the use of Maybe wrapper. 51 | -------------------------------------------------------------------------------- /src/components/link/LinksRenderer.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | import React from 'react'; 3 | import renderer from 'react-test-renderer'; 4 | import { Map, OrderedMap } from 'immutable'; 5 | 6 | import LinksRenderer from './LinksRenderer.component'; 7 | import { LinkRecord, PortRecord, PositionRecord } from '../../constants/flowdesigner.model'; 8 | import { 9 | Id, 10 | LinkRecord as LinkRecordType, 11 | PortRecord as PortRecordType, 12 | } from '../../customTypings/index.d'; 13 | 14 | const MockLink = () => MockLink; 15 | 16 | describe('', () => { 17 | it('renders correctly', () => { 18 | const links = Map().set( 19 | 'id', 20 | new LinkRecord({ 21 | id: 'id', 22 | sourceId: 'port1', 23 | targetId: 'port2', 24 | graphicalAttributes: Map({ 25 | linkType: 'id', 26 | }), 27 | }), 28 | ); 29 | const ports = OrderedMap() 30 | .set( 31 | 'port1', 32 | new PortRecord({ 33 | id: 'port1', 34 | nodeId: 'nodeId', 35 | graphicalAttributes: Map({ 36 | position: new PositionRecord({ x: 100, y: 100 }), 37 | }), 38 | }), 39 | ) 40 | .set( 41 | 'port2', 42 | new PortRecord({ 43 | id: 'port2', 44 | nodeId: 'nodeId', 45 | graphicalAttributes: Map({ 46 | position: new PositionRecord({ x: 200, y: 200 }), 47 | }), 48 | }), 49 | ); 50 | const linkTypeMap = { id: { id: 'id', component: MockLink } }; 51 | const tree = renderer 52 | .create() 53 | .toJSON(); 54 | expect(tree).toMatchSnapshot(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/selectors/linkSelectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { Map } from 'immutable'; 3 | import { 4 | State, 5 | PortRecordMap, 6 | LinkRecordMap, 7 | LinkRecord, 8 | PortRecord, 9 | Id, 10 | } from '../customTypings/index.d'; 11 | 12 | const getPorts = (state: State): PortRecordMap => state.get('ports'); 13 | const getLinks = (state: State): LinkRecordMap => state.get('links'); 14 | 15 | export const getDetachedLinks = createSelector( 16 | [getLinks, getPorts], 17 | (links: LinkRecordMap, ports: PortRecordMap) => 18 | links.filter( 19 | (link: LinkRecord) => 20 | !ports.find((port: PortRecord) => port.id === link.sourceId) || 21 | !ports.find((port: PortRecord) => port.id === link.targetId), 22 | ), 23 | ); 24 | 25 | /** 26 | * get outgoing link from a port 27 | * 28 | * @return {Link} 29 | */ 30 | export function portOutLink(state: State, portId: Id) { 31 | return state.get('links').filter((link: LinkRecord) => link.sourceId === portId) || Map(); 32 | } 33 | 34 | /** 35 | * get ingoing link from a port 36 | * 37 | * @return {Link} 38 | */ 39 | export function portInLink(state: State, portId: Id) { 40 | return state.get('links').filter((link: LinkRecord) => link.targetId === portId) || Map(); 41 | } 42 | 43 | /** 44 | * get outgoing linkId from a node 45 | * 46 | * @return number 47 | */ 48 | export function outLink(state: State, nodeId: Id) { 49 | return state 50 | .getIn(['out', nodeId]) 51 | .reduce((reduction: PortRecordMap, port: PortRecord) => reduction.merge(port), Map()); 52 | } 53 | 54 | /** 55 | * get inGoing linkId from a node 56 | * 57 | * @return number 58 | */ 59 | export function inLink(state: State, nodeId: Id) { 60 | return state 61 | .getIn(['in', nodeId]) 62 | .reduce((reduction: PortRecordMap, port: PortRecord) => reduction.merge(port), Map()); 63 | } 64 | 65 | export default getDetachedLinks; 66 | -------------------------------------------------------------------------------- /src/components/node/NodesRenderer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { List, Map } from 'immutable'; 4 | 5 | import NodesRenderer from './NodesRenderer.component'; 6 | import { NestedNodeRecord, NodeRecord, NodeGraphicalAttributes } from '../../constants/flowdesigner.model'; 7 | import { NodeRecord as NodeRecordType } from '../../customTypings/index.d'; 8 | 9 | const MockNode = () => MockNodes; 10 | 11 | const noOp = () => {}; 12 | 13 | describe('', () => { 14 | it('renders correctly', () => { 15 | const nodes = Map().set( 16 | 'id', 17 | new NodeRecord({ 18 | id: 'id', 19 | type: 'id', 20 | graphicalAttributes: new NodeGraphicalAttributes({ 21 | nodeType: 'id', 22 | }), 23 | }), 24 | ); 25 | const nodeTypeMap = { id: { id: 'id', component: MockNode } }; 26 | const tree = renderer 27 | .create( 28 | , 36 | ) 37 | .toJSON(); 38 | expect(tree).toMatchSnapshot(); 39 | }); 40 | it('renders correctly if nested', () => { 41 | const nodes = Map().set( 42 | 'id', 43 | new NestedNodeRecord({ 44 | id: 'id', 45 | type: 'id', 46 | graphicalAttributes: new NodeGraphicalAttributes({ 47 | nodeType: 'id', 48 | }), 49 | components: List(), 50 | }), 51 | ); 52 | const nodeTypeMap = { id: { id: 'id', component: MockNode } }; 53 | const tree = renderer 54 | .create( 55 | , 63 | ) 64 | .toJSON(); 65 | expect(tree).toMatchSnapshot(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/actions/__snapshots__/port.actions.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Check that port action creators generate proper action objects and perform checking addPort 1`] = ` 4 | Array [ 5 | Object { 6 | "data": Object { 7 | "flowType": "string", 8 | }, 9 | "graphicalAttributes": Object { 10 | "portType": "test", 11 | "properties": Object { 12 | "type": "INCOMING", 13 | }, 14 | }, 15 | "id": "portId", 16 | "nodeId": "nodeId", 17 | "type": "FLOWDESIGNER_PORT_ADD", 18 | }, 19 | ] 20 | `; 21 | 22 | exports[`Check that port action creators generate proper action objects and perform checking removePort 1`] = ` 23 | Array [ 24 | Object { 25 | "portId": "portId", 26 | "type": "FLOWDESIGNER_PORT_REMOVE", 27 | }, 28 | ] 29 | `; 30 | 31 | exports[`Check that port action creators generate proper action objects and perform checking removePortAttribute 1`] = ` 32 | Array [ 33 | Object { 34 | "graphicalAttributesKey": "selected", 35 | "portId": "id", 36 | "type": "FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES", 37 | }, 38 | ] 39 | `; 40 | 41 | exports[`Check that port action creators generate proper action objects and perform checking removePortData 1`] = ` 42 | Array [ 43 | Object { 44 | "dataKey": "type", 45 | "portId": "id", 46 | "type": "FLOWDESIGNER_PORT_REMOVE_DATA", 47 | }, 48 | ] 49 | `; 50 | 51 | exports[`Check that port action creators generate proper action objects and perform checking setPortData 1`] = ` 52 | Array [ 53 | Object { 54 | "data": Object { 55 | "type": "test", 56 | }, 57 | "portId": "id", 58 | "type": "FLOWDESIGNER_PORT_SET_DATA", 59 | }, 60 | ] 61 | `; 62 | 63 | exports[`Check that port action creators generate proper action objects and perform checking setPortGraphicalAttribute 1`] = ` 64 | Array [ 65 | Object { 66 | "graphicalAttributes": Object { 67 | "selected": true, 68 | }, 69 | "portId": "id", 70 | "type": "FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES", 71 | }, 72 | ] 73 | `; 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-flow-designer", 3 | "description": "Flow designer for react and redux", 4 | "version": "4.2.2", 5 | "main": "lib/index.js", 6 | "mainSrc": "src/index.js", 7 | "scripts": { 8 | "build": "talend-scripts build:ts:lib", 9 | "lint": "talend-scripts lint:es", 10 | "test": "talend-scripts test --silent", 11 | "pre-release": "talend-scripts build:lib:umd", 12 | "prepare": "yarn build", 13 | "release": "yarn changeset publish" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/Talend/react-flow-designer.git" 18 | }, 19 | "author": "Talend ", 20 | "license": "Apache-2.0", 21 | "devDependencies": { 22 | "@changesets/cli": "^2.17.0", 23 | "@talend/scripts-config-jest": "^9.6.4", 24 | "@talend/scripts-core": "^11.0.1", 25 | "@talend/scripts-preset-react-lib": "^9.9.2", 26 | "@types/d3": "^6.7.5", 27 | "@types/enzyme": "^3.10.9", 28 | "@types/invariant": "^2.2.35", 29 | "@types/lodash": "^4.14.175", 30 | "@types/react": "^16.14.16", 31 | "@types/react-redux": "^5.0.22", 32 | "@types/react-test-renderer": "^16.9.5", 33 | "@types/redux-mock-store": "^1.0.3", 34 | "@types/redux-thunk": "^2.1.0", 35 | "@typescript-eslint/parser": "^4.32.0", 36 | "enzyme": "^3.11.0", 37 | "i18next": "^19.9.2", 38 | "immutable": "^3.8.2", 39 | "lodash": "^4.17.21", 40 | "prop-types": "^15.7.2", 41 | "react": "^16.14.0", 42 | "react-dom": "^16.14.0", 43 | "react-i18next": "^11.12.0", 44 | "react-redux": "^5.1.2", 45 | "react-test-renderer": "^17.0.2", 46 | "redux": "^3.7.2", 47 | "redux-mock-store": "^1.5.4", 48 | "redux-thunk": "^2.3.0", 49 | "reselect": "^4.0.0" 50 | }, 51 | "peerDependencies": { 52 | "immutable": "3", 53 | "lodash": "4", 54 | "react": "15 || ^16.0.0", 55 | "react-dom": "15 || ^16.0.0", 56 | "react-redux": ">= 5", 57 | "redux": ">= 3", 58 | "reselect": ">= 2" 59 | }, 60 | "dependencies": { 61 | "classnames": "^2.3.1", 62 | "d3": "^6.7.0", 63 | "invariant": "^2.2.4", 64 | "prop-types": "^15.7.2", 65 | "react-immutable-proptypes": "^2.2.0" 66 | }, 67 | "files": [ 68 | "/dist", 69 | "/lib", 70 | "/src" 71 | ], 72 | "publishConfig": { 73 | "access": "public" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/selectors/portSelectors.test.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import * as Selectors from './portSelectors'; 3 | import { defaultState } from '../reducers/flow.reducer'; 4 | import { LinkRecord } from '../constants/flowdesigner.model'; 5 | import { PORT_SINK, PORT_SOURCE } from '../constants/flowdesigner.constants'; 6 | import { Port } from '../api'; 7 | import { 8 | State, 9 | Id, 10 | LinkRecord as LinkRecordType, 11 | PortRecord as PortRecordType, 12 | } from '../customTypings/index.d'; 13 | 14 | const port1 = Port.create('id1', 'nodeId1', 0, PORT_SINK, 'reactComponentType'); 15 | const port2 = Port.create('id2', 'nodeId1', 0, PORT_SOURCE, 'reactComponentType'); 16 | const port3 = Port.create('id3', 'nodeId2', 0, PORT_SINK, 'reactComponentType'); 17 | const port4 = Port.create('id4', 'nodeId2', 0, PORT_SOURCE, 'reactComponentType'); 18 | 19 | const givenState: Partial = defaultState 20 | .set( 21 | 'links', 22 | Map().set( 23 | 'id1', 24 | new LinkRecord({ 25 | id: 'id1', 26 | source: 'id1', 27 | target: 'id2', 28 | }), 29 | ), 30 | ) 31 | .set( 32 | 'ports', 33 | Map() 34 | .set('id1', port1) 35 | .set('id2', port2) 36 | .set('id3', port3) 37 | .set('id4', port4), 38 | ); 39 | 40 | describe('getEmitterPorts', () => { 41 | it('return a map with port id2 && id4', () => { 42 | // given 43 | // when 44 | const result = Selectors.getEmitterPorts(givenState); 45 | // expect 46 | expect(result.has('id2')).toBe(true); 47 | expect(result.has('id4')).toBe(true); 48 | }); 49 | }); 50 | 51 | describe('getSinkPorts', () => { 52 | it('return a map with port id1 & id3', () => { 53 | // given 54 | // when 55 | const result = Selectors.getSinkPorts(givenState); 56 | // expect 57 | expect(result.has('id1')).toBe(true); 58 | expect(result.has('id3')).toBe(true); 59 | }); 60 | }); 61 | 62 | describe('getEmitterPortsForNode', () => { 63 | it('return a map with port id2', () => { 64 | // given 65 | // when 66 | const result = Selectors.getEmitterPortsForNode(givenState)('nodeId1'); 67 | // expect 68 | expect(result.has('id2')).toBe(true); 69 | }); 70 | }); 71 | 72 | describe('getSinkPortsForNode', () => { 73 | it('return a map with port id1', () => { 74 | // given 75 | // when 76 | const result = Selectors.getSinkPortsForNode(givenState)('nodeId1'); 77 | // expect 78 | expect(result.has('id1')).toBe(true); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/components/ZoomHandler.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { select, zoom as d3ZoomFactory, ZoomBehavior } from 'd3'; 3 | import { Transform } from '../customTypings/index.d'; 4 | 5 | export function transformToString(transform?: Transform) { 6 | if (transform) { 7 | return `translate(${transform.x}, ${transform.y}) scale(${transform.k})`; 8 | } 9 | return ''; 10 | } 11 | 12 | type State = { 13 | transform?: Transform; 14 | transformToApply?: Transform; 15 | }; 16 | 17 | type Props = { 18 | children?: any; 19 | setZoom?: (transform: Transform) => void; 20 | transform?: Transform; 21 | transformToApply?: Transform; 22 | }; 23 | 24 | class ZoomHandler extends React.Component { 25 | zoom: ZoomBehavior; 26 | 27 | selection: any; 28 | 29 | zoomCatcher: any; 30 | 31 | constructor(props: Props) { 32 | super(props); 33 | 34 | this.zoom = d3ZoomFactory() 35 | .scaleExtent([1 / 4, 2]) 36 | .on('zoom', this.onZoom) 37 | .on('end', this.onZoomEnd); 38 | } 39 | 40 | UNSAFE_componentWillMount() { 41 | this.setState({ transform: this.props.transform }); 42 | } 43 | 44 | componentDidMount() { 45 | this.selection = select(this.zoomCatcher); 46 | this.selection.call(this.zoom); 47 | } 48 | 49 | UNSAFE_componentWillReceiveProps(nextProps: Props) { 50 | if (nextProps.transformToApply) { 51 | if (nextProps.transformToApply !== this.props.transformToApply) { 52 | this.selection 53 | .transition() 54 | .duration(230) 55 | .call(this.zoom.transform, nextProps.transformToApply); 56 | } 57 | } 58 | } 59 | 60 | onZoomEnd = (event: any) => { 61 | if (this.props.setZoom) this.props.setZoom(event.transform); 62 | }; 63 | 64 | onZoom = (event: any) => { 65 | this.setState({ transform: event.transform }); 66 | }; 67 | 68 | render() { 69 | const { transform } = this.state; 70 | const childrens = React.Children.map(this.props.children, children => 71 | React.cloneElement(children, { 72 | transformData: transform, 73 | transform: transformToString(transform), 74 | }), 75 | ); 76 | return ( 77 | 78 | { 80 | this.zoomCatcher = c; 81 | }} 82 | style={{ fill: 'none', pointerEvents: 'all' }} 83 | x="0" 84 | y="0" 85 | width="100%" 86 | height="100%" 87 | /> 88 | {childrens} 89 | 90 | ); 91 | } 92 | } 93 | 94 | export default ZoomHandler; 95 | -------------------------------------------------------------------------------- /src/actions/__snapshots__/link.actions.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Check that link action creators generate proper action objects and perform checking addLink 1`] = ` 4 | Array [ 5 | Object { 6 | "data": Object {}, 7 | "graphicalAttributes": Object { 8 | "selected": true, 9 | }, 10 | "linkId": "linkId", 11 | "sourceId": "sourceId", 12 | "targetId": "targetId", 13 | "type": "FLOWDESIGNER_LINK_ADD", 14 | }, 15 | ] 16 | `; 17 | 18 | exports[`Check that link action creators generate proper action objects and perform checking removeLink 1`] = ` 19 | Array [ 20 | Object { 21 | "linkId": "id", 22 | "type": "FLOWDESIGNER_LINK_REMOVE", 23 | }, 24 | ] 25 | `; 26 | 27 | exports[`Check that link action creators generate proper action objects and perform checking removeLinkData 1`] = ` 28 | Array [ 29 | Object { 30 | "dataKey": "type", 31 | "linkId": "id", 32 | "type": "FLOWDESIGNER_LINK_REMOVE_DATA", 33 | }, 34 | ] 35 | `; 36 | 37 | exports[`Check that link action creators generate proper action objects and perform checking removeLinkGrahicalAttribute 1`] = ` 38 | Array [ 39 | Object { 40 | "graphicalAttributesKey": "selected", 41 | "linkId": "id", 42 | "type": "FLOWDESIGNER_LINK_REMOVE_GRAPHICAL_ATTRIBUTES", 43 | }, 44 | ] 45 | `; 46 | 47 | exports[`Check that link action creators generate proper action objects and perform checking setLinkData 1`] = ` 48 | Array [ 49 | Object { 50 | "data": Object { 51 | "type": "test", 52 | }, 53 | "linkId": "id", 54 | "type": "FLOWDESIGNER_LINK_SET_DATA", 55 | }, 56 | ] 57 | `; 58 | 59 | exports[`Check that link action creators generate proper action objects and perform checking setLinkGraphicalAttributes 1`] = ` 60 | Array [ 61 | Object { 62 | "graphicalAttributes": Object { 63 | "selected": true, 64 | }, 65 | "linkId": "id", 66 | "type": "FLOWDESIGNER_LINK_SET_GRAPHICAL_ATTRIBUTES", 67 | }, 68 | ] 69 | `; 70 | 71 | exports[`Check that link action creators generate proper action objects and perform checking setLinkSource 1`] = ` 72 | Array [ 73 | Object { 74 | "linkId": "linkId", 75 | "sourceId": "portId", 76 | "type": "FLOWDESIGNER_LINK_SET_SOURCE", 77 | }, 78 | ] 79 | `; 80 | 81 | exports[`Check that link action creators generate proper action objects and perform checking setLinkTarget 1`] = ` 82 | Array [ 83 | Object { 84 | "linkId": "linkId", 85 | "targetId": "portId", 86 | "type": "FLOWDESIGNER_LINK_SET_TARGET", 87 | }, 88 | ] 89 | `; 90 | -------------------------------------------------------------------------------- /src/actions/port.actions.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import configureMockStore from 'redux-mock-store'; 3 | import { Map } from 'immutable'; 4 | 5 | import * as portActions from './port.actions'; 6 | import { PORT_SINK } from '../constants/flowdesigner.constants'; 7 | 8 | const mockStore = configureMockStore(); 9 | 10 | describe('Check that port action creators generate proper action objects and perform checking', () => { 11 | it('addPort', () => { 12 | const store = mockStore({ 13 | flowDesigner: { 14 | nodes: Map({ nodeId: { id: 'nodeId', nodeType: 'type' } }), 15 | ports: Map(), 16 | }, 17 | }); 18 | 19 | store.dispatch( 20 | portActions.addPort('nodeId', 'portId', { 21 | graphicalAttributes: { 22 | portType: 'test', 23 | properties: { 24 | type: PORT_SINK, 25 | }, 26 | }, 27 | data: { 28 | flowType: 'string', 29 | }, 30 | }), 31 | ); 32 | expect(store.getActions()).toMatchSnapshot(); 33 | }); 34 | 35 | it('setPortGraphicalAttribute', () => { 36 | const store = mockStore({ 37 | flowDesigner: { 38 | ports: Map({ id: { id: 'portId', portType: 'type' } }), 39 | }, 40 | }); 41 | 42 | store.dispatch(portActions.setPortGraphicalAttribute('id', { selected: true })); 43 | 44 | expect(store.getActions()).toMatchSnapshot(); 45 | }); 46 | 47 | it('removePortAttribute', () => { 48 | const store = mockStore({ 49 | flowDesigner: { 50 | ports: Map({ id: { id: 'portId' } }), 51 | }, 52 | }); 53 | 54 | store.dispatch(portActions.removePortGraphicalAttribute('id', 'selected')); 55 | 56 | expect(store.getActions()).toMatchSnapshot(); 57 | }); 58 | 59 | it('setPortData', () => { 60 | const store = mockStore({ 61 | flowDesigner: { 62 | ports: Map({ id: { id: 'portId', portType: 'type' } }), 63 | }, 64 | }); 65 | 66 | store.dispatch(portActions.setPortdata('id', { type: 'test' })); 67 | 68 | expect(store.getActions()).toMatchSnapshot(); 69 | }); 70 | 71 | it('removePortData', () => { 72 | const store = mockStore({ 73 | flowDesigner: { 74 | ports: Map({ id: { id: 'portId' }, data: Map({ type: 'test' }) }), 75 | }, 76 | }); 77 | 78 | store.dispatch(portActions.removePortData('id', 'type')); 79 | 80 | expect(store.getActions()).toMatchSnapshot(); 81 | }); 82 | 83 | it('removePort', () => { 84 | const store = mockStore({ 85 | flowDesigner: { 86 | ports: { portId: { id: 'portId' } }, 87 | }, 88 | }); 89 | 90 | store.dispatch(portActions.removePort('portId')); 91 | expect(store.getActions()).toMatchSnapshot(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/api/data/data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is private and deal with updating a graph object internal Immutable.Map 3 | */ 4 | import curry from 'lodash/curry'; 5 | import isString from 'lodash/isString'; 6 | import { Map } from 'immutable'; 7 | 8 | import { throwInDev, throwTypeError } from '../throwInDev'; 9 | 10 | /** 11 | * return true if the parameter is an Immutable.Map throw otherwise 12 | * @private 13 | * @param {any} map - the value to be checkd as Immutable.Map 14 | * @return {bool} 15 | */ 16 | export function isMapElseThrow(map: Map) { 17 | const test = Map.isMap(map); 18 | if (!test) { 19 | throwTypeError('Immutable.Map', map, 'map'); 20 | } 21 | return test; 22 | } 23 | 24 | /** 25 | * return true if the parameter key is a String throw otherwise 26 | * @private 27 | * @param {any} key - the value to be checked as String 28 | * @return {bool} 29 | */ 30 | export function isKeyElseThrow(key: string | number) { 31 | const test = isString(key); 32 | if (!test) { 33 | throwInDev( 34 | `key should be a string, was given ${key && key.toString()} of type ${typeof key}`, 35 | ); 36 | } 37 | return test; 38 | } 39 | 40 | /** 41 | * given a key and a value, add those to a map 42 | * @function 43 | * @param {string} key 44 | * @param {any} value 45 | * @param {Immutable.Map} map 46 | * @returns {Immutable.Map} 47 | */ 48 | export const set = curry((key: any, value: any, map: Map) => { 49 | if (isKeyElseThrow(key) && isMapElseThrow(map)) { 50 | return map.set(key, value); 51 | } 52 | return map; 53 | }); 54 | 55 | /** 56 | * given a key and a map return the value associated if exist 57 | * @function 58 | * @param {string} key 59 | * @param {Immutable.Map} map 60 | * @returns {any | null} 61 | */ 62 | export const get = curry((key: any, map: Map) => { 63 | if (isKeyElseThrow(key) && isMapElseThrow(map)) { 64 | return map.get(key); 65 | } 66 | return null; 67 | }); 68 | 69 | /** 70 | * Given a key and a map check if this key exist on the map 71 | * @function 72 | * @param {string} key 73 | * @param {Immutable.Map} map 74 | * @return {bool} 75 | */ 76 | export const has = curry((key: any, map: Map) => { 77 | if (isKeyElseThrow(key) && isMapElseThrow(map)) { 78 | return map.has(key); 79 | } 80 | return false; 81 | }); 82 | 83 | /** 84 | * remove given key and its value from the map 85 | * @function 86 | * @param {string} key 87 | * @param {Immutable.Map} map 88 | * @returns {Immutable.Map} 89 | */ 90 | export const deleteKey = curry((key: any, map: Map) => { 91 | if (isKeyElseThrow(key) && isMapElseThrow(map)) { 92 | return map.delete(key); 93 | } 94 | return map; 95 | }); 96 | -------------------------------------------------------------------------------- /src/actions/port.actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Id, 3 | PortData, 4 | PortGraphicalAttributes, 5 | Port, 6 | PortAction, 7 | PortActionAdd, 8 | } from '../customTypings/index.d'; 9 | 10 | /** 11 | * return an action to create a new port 12 | * @param {string} nodeId - identifier of the node to wich the created connector should be attached 13 | * @param {string} id 14 | * @param {Object} attributes 15 | */ 16 | export function addPort( 17 | nodeId: Id, 18 | id: Id, 19 | { 20 | data, 21 | graphicalAttributes, 22 | }: { data?: PortData; graphicalAttributes?: PortGraphicalAttributes }, 23 | ): PortActionAdd { 24 | return { 25 | type: 'FLOWDESIGNER_PORT_ADD', 26 | nodeId, 27 | id, 28 | data, 29 | graphicalAttributes, 30 | }; 31 | } 32 | 33 | /** 34 | * @deprecated 35 | */ 36 | export function addPorts(nodeId: Id, ports: Array): PortAction { 37 | return { 38 | type: 'FLOWDESIGNER_PORT_ADDS', 39 | nodeId, 40 | ports, 41 | }; 42 | } 43 | 44 | /** 45 | * return an action to set port attributes 46 | * @deprecated 47 | * @param {string} portId 48 | * @param {Object} graphicalAttributes 49 | */ 50 | export function setPortGraphicalAttribute(portId: Id, graphicalAttributes: {}): PortAction { 51 | return { 52 | type: 'FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES', 53 | portId, 54 | graphicalAttributes, 55 | }; 56 | } 57 | 58 | /** 59 | * Ask to remove an attribute on target port 60 | * @deprecated 61 | * @param {string} portId 62 | * @param {string} graphicalAttributesKey - the key of the attribute to be removed 63 | */ 64 | export function removePortGraphicalAttribute( 65 | portId: Id, 66 | graphicalAttributesKey: string, 67 | ): PortAction { 68 | return { 69 | type: 'FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES', 70 | portId, 71 | graphicalAttributesKey, 72 | }; 73 | } 74 | 75 | /** 76 | * return an action to set port attributes 77 | * @deprecated 78 | * @param {string} portId 79 | * @param {Object} graphicalAttributes 80 | */ 81 | export function setPortdata(portId: Id, data: Object): PortAction { 82 | return { 83 | type: 'FLOWDESIGNER_PORT_SET_DATA', 84 | portId, 85 | data, 86 | }; 87 | } 88 | 89 | /** 90 | * Ask to remove an attribute on target port 91 | * @deprecated 92 | * @param {string} portId 93 | * @param {string} datasKey - the key of the attribute to be removed 94 | */ 95 | export function removePortData(portId: Id, dataKey: string): PortAction { 96 | return { 97 | type: 'FLOWDESIGNER_PORT_REMOVE_DATA', 98 | portId, 99 | dataKey, 100 | }; 101 | } 102 | 103 | /** 104 | * return an action to remove port and all attached links 105 | * @deprecated use deletePort action 106 | * @param {string} portId 107 | */ 108 | export function removePort(portId: Id): PortAction { 109 | return { 110 | type: 'FLOWDESIGNER_PORT_REMOVE', 111 | portId, 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /src/components/node/AbstractNode.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import { 5 | NodeGraphicalAttributes, 6 | NodeRecord, 7 | PositionRecord, 8 | SizeRecord, 9 | } from '../../constants/flowdesigner.model'; 10 | import AbstractNode, { ABSTRACT_NODE_INVARIANT } from './AbstractNode.component'; 11 | 12 | const node = new NodeRecord({ 13 | id: 'id', 14 | graphicalAttributes: new NodeGraphicalAttributes({ 15 | position: new PositionRecord({ x: 100, y: 50 }), 16 | nodeSize: new SizeRecord({ width: 125, height: 75 }), 17 | }), 18 | }); 19 | 20 | function noOp() {} 21 | 22 | describe('Testing ', () => { 23 | it('should create a bare node component with provided position', () => { 24 | const wrapper = mount( 25 | 26 | 27 | , 28 | ); 29 | const rect = wrapper.find('g[transform]'); 30 | expect(rect.prop('transform')).toBe('translate(100, 50)'); 31 | }); 32 | 33 | it('call the injected onClick action when clicked', () => { 34 | const onClick = jest.fn(); 35 | const wrapper = mount( 36 | 43 | 44 | , 45 | ); 46 | wrapper.find('g[transform]').simulate('click'); 47 | expect(onClick).toHaveBeenCalledTimes(1); 48 | }); 49 | 50 | // if anyone got a clue on how to test react + d3 events 51 | 52 | xit('call the injected onDragStart action when drag action start', done => { 53 | const evt = document.createEvent('HTMLEvents'); 54 | evt.initEvent('click', false, true); 55 | const onDragStart = jest.fn(); 56 | mount( 57 | 64 | 65 | , 66 | { attachTo: document.body }, 67 | ); 68 | 69 | const element = document.querySelector('g g') || new HTMLDivElement(); 70 | 71 | element.addEventListener('click', () => { 72 | done(); 73 | }); 74 | element.dispatchEvent(new window.MouseEvent('click')); 75 | expect(onDragStart.mock.calls.length).toEqual(1); 76 | fail(); 77 | }); 78 | 79 | xit('call the injected onDrag action when drag action start', () => { 80 | fail(); 81 | }); 82 | 83 | xit('call the injected onDragEnd action when drag action start', () => { 84 | fail(); 85 | }); 86 | 87 | it('should fire an error if its rendered without a children set up', () => { 88 | expect(() => { 89 | shallow( 90 | , 96 | ); 97 | }).toThrowError(ABSTRACT_NODE_INVARIANT); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/components/link/AbstractLink.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { line, curveBasis, interpolateBasis } from 'd3'; 4 | 5 | import LinkHandle from './LinkHandle.component'; 6 | import { Position, PortRecord } from '../../customTypings/index.d'; 7 | 8 | const concreteLine = line() 9 | .x((d: any) => d.x) 10 | .y((d: any) => d.y) 11 | .curve(curveBasis); 12 | 13 | function calculatePath(sourcePosition: Position, targetPosition: Position) { 14 | const pathCoords: Position[] = []; 15 | pathCoords[0] = targetPosition; 16 | pathCoords[1] = sourcePosition; 17 | const xInterpolate = interpolateBasis([targetPosition.x, pathCoords[1].x]); 18 | const yInterpolate = interpolateBasis([targetPosition.y, pathCoords[1].y]); 19 | const path = concreteLine(pathCoords as any); 20 | return { path, xInterpolate, yInterpolate }; 21 | } 22 | 23 | type Props = { 24 | source: PortRecord; 25 | target: PortRecord; 26 | targetHandlePosition: Position; 27 | calculatePath: ( 28 | sourcePosition: Position, 29 | targetPosition: Position, 30 | ) => { path: any; xInterpolate: number; yInterpolate: number }; 31 | onSourceDrag?: (event: any) => void; 32 | onSourceDragEnd?: (event: any) => void; 33 | onTargetDrag?: (event: any) => void; 34 | onTargetDragEnd?: (event: any) => void; 35 | children?: any; 36 | linkSourceHandleComponent?: React.ReactElement; 37 | sourceHandlePosition?: Position; 38 | linkTargetHandleComponent?: React.ReactElement; 39 | }; 40 | 41 | class AbstractLink extends React.PureComponent { 42 | static calculatePath = calculatePath; 43 | 44 | renderLinkSourceHandle() { 45 | if (this.props.linkSourceHandleComponent) { 46 | return ( 47 | 53 | ); 54 | } 55 | return null; 56 | } 57 | 58 | renderLinkTargetHandle() { 59 | if (this.props.linkTargetHandleComponent) { 60 | return ( 61 | 67 | ); 68 | } 69 | return null; 70 | } 71 | 72 | render() { 73 | const pathCalculationMethod = this.props.calculatePath || AbstractLink.calculatePath; 74 | const { path, xInterpolate, yInterpolate } = pathCalculationMethod( 75 | this.props.source.getPosition(), 76 | this.props.targetHandlePosition || this.props.target.getPosition(), 77 | ); 78 | const newChildren = React.Children.map(this.props.children, child => 79 | React.cloneElement(child, { d: path, xInterpolate, yInterpolate }), 80 | ); 81 | return ( 82 | 83 | {newChildren} 84 | {this.renderLinkSourceHandle()} 85 | {this.renderLinkTargetHandle()} 86 | 87 | ); 88 | } 89 | } 90 | 91 | export default AbstractLink; 92 | -------------------------------------------------------------------------------- /src/constants/flowdesigner.constants.ts: -------------------------------------------------------------------------------- 1 | export const FLOWDESIGNER_FLOW_LOAD = 'FLOWDESIGNER.FLOW_LOAD'; 2 | export const FLOWDESIGNER_FLOW_RESET = 'FLOWDESIGNER.FLOW_RESET'; 3 | export const FLOWDESIGNER_FLOW_ADD_ELEMENTS = 'FLOWDESIGNER.FLOW_ADD_ELEMENTS'; 4 | export const FLOWDESIGNER_FLOW_SET_ZOOM = 'FLOWDESIGNER.FLOW_SET_ZOOM'; 5 | export const FLOWDESIGNER_FLOW_ZOOM_IN = 'FLOWDESIGNER.FLOW_ZOOM_IN'; 6 | export const FLOWDESIGNER_FLOW_ZOOM_OUT = 'FLOWDESIGNER.FLOW_ZOOM_OUT'; 7 | export const FLOWDESIGNER_PAN_TO = 'FLOWDESIGNER_PAN_TO'; 8 | 9 | export const FLOWDESIGNER_NODE_ADD = 'FLOWDESIGNER_NODE_ADD'; 10 | export const FLOWDESIGNER_NODE_MOVE_START = 'FLOWDESIGNER_NODE_MOVE_START'; 11 | export const FLOWDESIGNER_NODE_MOVE = 'FLOWDESIGNER_NODE_MOVE'; 12 | export const FLOWDESIGNER_NODE_APPLY_MOVEMENT = 'FLOWDESIGNER_NODE_APPLY_MOVEMENT'; 13 | export const FLOWDESIGNER_NODE_MOVE_END = 'FLOWDESIGNER_NODE_MOVE_END'; 14 | export const FLOWDESIGNER_NODE_SET_SIZE = 'FLOWDESIGNER_NODE_SET_SIZE'; 15 | export const FLOWDESIGNER_NODE_SET_TYPE = 'FLOWDESIGNER_NODE_SET_TYPE'; 16 | export const FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES = 17 | 'FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES'; 18 | export const FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES = 19 | 'FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES'; 20 | export const FLOWDESIGNER_NODE_SET_DATA = 'FLOWDESIGNER_NODE_SET_DATA'; 21 | export const FLOWDESIGNER_NODE_REMOVE_DATA = 'FLOWDESIGNER_NODE_REMOVE_DATA'; 22 | export const FLOWDESIGNER_NODE_REMOVE = 'FLOWDESIGNER_NODE_REMOVE'; 23 | export const FLOWDESIGNER_NODE_UPDATE = 'FLOWDESIGNER_NODE_UPDATE'; 24 | 25 | export const FLOWDESIGNER_NODETYPE_SET = 'FLOWDESIGNER_NODETYPE_SET'; 26 | 27 | export const FLOWDESIGNER_PORT_ADD = 'FLOWDESIGNER_PORT_ADD'; 28 | export const FLOWDESIGNER_PORT_ADDS = 'FLOWDESIGNER_PORT_ADDS'; 29 | export const FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES = 30 | 'FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES'; 31 | export const FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES = 32 | 'FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES'; 33 | export const FLOWDESIGNER_PORT_SET_DATA = 'FLOWDESIGNER_PORT_SET_DATA'; 34 | export const FLOWDESIGNER_PORT_REMOVE_DATA = 'FLOWDESIGNER_PORT_REMOVE_DATA'; 35 | export const FLOWDESIGNER_PORT_REMOVE = 'FLOWDESIGNER_PORT_REMOVE'; 36 | 37 | export const FLOWDESIGNER_LINK_ADD = 'FLOWDESIGNER_LINK_ADD'; 38 | export const FLOWDESIGNER_LINK_SET_TARGET = 'FLOWDESIGNER_LINK_SET_TARGET'; 39 | export const FLOWDESIGNER_LINK_SET_SOURCE = 'FLOWDESIGNER_LINK_SET_SOURCE'; 40 | export const FLOWDESIGNER_LINK_REMOVE = 'FLOWDESIGNER_LINK_REMOVE'; 41 | export const FLOWDESIGNER_LINK_SET_GRAPHICAL_ATTRIBUTES = 42 | 'FLOWDESIGNER_LINK_SET_GRAPHICAL_ATTRIBUTES'; 43 | export const FLOWDESIGNER_LINK_REMOVE_GRAPHICAL_ATTRIBUTES = 44 | 'FLOWDESIGNER_LINK_REMOVE_GRAPHICAL_ATTRIBUTES'; 45 | export const FLOWDESIGNER_LINK_SET_DATA = 'FLOWDESIGNER_LINK_SET_DATA'; 46 | export const FLOWDESIGNER_LINK_REMOVE_DATA = 'FLOWDESIGNER_LINK_REMOVE_DATA'; 47 | 48 | export const PORT_SOURCE = 'OUTGOING'; 49 | export const PORT_SINK = 'INCOMING'; 50 | 51 | export const GRID_SIZE = 40; 52 | -------------------------------------------------------------------------------- /src/actions/link.actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FLOWDESIGNER_LINK_ADD, 3 | FLOWDESIGNER_LINK_SET_TARGET, 4 | FLOWDESIGNER_LINK_SET_SOURCE, 5 | FLOWDESIGNER_LINK_REMOVE, 6 | FLOWDESIGNER_LINK_SET_GRAPHICAL_ATTRIBUTES, 7 | FLOWDESIGNER_LINK_REMOVE_GRAPHICAL_ATTRIBUTES, 8 | FLOWDESIGNER_LINK_SET_DATA, 9 | FLOWDESIGNER_LINK_REMOVE_DATA, 10 | } from '../constants/flowdesigner.constants'; 11 | 12 | /** 13 | * Ask for link creation 14 | * @param {string} linkId 15 | * @param {string} sourceId - the source port Identifier 16 | * @param {string} targetId - the target port Identifier 17 | * @param {Object} attr 18 | */ 19 | export const addLink = ( 20 | linkId: string, 21 | sourceId: string, 22 | targetId: string, 23 | { data = {}, graphicalAttributes = {} }: any = {}, 24 | ) => ({ 25 | type: FLOWDESIGNER_LINK_ADD, 26 | linkId, 27 | sourceId, 28 | targetId, 29 | data, 30 | graphicalAttributes, 31 | }); 32 | 33 | /** 34 | * Ask for change of link target 35 | * @deprecated 36 | * @param {string} linkId 37 | * @param {string} targetId - the target port identifier 38 | */ 39 | export const setLinkTarget = (linkId: string, targetId: string) => ({ 40 | type: FLOWDESIGNER_LINK_SET_TARGET, 41 | linkId, 42 | targetId, 43 | }); 44 | 45 | /** 46 | * Ask for change of link source 47 | * @deprecated 48 | * @param {string} linkId 49 | * @param {string} sourceId - the source port identifier 50 | */ 51 | export const setLinkSource = (linkId: string, sourceId: string) => ({ 52 | type: FLOWDESIGNER_LINK_SET_SOURCE, 53 | linkId, 54 | sourceId, 55 | }); 56 | 57 | /** 58 | * Ask to set graphical attributes on link 59 | * @deprecated 60 | * @param {string} linkId 61 | * @param {Object} attr 62 | */ 63 | export const setLinkGraphicalAttributes = (linkId: string, graphicalAttributes: any) => ({ 64 | type: FLOWDESIGNER_LINK_SET_GRAPHICAL_ATTRIBUTES, 65 | linkId, 66 | graphicalAttributes, 67 | }); 68 | 69 | /** 70 | * Ask to remove an graphical attribute on target link 71 | * @deprecated 72 | * @param {string} linkId 73 | * @param {string} attrKey - the key of the attribute to be removed 74 | */ 75 | export const removeLinkGraphicalAttribute = (linkId: string, graphicalAttributesKey: any) => ({ 76 | type: FLOWDESIGNER_LINK_REMOVE_GRAPHICAL_ATTRIBUTES, 77 | linkId, 78 | graphicalAttributesKey, 79 | }); 80 | 81 | /** 82 | * Ask to set data on link 83 | * @deprecated 84 | * @param {string} linkId 85 | * @param {Object} attr 86 | */ 87 | export const setLinkData = (linkId: string, data: any) => ({ 88 | type: FLOWDESIGNER_LINK_SET_DATA, 89 | linkId, 90 | data, 91 | }); 92 | 93 | /** 94 | * Ask to remove a data on target link 95 | * @deprecated 96 | * @param {string} linkId 97 | * @param {string} attrKey - the key of the attribute to be removed 98 | */ 99 | export const removeLinkData = (linkId: string, dataKey: any) => ({ 100 | type: FLOWDESIGNER_LINK_REMOVE_DATA, 101 | linkId, 102 | dataKey, 103 | }); 104 | 105 | /** 106 | * Ask for link removal 107 | * @deprecated use deleteLink action 108 | * @param {string} linkId 109 | */ 110 | export const removeLink = (linkId: string) => ({ 111 | type: FLOWDESIGNER_LINK_REMOVE, 112 | linkId, 113 | }); 114 | -------------------------------------------------------------------------------- /src/actions/__snapshots__/node.actions.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Check that node action creators generate proper action objects and perform checking addNode generate action with 0 configuration 1`] = ` 4 | Array [ 5 | Object { 6 | "data": Object {}, 7 | "graphicalAttributes": Object { 8 | "nodePosition": Object { 9 | "x": 75, 10 | "y": 75, 11 | }, 12 | "nodeSize": Object { 13 | "height": 50, 14 | "width": 50, 15 | }, 16 | "nodeType": "nodeType", 17 | }, 18 | "nodeId": "id", 19 | "nodeType": "type", 20 | "type": "FLOWDESIGNER_NODE_ADD", 21 | }, 22 | ] 23 | `; 24 | 25 | exports[`Check that node action creators generate proper action objects and perform checking moveNode generate a proper action object witch nodeId and nodePosition parameter 1`] = ` 26 | Array [ 27 | Object { 28 | "nodeId": "nodeId", 29 | "nodePosition": Object { 30 | "x": 10, 31 | "y": 20, 32 | }, 33 | "type": "FLOWDESIGNER_NODE_MOVE", 34 | }, 35 | ] 36 | `; 37 | 38 | exports[`Check that node action creators generate proper action objects and perform checking removeNode 1`] = ` 39 | Array [ 40 | Object { 41 | "nodeId": "id", 42 | "type": "FLOWDESIGNER_NODE_REMOVE", 43 | }, 44 | ] 45 | `; 46 | 47 | exports[`Check that node action creators generate proper action objects and perform checking removeNodeData 1`] = ` 48 | Array [ 49 | Object { 50 | "dataKey": "type", 51 | "nodeId": "id", 52 | "type": "FLOWDESIGNER_NODE_REMOVE_DATA", 53 | }, 54 | ] 55 | `; 56 | 57 | exports[`Check that node action creators generate proper action objects and perform checking removeNodeGraphicalAttribute 1`] = ` 58 | Array [ 59 | Object { 60 | "graphicalAttributesKey": "selected", 61 | "nodeId": "id", 62 | "type": "FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES", 63 | }, 64 | ] 65 | `; 66 | 67 | exports[`Check that node action creators generate proper action objects and perform checking setNodeData 1`] = ` 68 | Array [ 69 | Object { 70 | "bySubmit": false, 71 | "data": Object { 72 | "type": "test", 73 | }, 74 | "nodeId": "id", 75 | "type": "FLOWDESIGNER_NODE_SET_DATA", 76 | }, 77 | ] 78 | `; 79 | 80 | exports[`Check that node action creators generate proper action objects and perform checking setNodeGraphicalAttributes 1`] = ` 81 | Array [ 82 | Object { 83 | "graphicalAttributes": Object { 84 | "selected": true, 85 | }, 86 | "nodeId": "id", 87 | "type": "FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES", 88 | }, 89 | ] 90 | `; 91 | 92 | exports[`Check that node action creators generate proper action objects and perform checking setNodeSize 1`] = ` 93 | Array [ 94 | Object { 95 | "nodeId": "nodeId", 96 | "nodeSize": Object { 97 | "height": 100, 98 | "width": 100, 99 | }, 100 | "type": "FLOWDESIGNER_NODE_SET_SIZE", 101 | }, 102 | ] 103 | `; 104 | 105 | exports[`applyMovementTo generate proper action 1`] = ` 106 | Object { 107 | "movement": Object { 108 | "x": 10, 109 | "y": 5, 110 | }, 111 | "nodesId": Array [ 112 | 1, 113 | 2, 114 | 3, 115 | ], 116 | "type": "FLOWDESIGNER_NODE_APPLY_MOVEMENT", 117 | } 118 | `; 119 | -------------------------------------------------------------------------------- /src/api/size/size.ts: -------------------------------------------------------------------------------- 1 | import curry from 'lodash/curry'; 2 | import flow from 'lodash/flow'; 3 | 4 | import { throwInDev, throwTypeError } from '../throwInDev'; 5 | import { SizeRecord } from '../../constants/flowdesigner.model'; 6 | import { SizeRecord as SizeRecordType } from '../../customTypings/index.d'; 7 | 8 | /** 9 | * @desc Represent a size comprised of width and height 10 | * avoid reading directly, use the Size module api 11 | * Do not mutate it manually, use the Size module api 12 | * @example Create a Size 13 | * const size = Size.create(100, 200); 14 | * @example Read from Size 15 | * const width = Size.getWidth(size); 16 | * @example transform a Size 17 | * const size = Size.setWidth(100, size); 18 | * @typedef {Immutable.Record} SizeRecord 19 | */ 20 | 21 | /** 22 | * check if parameter is SizeRecord 23 | * @param {*} size 24 | * @return {bool} 25 | */ 26 | export function isSize(size: SizeRecordType) { 27 | if (size && size instanceof SizeRecord) { 28 | return true; 29 | } 30 | return false; 31 | } 32 | 33 | /** 34 | * check if parameter is SizeRecord else throw in dev mode 35 | * @param {*} size 36 | * @return {bool} 37 | */ 38 | export function isSizeElseThrow(size: SizeRecordType) { 39 | const test = isSize(size); 40 | if (!test) { 41 | throwTypeError('SizeRecord', size, 'Size'); 42 | } 43 | return test; 44 | } 45 | 46 | /** 47 | * return width of SizeRecord 48 | * @param {SizeRecord} size 49 | * @return {number} 50 | */ 51 | export function getWidth(size: SizeRecordType) { 52 | if (isSizeElseThrow(size)) { 53 | return size.get('width'); 54 | } 55 | return null; 56 | } 57 | 58 | /** 59 | * set width of given SizeRecord 60 | * @function 61 | * @param {number} width 62 | * @param {SizeRecord} size 63 | * @return {SizeRecord} 64 | */ 65 | export const setWidth = curry((width: number, size: SizeRecordType) => { 66 | if (isSizeElseThrow(size) && typeof width === 'number') { 67 | return size.set('width', width); 68 | } 69 | throwInDev(`width should be a number, was given ${width.toString()} of type ${typeof width}`); 70 | return size; 71 | }); 72 | 73 | /** 74 | * return height of the SizeRecord 75 | * @param {SizeRecord} size 76 | * @return {number} 77 | */ 78 | export function getHeight(size: SizeRecordType) { 79 | if (isSizeElseThrow(size)) { 80 | return size.get('height'); 81 | } 82 | return null; 83 | } 84 | 85 | /** 86 | * set height of given SizeRecord 87 | * @function 88 | * @param {number} height 89 | * @param {SizeRecord} size 90 | * @returns {SizeRecord} 91 | */ 92 | export const setHeight = curry((height: number, size: SizeRecordType) => { 93 | if (isSizeElseThrow(size) && typeof height === 'number') { 94 | return size.set('height', height); 95 | } 96 | throwInDev( 97 | `height should be a number, was given ${height.toString()} of type ${typeof height}`, 98 | ); 99 | return size; 100 | }); 101 | 102 | /** 103 | * given width and height create a SizeRecord 104 | * @function 105 | * @param {number} width 106 | * @param {number} height 107 | * @return {SizeRecord} 108 | */ 109 | export const create = curry((width: number, height: number) => 110 | flow([setWidth(width), setHeight(height)])(new SizeRecord()), 111 | ); 112 | -------------------------------------------------------------------------------- /src/actions/link.actions.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import configureMockStore from 'redux-mock-store'; 3 | import thunk from 'redux-thunk'; 4 | import { Map } from 'immutable'; 5 | 6 | import * as linkActions from './link.actions'; 7 | 8 | const middlewares = [thunk]; 9 | const mockStore = configureMockStore(middlewares); 10 | 11 | describe('Check that link action creators generate proper action objects and perform checking', () => { 12 | it('addLink', () => { 13 | const store = mockStore({ 14 | flowDesigner: { 15 | links: Map(), 16 | ports: Map({ 17 | id1: { id: 'portId', portType: 'type' }, 18 | id2: { id: 'portId', portType: 'type' }, 19 | }), 20 | }, 21 | }); 22 | 23 | store.dispatch( 24 | linkActions.addLink('linkId', 'sourceId', 'targetId', { 25 | graphicalAttributes: { selected: true }, 26 | }), 27 | ); 28 | expect(store.getActions()).toMatchSnapshot(); 29 | }); 30 | 31 | it('setLinkTarget', () => { 32 | const store = mockStore({ 33 | flowDesigner: { 34 | links: Map({ linkId: { id: 'linkId' } }), 35 | ports: Map({ id1: { id: 'portId', portType: 'type' } }), 36 | }, 37 | }); 38 | 39 | store.dispatch(linkActions.setLinkTarget('linkId', 'portId')); 40 | 41 | expect(store.getActions()).toMatchSnapshot(); 42 | }); 43 | 44 | it('setLinkSource', () => { 45 | const store = mockStore({ 46 | flowDesigner: { 47 | links: Map({ linkId: { id: 'linkId' } }), 48 | ports: Map({ id1: { id: 'portId', portType: 'type' } }), 49 | }, 50 | }); 51 | 52 | store.dispatch(linkActions.setLinkSource('linkId', 'portId')); 53 | 54 | expect(store.getActions()).toMatchSnapshot(); 55 | }); 56 | 57 | it('setLinkGraphicalAttributes', () => { 58 | const store = mockStore({ 59 | flowDesigner: { 60 | links: Map({ id: { id: 'linkId', linkType: 'type' } }), 61 | }, 62 | }); 63 | 64 | store.dispatch(linkActions.setLinkGraphicalAttributes('id', { selected: true })); 65 | 66 | expect(store.getActions()).toMatchSnapshot(); 67 | }); 68 | 69 | it('removeLinkGrahicalAttribute', () => { 70 | const store = mockStore({ 71 | flowDesigner: { 72 | links: Map({ id: { id: 'linkId', linkType: 'type' } }), 73 | }, 74 | }); 75 | 76 | store.dispatch(linkActions.removeLinkGraphicalAttribute('id', 'selected')); 77 | 78 | expect(store.getActions()).toMatchSnapshot(); 79 | }); 80 | 81 | it('setLinkData', () => { 82 | const store = mockStore({ 83 | flowDesigner: { 84 | links: Map({ id: { id: 'linkId', linkType: 'type' } }), 85 | }, 86 | }); 87 | 88 | store.dispatch(linkActions.setLinkData('id', { type: 'test' })); 89 | 90 | expect(store.getActions()).toMatchSnapshot(); 91 | }); 92 | 93 | it('removeLinkData', () => { 94 | const store = mockStore({ 95 | flowDesigner: { 96 | links: Map({ id: { id: 'linkId', linkType: 'type' } }), 97 | }, 98 | }); 99 | 100 | store.dispatch(linkActions.removeLinkData('id', 'type')); 101 | 102 | expect(store.getActions()).toMatchSnapshot(); 103 | }); 104 | 105 | it('removeLink', () => { 106 | const store = mockStore({ 107 | flowDesigner: { 108 | links: Map({ id: { id: 'linkId' } }), 109 | }, 110 | }); 111 | 112 | store.dispatch(linkActions.removeLink('id')); 113 | expect(store.getActions()).toMatchSnapshot(); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/api/position/position.ts: -------------------------------------------------------------------------------- 1 | import curry from 'lodash/curry'; 2 | import flow from 'lodash/flow'; 3 | import isNumber from 'lodash/isNumber'; 4 | 5 | import { throwInDev, throwTypeError } from '../throwInDev'; 6 | import { PositionRecord } from '../../constants/flowdesigner.model'; 7 | import { PositionRecord as PositionRecordType } from '../../customTypings/index.d'; 8 | 9 | /** 10 | * @desc Represent a position comprised of X and Y coordinates 11 | * avoid reading directly, use the Size module api 12 | * Do not mutate it manually, use the Size module api 13 | * @example Create a Position 14 | * const position = Position.create(100, 200); 15 | * @example Read from Position 16 | * const x = Size.getXCoordinate(position); 17 | * @example transform a Position 18 | * const position = Size.setXCoordinate(100, position); 19 | * @typedef {Immutable.Record} PositionRecord 20 | */ 21 | 22 | /** 23 | * given a parameter check if it is a PositionRecord 24 | * @param {*} position 25 | * @return {bool} 26 | */ 27 | export function isPosition(position: PositionRecordType) { 28 | if (position && position instanceof PositionRecord) { 29 | return true; 30 | } 31 | return false; 32 | } 33 | 34 | /** 35 | * given a parameter check if it is a PositionRecord, if not throw in developpement 36 | * @param {*} position 37 | * @return {bool} 38 | */ 39 | export function isPositionElseThrow(position: PositionRecordType) { 40 | const test = isPosition(position); 41 | if (!test) { 42 | throwTypeError('PositionRecord', position, 'Position'); 43 | } 44 | return test; 45 | } 46 | 47 | /** 48 | * given a PositionRecord return X coordinate 49 | * @param {PositionRecord} position 50 | * @return {number} 51 | */ 52 | export function getXCoordinate(position: PositionRecordType) { 53 | if (isPositionElseThrow(position)) { 54 | return position.get('x'); 55 | } 56 | return null; 57 | } 58 | 59 | /** 60 | * given a number and a PositionRecord return updated PositionRecord 61 | * @function 62 | * @param {number} x 63 | * @param {PositionRecord} position 64 | * @return {PositionRecord} 65 | */ 66 | export const setXCoordinate = curry((x: number, position: PositionRecordType) => { 67 | if (isPositionElseThrow(position) && isNumber(x)) { 68 | return position.set('x', x); 69 | } 70 | throwInDev(`x should be a number, was given ${x && x.toString()} of type ${typeof x}`); 71 | return position; 72 | }); 73 | 74 | /** 75 | * given a PositionRecord return the Y coordinate 76 | * @param {PositionRecord} position 77 | * @return {number} 78 | */ 79 | export function getYCoordinate(position: PositionRecordType) { 80 | if (isPositionElseThrow(position)) { 81 | return position.get('y'); 82 | } 83 | return null; 84 | } 85 | 86 | /** 87 | * given a number and a PositionRecord return updated PositionRecord 88 | * @param {number} y 89 | * @param {PositionRecord} position 90 | * @return {PositionRecord} 91 | */ 92 | export const setYCoordinate = curry((y: number, position: PositionRecordType) => { 93 | if (isPositionElseThrow(position) && isNumber(y)) { 94 | return position.set('y', y); 95 | } 96 | throwInDev(`y should be a number, was given ${y && y.toString()} of type ${typeof y}`); 97 | return position; 98 | }); 99 | 100 | /** 101 | * given x and y coordinate return a PositionRecord 102 | * @param {number} x 103 | * @param {number} y 104 | * @return {PositionRecord} 105 | */ 106 | export const create = curry((x: number, y: number) => 107 | flow([setXCoordinate(x), setYCoordinate(y)])(new PositionRecord()), 108 | ); 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The code has been moved to https://github.com/Talend/ui 2 | 3 | 4 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/1391fe51ad7e4a409f9bdb7df0ad7754)](https://www.codacy.com/app/Talend/react-flow-designer_2?utm_source=github.com&utm_medium=referral&utm_content=Talend/react-flow-designer&utm_campaign=badger) 5 | [![Build Status](https://travis-ci.org/Talend/react-flow-designer.svg?branch=master)](https://travis-ci.org/Talend/react-flow-designer.svg?branch=master) 6 | 7 | [![dependencies Status](https://david-dm.org/acateland/react-flow-designer/status.svg)](https://david-dm.org/acateland/react-flow-designer) 8 | 9 | [![Coverage Status](https://coveralls.io/repos/github/acateland/react-flow-designer/badge.svg)](https://coveralls.io/github/acateland/react-flow-designer) 10 | 11 | # Datastream Designer 12 | 13 | Use D3 for calculations. 14 | Redux is used as default state manager, still underlying pure component has been exposed to be used in a non-opiniated maner. 15 | 16 | ## Designed inside dataflow webapp but meant to be used as a module. 17 | 18 | ### How to use it 19 | 20 | #### Use the rendering component 21 | 22 | ```js 23 | import React from 'react'; 24 | import { render } from 'react-dom'; 25 | import { Provider } from 'react-redux'; 26 | import configureStore from './store/configureStore'; 27 | 28 | import { DatastreamDesigner } from './datastream_designer/'; 29 | 30 | const store = configureStore(); 31 | 32 | render( 33 | 34 | 35 | , 36 | document.getElementById('app'), 37 | ); 38 | ``` 39 | 40 | #### integrate the reducer into your redux data store 41 | 42 | ```js 43 | import { combineReducers } from 'redux'; 44 | import { routerReducer } from 'react-router-redux'; 45 | 46 | import { datastreamDesignerReducer } from '../datastream_designer/'; 47 | 48 | const rootReducer = combineReducers({ 49 | routing: routerReducer, 50 | datastream: datastreamDesignerReducer, 51 | }); 52 | 53 | export default rootReducer; 54 | ``` 55 | 56 | the datastream_designer module expose its components, reducers, and action type constants. 57 | 58 | Action type constants are exposed for the sake of listening to them and add new feature to your application arround the datastream designer. 59 | 60 | Exemple a reducer listening for 'DATASTREAM_DESIGNER_NODE_SELECTED' could trigger a form so you can edit the node data. 61 | 62 | ## Redux API 63 | 64 | the idea is to reduce the surface api of the redux action, encouraging batching multiple transformation in a transaction 65 | 66 | ### Graph 67 | 68 | - Graph 69 | - transaction [List>] 70 | - Node 71 | - add NodeRecord 72 | - update NodeRecord 73 | - delete NodeRecord 74 | - moveStart nodeId Position 75 | - move nodeId Vector 76 | - moveEnd nodeId Position 77 | - Link 78 | - add LinkRecord 79 | - update LinkRecord 80 | - delete LinkRecord 81 | - Port 82 | - add PortRecord 83 | - update PortRecord 84 | - delete PortRecord 85 | 86 | each of those action are intended to be used with the apply function 87 | 88 | Each of those action are backed by the graph API wich check graph integrity, if one action fail to apply the whole transaction is void and the original graph is returned, one or many errors are logged. 89 | 90 | special action for movement are kept for optimisation purpose, nothing prevent the user to update position via the `update` action 91 | 92 | #### deprecate 93 | 94 | removeNode 95 | removeNodeData 96 | setNodeData 97 | removeNodeGraphicalAttribute 98 | setNodeGraphicalAttributes 99 | setNodeType 100 | setNodeSize 101 | moveNodeToEnd 102 | applyMovementTo 103 | moveNodeTo 104 | startMoveNodeTo 105 | 106 | ## Element API 107 | 108 | ### Node 109 | 110 | ### Link 111 | 112 | ### Port 113 | 114 | ### Graph 115 | 116 | ### Data 117 | 118 | ### Size 119 | 120 | ### Position 121 | 122 | ## Versioning and publication 123 | 124 | The package is **automatically** published on npm as soon as a **pull request is merged on the master with a different version**. 125 | 126 | DISCLAIMER: **We don't control the version**, you have to ensure you don't have an old version compared to the master, and you need to follow [semantic versioning](https://semver.org/) to upgrade the version when needed. 127 | -------------------------------------------------------------------------------- /src/actions/node.actions.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import configureMockStore from 'redux-mock-store'; 3 | import thunk from 'redux-thunk'; 4 | import { Map, OrderedMap } from 'immutable'; 5 | 6 | import * as nodeActions from './node.actions'; 7 | import { FLOWDESIGNER_NODE_SET_TYPE } from '../constants/flowdesigner.constants'; 8 | 9 | const middlewares = [thunk]; 10 | const mockStore = configureMockStore(middlewares); 11 | 12 | describe('Check that node action creators generate proper action objects and perform checking', () => { 13 | it('addNode generate action with 0 configuration', () => { 14 | const store = mockStore({ 15 | flowDesigner: { 16 | nodes: Map({}), 17 | }, 18 | }); 19 | 20 | store.dispatch( 21 | nodeActions.addNode('id', 'type', { 22 | data: {}, 23 | graphicalAttributes: { 24 | nodePosition: { x: 75, y: 75 }, 25 | nodeSize: { width: 50, height: 50 }, 26 | nodeType: 'nodeType', 27 | }, 28 | }), 29 | ); 30 | 31 | expect(store.getActions()).toMatchSnapshot(); 32 | }); 33 | 34 | it('moveNode generate a proper action object witch nodeId and nodePosition parameter', () => { 35 | const store = mockStore({ 36 | flowDesigner: { 37 | nodes: Map({ nodeId: { id: 'nodeId', type: 'type' } }), 38 | nodeTypes: Map({ 39 | type: Map({ 40 | component: { calculatePortPosition: () => ({}) }, 41 | }), 42 | }), 43 | // eslint-disable-next-line new-cap 44 | ports: OrderedMap(), 45 | }, 46 | }); 47 | 48 | store.dispatch(nodeActions.moveNodeTo('nodeId', { x: 10, y: 20 })); 49 | 50 | expect(store.getActions()).toMatchSnapshot(); 51 | }); 52 | 53 | it('setNodeSize', () => { 54 | const store = mockStore({ 55 | flowDesigner: { 56 | nodes: Map({ nodeId: { id: 'nodeId', type: 'type' } }), 57 | }, 58 | }); 59 | 60 | store.dispatch(nodeActions.setNodeSize('nodeId', { width: 100, height: 100 })); 61 | expect(store.getActions()).toMatchSnapshot(); 62 | }); 63 | 64 | it('setNodeType', () => { 65 | const nodeId = 'nodeId'; 66 | const nodeType = 'newNodeType'; 67 | const store = mockStore({ 68 | flowDesigner: { 69 | nodes: Map({ nodeId: { id: nodeId, type: 'type' } }), 70 | }, 71 | }); 72 | 73 | store.dispatch(nodeActions.setNodeType(nodeId, nodeType)); 74 | expect(store.getActions()[0]).toEqual({ 75 | type: FLOWDESIGNER_NODE_SET_TYPE, 76 | nodeId, 77 | nodeType, 78 | }); 79 | }); 80 | 81 | it('setNodeGraphicalAttributes', () => { 82 | const store = mockStore({ 83 | flowDesigner: { 84 | nodes: Map({ id: { id: 'nodeId', type: 'type' } }), 85 | }, 86 | }); 87 | 88 | store.dispatch(nodeActions.setNodeGraphicalAttributes('id', { selected: true })); 89 | 90 | expect(store.getActions()).toMatchSnapshot(); 91 | }); 92 | 93 | it('removeNodeGraphicalAttribute', () => { 94 | const store = mockStore({ 95 | flowDesigner: { 96 | nodes: Map({ id: { id: 'nodeId', type: 'type' } }), 97 | }, 98 | }); 99 | 100 | store.dispatch(nodeActions.removeNodeGraphicalAttribute('id', 'selected')); 101 | 102 | expect(store.getActions()).toMatchSnapshot(); 103 | }); 104 | 105 | it('setNodeData', () => { 106 | const store = mockStore({ 107 | flowDesigner: { 108 | nodes: Map({ id: { id: 'nodeId', type: 'type' } }), 109 | }, 110 | }); 111 | 112 | store.dispatch(nodeActions.setNodeData('id', { type: 'test' }, false)); 113 | 114 | expect(store.getActions()).toMatchSnapshot(); 115 | }); 116 | 117 | it('removeNodeData', () => { 118 | const store = mockStore({ 119 | flowDesigner: { 120 | nodes: Map({ 121 | id: { 122 | id: 'nodeId', 123 | type: 'type', 124 | data: Map({ testProperties: 'testProperties' }), 125 | }, 126 | }), 127 | }, 128 | }); 129 | 130 | store.dispatch(nodeActions.removeNodeData('id', 'type')); 131 | 132 | expect(store.getActions()).toMatchSnapshot(); 133 | }); 134 | 135 | it('removeNode', () => { 136 | const store = mockStore({ 137 | flowDesigner: { 138 | nodes: Map({ id: { id: 'nodeId', type: 'type' } }), 139 | }, 140 | }); 141 | 142 | store.dispatch(nodeActions.removeNode('id')); 143 | 144 | expect(store.getActions()).toMatchSnapshot(); 145 | }); 146 | }); 147 | 148 | describe('applyMovementTo', () => { 149 | it('generate proper action', () => { 150 | expect(nodeActions.applyMovementTo([1, 2, 3], { x: 10, y: 5 })).toMatchSnapshot(); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/selectors/portSelectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import memoize from 'lodash/memoize'; 3 | import { Map } from 'immutable'; 4 | 5 | import { Port } from '../api'; 6 | import { PORT_SINK, PORT_SOURCE } from '../constants/flowdesigner.constants'; 7 | import { 8 | PortRecordMap, 9 | PortRecord, 10 | NodeRecordMap, 11 | LinkRecordMap, 12 | State, 13 | LinkRecord, 14 | } from '../customTypings/index.d'; 15 | 16 | const getNodes = (state: State): NodeRecordMap => state.get('nodes'); 17 | const getPorts = (state: State): PortRecordMap => state.get('ports'); 18 | const getLinks = (state: State): LinkRecordMap => state.get('links'); 19 | 20 | /** 21 | * return a list of outgoing port for this node 22 | */ 23 | export function outPort(state: State, nodeId: string) { 24 | return state.getIn(['out', nodeId]) || Map(); 25 | } 26 | 27 | /** 28 | * return a list of ingoing port for this node 29 | */ 30 | export function inPort(state: State, nodeId: string) { 31 | return state.getIn(['in', nodeId]) || Map(); 32 | } 33 | 34 | /** 35 | * Create and return function who will return all ports for a specific node 36 | * @return {getPortsForNode} 37 | */ 38 | export const getPortsForNode = createSelector( 39 | getPorts, 40 | (ports: PortRecordMap): PortRecord => 41 | memoize((nodeId: string) => 42 | ports.filter((port: PortRecord) => Port.getNodeId(port) === nodeId), 43 | ), 44 | ); 45 | 46 | /** 47 | * Get all the data Emitter port attached to every nodes as a single map of port 48 | * map key is the port id 49 | * @return Map 50 | */ 51 | export const getEmitterPorts = createSelector( 52 | getPorts, 53 | (ports: PortRecordMap): PortRecord => 54 | ports.filter((port: any) => Port.getTopology(port) === PORT_SOURCE), 55 | ); 56 | 57 | /** 58 | * Get all the data Sink port attached to every nodes as a single map of port 59 | * map key is the port id 60 | * @return Map 61 | */ 62 | export const getSinkPorts = createSelector( 63 | getPorts, 64 | (ports: PortRecordMap): PortRecord => 65 | ports.filter((port: any) => Port.getTopology(port) === PORT_SINK), 66 | ); 67 | 68 | /** 69 | * Create and return function who will return all Emitter ports for a specific node 70 | */ 71 | export const getEmitterPortsForNode = createSelector( 72 | getEmitterPorts, 73 | (ports: PortRecordMap): PortRecord => (nodeId: string) => 74 | ports.filter((port: any) => Port.getNodeId(port) === nodeId), 75 | ); 76 | 77 | /** 78 | * Create and return function who will return all Sink ports for a specific node 79 | */ 80 | export const getSinkPortsForNode = createSelector( 81 | getSinkPorts, 82 | (ports: PortRecordMap): PortRecord => (nodeId: string) => 83 | ports.filter((port: any) => Port.getNodeId(port) === nodeId), 84 | ); 85 | 86 | /** 87 | * Get all the data Sink port attached to every nodes not attached at a single edge 88 | * as a single map of port 89 | * map key is the port id 90 | * @return Map 91 | */ 92 | export const getFreeSinkPorts = createSelector( 93 | [getSinkPorts, getLinks], 94 | (sinkPorts: PortRecordMap, links: LinkRecordMap) => { 95 | return sinkPorts.filter( 96 | (sinkPort: PortRecord) => 97 | !links.find((link: LinkRecord) => link.targetId === Port.getId(sinkPort)), 98 | ) as PortRecordMap; 99 | }, 100 | ); 101 | 102 | /** 103 | * Get all the data Emitter port attached to every nodes not attached at a single edge 104 | * as a single map of port 105 | * map key is the port id 106 | * @return Map 107 | */ 108 | export const getFreeEmitterPorts = createSelector( 109 | [getEmitterPorts, getLinks], 110 | (emitterPorts: PortRecordMap, links: LinkRecordMap) => 111 | emitterPorts.filter( 112 | (emitterPort: PortRecord) => 113 | !links.find((link: LinkRecord) => link.sourceId === Port.getId(emitterPort)), 114 | ), 115 | ); 116 | 117 | /** 118 | * Get all the data sink port attached to every node not attached at a single edge 119 | * as single map of port with an generated attached key 120 | * map key is the port id 121 | * @return Map 122 | */ 123 | export const getActionKeyedPorts = createSelector( 124 | getFreeSinkPorts, 125 | (freeSinkPorts: PortRecordMap) => 126 | freeSinkPorts.filter((sinkPort: { accessKey: any }) => sinkPort.accessKey), 127 | ); 128 | 129 | export const getDetachedPorts = createSelector( 130 | [getPorts, getNodes], 131 | (ports: PortRecordMap, nodes: NodeRecordMap) => 132 | ports.filter( 133 | (port: any) => !nodes.find((node: { id: any }) => node.id === Port.getNodeId(port)), 134 | ), 135 | ); 136 | -------------------------------------------------------------------------------- /src/constants/flowdesigner.model.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | import { Record, Map, List } from 'immutable'; 3 | import { 4 | Size, 5 | Position, 6 | PortDirection, 7 | PortRecord as PortRecordType, 8 | } from '../customTypings/index.d'; 9 | 10 | export const NONE = 'NONE'; 11 | export const SELECTED = 'SELECTED'; 12 | export const DROP_TARGET = 'DROP_TARGET'; 13 | export const FORBIDDEN_DROP_TARGET = 'FORBIDDEN_DROP_TARGET'; 14 | 15 | export const PositionRecord = Record({ 16 | x: undefined, 17 | y: undefined, 18 | }); 19 | 20 | export const SizeRecord = Record({ 21 | width: undefined, 22 | height: undefined, 23 | }); 24 | 25 | /** TO BE REMOVED */ 26 | export const NodeGraphicalAttributes = Record({ 27 | position: new PositionRecord(), 28 | nodeSize: new SizeRecord(), 29 | nodeType: undefined, 30 | label: '', 31 | description: '', 32 | properties: Map(), 33 | }); 34 | 35 | /** TO BE REMOVED */ 36 | export const NodeData = Record({ 37 | properties: Map(), 38 | label: '', 39 | description: '', 40 | datasetInfo: Map(), 41 | }); 42 | 43 | /** TO BE REMOVED */ 44 | export const LinkGraphicalAttributes = Record({ 45 | linkType: undefined, 46 | properties: Map(), 47 | }); 48 | 49 | /** TO BE REMOVED */ 50 | export const LinkData = Record({ 51 | properties: Map(), 52 | }); 53 | 54 | /** TO BE REMOVED */ 55 | export const PortGraphicalAttributes = Record({ 56 | position: PositionRecord, 57 | portType: undefined, 58 | properties: Map(), 59 | }); 60 | 61 | /** TO BE REMOVED */ 62 | export const PortData = Record({ 63 | properties: Map(), 64 | flowType: undefined, 65 | }); 66 | 67 | const nodeRecordDefinition = { 68 | id: undefined, 69 | type: undefined, 70 | data: Map({ 71 | properties: Map(), 72 | label: '', 73 | description: '', 74 | datasetInfo: Map(), 75 | }), 76 | graphicalAttributes: Map({ 77 | position: new PositionRecord(), 78 | nodeSize: new SizeRecord(), 79 | nodeType: undefined, 80 | label: '', 81 | description: '', 82 | properties: Map(), 83 | }), 84 | }; 85 | 86 | export class NodeRecord extends Record({ 87 | ...nodeRecordDefinition, 88 | getPosition(): Position { 89 | return this.getIn(['graphicalAttributes', 'position']); 90 | }, 91 | 92 | getSize(): Size { 93 | return this.getIn(['graphicalAttributes', 'nodeSize']); 94 | }, 95 | 96 | getNodeType(): string { 97 | return this.getIn(['graphicalAttributes', 'nodeType']); 98 | }, 99 | }) {} 100 | 101 | export class NestedNodeRecord extends Record({ 102 | ...nodeRecordDefinition, 103 | components: List(), 104 | getComponents(): Map { 105 | return this.get('components'); 106 | }, 107 | setComponents(components: Map) { 108 | return this.set('components', components); 109 | }, 110 | getPosition(): Position { 111 | return this.getIn(['graphicalAttributes', 'position']); 112 | }, 113 | 114 | getSize(): Size { 115 | return this.getIn(['graphicalAttributes', 'nodeSize']); 116 | }, 117 | 118 | getNodeType(): string { 119 | return this.getIn(['graphicalAttributes', 'nodeType']); 120 | }, 121 | }) {} 122 | 123 | export const LinkRecord = Record({ 124 | id: undefined, 125 | sourceId: undefined, 126 | targetId: undefined, 127 | data: Map({ 128 | properties: Map(), 129 | }), 130 | graphicalAttributes: Map({ 131 | linkType: undefined, 132 | properties: Map(), 133 | }), 134 | 135 | /** methods TO BE REMOVED */ 136 | getLinkType(): string { 137 | return this.getIn(['graphicalAttributes', 'linkType']); 138 | }, 139 | }); 140 | 141 | export const PortRecord = Record({ 142 | id: undefined, 143 | nodeId: undefined, 144 | data: Map({ 145 | properties: Map(), 146 | flowType: undefined, 147 | }), 148 | graphicalAttributes: Map({ 149 | position: PositionRecord, 150 | portType: undefined, 151 | properties: Map(), 152 | }), 153 | 154 | /** methods TO BE REMOVED */ 155 | getPosition(): Position { 156 | return this.getIn(['graphicalAttributes', 'position']); 157 | }, 158 | setPosition(position: Position): PortRecordType { 159 | return this.setIn(['graphicalAttributes', 'position'], position); 160 | }, 161 | getPortType(): string { 162 | return this.getIn(['graphicalAttributes', 'portType']); 163 | }, 164 | getPortDirection(): PortDirection { 165 | return this.getIn(['graphicalAttributes', 'properties', 'type']); 166 | }, 167 | getPortFlowType(): string { 168 | return this.getIn(['data', 'flowType']); 169 | }, 170 | getIndex(): number { 171 | return this.getIn(['graphicalAttributes', 'properties', 'index']); 172 | }, 173 | setIndex(index: number): PortRecordType { 174 | return this.setIn(['graphicalAttributes', 'properties', 'index'], index); 175 | }, 176 | }); 177 | -------------------------------------------------------------------------------- /src/reducers/link.reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { Map, fromJS } from 'immutable'; 2 | 3 | import { defaultState } from './flow.reducer'; 4 | import linkReducer from './link.reducer'; 5 | import { LinkRecord, PortRecord, NodeRecord } from '../constants/flowdesigner.model'; 6 | 7 | describe('check linkreducer', () => { 8 | const initialState = defaultState 9 | .set( 10 | 'nodes', 11 | Map() 12 | .set( 13 | 'id1', 14 | new NodeRecord({ 15 | id: 'id1', 16 | }), 17 | ) 18 | .set( 19 | 'id2', 20 | new NodeRecord({ 21 | id: 'id2', 22 | }), 23 | ) 24 | .set( 25 | 'id3', 26 | new NodeRecord({ 27 | id: 'id3', 28 | }), 29 | ), 30 | ) 31 | .set( 32 | 'links', 33 | Map().set( 34 | 'id1', 35 | new LinkRecord({ 36 | id: 'id1', 37 | sourceId: 'id1', 38 | targetId: 'id2', 39 | data: Map().set('attr', 'attr'), 40 | graphicalAttributes: Map().set('properties', fromJS({ attr: 'attr' })), 41 | }), 42 | ), 43 | ) 44 | .set( 45 | 'ports', 46 | Map() 47 | .set( 48 | 'id1', 49 | new PortRecord({ 50 | id: 'id1', 51 | nodeId: 'id1', 52 | }), 53 | ) 54 | .set( 55 | 'id2', 56 | new PortRecord({ 57 | id: 'id2', 58 | nodeId: 'id2', 59 | }), 60 | ) 61 | .set( 62 | 'id3', 63 | new PortRecord({ 64 | id: 'id3', 65 | nodeId: 'id2', 66 | }), 67 | ) 68 | .set( 69 | 'id4', 70 | new PortRecord({ 71 | id: 'id4', 72 | nodeId: 'id1', 73 | }), 74 | ) 75 | .set( 76 | 'id5', 77 | new PortRecord({ 78 | id: 'id5', 79 | nodeId: 'id3', 80 | }), 81 | ) 82 | .set( 83 | 'id6', 84 | new PortRecord({ 85 | id: 'id6', 86 | nodeID: 'id3', 87 | }), 88 | ), 89 | ) 90 | .set( 91 | 'parents', 92 | Map().set('id1', Map()).set('id2', Map().set('id1', 'id1')).set('id3', Map()), 93 | ) 94 | .set( 95 | 'childrens', 96 | Map().set('id1', Map().set('id2', 'id2')).set('id2', Map()).set('id3', Map()), 97 | ); 98 | 99 | it('FLOWDESIGNER_LINK_ADD should add a new link to the state', () => { 100 | const newState = linkReducer(initialState, { 101 | type: 'FLOWDESIGNER_LINK_ADD', 102 | linkId: 'id2', 103 | sourceId: 'id1', 104 | targetId: 'id2', 105 | }); 106 | expect(newState).toMatchSnapshot(); 107 | expect( 108 | linkReducer(newState, { 109 | type: 'FLOWDESIGNER_LINK_ADD', 110 | linkId: 'id3', 111 | sourceId: 'id3', 112 | targetId: 'id5', 113 | }), 114 | ).toMatchSnapshot(); 115 | }); 116 | 117 | it('FLOWDESIGNER_LINK_REMOVE should remove link from state', () => { 118 | expect( 119 | linkReducer(initialState, { type: 'FLOWDESIGNER_LINK_REMOVE', linkId: 'id1' }), 120 | ).toMatchSnapshot(); 121 | }); 122 | 123 | it('FLOWDESIGNER_LINK_SET_TARGET switch target to correct port if it exist', () => { 124 | expect( 125 | linkReducer(initialState, { 126 | type: 'FLOWDESIGNER_LINK_SET_TARGET', 127 | linkId: 'id1', 128 | targetId: 'id5', 129 | }), 130 | ).toMatchSnapshot(); 131 | }); 132 | 133 | it('FLOWDESIGNER_LINK_SET_SOURCE switch source to correct port if it exist', () => { 134 | expect( 135 | linkReducer(initialState, { 136 | type: 'FLOWDESIGNER_LINK_SET_SOURCE', 137 | linkId: 'id1', 138 | sourceId: 'id4', 139 | }), 140 | ).toMatchSnapshot(); 141 | }); 142 | 143 | it('FLOWDESIGNER_LINK_SET_GRAPHICAL_ATTRIBUTES should merge attributes within link attr property', () => { 144 | expect( 145 | linkReducer(initialState, { 146 | type: 'FLOWDESIGNER_LINK_SET_GRAPHICAL_ATTRIBUTES', 147 | linkId: 'id1', 148 | graphicalAttributes: { selected: false }, 149 | }), 150 | ).toMatchSnapshot(); 151 | }); 152 | 153 | it('FLOWDESIGNER_LINK_REMOVE_GRAPHICAL_ATTRIBUTES should remove a specific attributes from attr map', () => { 154 | expect( 155 | linkReducer(initialState, { 156 | type: 'FLOWDESIGNER_LINK_REMOVE_GRAPHICAL_ATTRIBUTES', 157 | linkId: 'id1', 158 | graphicalAttributesKey: 'attr', 159 | }), 160 | ).toMatchSnapshot(); 161 | }); 162 | 163 | it("FLOWDESIGNER_LINK_SET_DATA should add a data attribute type: 'test' from data map", () => { 164 | expect( 165 | linkReducer(initialState, { 166 | type: 'FLOWDESIGNER_LINK_SET_DATA', 167 | linkId: 'id1', 168 | data: { type: 'test' }, 169 | }), 170 | ).toMatchSnapshot(); 171 | }); 172 | 173 | it("FLOWDESIGNER_LINK_REMOVE_DATA should remove 'attr'from data map", () => { 174 | expect( 175 | linkReducer(initialState, { 176 | type: 'FLOWDESIGNER_LINK_SET_DATA', 177 | linkId: 'id1', 178 | datakey: 'attr', 179 | }), 180 | ).toMatchSnapshot(); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /src/reducers/port.reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { Map, OrderedMap } from 'immutable'; 2 | 3 | import { defaultState } from './flow.reducer'; 4 | import portReducer from './port.reducer'; 5 | import { PortRecord, PositionRecord } from '../constants/flowdesigner.model'; 6 | import { PORT_SINK, PORT_SOURCE } from '../constants/flowdesigner.constants'; 7 | 8 | describe('Check port reducer', () => { 9 | const initialState = defaultState 10 | .set( 11 | 'ports', 12 | // eslint-disable-next-line new-cap 13 | OrderedMap() 14 | .set( 15 | 'id1', 16 | new PortRecord({ 17 | id: 'id1', 18 | data: Map({ type: 'test' }), 19 | graphicalAttributes: Map({ 20 | type: 'test', 21 | position: new PositionRecord({ x: 10, y: 10 }), 22 | }), 23 | }), 24 | ) 25 | .set( 26 | 'id2', 27 | new PortRecord({ 28 | id: 'id2', 29 | nodeId: 'test', 30 | graphicalAttributes: Map({ 31 | position: new PositionRecord({ x: 10, y: 10 }), 32 | }), 33 | }), 34 | ) 35 | .set( 36 | 'id3', 37 | new PortRecord({ 38 | id: 'id3', 39 | graphicalAttributes: Map({ 40 | position: new PositionRecord({ x: 10, y: 10 }), 41 | }), 42 | }), 43 | ), 44 | ) 45 | .set('nodes', Map().set('nodeId', Map())) 46 | .set('links', Map()); 47 | 48 | it('FLOWDESIGNER_PORT_ADD properly add the port to the port Map', () => { 49 | expect( 50 | portReducer(initialState, { 51 | type: 'FLOWDESIGNER_PORT_ADD', 52 | nodeId: 'nodeId', 53 | id: 'portId', 54 | data: { flowType: 'string', properties: {} }, 55 | graphicalAttributes: { 56 | portType: 'portType', 57 | properties: { type: PORT_SOURCE, index: 1 }, 58 | }, 59 | }), 60 | ).toMatchSnapshot(); 61 | }); 62 | 63 | it('FLOWDESIGNER_PORT_ADDS to add multiple ports (portId1, portId2) to port collection', () => { 64 | expect( 65 | portReducer(initialState, { 66 | type: 'FLOWDESIGNER_PORT_ADDS', 67 | nodeId: 'nodeId', 68 | ports: [ 69 | { 70 | id: 'portId1', 71 | nodeId: 'nodeId', 72 | data: { flowType: 'string', properties: {} }, 73 | graphicalAttributes: { 74 | portType: 'portType', 75 | properties: { type: PORT_SOURCE }, 76 | }, 77 | }, 78 | { 79 | id: 'portId2', 80 | nodeId: 'nodeId', 81 | data: { flowType: 'string', properties: {} }, 82 | graphicalAttributes: { 83 | portType: 'portType', 84 | properties: { type: PORT_SINK }, 85 | }, 86 | }, 87 | { 88 | id: 'portId3', 89 | nodeId: 'nodeId', 90 | data: { flowType: 'string', properties: {} }, 91 | graphicalAttributes: { 92 | portType: 'portType', 93 | properties: { type: PORT_SINK }, 94 | }, 95 | }, 96 | ], 97 | }), 98 | ).toMatchSnapshot(); 99 | }); 100 | 101 | it('FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES to merge { selected: true } on port id1 graphicalAttribute map', () => { 102 | expect( 103 | portReducer(initialState, { 104 | type: 'FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES', 105 | portId: 'id1', 106 | graphicalAttributes: { selected: true }, 107 | }), 108 | ).toMatchSnapshot(); 109 | }); 110 | 111 | it('FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES to remove attr on port id1 graphicalAttribute map', () => { 112 | expect( 113 | portReducer(initialState, { 114 | type: 'FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES', 115 | portId: 'id1', 116 | graphicalAttributesKey: 'attr', 117 | }), 118 | ).toMatchSnapshot(); 119 | }); 120 | 121 | it("FLOWDESIGNER_PORT_SET_DATA to merge { type: 'string' } on port id1 data map", () => { 122 | expect( 123 | portReducer(initialState, { 124 | type: 'FLOWDESIGNER_PORT_SET_DATA', 125 | portId: 'id1', 126 | data: { type: 'string' }, 127 | }), 128 | ).toMatchSnapshot(); 129 | }); 130 | 131 | it('FLOWDESIGNER_PORT_REMOVE_DATA remove type on port id1 on port data map', () => { 132 | expect( 133 | portReducer(initialState, { 134 | type: 'FLOWDESIGNER_PORT_REMOVE_DATA', 135 | portId: 'id1', 136 | dataKey: 'type', 137 | }), 138 | ).toMatchSnapshot(); 139 | }); 140 | 141 | it('FLOWDESIGNER_PORT_REMOVE should only remove port id1 from ports collection', () => { 142 | expect( 143 | portReducer(initialState, { 144 | type: 'FLOWDESIGNER_PORT_REMOVE', 145 | portId: 'id1', 146 | }), 147 | ).toMatchSnapshot(); 148 | }); 149 | 150 | it('FLOWDESIGNER_PORT_REMOVE should only remove port id2 from ports collection if its parent node does not exist', () => { 151 | expect( 152 | portReducer(initialState, { 153 | type: 'FLOWDESIGNER_PORT_REMOVE', 154 | portId: 'id2', 155 | }), 156 | ).toMatchSnapshot(); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/api/size/size.test.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { SizeRecord } from '../../constants/flowdesigner.model'; 4 | 5 | import * as Size from './size'; 6 | 7 | const isNotSizeException = `SizeRecord should be a SizeRecord, was given 8 | """ 9 | object 10 | """ 11 | Map {} 12 | """ 13 | you should use Size module functions to create and transform Size`; 14 | const isNotProperSizeException = `SizeRecord should be a SizeRecord, was given 15 | """ 16 | object 17 | """ 18 | Map { "width": 10, "height": 10 } 19 | """ 20 | you should use Size module functions to create and transform Size`; 21 | const isImproperWidth = 'width should be a number, was given 10 of type string'; 22 | const isImproperHeight = 'height should be a number, was given 50 of type string'; 23 | 24 | describe('isSizeElseThrow', () => { 25 | it('return true if parameter size is a SizeRecord', () => { 26 | // given 27 | const testSize = new SizeRecord(); 28 | // when 29 | const test = Size.isSizeElseThrow(testSize); 30 | // expect 31 | expect(test).toEqual(true); 32 | }); 33 | 34 | it('throw an error if parameter is not a SizeRecord', () => { 35 | // given 36 | const testSize = Map(); 37 | // when 38 | // expect 39 | expect(() => Size.isSizeElseThrow(testSize)).toThrow(isNotSizeException); 40 | }); 41 | }); 42 | 43 | describe('Size', () => { 44 | const width = 10; 45 | const height = 50; 46 | const testSize = Size.create(width, height); 47 | 48 | const improperWidth = '10'; 49 | const improperHeight = '50'; 50 | const improperTestSize = Map({ width: 10, height: 10 }); 51 | describe('create', () => { 52 | it('given proper width and height return a Size', () => { 53 | // given 54 | // when 55 | const test = Size.create(width, height); 56 | // expect 57 | expect(Size.isSize(test)).toEqual(true); 58 | }); 59 | it('throw if given an improper width', () => { 60 | // given 61 | // when 62 | // expect 63 | expect(() => Size.create(improperWidth as any, height)).toThrow(isImproperWidth); 64 | }); 65 | it('throw if given an improper Height', () => { 66 | // given 67 | // when 68 | // expect 69 | expect(() => Size.create(width, improperHeight as any)).toThrow(isImproperHeight); 70 | }); 71 | }); 72 | describe('isSize', () => { 73 | it('return true if parameter size is a SizeRecord', () => { 74 | // given 75 | // when 76 | const test = Size.isSize(testSize); 77 | // expect 78 | expect(test).toEqual(true); 79 | }); 80 | 81 | it('thow an error if parameter is not a NodeRecord', () => { 82 | // given 83 | // when 84 | const test = Size.isSize(improperTestSize); 85 | // expect 86 | expect(test).toEqual(false); 87 | }); 88 | }); 89 | describe('getWidth', () => { 90 | it('given a proper Size return width', () => { 91 | // given 92 | // when 93 | const test = Size.getWidth(testSize); 94 | // expect 95 | expect(test).toEqual(width); 96 | }); 97 | it('throw given an improper Size', () => { 98 | expect(() => Size.getWidth(improperTestSize)).toThrow(isNotProperSizeException); 99 | }); 100 | }); 101 | describe('setWidth', () => { 102 | it('given a proper Size and a width return a Size with updated width', () => { 103 | // given 104 | const newWidth = 500; 105 | // when 106 | const test = Size.setWidth(newWidth, testSize); 107 | // expect 108 | expect(Size.getWidth(test)).toEqual(newWidth); 109 | }); 110 | it('throw given an improper width', () => { 111 | // given 112 | // when 113 | // expect 114 | expect(() => Size.setWidth(improperWidth as any, testSize)).toThrow( 115 | 'width should be a number, was given 10 of type string', 116 | ); 117 | }); 118 | it('throw given an improper Position', () => { 119 | // given 120 | // when 121 | // expect 122 | expect(() => Size.setWidth(width, improperTestSize)).toThrow(isNotProperSizeException); 123 | }); 124 | }); 125 | describe('getHeight', () => { 126 | it('given a proper size return height', () => { 127 | // given 128 | // when 129 | const test = Size.getHeight(testSize); 130 | // expect 131 | expect(test).toEqual(height); 132 | }); 133 | it('throw given an improper size', () => { 134 | expect(() => Size.getHeight(improperTestSize)).toThrow(isNotProperSizeException); 135 | }); 136 | }); 137 | describe('setHeight', () => { 138 | it('given a proper Size and width return a Position with updated width', () => { 139 | // given 140 | const newHeight = 500; 141 | // when 142 | const test = Size.setHeight(newHeight, testSize); 143 | // expect 144 | expect(Size.getHeight(test)).toEqual(newHeight); 145 | }); 146 | it('throw given an improper height', () => { 147 | // given 148 | // when 149 | // expect 150 | expect(() => Size.setHeight(improperHeight as any, testSize)).toThrow( 151 | 'height should be a number, was given 50 of type string', 152 | ); 153 | }); 154 | it('throw given an improper Size', () => { 155 | // given 156 | // when 157 | // expect 158 | expect(() => Size.setHeight(height, improperTestSize)).toThrow( 159 | isNotProperSizeException, 160 | ); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/customTypings/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Record, Map } from 'immutable'; 2 | import { PORT_SINK, PORT_SOURCE } from '../constants/flowdesigner.constants'; 3 | 4 | /** $BASIC */ 5 | 6 | export type Id = string; 7 | 8 | export interface Position { 9 | x: number; 10 | y: number; 11 | } 12 | 13 | export interface Size { 14 | width: number; 15 | height: number; 16 | } 17 | 18 | export interface Action { 19 | type: string; 20 | } 21 | 22 | export type PortDirection = typeof PORT_SINK | typeof PORT_SOURCE; 23 | 24 | export interface PortGraphicalAttributes { 25 | portType?: string; 26 | position?: Position; 27 | properties: { 28 | type: PortDirection; 29 | index?: number; 30 | } & any; 31 | } 32 | 33 | export interface PortData { 34 | flowType: string; 35 | properties?: {}; 36 | } 37 | 38 | export interface Port { 39 | id: Id; 40 | nodeId: string; 41 | data?: PortData; 42 | graphicalAttributes?: PortGraphicalAttributes; 43 | } 44 | 45 | export interface NodeGraphicalAttributes { 46 | position: Position; 47 | nodeSize: Size; 48 | nodeType: string; 49 | label: string; 50 | description: string; 51 | properties?: {}; 52 | } 53 | 54 | export interface NodeData { 55 | datasetId: Id; 56 | properties?: {}; 57 | label: string; 58 | description: string; 59 | datasetInfo?: {}; 60 | } 61 | 62 | export interface Node { 63 | id: Id; 64 | type: string; 65 | data: NodeData; 66 | graphicalAttributes: NodeGraphicalAttributes; 67 | components?: List; 68 | } 69 | 70 | export interface LinkGraphicalAttributes { 71 | linkType: string; 72 | properties?: {}; 73 | } 74 | 75 | export interface LinkData { 76 | properties?: {}; 77 | } 78 | 79 | export interface Link { 80 | id: Id; 81 | source: Id; 82 | target: Id; 83 | data: LinkData; 84 | graphicalAttributes: LinkGraphicalAttributes; 85 | } 86 | 87 | /** $RECORDS */ 88 | export type PositionRecord = Record & Position; 89 | 90 | export type SizeRecord = Record & Size; 91 | 92 | export type PortRecord = Record & { 93 | getPosition: () => Position; 94 | getPortType: () => string; 95 | getPortDirection: () => PortDirection; 96 | getPortFlowType: () => string; 97 | getIndex: () => number; 98 | setIndex: (index: number) => PortRecord; 99 | } & Port; 100 | 101 | // TODO add record 102 | export type NodeRecord = Record & { 103 | getPosition: () => Position; 104 | getSize: () => Size; 105 | getNodeType: () => string; 106 | } & Node; 107 | 108 | export type NestedNodeRecord = Record & { 109 | getPosition: () => Position; 110 | getSize: () => Size; 111 | getNodeType: () => string; 112 | } & Node; 113 | 114 | export type LinkRecord = Record & { 115 | getLinkType: () => string; 116 | } & Link; 117 | 118 | /** $STATE */ 119 | 120 | export type PortRecordMap = Map; 121 | export type NodeRecordMap = Map; 122 | export type LinkRecordMap = Map; 123 | 124 | type getStateNodes = (selector: ['nodes', Id]) => NodeRecord; 125 | type getStatePorts = (selector: ['ports', Id]) => PortRecord; 126 | type getStateLinks = (selector: ['links', Id]) => LinkRecord; 127 | type getStateIn = (selector: ['in', Id]) => Id; 128 | type getStateOut = (selector: ['out', Id]) => Id; 129 | 130 | export type State = { 131 | in: Map>; 132 | parents: Map>; 133 | transform: Transform; 134 | transformToApply?: Transform; 135 | out: Map>; 136 | nodes: Map>; 137 | ports: Map>; 138 | children: Map>; 139 | nodeTypes: Map>; 140 | links: Map>; 141 | } & Map & { getIn: getStateNodes | getStatePorts | getStateLinks | getStateIn | getStateOut }; 142 | 143 | /** $ACTIONS */ 144 | export interface PortActionAdd { 145 | type: 'FLOWDESIGNER_PORT_ADD'; 146 | nodeId: Id; 147 | id: Id; 148 | data?: PortData; 149 | graphicalAttributes?: PortGraphicalAttributes; 150 | } 151 | 152 | export type PortAction = 153 | | PortActionAdd 154 | | { 155 | type: 'FLOWDESIGNER_PORT_ADDS'; 156 | nodeId: Id; 157 | ports: Array; 158 | } 159 | | { 160 | type: 'FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES'; 161 | portId: Id; 162 | graphicalAttributes: {}; 163 | } 164 | | { 165 | type: 'FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES'; 166 | portId: Id; 167 | graphicalAttributesKey: string; 168 | } 169 | | { 170 | type: 'FLOWDESIGNER_PORT_SET_DATA'; 171 | portId: Id; 172 | data: Object; 173 | } 174 | | { 175 | type: 'FLOWDESIGNER_PORT_REMOVE_DATA'; 176 | portId: Id; 177 | dataKey: string; 178 | } 179 | | { 180 | type: 'FLOWDESIGNER_PORT_REMOVE'; 181 | portId: Id; 182 | }; 183 | 184 | export interface NodeType { 185 | id: string; 186 | position: Position; 187 | } 188 | 189 | export interface PortType { 190 | id: string; 191 | nodeId: string; 192 | position: Position; 193 | } 194 | 195 | export interface LinkType { 196 | id: string; 197 | sourceId: string; 198 | targetId: string; 199 | } 200 | 201 | export interface Transform { 202 | k: number; 203 | x: number; 204 | y: number; 205 | } 206 | -------------------------------------------------------------------------------- /src/actions/node.actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FLOWDESIGNER_NODE_MOVE_START, 3 | FLOWDESIGNER_NODE_APPLY_MOVEMENT, 4 | FLOWDESIGNER_NODE_MOVE, 5 | FLOWDESIGNER_NODE_MOVE_END, 6 | FLOWDESIGNER_NODE_ADD, 7 | FLOWDESIGNER_NODE_SET_TYPE, 8 | FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES, 9 | FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES, 10 | FLOWDESIGNER_NODE_SET_DATA, 11 | FLOWDESIGNER_NODE_REMOVE_DATA, 12 | FLOWDESIGNER_NODE_SET_SIZE, 13 | FLOWDESIGNER_NODE_REMOVE, 14 | FLOWDESIGNER_NODE_UPDATE, 15 | } from '../constants/flowdesigner.constants'; 16 | import { Position } from '../customTypings/index.d'; 17 | 18 | /** 19 | * Ask for node creation and injection into current dataflow 20 | * @param {string} nodeId 21 | * @param {string} nodeType 22 | * @param {Object} attr 23 | * @return {Object} 24 | */ 25 | export const addNode = ( 26 | nodeId: string, 27 | nodeType?: string, 28 | { data = {}, graphicalAttributes = {} }: any = {}, 29 | ) => ({ 30 | type: FLOWDESIGNER_NODE_ADD, 31 | nodeId, 32 | nodeType, 33 | data, 34 | graphicalAttributes, 35 | }); 36 | 37 | /** 38 | * @deprecated use moveStart action 39 | */ 40 | export function startMoveNodeTo(nodeId: string, nodePosition: string) { 41 | return { 42 | type: FLOWDESIGNER_NODE_MOVE_START, 43 | nodeId, 44 | nodePosition, 45 | }; 46 | } 47 | 48 | /** 49 | * Ask for moving node 50 | * @deprecated use move action 51 | * @param {string} nodeId - identifier of the targeted node 52 | * @param {{x: number, y: number}} nodePosition - the new absolute position of the node 53 | * @return {Object} 54 | */ 55 | export function moveNodeTo(nodeId: string, nodePosition: Position) { 56 | return { 57 | type: FLOWDESIGNER_NODE_MOVE, 58 | nodeId, 59 | nodePosition, 60 | }; 61 | } 62 | 63 | /** 64 | * Ask to apply the same movement to multiples nodesId 65 | * @deprecated 66 | * @param nodesId {array} list of nodeId 67 | * @param movement {Object} relative movement to apply on all nodes 68 | * 69 | * @return {Object} 70 | */ 71 | export const applyMovementTo = (nodesId: number[], movement: Position) => ({ 72 | type: FLOWDESIGNER_NODE_APPLY_MOVEMENT, 73 | nodesId, 74 | movement, 75 | }); 76 | 77 | /** 78 | * When node movement is done 79 | * @deprecated use moveEnd action 80 | * @param {string} nodeId - identifier of the targeted node 81 | * @param {{x: number, y: number}} nodePosition - the new absolute position of the node 82 | * @return {Object} 83 | */ 84 | export function moveNodeToEnd(nodeId: string, nodePosition: { x: number; y: number }) { 85 | return { 86 | type: FLOWDESIGNER_NODE_MOVE_END, 87 | nodeId, 88 | nodePosition, 89 | }; 90 | } 91 | 92 | /** 93 | * set node size 94 | * @deprecated 95 | * @param {string} nodeId 96 | * @param {{height: number, width: number}} nodeSize 97 | * @return {Object} 98 | */ 99 | export const setNodeSize = (nodeId: string, nodeSize: { width: number; height: number }) => ({ 100 | type: FLOWDESIGNER_NODE_SET_SIZE, 101 | nodeId, 102 | nodeSize, 103 | }); 104 | 105 | /** 106 | * Ask for node creation and injection into current dataflow 107 | * @deprecated 108 | * @param {string} nodeId 109 | * @param {string} nodeType 110 | * @return {Object} 111 | */ 112 | export function setNodeType(nodeId: string, nodeType: string) { 113 | return { 114 | type: FLOWDESIGNER_NODE_SET_TYPE, 115 | nodeId, 116 | nodeType, 117 | }; 118 | } 119 | 120 | /** 121 | * Give the ability to a graphical attribute onto the node 122 | * @deprecated 123 | * @param {string} nodeId 124 | * @param {Object} graphicalAttributes 125 | */ 126 | export const setNodeGraphicalAttributes = ( 127 | nodeId: string, 128 | graphicalAttributes: { selected: boolean }, 129 | ) => ({ 130 | type: FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES, 131 | nodeId, 132 | graphicalAttributes, 133 | }); 134 | 135 | /** 136 | * Ask to remove a graphical attribute on target node 137 | * @deprecated 138 | * @param {string} nodeId 139 | * @param {string} graphicalAttributesKey - the key of the attribute to be removed 140 | */ 141 | export const removeNodeGraphicalAttribute = (nodeId: string, graphicalAttributesKey: string) => ({ 142 | type: FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES, 143 | nodeId, 144 | graphicalAttributesKey, 145 | }); 146 | 147 | /** 148 | * Give the ability to set data onto a node 149 | * @deprecated 150 | * @param {string} nodeId 151 | * @param {Object} data 152 | * @param {boolean} bySubmit Flag to indicates that the action was triggered by a manual user action 153 | */ 154 | export const setNodeData = (nodeId: string, data: { type: string }, bySubmit: boolean) => ({ 155 | type: FLOWDESIGNER_NODE_SET_DATA, 156 | nodeId, 157 | data, 158 | bySubmit, 159 | }); 160 | 161 | /** 162 | * Ask to remove a graphical attribute on target node 163 | * @deprecated 164 | * @param {string} nodeId 165 | * @param {string} dataKey - the key of the data to be removed 166 | */ 167 | export const removeNodeData = (nodeId: string, dataKey: string) => ({ 168 | type: FLOWDESIGNER_NODE_REMOVE_DATA, 169 | nodeId, 170 | dataKey, 171 | }); 172 | 173 | /** 174 | * Ask for removal of target node and each ports/links attached to it 175 | * @deprecated use deleteNode action 176 | * @param {string} nodeId 177 | */ 178 | export const removeNode = (nodeId: string) => ({ 179 | type: FLOWDESIGNER_NODE_REMOVE, 180 | nodeId, 181 | }); 182 | 183 | export const update = (nodeId: string, node: any) => ({ 184 | type: FLOWDESIGNER_NODE_UPDATE, 185 | node, 186 | nodeId, 187 | }); 188 | -------------------------------------------------------------------------------- /src/api/position/position.test.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { PositionRecord } from '../../constants/flowdesigner.model'; 4 | 5 | import * as Position from './position'; 6 | 7 | const isNotPositionException = `PositionRecord should be a PositionRecord, was given 8 | """ 9 | object 10 | """ 11 | Map {} 12 | """ 13 | you should use Position module functions to create and transform Position`; 14 | const improperPositionException = `PositionRecord should be a PositionRecord, was given 15 | """ 16 | object 17 | """ 18 | Map { "x": 10, "y": 10 } 19 | """ 20 | you should use Position module functions to create and transform Position`; 21 | const isImproperXCoordinate = 'x should be a number, was given 10 of type string'; 22 | const isImproperYCoordinate = 'y should be a number, was given 50 of type string'; 23 | 24 | describe('isPositionElseThrow', () => { 25 | it('return true if parameter position is a PositionRecord', () => { 26 | // given 27 | const testPosition = new PositionRecord(); 28 | // when 29 | const test = Position.isPositionElseThrow(testPosition); 30 | // expect 31 | expect(test).toEqual(true); 32 | }); 33 | 34 | it('thow an error if parameter is not a PositionRecord', () => { 35 | // given 36 | const testPosition = Map(); 37 | // when 38 | // expect 39 | expect(() => Position.isPositionElseThrow(testPosition)).toThrow(isNotPositionException); 40 | }); 41 | }); 42 | 43 | describe('Position', () => { 44 | const x = 10; 45 | const y = 50; 46 | const testPosition = Position.create(x, y); 47 | 48 | const improperX = '10'; 49 | const improperY = '50'; 50 | const improperTestPosition = Map({ x: 10, y: 10 }); 51 | describe('create', () => { 52 | it('given proper x and y coordinate return a Position', () => { 53 | // given 54 | // when 55 | const test = Position.create(x, y); 56 | // expect 57 | expect(Position.isPosition(test)).toEqual(true); 58 | }); 59 | it('throw if given an improper id', () => { 60 | // given 61 | // when 62 | // expect 63 | expect(() => Position.create(improperX as any, y)).toThrow(isImproperXCoordinate); 64 | }); 65 | it('throw if given an improper Position', () => { 66 | // given 67 | // when 68 | // expect 69 | expect(() => Position.create(x, improperY as any)).toThrow(isImproperYCoordinate); 70 | }); 71 | }); 72 | describe('isPosition', () => { 73 | it('return true if parameter position is a PositionRecord', () => { 74 | // given 75 | // when 76 | const test = Position.isPosition(testPosition); 77 | // expect 78 | expect(test).toEqual(true); 79 | }); 80 | 81 | it('thow an error if parameter is not a NodeRecord', () => { 82 | // given 83 | // when 84 | const test = Position.isPosition(improperTestPosition); 85 | // expect 86 | expect(test).toEqual(false); 87 | }); 88 | }); 89 | describe('getXCoordinate', () => { 90 | it('given a proper position return x', () => { 91 | // given 92 | // when 93 | const test = Position.getXCoordinate(testPosition); 94 | // expect 95 | expect(test).toEqual(x); 96 | }); 97 | it('throw given an improper position', () => { 98 | expect(() => Position.getXCoordinate(improperTestPosition)).toThrow( 99 | improperPositionException, 100 | ); 101 | }); 102 | }); 103 | describe('setXCoordinate', () => { 104 | it('given a proper Position and X coordinate return a Position with updated coordinate', () => { 105 | // given 106 | const newX = 500; 107 | // when 108 | const test = Position.setXCoordinate(newX, testPosition); 109 | // expect 110 | expect(Position.getXCoordinate(test)).toEqual(newX); 111 | }); 112 | it('throw given an improper X coordinate', () => { 113 | // given 114 | // when 115 | // expect 116 | expect(() => Position.setXCoordinate(improperX as any, testPosition)).toThrow( 117 | 'x should be a number, was given 10 of type string', 118 | ); 119 | }); 120 | it('throw given an improper Position', () => { 121 | // given 122 | // when 123 | // expect 124 | expect(() => Position.setXCoordinate(x, improperTestPosition)).toThrow( 125 | improperPositionException, 126 | ); 127 | }); 128 | }); 129 | describe('getYCoordinate', () => { 130 | it('given a proper Position return y', () => { 131 | // given 132 | // when 133 | const test = Position.getYCoordinate(testPosition); 134 | // expect 135 | expect(test).toEqual(y); 136 | }); 137 | it('throw given an improper position', () => { 138 | expect(() => Position.getYCoordinate(improperTestPosition)).toThrow( 139 | improperPositionException, 140 | ); 141 | }); 142 | }); 143 | describe('setYCoordinate', () => { 144 | it('given a proper Position and Y coordinate return a Position with updated coordinate', () => { 145 | // given 146 | const newY = 500; 147 | // when 148 | const test = Position.setYCoordinate(newY, testPosition); 149 | // expect 150 | expect(Position.getYCoordinate(test)).toEqual(newY); 151 | }); 152 | it('throw given an improperY coordinate', () => { 153 | // given 154 | // when 155 | // expect 156 | expect(() => Position.setYCoordinate(improperY as any, testPosition)).toThrow( 157 | 'y should be a number, was given 50 of type string', 158 | ); 159 | }); 160 | it('throw given an improper Position', () => { 161 | // given 162 | // when 163 | // expect 164 | expect(() => Position.setYCoordinate(y, improperTestPosition)).toThrow( 165 | improperPositionException, 166 | ); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/reducers/flow.reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { reducer, calculatePortsPosition, defaultState } from './flow.reducer'; 4 | import * as nodeActions from '../actions/node.actions'; 5 | import * as portActions from '../actions/port.actions'; 6 | import { NodeRecord, PortRecord } from '../constants/flowdesigner.model'; 7 | import { PORT_SOURCE } from '../constants/flowdesigner.constants'; 8 | 9 | describe('FLOWDESIGNER_FLOW_ADD_ELEMENTS', () => { 10 | it('should batch one element creation', () => { 11 | expect( 12 | reducer(defaultState, { 13 | type: 'FLOWDESIGNER.FLOW_ADD_ELEMENTS', 14 | listOfActionCreation: [ 15 | nodeActions.addNode('nodeId', undefined, { 16 | data: {}, 17 | graphicalAttributes: { 18 | nodeSize: { height: 10, width: 10 }, 19 | position: { x: 10, y: 10 }, 20 | }, 21 | }), 22 | ], 23 | }), 24 | ).toMatchSnapshot(); 25 | }); 26 | 27 | it('should batch many elements creation', () => { 28 | expect( 29 | reducer(defaultState, { 30 | type: 'FLOWDESIGNER.FLOW_ADD_ELEMENTS', 31 | listOfActionCreation: [ 32 | nodeActions.addNode('nodeId', undefined, { 33 | data: {}, 34 | graphicalAttributes: { 35 | nodeSize: { height: 10, width: 10 }, 36 | position: { x: 10, y: 10 }, 37 | }, 38 | }), 39 | nodeActions.addNode('node2', undefined, { 40 | data: {}, 41 | graphicalAttributes: { 42 | nodeSize: { height: 10, width: 10 }, 43 | position: { x: 10, y: 10 }, 44 | }, 45 | }), 46 | portActions.addPort('nodeId', 'portId', { 47 | data: { 48 | flowType: 'batch', 49 | }, 50 | graphicalAttributes: { 51 | properties: { 52 | type: PORT_SOURCE, 53 | }, 54 | }, 55 | }), 56 | ], 57 | }), 58 | ).toMatchSnapshot(); 59 | }); 60 | 61 | it('should throw in reducer', () => { 62 | const shouldThrow = () => 63 | reducer(defaultState, { 64 | type: 'FLOWDESIGNER.FLOW_ADD_ELEMENTS', 65 | listOfActionCreation: [ 66 | nodeActions.addNode('nodeId', undefined, { 67 | data: {}, 68 | graphicalAttributes: { 69 | nodeSize: { height: 10, width: 10 }, 70 | position: { x: 10, y: 10 }, 71 | }, 72 | }), 73 | nodeActions.addNode('node2', undefined, { 74 | data: {}, 75 | graphicalAttributes: { 76 | nodeSize: { height: 10, width: 10 }, 77 | position: { x: 10, y: 10 }, 78 | }, 79 | }), 80 | portActions.addPort('node3', 'portId', { 81 | data: undefined, 82 | graphicalAttributes: undefined, 83 | }), 84 | ], 85 | }); 86 | expect(shouldThrow).toThrow(); 87 | }); 88 | }); 89 | 90 | describe('FLOWDESIGNER_FLOW_LOAD should reset old flow state and load news not touching flow config', () => { 91 | it('should load elements', () => { 92 | expect( 93 | reducer(defaultState, { 94 | type: 'FLOWDESIGNER.FLOW_LOAD', 95 | listOfActionCreation: [ 96 | nodeActions.addNode('nodeId', undefined, { 97 | data: {}, 98 | graphicalAttributes: { 99 | nodeSize: { height: 10, width: 10 }, 100 | position: { x: 10, y: 10 }, 101 | }, 102 | }), 103 | nodeActions.addNode('node2', undefined, { 104 | data: {}, 105 | graphicalAttributes: { 106 | nodeSize: { height: 10, width: 10 }, 107 | position: { x: 10, y: 10 }, 108 | }, 109 | }), 110 | portActions.addPort('nodeId', 'portId', { 111 | graphicalAttributes: { 112 | properties: { 113 | type: PORT_SOURCE, 114 | }, 115 | }, 116 | }), 117 | ], 118 | }), 119 | ).toMatchSnapshot(); 120 | }); 121 | }); 122 | 123 | describe('FLOWDESIGNER_PAN_TO set a calculated transformation into transformToApply', () => { 124 | it('', () => { 125 | expect( 126 | reducer(defaultState, { 127 | type: 'FLOWDESIGNER_PAN_TO', 128 | x: 400, 129 | y: 400, 130 | }), 131 | ).toMatchSnapshot(); 132 | }); 133 | }); 134 | 135 | describe('calculatePortsPosition behavior', () => { 136 | const state = defaultState 137 | .set( 138 | 'nodes', 139 | Map().set( 140 | '42', 141 | new NodeRecord({ 142 | id: '42', 143 | graphicalAttributes: Map({ 144 | nodeType: '42', 145 | }), 146 | }), 147 | ), 148 | ) 149 | .set( 150 | 'ports', 151 | Map().set( 152 | '42', 153 | new PortRecord({ 154 | id: '42', 155 | nodeId: '42', 156 | }), 157 | ), 158 | ) 159 | .set('nodeTypes', Map().set('42', Map().set('component', {}))); 160 | 161 | it('should trigger only if NODE/PORT/FLOW action are dispatched', () => { 162 | const calculatePortPosition = jest.fn(); 163 | const givenState = state.setIn(['nodeTypes', '42', 'component'], { calculatePortPosition }); 164 | calculatePortsPosition(givenState, { 165 | type: 'FLOWDESIGNER_NODE_MOVE', 166 | }); 167 | calculatePortsPosition(givenState, { 168 | type: 'FLOWDESIGNER_PORT_ADD', 169 | }); 170 | calculatePortsPosition(givenState, { 171 | type: 'FLOWDESIGNER_FLOW_RESET', 172 | }); 173 | expect(calculatePortPosition.mock.calls.length).toEqual(3); 174 | }); 175 | 176 | it('should not trigger on FLOWDESIGNER_NODE_REMOVE and FLOWDESIGNER_PORT_REMOVE', () => { 177 | const calculatePortPosition = jest.fn(); 178 | const givenState = state.setIn(['nodeTypes', '42', 'component'], { calculatePortPosition }); 179 | calculatePortsPosition(givenState, { 180 | type: 'FLOWDESIGNER_NODE_REMOVE', 181 | }); 182 | calculatePortsPosition(givenState, { 183 | type: 'FLOWDESIGNER_PORT_REMOVE', 184 | }); 185 | expect(calculatePortPosition.mock.calls.length).toEqual(0); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /jenkins/veracode-scan.groovy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/groovy 2 | import groovy.transform.Field 3 | 4 | /** 5 | * Security scan pipeline for the react-flow-designer project 6 | */ 7 | 8 | /** 9 | * Global variables 10 | */ 11 | @Field def dockerArtifactoryDomain = "artifactory.datapwn.com" 12 | @Field def dockerRegistryDomain = "registry.datapwn.com" 13 | 14 | def slackChannel = 'pipeline-designer-notifications' 15 | def decodedJobName = env.JOB_NAME.replaceAll("%2F", "/") 16 | def projectName = 'react-flow-designer' 17 | def pipelineName = 'security-scan' 18 | 19 | 20 | /** 21 | * Pod configuration 22 | */ 23 | def podLabel = "${projectName}-${pipelineName}-${UUID.randomUUID().toString()}".take(53) 24 | def podConfiguration = """ 25 | apiVersion: v1 26 | kind: Pod 27 | spec: 28 | imagePullSecrets: 29 | - talend-registry 30 | containers: 31 | - name: nodejs 32 | image: artifactory.datapwn.com/tlnd-docker-prod/talend/common/tsbi/node-builder:2.5.4-20210112094055 33 | resources: 34 | requests: 35 | memory: "2G" 36 | cpu: "2" 37 | limits: 38 | memory: "8G" 39 | cpu: "4" 40 | command: 41 | - cat 42 | tty: true 43 | volumeMounts: 44 | - name: npm-cache 45 | mountPath: /root/.npm 46 | - name: docker 47 | mountPath: /var/run/docker.sock 48 | 49 | volumes: 50 | - name: docker 51 | hostPath: 52 | path: /var/run/docker.sock 53 | - name: npm-cache 54 | persistentVolumeClaim: 55 | claimName: efs-jenkins-dp-pipeline-designer-npm 56 | """ 57 | 58 | /** 59 | * Credentials 60 | */ 61 | 62 | def artifactoryCredentials = usernamePassword( 63 | credentialsId: 'artifactory-datapwn-credentials', 64 | passwordVariable: 'dockerArtifactoryPassword', 65 | usernameVariable: 'dockerArtifactoryUsername' 66 | ) 67 | 68 | def nexusCredentials = usernamePassword( 69 | credentialsId: 'nexus-credentials', 70 | passwordVariable: 'nexusPassword', 71 | usernameVariable: 'nexusUsername' 72 | ) 73 | 74 | def npmCredentials = string( 75 | credentialsId: 'npm-credentials', 76 | variable: 'npmAuthToken', 77 | ) 78 | 79 | def dockerCredentials = usernamePassword( 80 | credentialsId: 'docker-registry-credentials', 81 | passwordVariable: 'dockerRegistryPassword', 82 | usernameVariable: 'dockerRegistryUsername' 83 | ) 84 | 85 | /** 86 | * Options 87 | */ 88 | def discarderConfiguration = logRotator(numToKeepStr: '30', artifactNumToKeepStr: '10') 89 | def timeoutConfiguration = [time: 45, unit: 'MINUTES'] 90 | 91 | pipeline { 92 | agent { 93 | kubernetes { 94 | label podLabel 95 | yaml podConfiguration 96 | } 97 | } 98 | 99 | triggers { 100 | cron('0 11 * * 0') 101 | } 102 | 103 | options { 104 | buildDiscarder(discarderConfiguration) 105 | timeout(timeoutConfiguration) 106 | skipStagesAfterUnstable() 107 | } 108 | 109 | environment { 110 | branch = "${env.BRANCH_NAME}" 111 | commit = "${env.GIT_COMMIT}" 112 | timestamp = sh(returnStdout: true, script: "date +%Y%m%d_%H%M%S").trim() 113 | } 114 | 115 | stages { 116 | stage('Prepare Node JS Container') { 117 | steps { 118 | container('nodejs') { 119 | withCredentials([npmCredentials, dockerCredentials, artifactoryCredentials]) { 120 | script { 121 | sh """#! /bin/bash 122 | curl --silent --location https://dl.yarnpkg.com/rpm/yarn.repo | tee /etc/yum.repos.d/yarn.repo 123 | rpm --import https://dl.yarnpkg.com/rpm/pubkey.gpg 124 | yum --assumeyes install yarn 125 | echo -e "email=devops@talend.com\n//registry.npmjs.org/:_authToken=${npmAuthToken}" > /root/.npmrc 126 | docker login ${dockerRegistryDomain} -u ${dockerRegistryUsername} --password-stdin <<< ${dockerRegistryPassword} 127 | docker login ${dockerArtifactoryDomain} -u ${dockerArtifactoryUsername} --password-stdin <<< ${dockerArtifactoryPassword} 128 | """ 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | stage('Source clear scan') { 136 | steps { 137 | container('nodejs') { 138 | withCredentials([string(credentialsId: 'veracode-token', variable: 'SRCCLR_API_TOKEN')]) { 139 | sh '''#!/bin/bash 140 | curl -sSL https://download.sourceclear.com/ci.sh | SRCCLR_API_TOKEN=${SRCCLR_API_TOKEN} DEBUG=0 sh -s -- scan --recursive --loud; 141 | ''' 142 | } 143 | } 144 | } 145 | } 146 | } 147 | 148 | post { 149 | success { 150 | slackSend (color: 'good', channel: "${slackChannel}", message: "${decodedJobName} - #${env.BUILD_NUMBER} Success after ${currentBuild.durationString} (<${env.BUILD_URL}|Open>)") 151 | } 152 | 153 | unstable { 154 | slackSend (color: 'warning', channel: "${slackChannel}", message: "${decodedJobName} - #${env.BUILD_NUMBER} Unstable after ${currentBuild.durationString} (<${env.BUILD_URL}|Open>)") 155 | } 156 | 157 | failure { 158 | slackSend (color: 'danger', channel: "${slackChannel}", message: "${decodedJobName} - #${env.BUILD_NUMBER} Failure after ${currentBuild.durationString} (<${env.BUILD_URL}|Open>)") 159 | } 160 | 161 | aborted { 162 | slackSend (color: 'warning', channel: "${slackChannel}", message: "${decodedJobName} - #${env.BUILD_NUMBER} Aborted after ${currentBuild.durationString} (<${env.BUILD_URL}|Open>)") 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/components/FlowDesigner.container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import invariant from 'invariant'; 4 | import get from 'lodash/get'; 5 | import { Map } from 'immutable'; 6 | 7 | import { setZoom } from '../actions/flow.actions'; 8 | import Grid from './grid/Grid.component'; 9 | import ZoomHandler from './ZoomHandler.component'; 10 | import NodesRenderer from './node/NodesRenderer.component'; 11 | import LinksRenderer from './link/LinksRenderer.component'; 12 | import PortsRenderer from './port/PortsRenderer.component'; 13 | 14 | import { startMoveNodeTo, moveNodeTo, moveNodeToEnd } from '../actions/node.actions'; 15 | import { setNodeTypes } from '../actions/nodeType.actions'; 16 | import { 17 | Transform, 18 | NodeRecordMap, 19 | PortRecordMap, 20 | LinkRecordMap, 21 | Position, 22 | Id, 23 | } from '../customTypings/index.d'; 24 | 25 | type Props = { 26 | children?: any; 27 | setNodeTypes: (nodeTypeMap: Map) => void; 28 | startMoveNodeTo: (nodeId: Id, nodePosition: string) => void; 29 | moveNodeTo: (nodeId: Id, nodePosition: Position) => void; 30 | moveNodeToEnd: (nodeId: Id, nodePosition: Position) => void; 31 | nodes: NodeRecordMap; 32 | ports: PortRecordMap; 33 | links: LinkRecordMap; 34 | reduxMountPoint: string; 35 | onClick?: () => {}; 36 | transform?: Transform; 37 | transformToApply?: Transform; 38 | setZoom?: (transform: Transform) => void; 39 | snapToGrid?: boolean; 40 | }; 41 | 42 | type State = { 43 | nodeTypeMap: Map; 44 | linkTypeMap: Map; 45 | portTypeMap: Map; 46 | }; 47 | 48 | export class FlowDesigner extends React.Component { 49 | node: any; 50 | 51 | static defaultProps = { 52 | snapToGrid: false, 53 | }; 54 | 55 | constructor(props: Props) { 56 | super(props); 57 | this.state = { 58 | nodeTypeMap: Map(), 59 | linkTypeMap: Map(), 60 | portTypeMap: Map(), 61 | }; 62 | } 63 | 64 | UNSAFE_componentWillMount() { 65 | const { children } = this.props; 66 | let nodeTypeMap = Map(); 67 | let linkTypeMap = Map(); 68 | let portTypeMap = Map(); 69 | if (children) { 70 | (children as any).forEach( 71 | (element: { 72 | type: { displayName: string }; 73 | props: { component: any; type: string }; 74 | }) => { 75 | switch (element.type.displayName) { 76 | case 'NodeType': 77 | nodeTypeMap = { 78 | ...nodeTypeMap, 79 | [element.props.type]: { 80 | component: element.props.component, 81 | }, 82 | }; 83 | break; 84 | case 'LinkType': 85 | linkTypeMap = { 86 | ...linkTypeMap, 87 | [element.props.type]: { 88 | component: element.props.component, 89 | }, 90 | }; 91 | break; 92 | case 'PortType': 93 | portTypeMap = { 94 | ...portTypeMap, 95 | [element.props.type]: { 96 | component: element.props.component, 97 | }, 98 | }; 99 | break; 100 | default: 101 | invariant( 102 | false, 103 | `<${element.type.displayName} /> is an unknown component configuration`, 104 | ); 105 | } 106 | }, 107 | ); 108 | } else { 109 | invariant(false, ' should have configuration component as child'); 110 | } 111 | 112 | this.props.setNodeTypes(nodeTypeMap); 113 | this.setState({ nodeTypeMap, linkTypeMap, portTypeMap }); 114 | } 115 | 116 | render() { 117 | return ( 118 | { 121 | this.node = c; 122 | }} 123 | width="100%" 124 | > 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 142 | 143 | 144 | 152 | 156 | 161 | 162 | 163 | 164 | ); 165 | } 166 | } 167 | 168 | const mapStateToProps = (state: State, ownProps: Props) => ({ 169 | nodes: get(state, ownProps.reduxMountPoint).get('nodes'), 170 | links: get(state, ownProps.reduxMountPoint).get('links'), 171 | ports: get(state, ownProps.reduxMountPoint).get('ports'), 172 | transform: get(state, ownProps.reduxMountPoint).get('transform'), 173 | transformToApply: get(state, ownProps.reduxMountPoint).get('transformToApply'), 174 | }); 175 | 176 | const mapDispatchToProps = (dispatch: any) => ({ 177 | setNodeTypes: (nodeTypeMap: Map) => dispatch(setNodeTypes(nodeTypeMap)), 178 | startMoveNodeTo: (nodeId: Id, nodePosition: string) => 179 | dispatch(startMoveNodeTo(nodeId, nodePosition)), 180 | moveNodeTo: (nodeId: Id, nodePosition: Position) => dispatch(moveNodeTo(nodeId, nodePosition)), 181 | moveNodeToEnd: (nodeId: Id, nodePosition: Position) => 182 | dispatch(moveNodeToEnd(nodeId, nodePosition)), 183 | setZoom: (transform: Transform) => dispatch(setZoom(transform)), 184 | }); 185 | 186 | const connector: any = connect(mapStateToProps, mapDispatchToProps); 187 | 188 | export default connector(FlowDesigner); 189 | -------------------------------------------------------------------------------- /src/api/data/data.test.ts: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as Data from './data'; 4 | 5 | export const isNotMapException = `Immutable.Map should be a Immutable.Map, was given 6 | """ 7 | object 8 | """ 9 | [object Map] 10 | """ 11 | `; 12 | export const isNotKeyException = 'key should be a string, was given 8 of type number'; 13 | 14 | describe('isMapElseThrow', () => { 15 | it('return true if parameter is an Map', () => { 16 | // given 17 | const testMap = Immutable.Map(); 18 | // when 19 | const test = Data.isMapElseThrow(testMap); 20 | // expect 21 | expect(test).toEqual(true); 22 | }); 23 | 24 | it('throw an error if parameter is not an Map', () => { 25 | // given 26 | const testMap = new Map(); 27 | // when 28 | // expect 29 | expect(() => Data.isMapElseThrow(testMap as any)).toThrow(isNotMapException); 30 | }); 31 | }); 32 | 33 | describe('isKeyElseThrow', () => { 34 | it('return true if parameter key is a String', () => { 35 | // given 36 | const testString = 'a String'; 37 | // when 38 | const test = Data.isKeyElseThrow(testString); 39 | // expect 40 | expect(test).toEqual(true); 41 | }); 42 | 43 | it('throw an error if parameter is not a String', () => { 44 | // given 45 | const testString = 8; 46 | // when 47 | // expect 48 | expect(() => Data.isKeyElseThrow(testString)).toThrow(isNotKeyException); 49 | }); 50 | }); 51 | 52 | describe('Data', () => { 53 | describe('set', () => { 54 | it('given a proper key and map update said map', () => { 55 | // given 56 | const key = 'key'; 57 | const value = 'value'; 58 | const map = Immutable.Map({ 59 | withValue: 'value', 60 | }); 61 | // when 62 | const test = Data.set(key, value, map); 63 | // expect 64 | expect(test.get(key)).toEqual(value); 65 | }); 66 | 67 | it('given an improper key throw', () => { 68 | // given 69 | const key = 8; 70 | const value = 'value'; 71 | const map = Immutable.Map({ 72 | withValue: 'value', 73 | }); 74 | // when 75 | // expect 76 | expect(() => Data.set(key, value, map)).toThrow(isNotKeyException); 77 | }); 78 | 79 | it('given an improper map throw', () => { 80 | // given 81 | const key = 'key'; 82 | const value = 'value'; 83 | const map = new Map(); 84 | // when 85 | // expect 86 | expect(() => Data.set(key, value, map as any)).toThrow(isNotMapException); 87 | }); 88 | }); 89 | 90 | describe('get', () => { 91 | it('given a key and map containing said key return value', () => { 92 | // given 93 | const key = 'key'; 94 | const value = 'value'; 95 | const map = Immutable.Map({ 96 | key: value, 97 | }); 98 | // when 99 | const test = Data.get(key, map); 100 | // expect 101 | expect(test).toEqual(value); 102 | }); 103 | 104 | it('given a key and map not containing said key return undefined', () => { 105 | // given 106 | const key = 'anotherKey'; 107 | const value = 'value'; 108 | const map = Immutable.Map({ 109 | key: value, 110 | }); 111 | // when 112 | const test = Data.get(key, map); 113 | // expect 114 | expect(test).toEqual(undefined); 115 | }); 116 | 117 | it('given an improper key throw', () => { 118 | // given 119 | const key = 8; 120 | const map = Immutable.Map({ 121 | withValue: 'value', 122 | }); 123 | // when 124 | // expect 125 | expect(() => Data.get(key, map)).toThrow(isNotKeyException); 126 | }); 127 | 128 | it('given an improper map throw', () => { 129 | // given 130 | const key = 'key'; 131 | const map = new Map(); 132 | // when 133 | // expect 134 | expect(() => Data.get(key, map as any)).toThrow(isNotMapException); 135 | }); 136 | }); 137 | 138 | describe('has', () => { 139 | it('given a key and map containing said key return true', () => { 140 | // given 141 | const key = 'key'; 142 | const value = 'value'; 143 | const map = Immutable.Map({ 144 | key: value, 145 | }); 146 | // when 147 | const test = Data.has(key, map); 148 | // expect 149 | expect(test).toEqual(true); 150 | }); 151 | 152 | it('given a key and map not containing said key return false', () => { 153 | // given 154 | const key = 'anotherKey'; 155 | const value = 'value'; 156 | const map = Immutable.Map({ 157 | key: value, 158 | }); 159 | // when 160 | const test = Data.has(key, map); 161 | // expect 162 | expect(test).toEqual(false); 163 | }); 164 | 165 | it('given an improper key throw', () => { 166 | // given 167 | const key = 8; 168 | const map = Immutable.Map({ 169 | withValue: 'value', 170 | }); 171 | // when 172 | // expect 173 | expect(() => Data.has(key, map)).toThrow(isNotKeyException); 174 | }); 175 | 176 | it('given an improper map throw', () => { 177 | // given 178 | const key = 'key'; 179 | const map = new Map(); 180 | // when 181 | // expect 182 | expect(() => Data.has(key, map as any)).toThrow(isNotMapException); 183 | }); 184 | }); 185 | 186 | describe('delete', () => { 187 | it('given a key and map containing said key return map without this value', () => { 188 | // given 189 | const key = 'key'; 190 | const value = 'value'; 191 | const map = Immutable.Map({ 192 | key: value, 193 | }); 194 | // when 195 | const test = Data.deleteKey(key, map); 196 | // expect 197 | expect(test).toEqual(Immutable.Map()); 198 | }); 199 | 200 | it('given a key and map not containing said key return same map', () => { 201 | // given 202 | const key = 'anotherKey'; 203 | const value = 'value'; 204 | const map = Immutable.Map({ 205 | key: value, 206 | }); 207 | // when 208 | const test = Data.deleteKey(key, map); 209 | // expect 210 | expect(test).toEqual(map); 211 | }); 212 | 213 | it('given an improper key throw', () => { 214 | // given 215 | const key = 8; 216 | const map = Immutable.Map({ 217 | withValue: 'value', 218 | }); 219 | // when 220 | // expect 221 | expect(() => Data.deleteKey(key, map)).toThrow(isNotKeyException); 222 | }); 223 | 224 | it('given an improper map throw', () => { 225 | // given 226 | const key = 'key'; 227 | const map = new Map(); 228 | // when 229 | // expect 230 | expect(() => Data.deleteKey(key, map as any)).toThrow(isNotMapException); 231 | }); 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /src/reducers/node.reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { defaultState } from './flow.reducer'; 4 | import nodeReducer from './node.reducer'; 5 | import { 6 | NodeRecord, 7 | PositionRecord, 8 | NodeGraphicalAttributes, 9 | } from '../constants/flowdesigner.model'; 10 | import { 11 | FLOWDESIGNER_NODE_SET_TYPE, 12 | FLOWDESIGNER_NODE_MOVE_START, 13 | FLOWDESIGNER_NODE_MOVE, 14 | FLOWDESIGNER_NODE_MOVE_END, 15 | } from '../constants/flowdesigner.constants'; 16 | 17 | describe('Check node reducer', () => { 18 | const initialState = defaultState 19 | .setIn( 20 | ['nodes', 'id1'], 21 | new NodeRecord({ 22 | id: 'id1', 23 | type: 'type1', 24 | data: Map({ type: 'test' }), 25 | graphicalAttributes: new NodeGraphicalAttributes({ 26 | type: 'type1', 27 | selected: true, 28 | position: new PositionRecord({ x: 10, y: 10 }), 29 | }), 30 | }), 31 | ) 32 | .setIn( 33 | ['nodes', 'id2'], 34 | new NodeRecord({ 35 | id: 'id2', 36 | type: 'type2', 37 | data: Map({ type: 'test' }), 38 | graphicalAttributes: new NodeGraphicalAttributes({ 39 | type: 'type2', 40 | selected: false, 41 | position: new PositionRecord({ x: 10, y: 10 }), 42 | }), 43 | }), 44 | ); 45 | 46 | it('FLOWDESIGNER_NODE_ADD properly add a new node to the node collection', () => { 47 | expect( 48 | nodeReducer(defaultState, { 49 | type: 'FLOWDESIGNER_NODE_ADD', 50 | nodeId: 'id', 51 | graphicalAttributes: { 52 | position: { x: 10, y: 10 }, 53 | }, 54 | }), 55 | ).toMatchSnapshot(); 56 | }); 57 | 58 | it('FLOWDESIGNER_NODE_ADD add a new node to the node collection with the right type', () => { 59 | expect( 60 | nodeReducer(defaultState, { 61 | type: 'FLOWDESIGNER_NODE_ADD', 62 | nodeId: 'id', 63 | graphicalAttributes: { 64 | name: 'test', 65 | nodePosition: { x: 10, y: 10 }, 66 | type: 'MY_NODE_TYPE', 67 | }, 68 | }), 69 | ).toMatchSnapshot(); 70 | }); 71 | 72 | it('Create a startPosition when receiving a FLOWDESIGNER_NODE_MOVE_START command', () => { 73 | expect( 74 | nodeReducer(initialState, { 75 | type: FLOWDESIGNER_NODE_MOVE_START, 76 | nodeId: 'id1', 77 | nodePosition: { x: 50, y: 50 }, 78 | }) 79 | .getIn(['nodes', 'id1', 'graphicalAttributes', 'properties', 'startPosition']) 80 | .toJS(), 81 | ).toEqual({ x: 50, y: 50 }); 82 | }); 83 | 84 | it('FLOWDESIGNER_NODE_MOVE update node position', () => { 85 | expect( 86 | nodeReducer(initialState, { 87 | type: FLOWDESIGNER_NODE_MOVE, 88 | nodeId: 'id2', 89 | nodePosition: { x: 50, y: 50 }, 90 | }), 91 | ).toMatchSnapshot(); 92 | }); 93 | 94 | it('empty the startPostion when receiving a FLOW_DESIGNER_MOVE_END command', () => { 95 | expect( 96 | nodeReducer(initialState, { 97 | type: FLOWDESIGNER_NODE_MOVE_END, 98 | nodeId: 'id1', 99 | nodePosition: { x: 50, y: 50 }, 100 | }).getIn(['nodes', 'id1', 'graphicalAttributes', 'properties', 'startPosition']), 101 | ).toEqual(undefined); 102 | }); 103 | 104 | it('FLOWDESIGNER_NODE_SET_SIZE update node size property', () => { 105 | expect( 106 | nodeReducer(initialState, { 107 | type: 'FLOWDESIGNER_NODE_SET_SIZE', 108 | nodeId: 'id1', 109 | nodeSize: { height: 200, width: 200 }, 110 | }), 111 | ).toMatchSnapshot(); 112 | }); 113 | 114 | it('FLOWDESIGNER_NODE_SET_TYPE update node type', () => { 115 | const nodeId = 'id1'; 116 | const nodeType = 'nodetype'; 117 | 118 | expect( 119 | nodeReducer(initialState, { 120 | type: FLOWDESIGNER_NODE_SET_TYPE, 121 | nodeId, 122 | nodeType, 123 | }), 124 | ).toMatchSnapshot(); 125 | }); 126 | 127 | it('FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES should add { selected: false } attribute to node graphicalAttributes map', () => { 128 | expect( 129 | nodeReducer(initialState, { 130 | type: 'FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES', 131 | nodeId: 'id1', 132 | graphicalAttributes: { selected: false }, 133 | }), 134 | ).toMatchSnapshot(); 135 | }); 136 | 137 | it('FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES should remove {selected} attribute to node graphicalAttributes map', () => { 138 | expect( 139 | nodeReducer(initialState, { 140 | type: 'FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES', 141 | nodeId: 'id1', 142 | graphicalAttributesKey: 'selected', 143 | }), 144 | ).toMatchSnapshot(); 145 | }); 146 | 147 | it("FLOWDESIGNER_NODE_SET_DATA should add { type: 'string' } attribute to node data map", () => { 148 | expect( 149 | nodeReducer(initialState, { 150 | type: 'FLOWDESIGNER_NODE_SET_DATA', 151 | nodeId: 'id1', 152 | data: { type: 'string' }, 153 | }), 154 | ).toMatchSnapshot(); 155 | }); 156 | 157 | it('FLOWDESIGNER_NODE_REMOVE_DATA should remove {type} attribute to node data map', () => { 158 | expect( 159 | nodeReducer(initialState, { 160 | type: 'FLOWDESIGNER_NODE_REMOVE_DATA', 161 | nodeId: 'id1', 162 | data: 'type', 163 | }), 164 | ).toMatchSnapshot(); 165 | }); 166 | 167 | it('FLOWDESIGNER_NODE_REMOVE should remove node from node collection', () => { 168 | expect( 169 | nodeReducer(initialState, { 170 | type: 'FLOWDESIGNER_NODE_REMOVE', 171 | nodeId: 'id1', 172 | }), 173 | ).toMatchSnapshot(); 174 | }); 175 | }); 176 | 177 | describe('FLOWDESIGNER_NODE_APPLY_MOVEMENT', () => { 178 | const initialState = defaultState 179 | .setIn( 180 | ['nodes', 'id1'], 181 | new NodeRecord({ 182 | id: 'id1', 183 | nodeType: 'type1', 184 | graphicalAttributes: new NodeGraphicalAttributes({ 185 | position: new PositionRecord({ x: 10, y: 10 }), 186 | }), 187 | }), 188 | ) 189 | .setIn( 190 | ['nodes', 'id2'], 191 | new NodeRecord({ 192 | id: 'id2', 193 | nodeType: 'type2', 194 | graphicalAttributes: new NodeGraphicalAttributes({ 195 | position: new PositionRecord({ x: 10, y: 10 }), 196 | }), 197 | }), 198 | ) 199 | .setIn( 200 | ['nodes', 'id3'], 201 | new NodeRecord({ 202 | id: 'id3', 203 | nodeType: 'type2', 204 | graphicalAttributes: new NodeGraphicalAttributes({ 205 | position: new PositionRecord({ x: 10, y: 10 }), 206 | }), 207 | }), 208 | ); 209 | it('should apply the same relative movement to each node listed', () => { 210 | expect( 211 | nodeReducer(initialState, { 212 | type: 'FLOWDESIGNER_NODE_APPLY_MOVEMENT', 213 | nodesId: ['id1', 'id2'], 214 | movement: { x: 10, y: 5 }, 215 | }), 216 | ).toMatchSnapshot(); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /src/components/node/AbstractNode.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { scaleLinear, drag, select } from 'd3'; 3 | import { Map } from 'immutable'; 4 | 5 | import invariant from 'invariant'; 6 | 7 | import { Node, Port, Position, Size } from '../../api'; 8 | import { GRID_SIZE, PORT_SINK, PORT_SOURCE } from '../../constants/flowdesigner.constants'; 9 | import { 10 | PortRecordMap, 11 | Position as PositionType, 12 | Size as SizeType, 13 | NodeRecord, 14 | Id, 15 | } from '../../customTypings/index.d'; 16 | 17 | export const ABSTRACT_NODE_INVARIANT = ` should not be used without giving it a children 18 | ex: `; 19 | 20 | /** 21 | * calculate the position of each ports for a given node information 22 | * @param ports 23 | * @param nodePosition 24 | * @param nodeSize 25 | */ 26 | function calculatePortPosition( 27 | ports: PortRecordMap, 28 | nodePosition: PositionType, 29 | nodeSize: SizeType, 30 | ) { 31 | let portsWithPosition = Map(); 32 | const emitterPorts = ports.filter(port => Port.getTopology(port) === PORT_SOURCE); 33 | const sinkPorts = ports.filter(port => Port.getTopology(port) === PORT_SINK); 34 | const range = [ 35 | Position.getYCoordinate(nodePosition), 36 | Position.getYCoordinate(nodePosition) + Size.getHeight(nodeSize), 37 | ]; 38 | const scaleYEmitter = scaleLinear() 39 | .domain([0, emitterPorts.size + 1]) 40 | .range(range); 41 | const scaleYSink = scaleLinear() 42 | .domain([0, sinkPorts.size + 1]) 43 | .range(range); 44 | let emitterNumber = 0; 45 | let sinkNumber = 0; 46 | emitterPorts 47 | .sort((a, b) => { 48 | if (Port.getIndex(a) < Port.getIndex(b)) { 49 | return -1; 50 | } 51 | if (Port.getIndex(a) > Port.getIndex(b)) { 52 | return 1; 53 | } 54 | return 0; 55 | }) 56 | .forEach(port => { 57 | emitterNumber += 1; 58 | 59 | const position = Position.create( 60 | Position.getXCoordinate(nodePosition) + Size.getWidth(nodeSize), 61 | scaleYEmitter(emitterNumber), 62 | ); 63 | portsWithPosition = portsWithPosition.set( 64 | Port.getId(port), 65 | Port.setPosition(port, position), 66 | ); 67 | }); 68 | sinkPorts 69 | .sort((a, b) => { 70 | if (Port.getIndex(a) < Port.getIndex(b)) { 71 | return -1; 72 | } 73 | if (Port.getIndex(a) > Port.getIndex(b)) { 74 | return 1; 75 | } 76 | return 0; 77 | }) 78 | .forEach(port => { 79 | sinkNumber += 1; 80 | const position = Position.create( 81 | Position.getXCoordinate(nodePosition), 82 | scaleYSink(sinkNumber), 83 | ); 84 | portsWithPosition = portsWithPosition.set( 85 | Port.getId(port), 86 | Port.setPosition(port, position), 87 | ); 88 | }); 89 | return portsWithPosition; 90 | } 91 | 92 | type Props = { 93 | node: NodeRecord; 94 | startMoveNodeTo: (nodeId: Id, nodePosition: PositionType) => void; 95 | moveNodeTo: (nodeId: Id, nodePosition: PositionType) => void; 96 | moveNodeToEnd: (nodeId: Id, nodePosition: PositionType) => void; 97 | snapToGrid?: boolean; 98 | onDragStart?: (event: any) => void; 99 | onDrag?: (event: any) => void; 100 | onDragEnd?: (event: any) => void; 101 | onClick?: React.MouseEventHandler; 102 | children?: any; 103 | }; 104 | 105 | class AbstractNode extends React.Component { 106 | static calculatePortPosition = calculatePortPosition; 107 | 108 | d3Node: any; 109 | 110 | nodeElement: any; 111 | 112 | squaredDeltaDrag: number = 0; 113 | 114 | constructor(props: Props) { 115 | super(props); 116 | this.onClick = this.onClick.bind(this); 117 | this.onDragStart = this.onDragStart.bind(this); 118 | this.onDrag = this.onDrag.bind(this); 119 | this.onDragEnd = this.onDragEnd.bind(this); 120 | this.renderContent = this.renderContent.bind(this); 121 | this.getEventPosition = this.getEventPosition.bind(this); 122 | } 123 | 124 | componentDidMount() { 125 | this.d3Node = select(this.nodeElement); 126 | this.d3Node.data([this.props.node.getPosition()]); 127 | this.d3Node.call( 128 | drag().on('start', this.onDragStart).on('drag', this.onDrag).on('end', this.onDragEnd), 129 | ); 130 | } 131 | 132 | UNSAFE_componentWillReceiveProps(nextProps: Props) { 133 | const nextPosition = Node.getPosition(nextProps.node); 134 | if (nextPosition !== Node.getPosition(this.props.node)) { 135 | this.d3Node.data([nextPosition]); 136 | } 137 | } 138 | 139 | shouldComponentUpdate(nextProps: Props) { 140 | return nextProps !== this.props; 141 | } 142 | 143 | componentWillUnmount() { 144 | this.d3Node.remove(); 145 | } 146 | 147 | onClick(clickEvent: React.MouseEvent) { 148 | if (this.props.onClick) { 149 | this.props.onClick(clickEvent); 150 | } 151 | } 152 | 153 | onDragStart(event: any) { 154 | this.squaredDeltaDrag = 0; 155 | const position = { 156 | x: event.x, 157 | y: event.y, 158 | }; 159 | this.props.startMoveNodeTo(this.props.node.id, position); 160 | if (this.props.onDragStart) { 161 | this.props.onDragStart(event); 162 | } 163 | } 164 | 165 | onDrag(event: any) { 166 | this.squaredDeltaDrag += event.dx * event.dx + event.dy * event.dy; 167 | const position = { 168 | x: event.x, 169 | y: event.y, 170 | movementX: event.sourceEvent.movementX, 171 | movementY: event.sourceEvent.movementY, 172 | }; 173 | this.props.moveNodeTo(this.props.node.id, position); 174 | if (this.props.onDrag) { 175 | this.props.onDrag(position); 176 | } 177 | } 178 | 179 | onDragEnd(event: any) { 180 | // Ok this is pretty specific 181 | // for a chrome windows bug 182 | // where d3 inhibit onCLick propagation 183 | // if there is any delta between down and up of the mouse 184 | // here we add a tolerance, so the underlying click doesn't 185 | // get smooshed if the user do not initiate drag 186 | if (this.squaredDeltaDrag < 1) { 187 | select(window).on('click.drag', null); 188 | } 189 | const position = this.getEventPosition(event); 190 | this.props.moveNodeToEnd(this.props.node.id, position); 191 | this.d3Node.data([position]); 192 | if (this.props.onDragEnd) { 193 | this.props.onDragEnd(position); 194 | } 195 | } 196 | 197 | getEventPosition(event: any) { 198 | if (this.props.snapToGrid) { 199 | return { 200 | x: event.x - (event.x % GRID_SIZE), 201 | y: event.y - (event.y % GRID_SIZE), 202 | }; 203 | } 204 | return { x: event.x, y: event.y }; 205 | } 206 | 207 | renderContent() { 208 | if (this.props.children) { 209 | return this.props.children; 210 | } 211 | invariant(false, ABSTRACT_NODE_INVARIANT); 212 | return null; 213 | } 214 | 215 | render() { 216 | const { node } = this.props; 217 | const { x, y } = Node.getPosition(node); 218 | const transform = `translate(${x}, ${y})`; 219 | return ( 220 | 221 | { 224 | this.nodeElement = c; 225 | }} 226 | onClick={this.onClick} 227 | > 228 | {this.renderContent()} 229 | 230 | 231 | ); 232 | } 233 | } 234 | 235 | export default AbstractNode; 236 | -------------------------------------------------------------------------------- /src/reducers/flow.reducer.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import { zoomIdentity } from 'd3'; 3 | import { 4 | FLOWDESIGNER_FLOW_ADD_ELEMENTS, 5 | FLOWDESIGNER_FLOW_RESET, 6 | FLOWDESIGNER_FLOW_LOAD, 7 | FLOWDESIGNER_FLOW_SET_ZOOM, 8 | FLOWDESIGNER_FLOW_ZOOM_IN, 9 | FLOWDESIGNER_FLOW_ZOOM_OUT, 10 | FLOWDESIGNER_PAN_TO, 11 | FLOWDESIGNER_NODETYPE_SET, 12 | } from '../constants/flowdesigner.constants'; 13 | import nodesReducer from './node.reducer'; 14 | import linksReducer from './link.reducer'; 15 | import portsReducer from './port.reducer'; 16 | import nodeTypeReducer from './nodeType.reducer'; 17 | import { State, NodeRecord, Id, LinkRecord, PortRecord } from '../customTypings/index.d'; 18 | 19 | export const defaultState: Partial = Map({ 20 | nodes: Map(), 21 | links: Map(), 22 | ports: Map(), 23 | out: Map>(), 24 | in: Map>(), 25 | childrens: Map>(), 26 | parents: Map>(), 27 | nodeTypes: Map(), 28 | transform: { k: 1, x: 0, y: 0 }, 29 | transformToApply: undefined, 30 | }); 31 | 32 | function combinedReducer(state = defaultState, action: any) { 33 | return [nodesReducer, linksReducer, portsReducer, nodeTypeReducer].reduce( 34 | (cumulatedState, subReducer) => subReducer(cumulatedState, action), 35 | state, 36 | ); 37 | } 38 | 39 | enum ZoomDirection { 40 | IN = 'IN', 41 | OUT = 'OUT', 42 | } 43 | 44 | const DEFAULT_ZOOM_SCALE_STEP: number = 0.1; 45 | 46 | /** 47 | * Return the new zoom value based on parameters. 48 | * @param currentZoom Current zoom value 49 | * @param zoomDirection Indicate if we want to zoom in or out 50 | * @param step The zoom change to apply 51 | * @returns The new zoom value, rounded to be a multiple of step value 52 | * @example 53 | * A zoom at 100% has a currentZoom value of 1 54 | * If you want to zoom by step of 25%, step value is 0.25 55 | * Example 1: 56 | * You are at 125%, you zoom in. We want to zoom to 150%. 57 | * We have (1.25 + 0.25) / 0.25 = 6, no rounding, so then 6 * 0.25 = 1.5 58 | * So the new zoom value is 1.5, which is 150%, that what we want. 59 | * Example 2: 60 | * You had zoomed with the mouse wheel (do not use steps) at 136%, and you zoom in. 61 | * We want to go to the next step, so we want to zoom to 150%. 62 | * We have (1.36 + 0.25) = 1.61, it should means a zoom at 161% 63 | * So the goal is to round by step, to do that we have to check how many step are in the new zoom value: 1.61 / 0.25 = 6.44 64 | * Because we are zooming in, we want to round down the value, in order to stop at the first step encountered: Math.floor(6.44) = 6 65 | * Now we can multiply by the step to retrieve to correct value: 6 * 0.25 = 1.5 66 | * Bingo, we have the right value and zoom to 150% 67 | */ 68 | function calculateZoomScale( 69 | currentZoom: number, 70 | zoomDirection: ZoomDirection, 71 | step: number, 72 | ): number { 73 | let zoomValue; 74 | if (zoomDirection === ZoomDirection.IN) { 75 | zoomValue = Math.floor((currentZoom + step) / step) * step; 76 | } else { 77 | zoomValue = Math.ceil((currentZoom - step) / step) * step; 78 | } 79 | 80 | // If the new zoom should be 0, we set a zoom at 1%. 81 | if (zoomValue < step) zoomValue = step; 82 | 83 | return zoomValue; 84 | } 85 | 86 | export function reducer(state: State, action: any) { 87 | switch (action.type) { 88 | case FLOWDESIGNER_FLOW_ADD_ELEMENTS: 89 | return action.listOfActionCreation.reduce( 90 | (cumulativeState: State, actionCreation: any) => 91 | combinedReducer(cumulativeState, actionCreation), 92 | state, 93 | ); 94 | case FLOWDESIGNER_FLOW_RESET: 95 | return defaultState.set('nodeTypes', state.get('nodeTypes')); 96 | case FLOWDESIGNER_FLOW_LOAD: 97 | return action.listOfActionCreation.reduce( 98 | (cumulativeState: State, actionCreation: any) => 99 | combinedReducer(cumulativeState, actionCreation), 100 | defaultState.set('nodeTypes', state.get('nodeTypes')), 101 | ); 102 | case FLOWDESIGNER_FLOW_SET_ZOOM: 103 | return state.set('transform', action.transform); 104 | case FLOWDESIGNER_FLOW_ZOOM_IN: 105 | return state.set( 106 | 'transformToApply', 107 | zoomIdentity 108 | .translate(state.get('transform').x, state.get('transform').y) 109 | .scale( 110 | calculateZoomScale( 111 | state.get('transform').k, 112 | ZoomDirection.IN, 113 | action.scale || DEFAULT_ZOOM_SCALE_STEP, 114 | ), 115 | ), 116 | ); 117 | case FLOWDESIGNER_FLOW_ZOOM_OUT: 118 | return state.set( 119 | 'transformToApply', 120 | zoomIdentity 121 | .translate(state.get('transform').x, state.get('transform').y) 122 | .scale( 123 | calculateZoomScale( 124 | state.get('transform').k, 125 | ZoomDirection.OUT, 126 | action.scale || DEFAULT_ZOOM_SCALE_STEP, 127 | ), 128 | ), 129 | ); 130 | case FLOWDESIGNER_PAN_TO: 131 | return state.update('transformToApply', () => 132 | zoomIdentity 133 | .translate(state.get('transform').x, state.get('transform').y) 134 | .scale(state.get('transform').k) 135 | .scale(1 / state.get('transform').k) 136 | .translate( 137 | -(state.get('transform').x + action.x), 138 | -(state.get('transform').y + action.y), 139 | ), 140 | ); 141 | default: 142 | return combinedReducer(state, action); 143 | } 144 | } 145 | 146 | /** 147 | * Calculate port position with the methods provided by port parent node 148 | * calcul is done only if node moved or list of attached port have its size changed 149 | * also update position if registered nodetype change 150 | * because the node hold the function used to calculate position of their attached port 151 | * Beware could be slow if the calculus methode provided is slow 152 | * @params {object} state react-flow-designer state 153 | * @params {object} action 154 | * 155 | * @return {object} new state 156 | */ 157 | export function calculatePortsPosition(state: State, action: any) { 158 | let nodes = []; 159 | // TODO: NOT a big fan of this way to optimize port recalculations, don't feel future proof 160 | if ( 161 | (/FLOWDESIGNER_NODE_/.exec(action.type) && action.type !== 'FLOWDESIGNER_NODE_REMOVE') || 162 | (/FLOWDESIGNER_PORT_/.exec(action.type) && action.type !== 'FLOWDESIGNER_PORT_REMOVE') || 163 | /FLOWDESIGNER.FLOW_/.exec(action.type) || 164 | action.type === FLOWDESIGNER_NODETYPE_SET 165 | ) { 166 | if (action.nodeId) { 167 | nodes.push(state.getIn(['nodes', action.nodeId])); 168 | } else if (action.portId) { 169 | nodes.push(state.getIn(['nodes'], state.getIn(['ports', action.portId]).nodeId)); 170 | } else { 171 | nodes = state.get('nodes'); 172 | } 173 | return nodes.reduce((cumulativeState: State, node: NodeRecord) => { 174 | const nodeType = node.getNodeType(); 175 | const ports = state.get('ports').filter((port: PortRecord) => port.nodeId === node.id); 176 | const component = state.getIn(['nodeTypes', nodeType, 'component']); 177 | if (component) { 178 | const calculatePortPosition = component.calculatePortPosition; 179 | if (calculatePortPosition) { 180 | return cumulativeState.mergeIn( 181 | ['ports'], 182 | calculatePortPosition(ports, node.getPosition(), node.getSize()), 183 | ); 184 | } 185 | } 186 | return state; 187 | }, state); 188 | } 189 | return state; 190 | } 191 | 192 | function flowDesignerReducer(state: State, action: any) { 193 | let newState = reducer(state, action); 194 | newState = calculatePortsPosition(newState, action); 195 | return newState; 196 | } 197 | 198 | export default flowDesignerReducer; 199 | -------------------------------------------------------------------------------- /src/selectors/nodeSelectors.test.ts: -------------------------------------------------------------------------------- 1 | import { List, Map, OrderedMap } from 'immutable'; 2 | import * as Selectors from './nodeSelectors'; 3 | import { 4 | NodeRecord, 5 | NestedNodeRecord, 6 | PortRecord, 7 | LinkRecord, 8 | } from '../constants/flowdesigner.model'; 9 | import { 10 | State, 11 | NodeRecord as NodeRecordType, 12 | NestedNodeRecord as NestedNodeRecordType, 13 | PortRecord as PortRecordType, 14 | LinkRecord as LinkRecordType, 15 | Id, 16 | } from '../customTypings/index.d'; 17 | 18 | describe('Testing node selectors', () => { 19 | const node1 = new NodeRecord({ 20 | id: 'id1', 21 | }); 22 | 23 | const node2 = new NodeRecord({ 24 | id: 'id2', 25 | }); 26 | 27 | const node3 = new NodeRecord({ 28 | id: 'id3', 29 | }); 30 | 31 | const node4 = new NodeRecord({ 32 | id: 'id4', 33 | }); 34 | 35 | const node5 = new NodeRecord({ 36 | id: 'id5', 37 | }); 38 | 39 | const node6 = new NodeRecord({ 40 | id: 'id6', 41 | }); 42 | 43 | const node7 = new NodeRecord({ 44 | id: 'id7', 45 | }); 46 | 47 | const node8 = new NodeRecord({ 48 | id: 'id8', 49 | }); 50 | 51 | const port1 = new PortRecord({ 52 | id: 'port1', 53 | nodeId: 'id1', 54 | }); 55 | 56 | const port2 = new PortRecord({ 57 | id: 'port2', 58 | nodeId: 'id2', 59 | }); 60 | 61 | const port3 = new PortRecord({ 62 | id: 'port3', 63 | nodeId: 'id2', 64 | }); 65 | 66 | const port4 = new PortRecord({ 67 | id: 'id4', 68 | nodeId: 'id2', 69 | }); 70 | 71 | const port5 = new PortRecord({ 72 | id: 'id5', 73 | nodeId: 'id3', 74 | }); 75 | 76 | const port6 = new PortRecord({ 77 | id: 'id6', 78 | nodeId: 'id3', 79 | }); 80 | 81 | const port7 = new PortRecord({ 82 | id: 'id7', 83 | nodeId: 'id4', 84 | }); 85 | 86 | const port8 = new PortRecord({ 87 | id: 'id8', 88 | nodeId: 'id4', 89 | }); 90 | 91 | const port9 = new PortRecord({ 92 | id: 'id9', 93 | nodeId: 'id5', 94 | }); 95 | 96 | const port10 = new PortRecord({ 97 | id: 'id10', 98 | nodeId: 'id5', 99 | }); 100 | 101 | const port11 = new PortRecord({ 102 | id: 'id11', 103 | nodeId: 'id6', 104 | }); 105 | 106 | const port12 = new PortRecord({ 107 | id: 'id12', 108 | nodeId: 'id6', 109 | }); 110 | 111 | const port13 = new PortRecord({ 112 | id: 'id13', 113 | nodeId: 'id7', 114 | }); 115 | 116 | const port14 = new PortRecord({ 117 | id: 'id14', 118 | nodeId: 'id7', 119 | }); 120 | 121 | const port15 = new PortRecord({ 122 | id: 'id15', 123 | nodeId: 'id7', 124 | }); 125 | 126 | const port16 = new PortRecord({ 127 | id: 'id16', 128 | nodeId: 'id8', 129 | }); 130 | 131 | const link1 = new LinkRecord({ 132 | id: 'id1', 133 | sourceId: 'id1', 134 | targetId: 'id2', 135 | graphicalAttributes: Map().set('attr', 'attr'), 136 | }); 137 | 138 | const link2 = new LinkRecord({ 139 | id: 'id2', 140 | sourceId: 'id3', 141 | targetId: 'id5', 142 | graphicalAttributes: Map().set('attr', 'attr'), 143 | }); 144 | const link3 = new LinkRecord({ 145 | id: 'id3', 146 | sourceId: 'id6', 147 | targetId: 'id9', 148 | graphicalAttributes: Map().set('attr', 'attr'), 149 | }); 150 | const link4 = new LinkRecord({ 151 | id: 'id4', 152 | sourceId: 'id10', 153 | targetId: 'id13', 154 | graphicalAttributes: Map().set('attr', 'attr'), 155 | }); 156 | const link5 = new LinkRecord({ 157 | id: 'id5', 158 | sourceId: 'id15', 159 | targetId: 'id16', 160 | graphicalAttributes: Map().set('attr', 'attr'), 161 | }); 162 | const link6 = new LinkRecord({ 163 | id: 'id6', 164 | sourceId: 'id4', 165 | targetId: 'id7', 166 | graphicalAttributes: Map().set('attr', 'attr'), 167 | }); 168 | const link7 = new LinkRecord({ 169 | id: 'id7', 170 | sourceId: 'id8', 171 | targetId: 'id11', 172 | graphicalAttributes: Map().set('attr', 'attr'), 173 | }); 174 | const link8 = new LinkRecord({ 175 | id: 'id8', 176 | sourceId: 'id12', 177 | targetId: 'id14', 178 | graphicalAttributes: Map().set('attr', 'attr'), 179 | }); 180 | 181 | const givenState: State = Map({ 182 | nodes: Map() 183 | .set('id1', node1) 184 | .set('id2', node2) 185 | .set('id3', node3) 186 | .set('id4', node4) 187 | .set('id5', node5) 188 | .set('id6', node6) 189 | .set('id7', node7) 190 | .set('id8', node8), 191 | // eslint-disable-next-line new-cap 192 | ports: OrderedMap() 193 | .set('id1', port1) 194 | .set('id2', port2) 195 | .set('id3', port3) 196 | .set('id4', port4) 197 | .set('id5', port5) 198 | .set('id6', port6) 199 | .set('id7', port7) 200 | .set('id8', port8) 201 | .set('id9', port9) 202 | .set('id10', port10) 203 | .set('id11', port11) 204 | .set('id12', port12) 205 | .set('id13', port13) 206 | .set('id14', port14) 207 | .set('id15', port15) 208 | .set('id16', port16), 209 | links: Map() 210 | .set('id1', link1) 211 | .set('id2', link2) 212 | .set('id3', link3) 213 | .set('id4', link4) 214 | .set('id5', link5) 215 | .set('id6', link6) 216 | .set('id7', link7) 217 | .set('id8', link8), 218 | parents: Map>() 219 | .set('id1', Map({})) 220 | .set('id2', Map({ id1: 'id1' })) 221 | .set('id3', Map({ id2: 'id2' })) 222 | .set('id4', Map({ id2: 'id2' })) 223 | .set('id5', Map({ id3: 'id3' })) 224 | .set('id6', Map({ id4: 'id4' })) 225 | .set('id7', Map({ id5: 'id5', id6: 'id6' })) 226 | .set('id8', Map({ id7: 'id7' })), 227 | childrens: Map>() 228 | .set('id1', Map({ id2: 'id2' })) 229 | .set('id2', Map({ id3: 'id3', id4: 'id4' })) 230 | .set('id3', Map({ id5: 'id5' })) 231 | .set('id4', Map({ id6: 'id6' })) 232 | .set('id5', Map({ id7: 'id7' })) 233 | .set('id6', Map({ id7: 'id7' })) 234 | .set('id7', Map({ id8: 'id8' })) 235 | .set('id8', Map({})), 236 | }); 237 | 238 | it('node1 should not have any predecessors', () => { 239 | expect(Selectors.getPredecessors(givenState, 'id1')).toMatchSnapshot(); 240 | }); 241 | 242 | it('node1 should have 7 successors', () => { 243 | expect(Selectors.getSuccessors(givenState, 'id1')).toMatchSnapshot(); 244 | }); 245 | 246 | it('node8 should have 7 predecessors', () => { 247 | expect(Selectors.getPredecessors(givenState, 'id8')).toMatchSnapshot(); 248 | }); 249 | 250 | it('node8 should not have any successors', () => { 251 | expect(Selectors.getSuccessors(givenState, 'id8')).toMatchSnapshot(); 252 | }); 253 | 254 | it('node4 should have node1, node2 as predecessors', () => { 255 | expect(Selectors.getPredecessors(givenState, 'id4')).toMatchSnapshot(); 256 | }); 257 | 258 | it('node4 should have node6, node7, node8 as successors', () => { 259 | expect(Selectors.getSuccessors(givenState, 'id4')).toMatchSnapshot(); 260 | }); 261 | }); 262 | 263 | describe('Testing node selectors on nested nodes', () => { 264 | const nodeA1 = new NestedNodeRecord({ 265 | id: 'nodeIdA1', 266 | components: List(), 267 | }); 268 | const nodeA = new NestedNodeRecord({ 269 | id: 'nodeIdA', 270 | components: List([nodeA1]), 271 | }); 272 | 273 | const nodeB = new NestedNodeRecord({ 274 | id: 'nodeIdB', 275 | }); 276 | 277 | const givenState: State = Map({ 278 | nodes: Map().set('nodeIdA', nodeA).set('nodeIdB', nodeB), 279 | // eslint-disable-next-line new-cap 280 | ports: OrderedMap(), 281 | links: Map(), 282 | parents: Map>(), 283 | childrens: Map>(), 284 | }); 285 | 286 | it('nodeA should not have 1 embeded child and node B 0 children', () => { 287 | expect(givenState.get('nodes').get('nodeIdA').get('components').size).toBe(1); 288 | expect(givenState.get('nodes').get('nodeIdB').get('components').size).toBe(0); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /src/reducers/port.reducer.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { Map, fromJS } from 'immutable'; 3 | 4 | import { PortRecord, PositionRecord } from '../constants/flowdesigner.model'; 5 | import { removeLink } from '../actions/link.actions'; 6 | import linkReducer from './link.reducer'; 7 | import { portOutLink, portInLink } from '../selectors/linkSelectors'; 8 | 9 | import { 10 | FLOWDESIGNER_PORT_ADD, 11 | FLOWDESIGNER_PORT_ADDS, 12 | FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES, 13 | FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES, 14 | FLOWDESIGNER_PORT_SET_DATA, 15 | FLOWDESIGNER_PORT_REMOVE_DATA, 16 | FLOWDESIGNER_PORT_REMOVE, 17 | PORT_SINK, 18 | PORT_SOURCE, 19 | } from '../constants/flowdesigner.constants'; 20 | import { 21 | PortRecordMap, 22 | Id, 23 | LinkRecord as LinkRecordType, 24 | PortRecord as PortRecordType, 25 | PortDirection, 26 | State, 27 | PortAction, 28 | } from '../customTypings/index.d'; 29 | 30 | /** 31 | * get ports attached to a node 32 | */ 33 | function filterPortsByNode(ports: PortRecordMap, nodeId: Id): PortRecordMap { 34 | return ports.filter((port: PortRecordType) => port.nodeId === nodeId) as PortRecordMap; 35 | } 36 | 37 | /** 38 | * get ports of direction EMITTER or SINK 39 | */ 40 | function filterPortsByDirection(ports: PortRecordMap, direction: PortDirection): PortRecordMap { 41 | return ports.filter( 42 | (port: PortRecordType) => port.getPortDirection() === direction, 43 | ) as PortRecordMap; 44 | } 45 | 46 | /** 47 | * for a new port calculate its index by retrieving all its siblings 48 | */ 49 | function calculateNewPortIndex(ports: PortRecordMap, port: PortRecordType): number { 50 | return filterPortsByDirection( 51 | filterPortsByNode(ports, port.nodeId), 52 | port.graphicalAttributes.properties.type, 53 | ).size; 54 | } 55 | 56 | function indexPortMap(ports: PortRecordMap): PortRecordMap { 57 | let i = 0; 58 | return ports 59 | .sort((a, b) => { 60 | if (a.getIndex() < b.getIndex()) { 61 | return -1; 62 | } 63 | if (a.getIndex() > b.getIndex()) { 64 | return 1; 65 | } 66 | return 0; 67 | }) 68 | .map(port => { 69 | i += 1; 70 | return port.setIndex(i - 1); 71 | }) as PortRecordMap; 72 | } 73 | 74 | /** 75 | * @todo migration to new API 76 | * @param {*} state 77 | * @param {*} port 78 | */ 79 | function setPort(state: State, port: PortRecordType) { 80 | const index: number = 81 | port.graphicalAttributes.properties.index || 82 | calculateNewPortIndex(state.get('ports'), port); 83 | const newState = state.setIn( 84 | ['ports', port.id], 85 | new PortRecord({ 86 | id: port.id, 87 | nodeId: port.nodeId, 88 | data: Map(port.data).set( 89 | 'properties', 90 | fromJS(port.data && port.data.properties) || Map(), 91 | ), 92 | graphicalAttributes: Map(port.graphicalAttributes) 93 | .set('position', new PositionRecord(port.graphicalAttributes.position)) 94 | .set( 95 | 'properties', 96 | fromJS( 97 | port.graphicalAttributes && { 98 | index, 99 | ...port.graphicalAttributes.properties, 100 | }, 101 | ) || Map(), 102 | ), 103 | }), 104 | ); 105 | const type = port.graphicalAttributes.properties.type; 106 | if (type === PORT_SOURCE) { 107 | return newState.setIn(['out', port.nodeId, port.id], Map()); 108 | } else if (type === PORT_SINK) { 109 | return newState.setIn(['in', port.nodeId, port.id], Map()); 110 | } 111 | invariant( 112 | false, 113 | `Can't set a new port ${port.id} if it 114 | data.graphicalAttributes.properties.type !== EMITTER || SINK, 115 | given ${port.graphicalAttributes.properties.type}`, 116 | ); 117 | return state; 118 | } 119 | 120 | export default function portReducer(state: State, action: PortAction): State { 121 | switch (action.type) { 122 | case FLOWDESIGNER_PORT_ADD: 123 | if (!state.getIn(['nodes', action.nodeId])) { 124 | invariant( 125 | false, 126 | `Can't set a new port ${action.id} on non existing node ${action.nodeId}`, 127 | ); 128 | } 129 | 130 | return setPort(state, { 131 | id: action.id, 132 | nodeId: action.nodeId, 133 | data: action.data, 134 | graphicalAttributes: action.graphicalAttributes, 135 | }); 136 | case FLOWDESIGNER_PORT_ADDS: { 137 | const localAction = action; 138 | if (!state.getIn(['nodes', action.nodeId])) { 139 | invariant(false, `Can't set a new ports on non existing node ${action.nodeId}`); 140 | } 141 | return action.ports.reduce( 142 | (cumulatedState, port) => 143 | setPort(cumulatedState, { 144 | id: port.id, 145 | nodeId: localAction.nodeId, 146 | data: port.data, 147 | graphicalAttributes: port.graphicalAttributes, 148 | }), 149 | state, 150 | ); 151 | } 152 | case FLOWDESIGNER_PORT_SET_GRAPHICAL_ATTRIBUTES: 153 | if (!state.getIn(['ports', action.portId])) { 154 | invariant( 155 | false, 156 | `Can't set an graphical attribute on non existing port ${action.portId}`, 157 | ); 158 | } 159 | 160 | try { 161 | return state.mergeIn( 162 | ['ports', action.portId, 'graphicalAttributes'], 163 | fromJS(action.graphicalAttributes), 164 | ); 165 | } catch (error) { 166 | console.error(error); 167 | return state.mergeIn( 168 | ['ports', action.portId, 'graphicalAttributes', 'properties'], 169 | fromJS(action.graphicalAttributes), 170 | ); 171 | } 172 | 173 | case FLOWDESIGNER_PORT_REMOVE_GRAPHICAL_ATTRIBUTES: 174 | if (!state.getIn(['ports', action.portId])) { 175 | invariant( 176 | false, 177 | `Can't remove a graphical attribute on non existing port ${action.portId}`, 178 | ); 179 | } 180 | 181 | return state.deleteIn([ 182 | 'ports', 183 | action.portId, 184 | 'graphicalAttributes', 185 | 'properties', 186 | action.graphicalAttributesKey, 187 | ]); 188 | case FLOWDESIGNER_PORT_SET_DATA: 189 | if (!state.getIn(['ports', action.portId])) { 190 | invariant(false, `Can't set a data on non existing port ${action.portId}`); 191 | } 192 | 193 | try { 194 | return state.mergeIn(['ports', action.portId, 'data'], fromJS(action.data)); 195 | } catch (error) { 196 | console.error(error); 197 | return state.mergeIn( 198 | ['ports', action.portId, 'data', 'properties'], 199 | fromJS(action.data), 200 | ); 201 | } 202 | 203 | case FLOWDESIGNER_PORT_REMOVE_DATA: 204 | if (!state.getIn(['ports', action.portId])) { 205 | invariant(false, `Can't remove a data on non existing port ${action.portId}`); 206 | } 207 | 208 | return state.deleteIn(['ports', action.portId, 'data', 'properties', action.dataKey]); 209 | case FLOWDESIGNER_PORT_REMOVE: { 210 | if (!state.getIn(['ports', action.portId])) { 211 | invariant(false, `Can not remove port ${action.portId} since it doesn't exist`); 212 | } 213 | const port: PortRecordType | null | undefined = state.getIn(['ports', action.portId]); 214 | if (port) { 215 | const newState = portInLink(state, action.portId) 216 | .reduce( 217 | (cumulativeState: State, link: LinkRecordType) => 218 | linkReducer(cumulativeState, removeLink(link.id)), 219 | portOutLink(state, action.portId).reduce( 220 | (cumulativeState: State, link: LinkRecordType) => 221 | linkReducer(cumulativeState, removeLink(link.id)), 222 | state, 223 | ), 224 | ) 225 | .deleteIn(['ports', action.portId]) 226 | .deleteIn([ 227 | 'out', 228 | state.getIn(['ports', action.portId, 'nodeId']), 229 | action.portId, 230 | ]) 231 | .deleteIn([ 232 | 'in', 233 | state.getIn(['ports', action.portId, 'nodeId']), 234 | action.portId, 235 | ]); 236 | return newState.mergeDeep({ 237 | ports: indexPortMap( 238 | filterPortsByDirection( 239 | filterPortsByNode(newState.get('ports'), port.nodeId), 240 | port.getPortDirection(), 241 | ), 242 | ), 243 | }); 244 | } 245 | return state; 246 | } 247 | default: 248 | return state; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/reducers/node.reducer.ts: -------------------------------------------------------------------------------- 1 | import Immutable, { Map, fromJS } from 'immutable'; 2 | import invariant from 'invariant'; 3 | import { removePort } from '../actions/port.actions'; 4 | import portReducer from './port.reducer'; 5 | import { outPort, inPort } from '../selectors/portSelectors'; 6 | 7 | import { 8 | FLOWDESIGNER_NODE_ADD, 9 | FLOWDESIGNER_NODE_MOVE_START, 10 | FLOWDESIGNER_NODE_MOVE, 11 | FLOWDESIGNER_NODE_APPLY_MOVEMENT, 12 | FLOWDESIGNER_NODE_MOVE_END, 13 | FLOWDESIGNER_NODE_SET_TYPE, 14 | FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES, 15 | FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES, 16 | FLOWDESIGNER_NODE_SET_DATA, 17 | FLOWDESIGNER_NODE_REMOVE_DATA, 18 | FLOWDESIGNER_NODE_SET_SIZE, 19 | FLOWDESIGNER_NODE_REMOVE, 20 | FLOWDESIGNER_NODE_UPDATE, 21 | } from '../constants/flowdesigner.constants'; 22 | import { Node } from '../api'; 23 | import { 24 | NodeRecord, 25 | PositionRecord, 26 | SizeRecord, 27 | NodeGraphicalAttributes, 28 | } from '../constants/flowdesigner.model'; 29 | import { PortRecord, Id, State, NodeRecordMap } from '../customTypings/index.d'; 30 | 31 | const defaultState = Map(); 32 | const nodeReducer = (state: State = defaultState, action: any) => { 33 | switch (action.type) { 34 | case FLOWDESIGNER_NODE_ADD: 35 | if (state.getIn(['nodes', action.nodeId])) { 36 | invariant( 37 | false, 38 | `Can not create node ${action.nodeId} since it does already exist`, 39 | ); 40 | } 41 | 42 | return state 43 | .setIn( 44 | ['nodes', action.nodeId], 45 | new NodeRecord({ 46 | id: action.nodeId, 47 | type: action.nodeType, 48 | data: Immutable.Map(action.data).set( 49 | 'properties', 50 | fromJS(action.data && action.data.properties) || Map(), 51 | ), 52 | graphicalAttributes: new NodeGraphicalAttributes( 53 | fromJS(action.graphicalAttributes), 54 | ) 55 | .set('nodeSize', new SizeRecord(action.graphicalAttributes.nodeSize)) 56 | .set( 57 | 'position', 58 | new PositionRecord(action.graphicalAttributes.position), 59 | ) 60 | .set( 61 | 'properties', 62 | fromJS(action.graphicalAttributes.properties) || Map(), 63 | ), 64 | }), 65 | ) 66 | .setIn(['out', action.nodeId], Map()) 67 | .setIn(['in', action.nodeId], Map()) 68 | .setIn(['childrens', action.nodeId], Map()) 69 | .setIn(['parents', action.nodeId], Map()); 70 | case FLOWDESIGNER_NODE_UPDATE: 71 | if (action.nodeId === Node.getId(action.node)) { 72 | return state.setIn(['nodes', Node.getId(action.node)], action.node); 73 | } // special case here, the id got changed and it have lots of implication 74 | 75 | return state 76 | .setIn(['nodes', Node.getId(action.node)], action.node) 77 | .deleteIn(['nodes', action.nodeId]) 78 | .setIn(['out', Node.getId(action.node)], Map()) 79 | .setIn(['in', Node.getId(action.node)], Map()) 80 | .setIn(['childrens', Node.getId(action.node)], Map()) 81 | .setIn(['parents', Node.getId(action.node)], Map()); 82 | case FLOWDESIGNER_NODE_MOVE_START: 83 | if (!state.getIn('nodes', action.nodeId)) { 84 | invariant(false, `Can't move node ${action.nodeId} since it doesn't exist`); 85 | } 86 | 87 | return state.setIn( 88 | ['nodes', action.nodeId, 'graphicalAttributes', 'properties', 'startPosition'], 89 | new PositionRecord(action.nodePosition), 90 | ); 91 | case FLOWDESIGNER_NODE_MOVE: 92 | if (!state.getIn('nodes', action.nodeId)) { 93 | invariant(false, `Can't move node ${action.nodeId} since it doesn't exist`); 94 | } 95 | 96 | return state.setIn( 97 | ['nodes', action.nodeId, 'graphicalAttributes', 'position'], 98 | new PositionRecord(action.nodePosition), 99 | ); 100 | case FLOWDESIGNER_NODE_MOVE_END: 101 | if (!state.getIn('nodes', action.nodeId)) { 102 | invariant(false, `Can't move node ${action.nodeId} since it doesn't exist`); 103 | } 104 | 105 | return state 106 | .setIn( 107 | ['nodes', action.nodeId, 'graphicalAttributes', 'position'], 108 | new PositionRecord(action.nodePosition), 109 | ) 110 | .deleteIn([ 111 | 'nodes', 112 | action.nodeId, 113 | 'graphicalAttributes', 114 | 'properties', 115 | 'startPosition', 116 | ]); 117 | case FLOWDESIGNER_NODE_APPLY_MOVEMENT: 118 | return state.update('nodes', (nodes: NodeRecordMap) => 119 | nodes.map(node => { 120 | if (action.nodesId.find((id: Id) => id === node.id)) { 121 | return node 122 | .setIn( 123 | ['graphicalAttributes', 'position', 'x'], 124 | node.getPosition().x + action.movement.x, 125 | ) 126 | .setIn( 127 | ['graphicalAttributes', 'position', 'y'], 128 | node.getPosition().y + action.movement.y, 129 | ); 130 | } 131 | return node; 132 | }), 133 | ); 134 | case FLOWDESIGNER_NODE_SET_SIZE: 135 | if (!state.getIn(['nodes', action.nodeId])) { 136 | invariant(false, `Can't set size on node ${action.nodeId} since it doesn't exist`); 137 | } 138 | 139 | return state.setIn( 140 | ['nodes', action.nodeId, 'graphicalAttributes', 'nodeSize'], 141 | new SizeRecord(action.nodeSize), 142 | ); 143 | case FLOWDESIGNER_NODE_SET_TYPE: 144 | if (!state.getIn(['nodes', action.nodeId])) { 145 | invariant( 146 | false, 147 | `Can't set node.type on node ${action.nodeid} since it doesn't exist`, 148 | ); 149 | } 150 | 151 | return state.setIn(['nodes', action.nodeId, 'type'], action.nodeType); 152 | case FLOWDESIGNER_NODE_SET_GRAPHICAL_ATTRIBUTES: 153 | if (!state.getIn(['nodes', action.nodeId])) { 154 | invariant( 155 | false, 156 | `Can't set a graphical attribute on non existing node ${action.nodeId}`, 157 | ); 158 | } 159 | 160 | try { 161 | return state.mergeIn( 162 | ['nodes', action.nodeId, 'graphicalAttributes'], 163 | fromJS(action.graphicalAttributes), 164 | ); 165 | } catch (error) { 166 | console.error(error); 167 | return state.mergeIn( 168 | ['nodes', action.nodeId, 'graphicalAttributes', 'properties'], 169 | fromJS(action.graphicalAttributes), 170 | ); 171 | } 172 | 173 | case FLOWDESIGNER_NODE_REMOVE_GRAPHICAL_ATTRIBUTES: 174 | if (!state.getIn(['nodes', action.nodeId])) { 175 | invariant( 176 | false, 177 | `Can't remove a graphical attribute on non existing node ${action.nodeId}`, 178 | ); 179 | } 180 | 181 | return state.deleteIn([ 182 | 'nodes', 183 | action.nodeId, 184 | 'graphicalAttributes', 185 | 'properties', 186 | action.graphicalAttributesKey, 187 | ]); 188 | case FLOWDESIGNER_NODE_SET_DATA: 189 | if (!state.getIn(['nodes', action.nodeId])) { 190 | invariant(false, `Can't set a data on non existing node ${action.nodeId}`); 191 | } 192 | 193 | try { 194 | return state.mergeIn(['nodes', action.nodeId, 'data'], fromJS(action.data)); 195 | } catch (error) { 196 | console.error(error); 197 | return state.mergeIn( 198 | ['nodes', action.nodeId, 'data', 'properties'], 199 | fromJS(action.data), 200 | ); 201 | } 202 | 203 | case FLOWDESIGNER_NODE_REMOVE_DATA: 204 | if (!state.getIn(['nodes', action.nodeId])) { 205 | invariant(false, `Can't remove a data on non existing node ${action.nodeId}`); 206 | } 207 | 208 | return state.deleteIn(['nodes', action.nodeId, 'data', 'properties', action.dataKey]); 209 | case FLOWDESIGNER_NODE_REMOVE: 210 | if (!state.getIn(['nodes', action.nodeId])) { 211 | invariant(false, `Can not remove node ${action.nodeId} since it doesn't exist`); 212 | } 213 | 214 | return inPort(state, action.nodeId) 215 | .reduce( 216 | (cumulativeState: State, port: PortRecord, key: Id) => 217 | portReducer(cumulativeState, removePort(key)), 218 | outPort(state, action.nodeId).reduce( 219 | (cumulativeState: State, port: PortRecord, key: Id) => 220 | portReducer(cumulativeState, removePort(key)), 221 | state, 222 | ), 223 | ) 224 | .deleteIn(['nodes', action.nodeId]) 225 | .deleteIn(['out', action.nodeId]) 226 | .deleteIn(['in', action.nodeId]) 227 | .deleteIn(['childrens', action.nodeId]) 228 | .deleteIn(['parents', action.nodeId]); 229 | default: 230 | return state; 231 | } 232 | }; 233 | 234 | export default nodeReducer; 235 | -------------------------------------------------------------------------------- /src/api/link/link.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is public and deal with Graph's object Links 3 | */ 4 | 5 | import curry from 'lodash/curry'; 6 | import flow from 'lodash/flow'; 7 | import indexOf from 'lodash/indexOf'; 8 | import isString from 'lodash/isString'; 9 | import upperFirst from 'lodash/upperFirst'; 10 | 11 | import { throwInDev, throwTypeError } from '../throwInDev'; 12 | import { LinkRecord } from '../../constants/flowdesigner.model'; 13 | import * as Data from '../data/data'; 14 | import { LinkRecord as LinkRecordType, Id } from '../../customTypings/index.d'; 15 | 16 | const linkTypeSelector = ['graphicalAttributes', 'linkType']; 17 | 18 | /** in future properties should be removed from the react-flow-designer lib */ 19 | const FORBIDEN_GRAPHICAL_ATTRIBUTES = ['properties', 'linkType']; 20 | 21 | /** 22 | * @desc represent a link between Port of the flow diagram 23 | * @typedef {Immutable.Record} LinkRecord 24 | */ 25 | 26 | /** 27 | * Test if the first parameter is a LinkRecord instance 28 | * @param {LinkRecord} link 29 | * @return {bool} 30 | * @throws 31 | */ 32 | export function isLink(link: LinkRecordType) { 33 | if (link && link instanceof LinkRecord) { 34 | return true; 35 | } 36 | return false; 37 | } 38 | 39 | /** 40 | * Test if the first parameter is a LinkRecord, throw if not 41 | * @param {*} link 42 | * @return {bool} 43 | * @throws 44 | */ 45 | export function isLinkElseThrow(link: LinkRecordType) { 46 | const test = isLink(link); 47 | if (!test) { 48 | throwTypeError('Linkrecord', link, 'Link'); 49 | } 50 | return test; 51 | } 52 | 53 | /** 54 | 55 | * @param {LinkRecord} link 56 | * @return {string} 57 | */ 58 | export function getId(link: LinkRecordType) { 59 | if (isLinkElseThrow(link)) { 60 | return link.get('id'); 61 | } 62 | return null; 63 | } 64 | 65 | /** 66 | * @function 67 | * @param {string} id 68 | * @param {LinkRecord} link 69 | * @return {LinkRecord} 70 | */ 71 | export const setId = curry((id: Id, link: LinkRecordType) => { 72 | if (isString(id) && isLinkElseThrow(link)) { 73 | return link.set('id', id); 74 | } 75 | throwInDev(`id should be a string, was given ${id && id.toString()}`); 76 | return link; 77 | }); 78 | 79 | /** 80 | * @param {LinkRecord} link 81 | * @return {string} 82 | */ 83 | export function getSourceId(link: LinkRecordType) { 84 | if (isLinkElseThrow(link)) { 85 | return link.get('sourceId'); 86 | } 87 | return null; 88 | } 89 | 90 | /** 91 | * @function 92 | * @param {string} sourceId 93 | * @param {LinkRecord} link 94 | * @return {LinkRecord} 95 | */ 96 | export const setSourceId = curry((sourceId: Id, link: LinkRecordType) => { 97 | if (isString(sourceId) && isLinkElseThrow(link)) { 98 | return link.set('sourceId', sourceId); 99 | } 100 | throwInDev(`id should be a string, was given ${sourceId && sourceId.toString()}`); 101 | return link; 102 | }); 103 | 104 | /** 105 | * @param {LinkRecord} link 106 | * @return {string} 107 | */ 108 | export function getTargetId(link: LinkRecordType) { 109 | if (isLinkElseThrow(link)) { 110 | return link.get('targetId'); 111 | } 112 | return null; 113 | } 114 | 115 | /** 116 | * @function 117 | * @param {string} targetId 118 | * @param {LinkRecord} link 119 | * @return {LinkRecord} 120 | */ 121 | export const setTargetId = curry((targetId: Id, link: LinkRecordType) => { 122 | if (isString(targetId) && isLinkElseThrow(link)) { 123 | return link.set('targetId', targetId); 124 | } 125 | throwInDev(`id should be a string, was given ${targetId && targetId.toString()}`); 126 | return link; 127 | }); 128 | 129 | /** 130 | * @param {LinkRecord} link 131 | * @return {string} 132 | */ 133 | export function getComponentType(link: LinkRecordType) { 134 | if (isLinkElseThrow(link)) { 135 | return link.getIn(linkTypeSelector); 136 | } 137 | return null; 138 | } 139 | 140 | /** 141 | * @function 142 | * @param {string} linkType 143 | * @param {LinkRecord} link 144 | * @return {LinkRecord} 145 | */ 146 | export const setComponentType = curry((linkType: string, link: LinkRecordType) => { 147 | if (isString(linkType) && isLinkElseThrow(link)) { 148 | return link.setIn(linkTypeSelector, linkType); 149 | } 150 | throwInDev(`linkType should be a string, was given ${linkType && linkType.toString()}`); 151 | return link; 152 | }); 153 | 154 | /** 155 | * @function 156 | * @param {string} key 157 | * @param {any} value 158 | * @param {LinkRecord} link 159 | * @return {LinkRecord} 160 | */ 161 | export const setData = curry((key: string, value: any, link: LinkRecordType) => { 162 | if (isLinkElseThrow(link)) { 163 | return link.set('data', Data.set(key, value, link.get('data'))); 164 | } 165 | return link; 166 | }); 167 | 168 | /** 169 | * @function 170 | * @param {string} key 171 | * @param {LinkRecord} link 172 | * @return {any | null} 173 | */ 174 | export const getData = curry((key: string, link: LinkRecordType) => { 175 | if (isLinkElseThrow(link)) { 176 | return Data.get(key, link.get('data')); 177 | } 178 | return null; 179 | }); 180 | 181 | /** 182 | * @function 183 | * @param {string} key 184 | * @param {LinkRecord} link 185 | * @return {bool} 186 | */ 187 | export const hasData = curry((key: string, link: LinkRecordType) => { 188 | if (isLinkElseThrow(link)) { 189 | return Data.has(key, link.get('data')); 190 | } 191 | return false; 192 | }); 193 | 194 | /** 195 | * @function 196 | * @param {string} key 197 | * @param {LinkRecord} link 198 | * @return {NodeRecord} 199 | */ 200 | export const deleteData = curry((key: string, link: LinkRecordType) => { 201 | if (isLinkElseThrow(link)) { 202 | return link.set('data', Data.deleteKey(key, link.get('data'))); 203 | } 204 | return link; 205 | }); 206 | 207 | /** 208 | * given a key check if that key is white listed 209 | * @param {string} key 210 | * @return {bool} 211 | */ 212 | function isWhiteListAttribute(key: string) { 213 | if (indexOf(FORBIDEN_GRAPHICAL_ATTRIBUTES, key) === -1) { 214 | return true; 215 | } 216 | throwInDev( 217 | `${key} is a protected value of the Link, please use get${upperFirst(key)} set${upperFirst( 218 | key, 219 | )} from this module to make change on those values`, 220 | ); 221 | return false; 222 | } 223 | 224 | /** 225 | * @function 226 | * @param {string} key 227 | * @param {any} value 228 | * @param {LinkRecord} link 229 | * @return {LinkRecord} 230 | */ 231 | export const setGraphicalAttribute = curry((key: string, value: any, link: LinkRecordType) => { 232 | if (isLinkElseThrow(link) && isWhiteListAttribute(key)) { 233 | return link.set( 234 | 'graphicalAttributes', 235 | Data.set(key, value, link.get('graphicalAttributes')), 236 | ); 237 | } 238 | return link; 239 | }); 240 | 241 | /** 242 | * @function 243 | * @param {string} key 244 | * @param {LinkRecord} link 245 | * @return {any | null} 246 | */ 247 | export const getGraphicalAttribute = curry((key: string, link: LinkRecordType) => { 248 | if (isLinkElseThrow(link) && isWhiteListAttribute(key)) { 249 | return Data.get(key, link.get('graphicalAttributes')); 250 | } 251 | return null; 252 | }); 253 | 254 | /** 255 | * @function 256 | * @param {string} key 257 | * @param {LinkRecord} link 258 | * @return {bool} 259 | */ 260 | export const hasGraphicalAttribute = curry((key: string, link: LinkRecordType) => { 261 | if (isLinkElseThrow(link) && isWhiteListAttribute(key)) { 262 | return Data.has(key, link.get('graphicalAttributes')); 263 | } 264 | return false; 265 | }); 266 | 267 | /** 268 | * @function 269 | * @param {string} key 270 | * @param {LinkRecord} node 271 | * @return {LinkRecord} 272 | */ 273 | export const deleteGraphicalAttribute = curry((key: string, link: LinkRecordType) => { 274 | if (isLinkElseThrow(link) && isWhiteListAttribute(key)) { 275 | return link.set( 276 | 'graphicalAttributes', 277 | Data.deleteKey(key, link.get('graphicalAttributes')), 278 | ); 279 | } 280 | return link; 281 | }); 282 | 283 | /** 284 | * minimal link creation factory, additionnals information can be set trought 285 | * the above set* functions 286 | * @function 287 | * @param {string} id 288 | * @param {string} sourceId 289 | * @param {string} targetId 290 | * @param {string} componenttype 291 | * @return {LinkRecord} 292 | */ 293 | export const create = curry((id: Id, sourceId: Id, targetId: Id, componentType: string) => 294 | flow([ 295 | setId(id), 296 | setSourceId(sourceId), 297 | setTargetId(targetId), 298 | setComponentType(componentType), 299 | ])(new LinkRecord()), 300 | ); 301 | --------------------------------------------------------------------------------