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/cypress/integration/flow/basic.spec.js b/cypress/integration/flow/basic.spec.js index a073a03a..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); @@ -115,16 +117,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.spec.js b/cypress/integration/flow/controls.spec.js new file mode 100644 index 00000000..3975fadb --- /dev/null +++ b/cypress/integration/flow/controls.spec.js @@ -0,0 +1,80 @@ +describe('Controls Testing', () => { + before(() => { + cy.visit('/'); + }); + + 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/custom-node.js b/cypress/integration/flow/custom-node.js deleted file mode 100644 index 4cecdcec..00000000 --- a/cypress/integration/flow/custom-node.js +++ /dev/null @@ -1,10 +0,0 @@ -describe('Custom Node Flow Rendering', () => { - it('renders a flow', () => { - cy.visit('/custom-node'); - - 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/empty.spec.js b/cypress/integration/flow/empty.spec.js index d4029107..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'); @@ -16,26 +18,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'); 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/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 new file mode 100644 index 00000000..1ce5bb5a --- /dev/null +++ b/cypress/integration/flow/minimap.spec.js @@ -0,0 +1,69 @@ +describe('Minimap Testing', () => { + before(() => { + cy.visit('/'); + }); + + it('renders the mini map', () => { + 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); + }); + }); + }); +}); diff --git a/cypress/integration/flow/overview.spec.js b/cypress/integration/flow/overview.spec.js deleted file mode 100644 index d0153db2..00000000 --- a/cypress/integration/flow/overview.spec.js +++ /dev/null @@ -1,17 +0,0 @@ -describe('Overview Flow Rendering', () => { - it('renders a flow', () => { - cy.visit('/'); - - 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 d70b5847..00000000 --- a/cypress/integration/flow/stress.spec.js +++ /dev/null @@ -1,10 +0,0 @@ -describe('Stress Flow Rendering', () => { - it('renders initial flow', () => { - cy.visit('/stress'); - - 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/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 new file mode 100644 index 00000000..c6bae629 --- /dev/null +++ b/example/src/EdgeTypes/index.js @@ -0,0 +1,36 @@ +/** + * Example for checking the different edge types and source and target positions + */ +import React, { useState } from 'react'; + +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(); + +const EdgeTypesFlow = () => { + const [elements, setElements] = useState(initialElements); + const onElementsRemove = (elementsToRemove) => setElements((els) => removeElements(elementsToRemove, els)); + const onConnect = (params) => setElements((els) => addEdge(params, els)); + + 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/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 ( { onSelectionChange={onSelectionChange} style={{ width: '100%', height: '100%' }} onLoad={onLoad} - connectionLineStyle={{ stroke: '#ddd', strokeWidth: 2 }} + connectionLineStyle={{ stroke: '#ddd' }} snapToGrid={true} snapGrid={[16, 16]} > { - 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/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); 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": { 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 ( { + 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/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 ; +}); diff --git a/src/style.css b/src/style.css index e2057f62..7fdaef30 100644 --- a/src/style.css +++ b/src/style.css @@ -63,7 +63,7 @@ .react-flow__edge-path { fill: none; stroke: #bbb; - stroke-width: 2; + stroke-width: 1.5; } .react-flow__edge-text { @@ -83,7 +83,7 @@ .react-flow__connection-path { fill: none; stroke: #ddd; - stroke-width: 2; + stroke-width: 1.5; } .react-flow__nodes { @@ -107,7 +107,7 @@ .react-flow__node-input, .react-flow__node-output { padding: 10px; - border-radius: 5px; + border-radius: 3px; width: 150px; font-size: 12px; color: #222; @@ -127,16 +127,16 @@ } } -.react-flow__node-default { - background: #ff6060; -} - .react-flow__node-input { background: #9999ff; } +.react-flow__node-default { + background: #ff6060; +} + .react-flow__node-output { - background: #55dd99; + background: #79c9b7; } .react-flow__nodesselection { 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; 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 }); }