diff --git a/.babelrc b/.babelrc index edf9c8e5e..94bd4e6a0 100644 --- a/.babelrc +++ b/.babelrc @@ -2,6 +2,7 @@ "presets": ["@babel/preset-env", "@babel/preset-react"], "plugins": [ "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-object-rest-spread" + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-proposal-optional-chaining", ] } diff --git a/.eslintrc.js b/.eslintrc.js index 021ae755e..dd1352026 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,7 @@ module.exports = { jsx: true, }, }, - plugins: ["standard", "promise", "react", "jest", "cypress"], + plugins: ["standard", "promise", "react", "jest", "cypress", "babel"], env: { browser: true, }, @@ -27,7 +27,7 @@ module.exports = { camelcase: "error", "keyword-spacing": "error", "max-len": ["error", 120, 4, { ignoreComments: true }], - "max-lines": ["error", { max: 400, skipComments: true }], + "max-lines": ["error", { max: 450, skipComments: true }], "newline-after-var": ["error", "always"], "no-nested-ternary": "error", "no-useless-constructor": "error", diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..01b6482d4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "javascript.validate.enable": false +} diff --git a/README.md b/README.md index 2a4f09a60..4445270a0 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,15 @@ const onNodePositionChange = function(nodeId, x, y) { Contributions are welcome fell free to submit new ideas/features, just open an issue or send me an email or something. If you are more a _hands on_ person, just submit a pull request. Before jumping into coding, please take at the contribution guidelines [CONTRIBUTING.md](https://github.com/danielcaldas/react-d3-graph/blob/master/CONTRIBUTING.md). -To run react-d3-graph in development mode you just need to run `npm run dev` and the interactive sandbox will reload with the changes to the library code, that way you can test your changes not only through unit test but also through a real life example. It's that simple. +To run react-d3-graph in development mode you just need to run `npm run dev` and the interactive sandbox will reload with the changes to the library code, that way you can test your changes not only through unit test but also through a real life example. It's that simple. The development workflow usually should follow the steps: + +- Create a branch prefixed with `fix/` for bug fixes, `feature/` for new features, `chore/` or `refactor/` for refactoring or tolling and CI/CD related tasks. +- Make sure you are up to date running `npm install`. +- Run `npm run dev`. +- Do you changes inside the folder `src` and the interactive sandbox consumes your changes in real time + with webpack-dev-server. +- You can run tests locally with `npm run test` (for unit tests) or `npm run functional:local` for e2e tests. +- After you're done open the Pull Request and describe the changes you've made. ## Alternatives (Not what you where looking for?) diff --git a/cypress/integration/graph.directed.e2e.js b/cypress/integration/graph.directed.e2e.js index b8d0ed045..aefc6583a 100644 --- a/cypress/integration/graph.directed.e2e.js +++ b/cypress/integration/graph.directed.e2e.js @@ -64,9 +64,15 @@ describe("[rd3g-graph-directed]", function() { this.link34PO = new LinkPO(3); }); + afterEach(function() { + this.sandboxPO.exitFullScreenMode(); + }); + it("should behave correctly when directed is disabled after collapsed node", function() { const toggledLine = this.link12PO.getLine(); + this.sandboxPO.fullScreenMode().click(); + // Check the leaf node & link is present this.node2PO.getPath().should("be.visible"); toggledLine.should("be.visible"); @@ -87,10 +93,14 @@ describe("[rd3g-graph-directed]", function() { this.link14PO.getLine().should("be.visible"); this.link34PO.getLine().should("be.visible"); + this.sandboxPO.exitFullScreenMode(); + // Disable "directed" cy.contains("directed").scrollIntoView(); this.sandboxPO.getFieldInput("directed").click(); + this.sandboxPO.fullScreenMode().click(); + // Check if other nodes and links are still visible this.node1PO.getPath().should("be.visible"); this.node2PO.getPath().should("be.visible"); @@ -104,6 +114,8 @@ describe("[rd3g-graph-directed]", function() { }); it("should behave correctly when collapsible is disabled after collapsible node", function() { + this.sandboxPO.fullScreenMode().click(); + const toggledLine = this.link12PO.getLine(); // Check the leaf node & link is present @@ -126,10 +138,14 @@ describe("[rd3g-graph-directed]", function() { this.link14PO.getLine().should("be.visible"); this.link34PO.getLine().should("be.visible"); + this.sandboxPO.exitFullScreenMode(); + // Disable "collapsible" cy.contains("collapsible").scrollIntoView(); this.sandboxPO.getFieldInput("collapsible").click(); + this.sandboxPO.fullScreenMode().click(); + // The previously hidden node should reappear this.node2PO.getPath().should("be.visible"); toggledLine.should("be.visible"); diff --git a/cypress/integration/graph.e2e.js b/cypress/integration/graph.e2e.js index 13d32a0a3..1c5bcf171 100644 --- a/cypress/integration/graph.e2e.js +++ b/cypress/integration/graph.e2e.js @@ -163,6 +163,12 @@ describe("[rd3g-graph] graph tests", function() { this.node3PO = new NodePO(3); this.node4PO = new NodePO(4); this.link12PO = new LinkPO(0); + + this.sandboxPO.fullScreenMode().click(); + }); + + afterEach(function() { + this.sandboxPO.exitFullScreenMode(); }); it("should collapse leaf nodes", function() { diff --git a/package.json b/package.json index 466fbe202..fccc06e2e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@babel/core": "7.6.0", "@babel/plugin-proposal-class-properties": "7.5.5", + "@babel/plugin-proposal-optional-chaining": "7.6.0", "@babel/preset-env": "7.6.0", "@babel/preset-react": "7.0.0", "@cypress/webpack-preprocessor": "4.1.0", @@ -52,6 +53,7 @@ "documentation": "12.1.2", "eslint": "6.3.0", "eslint-config-recommended": "4.0.0", + "eslint-plugin-babel": "5.3.0", "eslint-plugin-cypress": "2.6.1", "eslint-plugin-jest": "22.17.0", "eslint-plugin-prettier": "3.1.0", diff --git a/sandbox/Sandbox.jsx b/sandbox/Sandbox.jsx index c5f443925..b0c7e0310 100644 --- a/sandbox/Sandbox.jsx +++ b/sandbox/Sandbox.jsx @@ -7,11 +7,11 @@ import "./styles.css"; import defaultConfig from "../src/components/graph/graph.config"; import { Graph } from "../src"; -import utils from "./utils"; -import reactD3GraphUtils from "../src/utils"; +import { generateFormSchema, loadDataset, setValue } from "./utils"; +import { isDeepEqual, merge } from "../src/utils"; import { JsonTree } from "react-editable-json-tree"; -const sandboxData = utils.loadDataset(); +const sandboxData = loadDataset(); /** * This is a sample integration of react-d3-graph, in this particular case all the rd3g config properties @@ -27,7 +27,7 @@ export default class Sandbox extends React.Component { const { config: configOverride, data, fullscreen } = sandboxData; const config = Object.assign(defaultConfig, configOverride); - const schemaProps = utils.generateFormSchema(config, "", {}); + const schemaProps = generateFormSchema(config, "", {}); const schema = { type: "object", @@ -179,7 +179,7 @@ export default class Sandbox extends React.Component { for (let k of Object.keys(data.formData)) { // Set value mapping correctly for config object of react-d3-graph - utils.setValue(config, k, data.formData[k]); + setValue(config, k, data.formData[k]); // Set new values for schema of jsonform schemaPropsValues[k] = {}; schemaPropsValues[k]["default"] = data.formData[k]; @@ -191,7 +191,7 @@ export default class Sandbox extends React.Component { refreshGraph = data => { const { config, schemaPropsValues } = this._buildGraphConfig(data); - this.state.schema.properties = reactD3GraphUtils.merge(this.state.schema.properties, schemaPropsValues); + this.state.schema.properties = merge(this.state.schema.properties, schemaPropsValues); this.setState({ config, @@ -215,7 +215,7 @@ export default class Sandbox extends React.Component { resetGraphConfig = () => { const generatedConfig = {}; - const schemaProps = utils.generateFormSchema(defaultConfig, "", {}); + const schemaProps = generateFormSchema(defaultConfig, "", {}); const schema = { type: "object", @@ -445,7 +445,7 @@ export default class Sandbox extends React.Component { class JSONContainer extends React.Component { shouldComponentUpdate(nextProps) { - return !this.props.staticData && !reactD3GraphUtils.isDeepEqual(nextProps.data, this.props.data); + return !this.props.staticData && !isDeepEqual(nextProps.data, this.props.data); } render() { diff --git a/sandbox/styles.css b/sandbox/styles.css index 68af40f92..f8f6020e1 100644 --- a/sandbox/styles.css +++ b/sandbox/styles.css @@ -57,7 +57,7 @@ .container__form { grid-column: 5/ 6; grid-row: 1 / 4; - min-width: 250px; + min-width: 400px; z-index: 3; } diff --git a/sandbox/utils.js b/sandbox/utils.js index fd54f9760..c1a435156 100644 --- a/sandbox/utils.js +++ b/sandbox/utils.js @@ -2,7 +2,7 @@ import queryString from "query-string"; import { LINE_TYPES } from "../src/components/link/link.const"; import DEFAULT_CONFIG from "../src/components/graph/graph.config"; -import utils from "../src/utils"; +import { merge } from "../src/utils"; /** * This two functions generate the react-jsonschema-form @@ -57,7 +57,7 @@ function loadDataset() { try { const data = require(`./data/${dataset}/${dataset}.data`); const datasetConfig = require(`./data/${dataset}/${dataset}.config`); - const config = utils.merge(DEFAULT_CONFIG, datasetConfig); + const config = merge(DEFAULT_CONFIG, datasetConfig); return { data, config, fullscreen }; } catch (error) { @@ -90,8 +90,4 @@ function setValue(obj, access, value) { access.length > 1 ? setValue(obj[access.shift()], access, value) : (obj[access[0]] = value); } -export default { - generateFormSchema, - loadDataset, - setValue, -}; +export { generateFormSchema, loadDataset, setValue }; diff --git a/src/components/graph/Graph.jsx b/src/components/graph/Graph.jsx index e115ad8ce..b15b6fedf 100644 --- a/src/components/graph/Graph.jsx +++ b/src/components/graph/Graph.jsx @@ -9,10 +9,16 @@ import CONST from "./graph.const"; import DEFAULT_CONFIG from "./graph.config"; import ERRORS from "../../err"; -import * as collapseHelper from "./collapse.helper"; -import * as graphHelper from "./graph.helper"; -import * as graphRenderer from "./graph.renderer"; -import utils from "../../utils"; +import { getTargetLeafConnections, toggleLinksMatrixConnections, toggleLinksConnections } from "./collapse.helper"; +import { + updateNodeHighlightedValue, + checkForGraphConfigChanges, + checkForGraphElementsChanges, + getCenterAndZoomTransformation, + initializeGraphState, +} from "./graph.helper"; +import { renderGraph } from "./graph.renderer"; +import { merge, throwErr } from "../../utils"; /** * Graph component is the main component for react-d3-graph components, its interface allows its user @@ -186,8 +192,11 @@ export default class Graph extends React.Component { * @returns {undefined} */ _onDragEnd = () => { - this.state.draggedNode && - this.onNodePositionChange(this.state.draggedNode.id, this.state.draggedNode.fx, this.state.draggedNode.fy); + if (this.state.draggedNode) { + this.onNodePositionChange(this.state.draggedNode); + this._tick({ draggedNode: null }); + } + !this.state.config.staticGraph && this.state.config.automaticRearrangeAfterDropNode && this.state.simulation.alphaTarget(this.state.config.d3.alphaTarget).restart(); @@ -209,6 +218,9 @@ export default class Graph extends React.Component { // this is where d3 and react bind let draggedNode = this.state.nodes[id]; + draggedNode.oldX = draggedNode.x; + draggedNode.oldY = draggedNode.y; + draggedNode.x += d3Event.dx; draggedNode.y += d3Event.dy; @@ -238,9 +250,7 @@ export default class Graph extends React.Component { * @returns {undefined} */ _setNodeHighlightedValue = (id, value = false) => - this._tick( - graphHelper.updateNodeHighlightedValue(this.state.nodes, this.state.links, this.state.config, id, value) - ); + this._tick(updateNodeHighlightedValue(this.state.nodes, this.state.links, this.state.config, id, value)); /** * The tick function simply calls React set state in order to update component and render nodes @@ -295,7 +305,7 @@ export default class Graph extends React.Component { // toUpperCase() is added as a precaution, as the documentation says tagName should always // return in UPPERCASE, but chrome returns lowercase const tagName = e.target && e.target.tagName; - const name = e.target && e.target.attributes && e.target.attributes.name && e.target.attributes.name.value; + const name = e?.target?.attributes?.name?.value; const svgContainerName = `svg-container-${this.state.id}`; if (tagName.toUpperCase() === "SVG" && name === svgContainerName) { @@ -310,18 +320,10 @@ export default class Graph extends React.Component { */ onClickNode = clickedNodeId => { if (this.state.config.collapsible) { - const leafConnections = collapseHelper.getTargetLeafConnections( - clickedNodeId, - this.state.links, - this.state.config - ); - const links = collapseHelper.toggleLinksMatrixConnections( - this.state.links, - leafConnections, - this.state.config - ); - const d3Links = collapseHelper.toggleLinksConnections(this.state.d3Links, links); - const firstLeaf = leafConnections && leafConnections.length && leafConnections[0]; + const leafConnections = getTargetLeafConnections(clickedNodeId, this.state.links, this.state.config); + const links = toggleLinksMatrixConnections(this.state.links, leafConnections, this.state.config); + const d3Links = toggleLinksConnections(this.state.d3Links, links); + const firstLeaf = leafConnections?.["0"]; let isExpanding = false; @@ -413,13 +415,22 @@ export default class Graph extends React.Component { /** * Handles node position change. - * @param {string} nodeId - id of the node whose position changed. - * @param {number} x - x coordinate of the node whose position changed. - * @param {number} y - y coordinate of the node whose position changed. + * @param {Object} node - an object holding information about the dragged node. * @returns {undefined} */ - onNodePositionChange = (nodeId, x, y) => - this.props.onNodePositionChange && this.props.onNodePositionChange(nodeId, x, y); + onNodePositionChange = node => { + if (!this.props.onNodePositionChange) { + return; + } + + const { id, oldX, oldY, x, y } = node; + const deltaX = x - oldX; + const deltaY = y - oldY; + + if (deltaX !== 0 || deltaY !== 0) { + this.props.onNodePositionChange(id, x, y); + } + }; /** * Calls d3 simulation.stop().
@@ -462,11 +473,12 @@ export default class Graph extends React.Component { super(props); if (!this.props.id) { - utils.throwErr(this.constructor.name, ERRORS.GRAPH_NO_ID_PROP); + throwErr(this.constructor.name, ERRORS.GRAPH_NO_ID_PROP); } this.focusAnimationTimeout = null; - this.state = graphHelper.initializeGraphState(this.props, this.state); + this.nodeClickTimer = null; + this.state = initializeGraphState(this.props, this.state); } /** @@ -481,14 +493,11 @@ export default class Graph extends React.Component { */ // eslint-disable-next-line UNSAFE_componentWillReceiveProps(nextProps) { - const { graphElementsUpdated, newGraphElements } = graphHelper.checkForGraphElementsChanges( - nextProps, - this.state - ); - const state = graphElementsUpdated ? graphHelper.initializeGraphState(nextProps, this.state) : this.state; + const { graphElementsUpdated, newGraphElements } = checkForGraphElementsChanges(nextProps, this.state); + const state = graphElementsUpdated ? initializeGraphState(nextProps, this.state) : this.state; const newConfig = nextProps.config || {}; - const { configUpdated, d3ConfigUpdated } = graphHelper.checkForGraphConfigChanges(nextProps, this.state); - const config = configUpdated ? utils.merge(DEFAULT_CONFIG, newConfig) : this.state.config; + const { configUpdated, d3ConfigUpdated } = checkForGraphConfigChanges(nextProps, this.state); + const config = configUpdated ? merge(DEFAULT_CONFIG, newConfig) : this.state.config; // in order to properly update graph data we need to pause eventual d3 ongoing animations newGraphElements && this.pauseSimulation(); @@ -496,7 +505,7 @@ export default class Graph extends React.Component { const transform = newConfig.panAndZoom !== this.state.config.panAndZoom ? 1 : this.state.transform; const focusedNodeId = nextProps.data.focusedNodeId; const d3FocusedNode = this.state.d3Nodes.find(node => `${node.id}` === `${focusedNodeId}`); - const focusTransformation = graphHelper.getCenterAndZoomTransformation(d3FocusedNode, this.state.config); + const focusTransformation = getCenterAndZoomTransformation(d3FocusedNode, this.state.config); const enableFocusAnimation = this.props.data.focusedNodeId !== nextProps.data.focusedNodeId; this.setState({ @@ -549,11 +558,20 @@ export default class Graph extends React.Component { componentWillUnmount() { this.pauseSimulation(); - this.nodeClickTimer && clearTimeout(this.nodeClickTimer); + + if (this.nodeClickTimer) { + clearTimeout(this.nodeClickTimer); + this.nodeClickTimer = null; + } + + if (this.focusAnimationTimeout) { + clearTimeout(this.focusAnimationTimeout); + this.focusAnimationTimeout = null; + } } render() { - const { nodes, links, defs } = graphRenderer.renderGraph( + const { nodes, links, defs } = renderGraph( this.state.nodes, { onClickNode: this.onClickNode, diff --git a/src/components/graph/collapse.helper.js b/src/components/graph/collapse.helper.js index 24a5e734b..3496b1e27 100644 --- a/src/components/graph/collapse.helper.js +++ b/src/components/graph/collapse.helper.js @@ -2,22 +2,10 @@ * @module Graph/collapse-helper * @description * Offers a series of methods that allow graph to perform the necessary operations to - * create the collapsible behavior. - * - * Developer notes - collapsing nodes and maintaining state on links matrix. - * - * User interaction flow (for a collapsible graph) - * 1. User clicks node - * 2. All leaf connections of that node are not rendered anymore - * 3. User clicks on same node - * 4. All leaf connections of that node are rendered - * - * Internal react-d3-graph flow - * 1. User clicks node - * 2. Compute leaf connections for clicked node (rootNode, root as in 'root' of the event) - * 3. Update connections matrix (based on 2.) - * 4. Update d3Links array with toggled connections (based on 2.) + * create the collapsible behavior. These functions will most likely operate on + * the links matrix. */ +import { getId } from "./graph.helper"; /** * For directed graphs. @@ -53,8 +41,9 @@ function _isLeafNotDirected(inDegree, outDegree) { */ function _isLeaf(nodeId, linksMatrix, directed) { const { inDegree, outDegree } = computeNodeDegree(nodeId, linksMatrix); + const fn = directed ? _isLeafDirected : _isLeafNotDirected; - return directed ? _isLeafDirected(inDegree, outDegree) : _isLeafNotDirected(inDegree, outDegree); + return fn(inDegree, outDegree); } /** @@ -78,17 +67,11 @@ function computeNodeDegree(nodeId, linksMatrix = {}) { return currentNodeConnections.reduce((_acc, target) => { if (nodeId === source) { - return { - ..._acc, - outDegree: _acc.outDegree + linksMatrix[nodeId][target], - }; + _acc.outDegree += linksMatrix[nodeId][target]; } if (nodeId === target) { - return { - ..._acc, - inDegree: _acc.inDegree + linksMatrix[source][nodeId], - }; + _acc.inDegree += linksMatrix[source][nodeId]; } return _acc; @@ -133,7 +116,7 @@ function getTargetLeafConnections(rootNodeId, linksMatrix = {}, { directed }) { * NOTE: this function is meant to be used under the `collapsible` toggle, meaning * that the `isNodeVisible` actually is checking visibility on collapsible graphs. * If you think that this code is confusing and could potentially collide (🤞) with #_isLeaf - * always remember that *A leaf can, through time, be both a visible or an invisible node!*. + * always remember that *A leaf can, throughout time, both a visible or an invisible node!*. * * @param {string} nodeId - The id of the node to get the cardinality of * @param {Object.} nodes - an object containing all nodes mapped by their id. @@ -142,10 +125,13 @@ function getTargetLeafConnections(rootNodeId, linksMatrix = {}, { directed }) { * @memberof Graph/collapse-helper */ function isNodeVisible(nodeId, nodes, linksMatrix) { + if (nodes[nodeId]._orphan) { + return true; + } + const { inDegree, outDegree } = computeNodeDegree(nodeId, linksMatrix); - const orphan = !!nodes[nodeId]._orphan; - return inDegree > 0 || outDegree > 0 || orphan; + return inDegree > 0 || outDegree > 0; } /** @@ -158,12 +144,13 @@ function isNodeVisible(nodeId, nodes, linksMatrix) { function toggleLinksConnections(d3Links, connectionMatrix) { return d3Links.map(d3Link => { const { source, target } = d3Link; - const sourceId = source.id || source; - const targetId = target.id || target; + const sourceId = getId(source); + const targetId = getId(target); // connectionMatrix[sourceId][targetId] can be 0 or non existent const connection = connectionMatrix && connectionMatrix[sourceId] && connectionMatrix[sourceId][targetId]; + const isHidden = !connection; - return connection ? { ...d3Link, isHidden: false } : { ...d3Link, isHidden: true }; + return { ...d3Link, isHidden }; }); } diff --git a/src/components/graph/graph.builder.js b/src/components/graph/graph.builder.js index 44ece2294..7648ae9e4 100644 --- a/src/components/graph/graph.builder.js +++ b/src/components/graph/graph.builder.js @@ -53,10 +53,10 @@ function _getNodeOpacity(node, highlightedNode, highlightedLink, config) { */ function buildLinkProps(link, nodes, links, config, linkCallbacks, highlightedNode, highlightedLink, transform) { const { source, target } = link; - const x1 = (nodes[source] && nodes[source].x) || 0; - const y1 = (nodes[source] && nodes[source].y) || 0; - const x2 = (nodes[target] && nodes[target].x) || 0; - const y2 = (nodes[target] && nodes[target].y) || 0; + const x1 = nodes?.[source]?.x || 0; + const y1 = nodes?.[source]?.y || 0; + const x2 = nodes?.[target]?.x || 0; + const y2 = nodes?.[target]?.y || 0; const d = buildLinkPathDefinition({ source: { x: x1, y: y1 }, target: { x: x2, y: y2 } }, config.link.type); @@ -74,11 +74,11 @@ function buildLinkProps(link, nodes, links, config, linkCallbacks, highlightedNo break; } - const reasonNode = mainNodeParticipates && nodes[source].highlighted && nodes[target].highlighted; - const reasonLink = + const guiltyNode = mainNodeParticipates && nodes[source].highlighted && nodes[target].highlighted; + const guiltyLink = source === (highlightedLink && highlightedLink.source) && target === (highlightedLink && highlightedLink.target); - const highlight = reasonNode || reasonLink; + const highlight = guiltyNode || guiltyLink; let opacity = link.opacity || config.link.opacity; @@ -122,23 +122,23 @@ function buildLinkProps(link, nodes, links, config, linkCallbacks, highlightedNo } return { - markerId, + className: CONST.LINK_CLASS_NAME, d, - source, - target, - strokeWidth, - stroke, - label, - mouseCursor: config.link.mouseCursor, fontColor, fontSize: fontSize * t, fontWeight, - className: CONST.LINK_CLASS_NAME, + label, + markerId, + mouseCursor: config.link.mouseCursor, opacity, + source, + stroke, + strokeWidth, + target, onClickLink: linkCallbacks.onClickLink, - onRightClickLink: linkCallbacks.onRightClickLink, - onMouseOverLink: linkCallbacks.onMouseOverLink, onMouseOutLink: linkCallbacks.onMouseOutLink, + onMouseOverLink: linkCallbacks.onMouseOverLink, + onRightClickLink: linkCallbacks.onRightClickLink, }; } @@ -195,20 +195,17 @@ function buildNodeProps(node, config, nodeCallbacks = {}, highlightedNode, highl ...node, className: CONST.NODE_CLASS_NAME, cursor: config.node.mouseCursor, - cx: (node && node.x) || "0", - cy: (node && node.y) || "0", + cx: node?.x || "0", + cy: node?.y || "0", + dx, fill, fontColor, fontSize: fontSize * t, - dx, fontWeight: highlight ? config.node.highlightFontWeight : config.node.fontWeight, id: node.id, label, - onClickNode: nodeCallbacks.onClickNode, - onRightClickNode: nodeCallbacks.onRightClickNode, - onMouseOverNode: nodeCallbacks.onMouseOverNode, - onMouseOut: nodeCallbacks.onMouseOut, opacity, + overrideGlobalViewGenerator: !node.viewGenerator && node.svg, renderLabel: config.node.renderLabel, size: nodeSize * t, stroke, @@ -216,7 +213,10 @@ function buildNodeProps(node, config, nodeCallbacks = {}, highlightedNode, highl svg, type: node.symbolType || config.node.symbolType, viewGenerator: node.viewGenerator || config.node.viewGenerator, - overrideGlobalViewGenerator: !node.viewGenerator && node.svg, + onClickNode: nodeCallbacks.onClickNode, + onMouseOut: nodeCallbacks.onMouseOut, + onMouseOverNode: nodeCallbacks.onMouseOverNode, + onRightClickNode: nodeCallbacks.onRightClickNode, }; } diff --git a/src/components/graph/graph.helper.js b/src/components/graph/graph.helper.js index bc9927851..277ff08d7 100644 --- a/src/components/graph/graph.helper.js +++ b/src/components/graph/graph.helper.js @@ -30,7 +30,7 @@ import CONST from "./graph.const"; import DEFAULT_CONFIG from "./graph.config"; import ERRORS from "../../err"; -import utils from "../../utils"; +import { isDeepEqual, isEmptyObject, merge, pick, antiPick, throwErr } from "../../utils"; import { computeNodeDegree } from "./collapse.helper"; const NODE_PROPS_WHITELIST = ["id", "highlighted", "x", "y", "index", "vy", "vx"]; @@ -70,8 +70,8 @@ function _createForceSimulation(width, height, gravity) { */ function _initializeLinks(graphLinks, config) { return graphLinks.reduce((links, l) => { - const source = l.source.id !== undefined && l.source.id !== null ? l.source.id : l.source; - const target = l.target.id !== undefined && l.target.id !== null ? l.target.id : l.target; + const source = getId(l.source); + const target = getId(l.target); if (!links[source]) { links[source] = {}; @@ -140,8 +140,8 @@ function _initializeNodes(graphNodes) { function _mergeDataLinkWithD3Link(link, index, d3Links = [], config, state = {}) { // find the matching link if it exists const tmp = d3Links.find(l => l.source.id === link.source && l.target.id === link.target); - const d3Link = tmp && utils.pick(tmp, LINK_PROPS_WHITELIST); - const customProps = utils.antiPick(link, ["source", "target"]); + const d3Link = tmp && pick(tmp, LINK_PROPS_WHITELIST); + const customProps = antiPick(link, ["source", "target"]); if (d3Link) { const toggledDirected = @@ -213,7 +213,7 @@ function _tagOrphanNodes(nodes, linksMatrix) { */ function _validateGraphData(data) { if (!data.nodes || !data.nodes.length) { - utils.throwErr("Graph", ERRORS.INSUFFICIENT_DATA); + throwErr("Graph", ERRORS.INSUFFICIENT_DATA); } const n = data.links.length; @@ -222,15 +222,15 @@ function _validateGraphData(data) { const l = data.links[i]; if (!data.nodes.find(n => n.id === l.source)) { - utils.throwErr("Graph", `${ERRORS.INVALID_LINKS} - "${l.source}" is not a valid source node id`); + throwErr("Graph", `${ERRORS.INVALID_LINKS} - "${l.source}" is not a valid source node id`); } if (!data.nodes.find(n => n.id === l.target)) { - utils.throwErr("Graph", `${ERRORS.INVALID_LINKS} - "${l.target}" is not a valid target node id`); + throwErr("Graph", `${ERRORS.INVALID_LINKS} - "${l.target}" is not a valid target node id`); } if (l && l.value !== undefined && typeof l.value !== "number") { - utils.throwErr( + throwErr( "Graph", `${ERRORS.INVALID_LINK_VALUE} - found in link with source "${l.source}" and target "${l.target}"` ); @@ -248,7 +248,7 @@ const NODE_PROPERTIES_DISCARD_TO_COMPARE = ["x", "y", "vx", "vy", "index"]; * @memberof Graph/helper */ function _pickId(o) { - return utils.pick(o, ["id"]); + return pick(o, ["id"]); } /** @@ -258,7 +258,7 @@ function _pickId(o) { * @memberof Graph/helper */ function _pickSourceAndTarget(o) { - return utils.pick(o, ["source", "target"]); + return pick(o, ["source", "target"]); } /** @@ -275,22 +275,19 @@ function _pickSourceAndTarget(o) { * @memberof Graph/helper */ function checkForGraphElementsChanges(nextProps, currentState) { - const nextNodes = nextProps.data.nodes.map(n => utils.antiPick(n, NODE_PROPERTIES_DISCARD_TO_COMPARE)); + const nextNodes = nextProps.data.nodes.map(n => antiPick(n, NODE_PROPERTIES_DISCARD_TO_COMPARE)); const nextLinks = nextProps.data.links; - const stateD3Nodes = currentState.d3Nodes.map(n => utils.antiPick(n, NODE_PROPERTIES_DISCARD_TO_COMPARE)); + const stateD3Nodes = currentState.d3Nodes.map(n => antiPick(n, NODE_PROPERTIES_DISCARD_TO_COMPARE)); const stateD3Links = currentState.d3Links.map(l => ({ - // FIXME: solve this source data inconsistency later - source: l.source.id !== undefined && l.source.id !== null ? l.source.id : l.source, - target: l.target.id !== undefined && l.target.id !== null ? l.target.id : l.target, + source: getId(l.source), + target: getId(l.target), })); - const graphElementsUpdated = !( - utils.isDeepEqual(nextNodes, stateD3Nodes) && utils.isDeepEqual(nextLinks, stateD3Links) - ); + const graphElementsUpdated = !(isDeepEqual(nextNodes, stateD3Nodes) && isDeepEqual(nextLinks, stateD3Links)); const newGraphElements = nextNodes.length !== stateD3Nodes.length || nextLinks.length !== stateD3Links.length || - !utils.isDeepEqual(nextNodes.map(_pickId), stateD3Nodes.map(_pickId)) || - !utils.isDeepEqual(nextLinks.map(_pickSourceAndTarget), stateD3Links.map(_pickSourceAndTarget)); + !isDeepEqual(nextNodes.map(_pickId), stateD3Nodes.map(_pickId)) || + !isDeepEqual(nextLinks.map(_pickSourceAndTarget), stateD3Links.map(_pickSourceAndTarget)); return { graphElementsUpdated, newGraphElements }; } @@ -306,9 +303,8 @@ function checkForGraphElementsChanges(nextProps, currentState) { */ function checkForGraphConfigChanges(nextProps, currentState) { const newConfig = nextProps.config || {}; - const configUpdated = - newConfig && !utils.isEmptyObject(newConfig) && !utils.isDeepEqual(newConfig, currentState.config); - const d3ConfigUpdated = newConfig && newConfig.d3 && !utils.isDeepEqual(newConfig.d3, currentState.config.d3); + const configUpdated = newConfig && !isEmptyObject(newConfig) && !isDeepEqual(newConfig, currentState.config); + const d3ConfigUpdated = newConfig && newConfig.d3 && !isDeepEqual(newConfig.d3, currentState.config.d3); return { configUpdated, d3ConfigUpdated }; } @@ -318,7 +314,7 @@ function checkForGraphConfigChanges(nextProps, currentState) { * selected node. * @param {Object} d3Node - node to focus the graph view on. * @param {Object} config - same as {@link #graphrenderer|config in renderGraph}. - * @returns {string} transform rule to apply. + * @returns {string|undefined} transform rule to apply. * @memberof Graph/helper */ function getCenterAndZoomTransformation(d3Node, config) { @@ -335,6 +331,24 @@ function getCenterAndZoomTransformation(d3Node, config) { `; } +/** + * This function extracts an id from a link. + * **Why this function?** + * According to [d3-force](https://github.com/d3/d3-force#link_links) + * d3 links might be initialized with "source" and "target" + * properties as numbers or strings, but after initialization they + * are converted to an object. This small utility functions ensures + * that weather in initialization or further into the lifetime of the graph + * we always get the id. + * @param {Object|string|number} sot source or target + * of the link to extract id. + * we want to extract an id. + * @returns {string|number} the id of the link. + */ +function getId(sot) { + return sot.id !== undefined && sot.id !== null ? sot.id : sot; +} + /** * Encapsulates common procedures to initialize graph. * @param {Object} props - Graph component props, object that holds data, id and config. @@ -353,7 +367,7 @@ function initializeGraphState({ data, id, config }, state) { if (state && state.nodes) { graph = { nodes: data.nodes.map(n => - state.nodes[n.id] ? { ...n, ...utils.pick(state.nodes[n.id], NODE_PROPS_WHITELIST) } : { ...n } + state.nodes[n.id] ? { ...n, ...pick(state.nodes[n.id], NODE_PROPS_WHITELIST) } : { ...n } ), links: data.links.map((l, index) => _mergeDataLinkWithD3Link(l, index, state && state.d3Links, config, state) @@ -366,7 +380,7 @@ function initializeGraphState({ data, id, config }, state) { }; } - let newConfig = { ...utils.merge(DEFAULT_CONFIG, config || {}) }, + let newConfig = { ...merge(DEFAULT_CONFIG, config || {}) }, links = _initializeLinks(graph.links, newConfig), // matrix of graph connections nodes = _tagOrphanNodes(_initializeNodes(graph.nodes), links); const { nodes: d3Nodes, links: d3Links } = graph; @@ -418,7 +432,9 @@ function updateNodeHighlightedValue(nodes, links, config, id, value = false) { updatedNodes = Object.keys(links[id]).reduce((acc, linkId) => { const updatedNode = { ...updatedNodes[linkId], highlighted: value }; - return { ...acc, [linkId]: updatedNode }; + acc[linkId] = updatedNode; + + return acc; }, updatedNodes); } @@ -432,6 +448,7 @@ export { checkForGraphConfigChanges, checkForGraphElementsChanges, getCenterAndZoomTransformation, + getId, initializeGraphState, updateNodeHighlightedValue, }; diff --git a/src/components/graph/graph.renderer.jsx b/src/components/graph/graph.renderer.jsx index 632976585..d70c49b60 100644 --- a/src/components/graph/graph.renderer.jsx +++ b/src/components/graph/graph.renderer.jsx @@ -12,6 +12,7 @@ import Link from "../link/Link"; import Node from "../node/Node"; import Marker from "../marker/Marker"; import { buildLinkProps, buildNodeProps } from "./graph.builder"; +import { getId } from "../graph/graph.helper"; import { isNodeVisible } from "./collapse.helper"; /** @@ -36,9 +37,8 @@ function _renderLinks(nodes, links, linksMatrix, config, linkCallbacks, highligh return outLinks.map(link => { const { source, target } = link; - // FIXME: solve this source data inconsistency later - const sourceId = source.id !== undefined && source.id !== null ? source.id : source; - const targetId = target.id !== undefined && target.id !== null ? target.id : target; + const sourceId = getId(source); + const targetId = getId(target); const key = `${sourceId}${CONST.COORDS_SEPARATOR}${targetId}`; const props = buildLinkProps( { ...link, source: `${sourceId}`, target: `${targetId}` }, diff --git a/src/components/marker/marker.helper.js b/src/components/marker/marker.helper.js index 86c66ab68..39b9359e1 100644 --- a/src/components/marker/marker.helper.js +++ b/src/components/marker/marker.helper.js @@ -7,7 +7,6 @@ import { MARKERS, SIZES, HIGHLIGHTED } from "./marker.const"; /** * This function is a key template builder to access MARKERS structure. - * WARN: function tightly coupled to the MARKERS object in marker.const. * @param {string} size - string that indicates size of marker. * @param {string} highlighted - string that indicates highlight state of marker. * @returns {string} the key of the marker. diff --git a/src/utils.js b/src/utils.js index ca30f1d75..1d6d2f7c7 100644 --- a/src/utils.js +++ b/src/utils.js @@ -94,10 +94,8 @@ function isEmptyObject(o) { * @memberof utils */ function deepClone(o, _clone = {}, _depth = 0) { - // TODO: Handle invalid input o is null, undefined, empty object const oKeys = Object.keys(o); - // TODO: handle arrays for (let k of oKeys) { const nested = _isPropertyNestedObject(o, k); @@ -187,12 +185,4 @@ function throwErr(component, msg) { throw Error(error); } -export default { - isDeepEqual, - isEmptyObject, - deepClone, - merge, - pick, - antiPick, - throwErr, -}; +export { isDeepEqual, isEmptyObject, deepClone, merge, pick, antiPick, throwErr }; diff --git a/test/graph/graph.builder.spec.js b/test/graph/graph.builder.spec.js index 26857821f..35a15f9e9 100644 --- a/test/graph/graph.builder.spec.js +++ b/test/graph/graph.builder.spec.js @@ -2,7 +2,7 @@ import * as graphHelper from "../../src/components/graph/graph.builder"; import config from "../../src/components/graph/graph.config"; -import utils from "../../src/utils"; +import * as utils from "../../src/utils"; import * as linkHelper from "../../src/components/link/link.helper"; describe("Graph Helper", () => { diff --git a/test/graph/graph.helper.spec.js b/test/graph/graph.helper.spec.js index 9e8c0aad2..d95fef9bf 100644 --- a/test/graph/graph.helper.spec.js +++ b/test/graph/graph.helper.spec.js @@ -1,6 +1,6 @@ import * as graphHelper from "../../src/components/graph/graph.helper"; -import utils from "../../src/utils"; +import * as utils from "../../src/utils"; jest.mock("d3-force"); import { diff --git a/test/utils.spec.js b/test/utils.spec.js index 6e5b26f9b..5bd7d8d3b 100644 --- a/test/utils.spec.js +++ b/test/utils.spec.js @@ -1,4 +1,4 @@ -import utils from "../src/utils"; +import * as utils from "../src/utils"; describe("Utils", () => { describe("#merge", () => {