From 8e7fcced9b8dbbff982ea69592671846ffd78e20 Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 11:13:42 +0200 Subject: [PATCH 01/10] test(controls): add tests --- cypress/integration/flow/basic.spec.js | 10 +--- cypress/integration/flow/controls.js | 82 ++++++++++++++++++++++++++ cypress/integration/flow/empty.spec.js | 20 ------- 3 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 cypress/integration/flow/controls.js diff --git a/cypress/integration/flow/basic.spec.js b/cypress/integration/flow/basic.spec.js index a073a03a..8784c395 100644 --- a/cypress/integration/flow/basic.spec.js +++ b/cypress/integration/flow/basic.spec.js @@ -115,16 +115,10 @@ describe('Basic Flow Rendering', () => { }); it('drags the pane', () => { - // for d3 we have to pass the window to the event - // https://github.com/cypress-io/cypress/issues/3441 - - const newPosition = { - clientX: Cypress.config('viewportWidth') * 0.6, - clientY: Cypress.config('viewportHeight') * 0.7, - }; - const styleBeforeDrag = Cypress.$('.react-flow__nodes').css('transform'); + // for d3 we have to pass the window to the event + // https://github.com/cypress-io/cypress/issues/3441 cy.window().then((win) => { cy.get('.react-flow__zoompane') .trigger('mousedown', 'topLeft', { which: 1, view: win }) diff --git a/cypress/integration/flow/controls.js b/cypress/integration/flow/controls.js new file mode 100644 index 00000000..c68dc836 --- /dev/null +++ b/cypress/integration/flow/controls.js @@ -0,0 +1,82 @@ +describe('Controls Testing', () => { + it('renders a flow', () => { + cy.visit('/'); + + cy.get('.react-flow__renderer'); + }); + + it('renders the control panel', () => { + cy.get('.react-flow__controls'); + }); + + it('zooms in', () => { + const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); + + cy.get('.react-flow__controls-zoomin') + .click() + .then(() => { + const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeZoom).to.not.equal(styleAfterZoom); + }); + }); + + it('zooms out', () => { + const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); + + cy.get('.react-flow__controls-zoomout') + .click() + .then(() => { + const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeZoom).to.not.equal(styleAfterZoom); + }); + }); + + // view is already fitted so we drag the pane to un-fit it + it('drags the pane', () => { + const styleBeforeDrag = Cypress.$('.react-flow__nodes').css('transform'); + + // for d3 we have to pass the window to the event + // https://github.com/cypress-io/cypress/issues/3441 + cy.window().then((win) => { + cy.get('.react-flow__zoompane') + .trigger('mousedown', 'topLeft', { which: 1, view: win }) + .trigger('mousemove', 'bottomLeft') + .trigger('mouseup', { force: true, view: win }) + .then(() => { + const styleAfterDrag = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeDrag).to.not.equal(styleAfterDrag); + }); + }); + }); + + it('fits view', () => { + const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); + + cy.get('.react-flow__controls-fitview') + .click() + .then(() => { + const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeZoom).to.not.equal(styleAfterZoom); + }); + }); + + it('uses interactive control - not interactive', () => { + cy.get('.react-flow__node:first').click().should('have.class', 'selected'); + cy.get('.react-flow__renderer').click('bottomRight'); + cy.get('.react-flow__node:first').should('not.have.class', 'selected'); + + cy.get('.react-flow__controls-interactive') + .click() + .then(() => { + cy.get('.react-flow__node:first').should('not.have.class', 'selected'); + }); + }); + + it('uses interactive control - interactive', () => { + cy.get('.react-flow__controls-interactive') + .click() + .then(() => { + cy.get('.react-flow__node:first').click().should('have.class', 'selected'); + }); + }); +}); diff --git a/cypress/integration/flow/empty.spec.js b/cypress/integration/flow/empty.spec.js index d4029107..54e23c67 100644 --- a/cypress/integration/flow/empty.spec.js +++ b/cypress/integration/flow/empty.spec.js @@ -16,26 +16,6 @@ describe('Empty Flow Rendering', () => { .trigger('mouseup', 'bottomRight', { force: true }); }); - it('renders a control panel', () => { - cy.get('.react-flow__controls'); - }); - - it('uses zoom in control', () => { - cy.get('.react-flow__controls-zoomin').click(); - }); - - it('uses zoom out control', () => { - cy.get('.react-flow__controls-zoomout').click(); - }); - - it('uses fit view control', () => { - cy.get('.react-flow__controls-fitview').click(); - }); - - it('uses lock view control', () => { - cy.get('.react-flow__controls-interactive'); - }); - it('renders an empty mini map', () => { cy.get('.react-flow__minimap'); cy.get('.react-flow__minimap-node').should('not.exist'); From edbc41963bdb59636dddac48b8564b1b63c296bb Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 11:37:36 +0200 Subject: [PATCH 02/10] test(minimap): add tests --- .../flow/{controls.js => controls.spec.js} | 6 +- .../{custom-node.js => custom-node.spec.js} | 0 cypress/integration/flow/minimap.spec.js | 67 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) rename cypress/integration/flow/{controls.js => controls.spec.js} (97%) rename cypress/integration/flow/{custom-node.js => custom-node.spec.js} (100%) create mode 100644 cypress/integration/flow/minimap.spec.js diff --git a/cypress/integration/flow/controls.js b/cypress/integration/flow/controls.spec.js similarity index 97% rename from cypress/integration/flow/controls.js rename to cypress/integration/flow/controls.spec.js index c68dc836..f460f4b3 100644 --- a/cypress/integration/flow/controls.js +++ b/cypress/integration/flow/controls.spec.js @@ -1,11 +1,7 @@ describe('Controls Testing', () => { - it('renders a flow', () => { + it('renders the control panel', () => { cy.visit('/'); - cy.get('.react-flow__renderer'); - }); - - it('renders the control panel', () => { cy.get('.react-flow__controls'); }); diff --git a/cypress/integration/flow/custom-node.js b/cypress/integration/flow/custom-node.spec.js similarity index 100% rename from cypress/integration/flow/custom-node.js rename to cypress/integration/flow/custom-node.spec.js diff --git a/cypress/integration/flow/minimap.spec.js b/cypress/integration/flow/minimap.spec.js new file mode 100644 index 00000000..92055ab9 --- /dev/null +++ b/cypress/integration/flow/minimap.spec.js @@ -0,0 +1,67 @@ +describe('Minimap Testing', () => { + it('renders the mini map', () => { + cy.visit('/'); + + cy.get('.react-flow__minimap'); + cy.get('.react-flow__minimap-mask'); + }); + + it('has same number of nodes as the pane', () => { + const paneNodes = Cypress.$('.react-flow__node').length; + const minimapNodes = Cypress.$('.react-flow__minimap-node').length; + + expect(paneNodes).equal(minimapNodes); + }); + + it('changes zoom level', () => { + const viewBoxBeforeZoom = Cypress.$('.react-flow__minimap').attr('viewBox'); + const maskPathBeforeZoom = Cypress.$('.react-flow__minimap-mask').attr('d'); + + cy.get('.react-flow__zoompane') + .trigger('wheel', 'topLeft', { deltaY: -200 }) + .then(() => { + const viewBoxAfterZoom = Cypress.$('.react-flow__minimap').attr('viewBox'); + const maskPathAfterZoom = Cypress.$('.react-flow__minimap-mask').attr('d'); + + expect(viewBoxBeforeZoom).to.not.equal(viewBoxAfterZoom); + expect(maskPathBeforeZoom).to.not.equal(maskPathAfterZoom); + }); + }); + + it('changes node position', () => { + const xPosBeforeDrag = Cypress.$('.react-flow__minimap-node:first').attr('x'); + const yPosBeforeDrag = Cypress.$('.react-flow__minimap-node:first').attr('y'); + const maskPathBeforeDrag = Cypress.$('.react-flow__minimap-mask').attr('d'); + + cy.drag('.react-flow__node:first', { x: 500, y: 25 }).then(($el) => { + const xPosAfterDrag = Cypress.$('.react-flow__minimap-node:first').attr('x'); + const yPosAfterDrag = Cypress.$('.react-flow__minimap-node:first').attr('y'); + const maskPathAfterDrag = Cypress.$('.react-flow__minimap-mask').attr('d'); + + expect(xPosBeforeDrag).to.not.equal(xPosAfterDrag); + expect(yPosBeforeDrag).to.not.equal(yPosAfterDrag); + expect(maskPathBeforeDrag).to.not.equal(maskPathAfterDrag); + }); + }); + + it('changes node positions via pane drag', () => { + const viewBoxBeforeDrag = Cypress.$('.react-flow__minimap').attr('viewBox'); + const maskPathBeforeDrag = Cypress.$('.react-flow__minimap-mask').attr('d'); + + // for d3 we have to pass the window to the event + // https://github.com/cypress-io/cypress/issues/3441 + cy.window().then((win) => { + cy.get('.react-flow__zoompane') + .trigger('mousedown', 'topLeft', { which: 1, view: win }) + .trigger('mousemove', 'bottomLeft') + .trigger('mouseup', { force: true, view: win }) + .then(() => { + const viewBoxAfterDrag = Cypress.$('.react-flow__minimap').attr('viewBox'); + const maskPathAfterDrag = Cypress.$('.react-flow__minimap-mask').attr('d'); + + expect(viewBoxBeforeDrag).to.not.equal(viewBoxAfterDrag); + expect(maskPathBeforeDrag).to.not.equal(maskPathAfterDrag); + }); + }); + }); +}); From d642d665741b02574a3dcdf958202b8324b61e58 Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 12:50:53 +0200 Subject: [PATCH 03/10] test(interaction): add tests --- cypress/integration/flow/basic.spec.js | 4 +- cypress/integration/flow/controls.spec.js | 4 +- cypress/integration/flow/custom-node.spec.js | 4 +- cypress/integration/flow/empty.spec.js | 4 +- cypress/integration/flow/hidden.spec.js | 4 +- cypress/integration/flow/interaction.spec.js | 85 +++++++++++++++++++- cypress/integration/flow/minimap.spec.js | 4 +- cypress/integration/flow/overview.spec.js | 5 +- cypress/integration/flow/stress.spec.js | 5 +- example/src/Interaction/index.js | 4 +- 10 files changed, 110 insertions(+), 13 deletions(-) diff --git a/cypress/integration/flow/basic.spec.js b/cypress/integration/flow/basic.spec.js index 8784c395..121896d3 100644 --- a/cypress/integration/flow/basic.spec.js +++ b/cypress/integration/flow/basic.spec.js @@ -1,7 +1,9 @@ describe('Basic Flow Rendering', () => { - it('renders a flow with three nodes', () => { + before(() => { cy.visit('/basic'); + }); + it('renders a flow with three nodes', () => { cy.get('.react-flow__renderer'); cy.get('.react-flow-basic-example'); // check if className prop works cy.get('.react-flow__node').should('have.length', 4); diff --git a/cypress/integration/flow/controls.spec.js b/cypress/integration/flow/controls.spec.js index f460f4b3..3975fadb 100644 --- a/cypress/integration/flow/controls.spec.js +++ b/cypress/integration/flow/controls.spec.js @@ -1,7 +1,9 @@ describe('Controls Testing', () => { - it('renders the control panel', () => { + before(() => { cy.visit('/'); + }); + it('renders the control panel', () => { cy.get('.react-flow__controls'); }); diff --git a/cypress/integration/flow/custom-node.spec.js b/cypress/integration/flow/custom-node.spec.js index 4cecdcec..f2cfee78 100644 --- a/cypress/integration/flow/custom-node.spec.js +++ b/cypress/integration/flow/custom-node.spec.js @@ -1,7 +1,9 @@ describe('Custom Node Flow Rendering', () => { - it('renders a flow', () => { + before(() => { cy.visit('/custom-node'); + }); + it('renders initial flow', () => { cy.get('.react-flow__renderer'); cy.get('.react-flow__node').should('have.length', 4); diff --git a/cypress/integration/flow/empty.spec.js b/cypress/integration/flow/empty.spec.js index 54e23c67..28853dfd 100644 --- a/cypress/integration/flow/empty.spec.js +++ b/cypress/integration/flow/empty.spec.js @@ -1,7 +1,9 @@ describe('Empty Flow Rendering', () => { - it('renders an empty flow', () => { + before(() => { cy.visit('/empty'); + }); + it('renders an empty flow', () => { cy.get('.react-flow__renderer'); cy.get('.react-flow__node').should('not.exist'); cy.get('.react-flow__edge').should('not.exist'); diff --git a/cypress/integration/flow/hidden.spec.js b/cypress/integration/flow/hidden.spec.js index ff534735..fdb0827f 100644 --- a/cypress/integration/flow/hidden.spec.js +++ b/cypress/integration/flow/hidden.spec.js @@ -1,7 +1,9 @@ describe('Hidden Flow Rendering', () => { - it('renders the initial flow', () => { + before(() => { cy.visit('/hidden'); + }); + it('renders initial flow', () => { cy.get('.react-flow__renderer'); cy.get('.react-flow__node').should('have.length', 4); cy.get('.react-flow__edge').should('have.length', 3); diff --git a/cypress/integration/flow/interaction.spec.js b/cypress/integration/flow/interaction.spec.js index 33e672ce..055a4214 100644 --- a/cypress/integration/flow/interaction.spec.js +++ b/cypress/integration/flow/interaction.spec.js @@ -1,7 +1,9 @@ describe('Interaction Flow Rendering', () => { - it('renders initial flow', () => { + before(() => { cy.visit('/interaction'); + }); + it('renders initial flow', () => { cy.get('.react-flow__renderer'); cy.get('.react-flow__node').should('have.length', 4); cy.get('.react-flow__edge').should('have.length', 2); @@ -23,6 +25,38 @@ describe('Interaction Flow Rendering', () => { cy.get('body').type('{shift}', { release: true }); }); + it('tries to connect to nodes', () => { + cy.get('.react-flow__node') + .contains('Node 3') + .find('.react-flow__handle.source') + .then(($el) => { + const pointerEvents = $el.css('pointer-events'); + expect(pointerEvents).to.equal('none'); + }); + }); + + it('tries to zoom by scroll', () => { + const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); + + cy.get('.react-flow__zoompane') + .dblclick() + .then(() => { + const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeZoom).to.equal(styleAfterZoom); + }); + }); + + it('tries to zoom by double click', () => { + const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); + + cy.get('.react-flow__zoompane') + .trigger('wheel', 'topLeft', { deltaY: -200 }) + .then(() => { + const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeZoom).to.equal(styleAfterZoom); + }); + }); + it('toggles draggable mode', () => { cy.get('.react-flow__draggable').click(); }); @@ -47,4 +81,53 @@ describe('Interaction Flow Rendering', () => { it('selects an edge by click', () => { cy.get('.react-flow__edge:first').click().should('have.class', 'selected'); }); + + it('toggles connectable mode', () => { + cy.get('.react-flow__connectable').click(); + }); + + it('connects two nodes', () => { + cy.get('.react-flow__node') + .contains('Node 3') + .find('.react-flow__handle.source') + .trigger('mousedown', { which: 1 }); + + cy.get('.react-flow__node') + .contains('Node 4') + .find('.react-flow__handle.target') + .trigger('mousemove') + .trigger('mouseup', { force: true }); + + cy.get('.react-flow__edge').should('have.length', 3); + }); + + it('toggles zoom on scroll', () => { + cy.get('.react-flow__zoomonscroll').click(); + }); + + it('zooms by scroll', () => { + const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); + + cy.get('.react-flow__zoompane') + .trigger('wheel', 'topLeft', { deltaY: 200 }) + .then(() => { + const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeZoom).not.to.equal(styleAfterZoom); + }); + }); + + it('toggles zoom on double click', () => { + cy.get('.react-flow__zoomondbl').click(); + }); + + it('zooms by double click', () => { + const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); + + cy.get('.react-flow__zoompane') + .dblclick() + .then(() => { + const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); + expect(styleBeforeZoom).not.to.equal(styleAfterZoom); + }); + }); }); diff --git a/cypress/integration/flow/minimap.spec.js b/cypress/integration/flow/minimap.spec.js index 92055ab9..1ce5bb5a 100644 --- a/cypress/integration/flow/minimap.spec.js +++ b/cypress/integration/flow/minimap.spec.js @@ -1,7 +1,9 @@ describe('Minimap Testing', () => { - it('renders the mini map', () => { + before(() => { cy.visit('/'); + }); + it('renders the mini map', () => { cy.get('.react-flow__minimap'); cy.get('.react-flow__minimap-mask'); }); diff --git a/cypress/integration/flow/overview.spec.js b/cypress/integration/flow/overview.spec.js index d0153db2..2f7ee017 100644 --- a/cypress/integration/flow/overview.spec.js +++ b/cypress/integration/flow/overview.spec.js @@ -1,9 +1,10 @@ describe('Overview Flow Rendering', () => { - it('renders a flow', () => { + before(() => { cy.visit('/'); + }); + it('renders a flow', () => { cy.get('.react-flow__renderer'); - cy.get('.react-flow__node').should('have.length', 7); cy.get('.react-flow__edge').should('have.length', 6); }); diff --git a/cypress/integration/flow/stress.spec.js b/cypress/integration/flow/stress.spec.js index d70b5847..290e3ef9 100644 --- a/cypress/integration/flow/stress.spec.js +++ b/cypress/integration/flow/stress.spec.js @@ -1,9 +1,10 @@ describe('Stress Flow Rendering', () => { - it('renders initial flow', () => { + before(() => { cy.visit('/stress'); + }); + it('renders initial flow', () => { cy.get('.react-flow__renderer'); - cy.get('.react-flow__node').should('have.length', 100); cy.get('.react-flow__edge').should('have.length', 99); }); diff --git a/example/src/Interaction/index.js b/example/src/Interaction/index.js index f2fabcdd..3599ab7b 100644 --- a/example/src/Interaction/index.js +++ b/example/src/Interaction/index.js @@ -22,8 +22,8 @@ const InteractionFlow = () => { const [isSelectable, setIsSelectable] = useState(false); const [isDraggable, setIsDraggable] = useState(false); const [isConnectable, setIsConnectable] = useState(false); - const [zoomOnScroll, setZoomOnScroll] = useState(true); - const [zoomOnDoubleClick, setZoomOnDoubleClick] = useState(true); + const [zoomOnScroll, setZoomOnScroll] = useState(false); + const [zoomOnDoubleClick, setZoomOnDoubleClick] = useState(false); return ( Date: Tue, 21 Jul 2020 13:08:47 +0200 Subject: [PATCH 04/10] test(integration): cleanup --- cypress/integration/flow/custom-node.spec.js | 12 ------------ cypress/integration/flow/overview.spec.js | 18 ------------------ cypress/integration/flow/stress.spec.js | 11 ----------- package.json | 6 ++++-- 4 files changed, 4 insertions(+), 43 deletions(-) delete mode 100644 cypress/integration/flow/custom-node.spec.js delete mode 100644 cypress/integration/flow/overview.spec.js delete mode 100644 cypress/integration/flow/stress.spec.js diff --git a/cypress/integration/flow/custom-node.spec.js b/cypress/integration/flow/custom-node.spec.js deleted file mode 100644 index f2cfee78..00000000 --- a/cypress/integration/flow/custom-node.spec.js +++ /dev/null @@ -1,12 +0,0 @@ -describe('Custom Node Flow Rendering', () => { - before(() => { - cy.visit('/custom-node'); - }); - - it('renders initial flow', () => { - cy.get('.react-flow__renderer'); - - cy.get('.react-flow__node').should('have.length', 4); - cy.get('.react-flow__edge').should('have.length', 3); - }); -}); diff --git a/cypress/integration/flow/overview.spec.js b/cypress/integration/flow/overview.spec.js deleted file mode 100644 index 2f7ee017..00000000 --- a/cypress/integration/flow/overview.spec.js +++ /dev/null @@ -1,18 +0,0 @@ -describe('Overview Flow Rendering', () => { - before(() => { - cy.visit('/'); - }); - - it('renders a flow', () => { - cy.get('.react-flow__renderer'); - cy.get('.react-flow__node').should('have.length', 7); - cy.get('.react-flow__edge').should('have.length', 6); - }); - - it('renders a grid', () => { - cy.get('.react-flow__background'); - - const gridStroke = Cypress.$('.react-flow__background path').attr('fill'); - expect(gridStroke).to.equal('#888'); - }); -}); diff --git a/cypress/integration/flow/stress.spec.js b/cypress/integration/flow/stress.spec.js deleted file mode 100644 index 290e3ef9..00000000 --- a/cypress/integration/flow/stress.spec.js +++ /dev/null @@ -1,11 +0,0 @@ -describe('Stress Flow Rendering', () => { - before(() => { - cy.visit('/stress'); - }); - - it('renders initial flow', () => { - cy.get('.react-flow__renderer'); - cy.get('.react-flow__node').should('have.length', 100); - cy.get('.react-flow__edge').should('have.length', 99); - }); -}); diff --git a/package.json b/package.json index 5bc59d3d..b7565ab5 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "start:examples": "npm run build && cd example && npm start", "dev:wait": "start-server-and-test start:examples http-get://localhost:3000", "build:example": "npm install && npm run build && cd example && npm install && npm run build", - "cy:run": "cypress run", "cy:open": "cypress open", "cypress": "npm run dev:wait cy:open", - "test": "npm run dev:wait cy:run", + "test:chrome": "cypress run --browser chrome", + "test:firefox": "cypress run --browser firefox", + "test:all": "npm run test:chrome && npm run test:firefox", + "test": "npm run dev:wait test:chrome", "release": "release-it" }, "dependencies": { From 69c41b9b91c66f7200272cfce9fa5415a94af694 Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 13:49:37 +0200 Subject: [PATCH 05/10] test(graph-utils): add tests --- cypress/integration/flow/graph-utils.spec.js | 130 +++++++++++++++++++ src/utils/graph.ts | 9 +- 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 cypress/integration/flow/graph-utils.spec.js diff --git a/cypress/integration/flow/graph-utils.spec.js b/cypress/integration/flow/graph-utils.spec.js new file mode 100644 index 00000000..128f3e6d --- /dev/null +++ b/cypress/integration/flow/graph-utils.spec.js @@ -0,0 +1,130 @@ +import { isNode, isEdge, getOutgoers, removeElements, addEdge } from '../../../src/utils/graph.ts'; + +const nodes = [ + { id: '1', type: 'input', data: { label: 'Node 1' }, position: { x: 250, y: 5 } }, + { id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 } }, + { id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 } }, + { id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 } }, +]; + +const edges = [ + { id: 'e1-2', source: '1', target: '2', animated: true }, + { id: 'e1-3', source: '1', target: '3' }, +]; + +const elements = [...nodes, ...edges]; + +describe('Graph Utils Testing', () => { + it('tests isNode function', () => { + expect(isNode(nodes[0])).to.be.true; + expect(isNode(edges[0])).to.be.false; + }); + + it('tests isEdge function', () => { + expect(isEdge(edges[0])).to.be.true; + expect(isEdge(nodes[0])).to.be.false; + }); + + it('tests getOutgoers function', () => { + const outgoers = getOutgoers(nodes[0], elements); + expect(outgoers.length).to.be.equal(2); + + const noOutgoers = getOutgoers(nodes[1], elements); + expect(noOutgoers.length).to.be.equal(0); + }); + + describe('tests addEdge function', () => { + it('adds edge', () => { + const newEdge = { source: '2', target: '3' }; + const nextElements = addEdge(newEdge, elements); + + expect(nextElements.length).to.be.equal(elements.length + 1); + }); + + it('tries to add invalid edge', () => { + const newEdge = { nosource: '1', notarget: '3' }; + + try { + addEdge(newEdge, elements); + } catch (e) { + console.log(e.message); + + expect(e.message).to.be.equal("Can't create edge. An edge needs a source and a target."); + } + }); + + it('tries to add edge with id that does not exist', () => { + const notExistingId = 'does-not-exist'; + const newEdge = { source: notExistingId, target: '3' }; + + try { + addEdge(newEdge, elements); + } catch (e) { + console.log(e.message); + expect(e.message).to.be.equal(`Can't create edge. Node with id=${notExistingId} does not exist.`); + } + }); + }); + + describe('tests removeElements function', () => { + it('removes a node', () => { + const nextElements = removeElements([nodes[0]], elements); + + const nextNodes = nextElements.filter((e) => isNode(e)); + const nextEdges = nextElements.filter((e) => isEdge(e)); + + expect(nextNodes.length).to.be.equal(nodes.length - 1); + expect(nextEdges.length).to.be.equal(edges.length - 2); + }); + + it('removes multiple nodes', () => { + const elementsToRemove = [nodes[0], nodes[1]]; + const nextElements = removeElements(elementsToRemove, elements); + const nextNodes = nextElements.filter((e) => isNode(e)); + const nextEdges = nextElements.filter((e) => isEdge(e)); + + expect(nextNodes.length).to.be.equal(nodes.length - 2); + expect(nextEdges.length).to.be.equal(0); + }); + + it('removes no node', () => { + const nextElementsNoRemove = removeElements([], elements); + expect(nextElementsNoRemove.length).to.be.equal(elements.length); + }); + + it('tries to removes node that does not exist', () => { + const nextElementsNoRemove = removeElements([{ id: 'id-that-does-not-exist' }], elements); + expect(nextElementsNoRemove.length).to.be.equal(elements.length); + }); + + it('removes an edge', () => { + const nextElements = removeElements([edges[0]], elements); + + const nextNodes = nextElements.filter((e) => isNode(e)); + const nextEdges = nextElements.filter((e) => isEdge(e)); + + expect(nextNodes.length).to.be.equal(nodes.length); + expect(nextEdges.length).to.be.equal(edges.length - 1); + }); + + it('removes multiple edges', () => { + const nextElements = removeElements([edges[0], edges[1]], elements); + + const nextNodes = nextElements.filter((e) => isNode(e)); + const nextEdges = nextElements.filter((e) => isEdge(e)); + + expect(nextNodes.length).to.be.equal(nodes.length); + expect(nextEdges.length).to.be.equal(edges.length - 2); + }); + + it('removes node and edge', () => { + const nextElements = removeElements([nodes[0], edges[0]], elements); + + const nextNodes = nextElements.filter((e) => isNode(e)); + const nextEdges = nextElements.filter((e) => isEdge(e)); + + expect(nextNodes.length).to.be.equal(nodes.length - 1); + expect(nextEdges.length).to.be.equal(edges.length - 2); + }); + }); +}); diff --git a/src/utils/graph.ts b/src/utils/graph.ts index 8b899913..79ebfc04 100644 --- a/src/utils/graph.ts +++ b/src/utils/graph.ts @@ -33,9 +33,16 @@ const getEdgeId = ({ source, target }: Connection): ElementId => `reactflow__edg export const addEdge = (edgeParams: Edge | Connection, elements: Elements): Elements => { if (!edgeParams.source || !edgeParams.target) { - throw new Error('Can not create edge. An edge needs a source and a target'); + throw new Error("Can't create edge. An edge needs a source and a target."); } + // make sure that there is node with the target and one with the source id + [edgeParams.source, edgeParams.target].forEach((id) => { + if (!elements.find((e) => isNode(e) && e.id === id)) { + throw new Error(`Can't create edge. Node with id=${id} does not exist.`); + } + }); + if (isEdge(edgeParams)) { return elements.concat({ ...edgeParams }); } From 4934cec6c9667af24e47366b00362ef727080109 Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 15:52:17 +0200 Subject: [PATCH 06/10] style(flow): adjust default styles --- example/src/Overview/index.js | 13 ++++++++----- .../MiniMap/MiniMapNode.tsx | 2 +- src/style.css | 16 ++++++++-------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/example/src/Overview/index.js b/example/src/Overview/index.js index 48224ea5..4e822101 100644 --- a/example/src/Overview/index.js +++ b/example/src/Overview/index.js @@ -95,8 +95,10 @@ const initialElements = [ source: '5', target: '7', type: 'step', + style: { stroke: '#f6ab6c' }, label: 'a step edge', - labelStyle: { fill: 'red', fontWeight: 700 }, + animated: true, + labelStyle: { fill: '#f6ab6c', fontWeight: 700 }, }, ]; @@ -132,11 +134,12 @@ const OverviewFlow = () => { > { - if (n.type === 'input') return 'blue'; - if (n.type === 'output') return 'green'; - if (n.type === 'default') return 'red'; + if (n.style?.background) return n.style.background; + if (n.type === 'input') return '#9999ff'; + if (n.type === 'output') return '#79c9b7'; + if (n.type === 'default') return '#ff6060'; - return '#FFCC00'; + return '#eee'; }} /> diff --git a/src/additional-components/MiniMap/MiniMapNode.tsx b/src/additional-components/MiniMap/MiniMapNode.tsx index e85ba788..24b1eee0 100644 --- a/src/additional-components/MiniMap/MiniMapNode.tsx +++ b/src/additional-components/MiniMap/MiniMapNode.tsx @@ -15,7 +15,7 @@ const MiniMapNode = ({ node, color, borderRadius }: MiniMapNodeProps) => { height, } = node.__rf; const { background, backgroundColor } = node.style || {}; - const fill = (background || backgroundColor || color) as string; + const fill = (color || background || backgroundColor) as string; return ( Date: Tue, 21 Jul 2020 16:33:38 +0200 Subject: [PATCH 07/10] chore(examples): add edge types example --- example/src/EdgeTypes/index.js | 56 ++++++++++++++++++ example/src/EdgeTypes/utils.js | 104 +++++++++++++++++++++++++++++++++ example/src/index.js | 5 ++ 3 files changed, 165 insertions(+) create mode 100644 example/src/EdgeTypes/index.js create mode 100644 example/src/EdgeTypes/utils.js diff --git a/example/src/EdgeTypes/index.js b/example/src/EdgeTypes/index.js new file mode 100644 index 00000000..7fef539d --- /dev/null +++ b/example/src/EdgeTypes/index.js @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; + +import ReactFlow, { removeElements, addEdge, MiniMap, isNode, Controls, Background } from 'react-flow-renderer'; +import { getElements } from './utils'; + +const onLoad = (reactFlowInstance) => { + reactFlowInstance.fitView(); + + console.log(reactFlowInstance.getElements()); +}; + +const initialElements = getElements(10, 10); + +const EdgeTypesFlow = () => { + const [elements, setElements] = useState(initialElements); + const onElementsRemove = (elementsToRemove) => setElements((els) => removeElements(elementsToRemove, els)); + const onConnect = (params) => setElements((els) => addEdge(params, els)); + + const updatePos = () => { + setElements((elms) => { + return elms.map((el) => { + if (isNode(el)) { + return { + ...el, + position: { + x: Math.random() * window.innerWidth, + y: Math.random() * window.innerHeight, + }, + }; + } + + return el; + }); + }); + }; + + return ( + + + + + + + + ); +}; + +export default EdgeTypesFlow; diff --git a/example/src/EdgeTypes/utils.js b/example/src/EdgeTypes/utils.js new file mode 100644 index 00000000..baf832a3 --- /dev/null +++ b/example/src/EdgeTypes/utils.js @@ -0,0 +1,104 @@ +const nodeWidth = 80; +const nodeGapWidth = nodeWidth * 2; +const nodeStyle = { width: nodeWidth, fontSize: 11, color: 'white' }; + +const sourceTargetPositions = [ + { source: 'bottom', target: 'top' }, + { source: 'right', target: 'left' }, +]; +const nodeColors = [ + ['#1e9e99', '#4cb3ac', '#6ec9c0', '#8ddfd4'], + ['#0f4c75', '#1b5d8b', '#276fa1', '#3282b8'], +]; +const edgeTypes = ['default', 'step', 'smoothstep', 'straight']; +const offsets = [ + { + x: 0, + y: -nodeGapWidth, + }, + { + x: nodeGapWidth, + y: -nodeGapWidth, + }, + { + x: nodeGapWidth, + y: 0, + }, + { + x: nodeGapWidth, + y: nodeGapWidth, + }, + { + x: 0, + y: nodeGapWidth, + }, + { + x: -nodeGapWidth, + y: nodeGapWidth, + }, + { + x: -nodeGapWidth, + y: 0, + }, + { + x: -nodeGapWidth, + y: -nodeGapWidth, + }, +]; + +let id = 0; +const getNodeId = () => (id++).toString(); + +export function getElements() { + const initialElements = []; + + for (let sourceTargetIndex = 0; sourceTargetIndex < sourceTargetPositions.length; sourceTargetIndex++) { + const currSourceTargetPos = sourceTargetPositions[sourceTargetIndex]; + + for (let edgeTypeIndex = 0; edgeTypeIndex < edgeTypes.length; edgeTypeIndex++) { + const currEdgeType = edgeTypes[edgeTypeIndex]; + + for (let offsetIndex = 0; offsetIndex < offsets.length; offsetIndex++) { + const currOffset = offsets[offsetIndex]; + + const style = { ...nodeStyle, background: nodeColors[sourceTargetIndex][edgeTypeIndex] }; + const sourcePosition = { + x: offsetIndex * nodeWidth * 4, + y: edgeTypeIndex * 300 + sourceTargetIndex * edgeTypes.length * 300, + }; + const sourceId = getNodeId(); + const sourceData = { label: `Source ${sourceId}` }; + const sourceNode = { + id: sourceId, + style, + data: sourceData, + position: sourcePosition, + sourcePosition: currSourceTargetPos.source, + targetPosition: currSourceTargetPos.target, + }; + + const targetId = getNodeId(); + const targetData = { label: `Target ${targetId}` }; + const targetPosition = { + x: sourcePosition.x + currOffset.x, + y: sourcePosition.y + currOffset.y, + }; + const targetNode = { + id: targetId, + style, + data: targetData, + position: targetPosition, + sourcePosition: currSourceTargetPos.source, + targetPosition: currSourceTargetPos.target, + }; + + initialElements.push(sourceNode); + initialElements.push(targetNode); + + initialElements.push({ id: `${sourceId}-${targetId}`, source: sourceId, target: targetId, type: currEdgeType }); + } + } + } + + return initialElements; +} diff --git a/example/src/index.js b/example/src/index.js index 5eb71cfa..51b9c00f 100644 --- a/example/src/index.js +++ b/example/src/index.js @@ -13,6 +13,7 @@ import Validation from './Validation'; import Horizontal from './Horizontal'; import Provider from './Provider'; import Hidden from './Hidden'; +import EdgeTypes from './EdgeTypes'; import './index.css'; @@ -69,6 +70,10 @@ const routes = [ path: '/hidden', component: Hidden, }, + { + path: '/edge-types', + component: EdgeTypes, + }, ]; const navLinks = routes.filter((route) => route.label); From 70121edcd880cbce5e87291bae07de606d8cb26d Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 16:47:35 +0200 Subject: [PATCH 08/10] feat(smoothstepedge): add border radius param --- src/components/Edges/SmoothStepEdge.tsx | 12 ++++++++---- src/types/index.ts | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Edges/SmoothStepEdge.tsx b/src/components/Edges/SmoothStepEdge.tsx index dfc860c0..f0dc1996 100644 --- a/src/components/Edges/SmoothStepEdge.tsx +++ b/src/components/Edges/SmoothStepEdge.tsx @@ -2,7 +2,7 @@ import React, { memo } from 'react'; import EdgeText from './EdgeText'; import { getMarkerEnd } from './utils'; -import { EdgeBezierProps, Position } from '../../types'; +import { EdgeSmoothStepProps, Position } from '../../types'; // These are some helper methods for drawing the round corners // The name indicates the direction of the path. "bottomLeftCorner" goes @@ -44,6 +44,7 @@ interface GetSmoothStepPathParams { targetX: number; targetY: number; targetPosition?: Position; + borderRadius?: number; } export function getSmoothStepPath({ @@ -57,9 +58,10 @@ export function getSmoothStepPath({ targetX, targetY, targetPosition = Position.Top, + borderRadius = 5, }: GetSmoothStepPathParams): string { - const cornerWidth = Math.min(5, Math.abs(targetX - sourceX)); - const cornerHeight = Math.min(5, Math.abs(targetY - sourceY)); + const cornerWidth = Math.min(borderRadius, Math.abs(targetX - sourceX)); + const cornerHeight = Math.min(borderRadius, Math.abs(targetY - sourceY)); const cornerSize = Math.min(cornerWidth, cornerHeight, xOffset, yOffset); const leftAndRight = [Position.Left, Position.Right]; @@ -141,7 +143,8 @@ export default memo( targetPosition = Position.Top, arrowHeadType, markerEndId, - }: EdgeBezierProps) => { + borderRadius = 5, + }: EdgeSmoothStepProps) => { const yOffset = Math.abs(targetY - sourceY) / 2; const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset; @@ -159,6 +162,7 @@ export default memo( targetX, targetY, targetPosition, + borderRadius, }); const markerEnd = getMarkerEnd(arrowHeadType, markerEndId); diff --git a/src/types/index.ts b/src/types/index.ts index 6bbf076d..8b9e3e83 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -99,6 +99,10 @@ export interface EdgeBezierProps extends EdgeProps { targetPosition: Position; } +export interface EdgeSmoothStepProps extends EdgeBezierProps { + borderRadius?: number; +} + export interface NodeProps { id: ElementId; type: string; From 8cf5b3f1378c017dd23f18413d137b1cea771f0a Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 16:47:57 +0200 Subject: [PATCH 09/10] refactor(stepedge): use smooth step edge with border radius=0 --- src/components/Edges/StepEdge.tsx | 63 +++---------------------------- 1 file changed, 5 insertions(+), 58 deletions(-) diff --git a/src/components/Edges/StepEdge.tsx b/src/components/Edges/StepEdge.tsx index 9475685b..a03e3052 100644 --- a/src/components/Edges/StepEdge.tsx +++ b/src/components/Edges/StepEdge.tsx @@ -1,61 +1,8 @@ import React, { memo } from 'react'; -import EdgeText from './EdgeText'; -import { getMarkerEnd } from './utils'; -import { EdgeProps } from '../../types'; +import { EdgeSmoothStepProps } from '../../types'; +import SmoothStepEdge from './SmoothStepEdge'; -interface GetStepPathParams { - centerY: number; - sourceX: number; - sourceY: number; - targetX: number; - targetY: number; -} - -export function getStepPath({ centerY, sourceX, sourceY, targetX, targetY }: GetStepPathParams): string { - return `M ${sourceX},${sourceY}L ${sourceX},${centerY}L ${targetX},${centerY}L ${targetX},${targetY}`; -} - -export default memo( - ({ - sourceX, - sourceY, - targetX, - targetY, - label, - labelStyle, - labelShowBg, - labelBgStyle, - style, - arrowHeadType, - markerEndId, - }: EdgeProps) => { - const yOffset = Math.abs(targetY - sourceY) / 2; - const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset; - - const xOffset = Math.abs(targetX - sourceX) / 2; - const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset; - - const path = getStepPath({ centerY, sourceX, sourceY, targetX, targetY }); - - const markerEnd = getMarkerEnd(arrowHeadType, markerEndId); - - const text = label ? ( - - ) : null; - - return ( - <> - - {text} - - ); - } -); +export default memo((props: EdgeSmoothStepProps) => { + return ; +}); From 8a16727cf893546f9445560b010b7ccdbf1ce2bc Mon Sep 17 00:00:00 2001 From: moklick Date: Tue, 21 Jul 2020 16:48:23 +0200 Subject: [PATCH 10/10] refactor(edge-types-example): cleanup --- README.md | 1 + example/src/CustomNode/index.js | 2 +- example/src/EdgeTypes/index.js | 30 +++++-------------------- example/src/Overview/index.js | 2 +- src/components/ConnectionLine/index.tsx | 9 ++++++-- 5 files changed, 15 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e87a9af2..3f9e9347 100644 --- a/README.md +++ b/README.md @@ -542,6 +542,7 @@ You can find all examples in the [example](example) folder or check out the live - [interaction](https://reactflow.dev/interaction) - [provider](https://reactflow.dev/provider) - [hidden](https://reactflow.dev/hidden) +- [edge types](https://reactflow.dev/edge-types) # Development diff --git a/example/src/CustomNode/index.js b/example/src/CustomNode/index.js index c346808f..9dc682c3 100644 --- a/example/src/CustomNode/index.js +++ b/example/src/CustomNode/index.js @@ -70,7 +70,7 @@ const CustomNodeFlow = () => { nodeTypes={{ selectorNode: ColorSelectorNode, }} - connectionLineStyle={{ stroke: '#ddd', strokeWidth: 2 }} + connectionLineStyle={{ stroke: '#ddd' }} snapToGrid={true} snapGrid={[16, 16]} > diff --git a/example/src/EdgeTypes/index.js b/example/src/EdgeTypes/index.js index 7fef539d..c6bae629 100644 --- a/example/src/EdgeTypes/index.js +++ b/example/src/EdgeTypes/index.js @@ -1,39 +1,23 @@ +/** + * Example for checking the different edge types and source and target positions + */ import React, { useState } from 'react'; -import ReactFlow, { removeElements, addEdge, MiniMap, isNode, Controls, Background } from 'react-flow-renderer'; +import ReactFlow, { removeElements, addEdge, MiniMap, Controls, Background } from 'react-flow-renderer'; import { getElements } from './utils'; const onLoad = (reactFlowInstance) => { reactFlowInstance.fitView(); - console.log(reactFlowInstance.getElements()); }; -const initialElements = getElements(10, 10); +const initialElements = getElements(); const EdgeTypesFlow = () => { const [elements, setElements] = useState(initialElements); const onElementsRemove = (elementsToRemove) => setElements((els) => removeElements(elementsToRemove, els)); const onConnect = (params) => setElements((els) => addEdge(params, els)); - const updatePos = () => { - setElements((elms) => { - return elms.map((el) => { - if (isNode(el)) { - return { - ...el, - position: { - x: Math.random() * window.innerWidth, - y: Math.random() * window.innerHeight, - }, - }; - } - - return el; - }); - }); - }; - return ( { - - ); }; diff --git a/example/src/Overview/index.js b/example/src/Overview/index.js index 4e822101..9eb7ddc2 100644 --- a/example/src/Overview/index.js +++ b/example/src/Overview/index.js @@ -128,7 +128,7 @@ const OverviewFlow = () => { onSelectionChange={onSelectionChange} style={{ width: '100%', height: '100%' }} onLoad={onLoad} - connectionLineStyle={{ stroke: '#ddd', strokeWidth: 2 }} + connectionLineStyle={{ stroke: '#ddd' }} snapToGrid={true} snapGrid={[16, 16]} > diff --git a/src/components/ConnectionLine/index.tsx b/src/components/ConnectionLine/index.tsx index 6daba9cb..00cc037b 100644 --- a/src/components/ConnectionLine/index.tsx +++ b/src/components/ConnectionLine/index.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState, CSSProperties } from 'react'; import cc from 'classcat'; import { getBezierPath } from '../Edges/BezierEdge'; -import { getStepPath } from '../Edges/StepEdge'; import { getSmoothStepPath } from '../Edges/SmoothStepEdge'; import { ElementId, Node, Transform, HandleElement, Position, ConnectionLineType, HandleType } from '../../types'; @@ -80,12 +79,18 @@ export default ({ targetPosition, }); } else if (connectionLineType === ConnectionLineType.Step) { - dAttr = getStepPath({ + dAttr = getSmoothStepPath({ + xOffset, + yOffset, + centerX, centerY, sourceX, sourceY, + sourcePosition: sourceHandle?.position, targetX, targetY, + targetPosition, + borderRadius: 0, }); } else if (connectionLineType === ConnectionLineType.SmoothStep) { dAttr = getSmoothStepPath({