diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index d7d32244f6..d4b6ca208e 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -23,8 +23,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge restrict: 'E', link: function(scope, element) { - let marginLeft = 20, - nodeW = 180, + let nodeW = 180, nodeH = 60, rootW = 60, rootH = 40, @@ -32,11 +31,12 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge maxNodeTextLength = 27, windowHeight, windowWidth, + line, zoomObj, baseSvg, svgGroup, graphLoaded, - force; + nodePositionMap = {}; scope.dimensionsSet = false; @@ -54,16 +54,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); function init() { - force = d3.layout.force() - // .gravity(0) - // .linkStrength(2) - // .friction(0.4) - // .charge(-4000) - // .linkDistance(300) - .gravity(0) - .charge(-300) - .linkDistance(300) - .size([windowHeight, windowWidth]); + line = d3.svg.line() + .x(function (d) { + return d.x; + }) + .y(function (d) { + return d.y; + }); zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); @@ -75,7 +72,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge svgGroup = baseSvg.append("g") .attr("id", "aw-workflow-chart-g") - .attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); + .attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); } function calcAvailableScreenSpace() { @@ -102,6 +99,37 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return dimensions; } + // Dagre is going to shift the root node around as nodes are added/removed + // This function ensures that the user doesn't experience that + let normalizeY = ((y) => { + return y - nodePositionMap[1].y; + }); + + function lineData(d) { + + let sourceX = nodePositionMap[d.source.id].x + (nodePositionMap[d.source.id].width); + let sourceY = normalizeY(nodePositionMap[d.source.id].y) + (nodePositionMap[d.source.id].height/2); + let targetX = nodePositionMap[d.target.id].x; + let targetY = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); + + // There's something off with the math on the root node... + if (d.source.id === 1) { + sourceY = sourceY + 10; + } + + let points = [{ + x: sourceX, + y: sourceY + }, + { + x: targetX, + y: targetY + } + ]; + + return line(points); + } + // TODO: this function is hacky and we need to come up with a better solution // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 function wrap(text) { @@ -137,7 +165,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge let scale = d3.event.scale, translation = d3.event.translate; - translation = [translation[0] + (marginLeft*scale), translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; + translation = [translation[0], translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); @@ -156,7 +184,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge translateX = unscaledOffsetX*scale - ((scale*windowWidth)-windowWidth)/2, translateY = unscaledOffsetY*scale - ((scale*windowHeight)-windowHeight)/2; - svgGroup.attr("transform", "translate(" + [translateX + (marginLeft*scale), translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); + svgGroup.attr("transform", "translate(" + [translateX, translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); zoomObj.scale(scale); zoomObj.translate([translateX, translateY]); } @@ -179,7 +207,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } function resetZoomAndPan() { - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); + svgGroup.attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); // Update the zoomObj zoomObj.scale(1); zoomObj.translate([0,0]); @@ -187,16 +215,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function zoomToFitChart() { let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), - startNodeDimensions = d3.select('.WorkflowChart-rootNode')[0][0].getBoundingClientRect(), availableScreenSpace = calcAvailableScreenSpace(), currentZoomValue = zoomObj.scale(), unscaledH = graphDimensions.height/currentZoomValue, unscaledW = graphDimensions.width/currentZoomValue, scaleNeededForMaxHeight = (availableScreenSpace.height)/unscaledH, - scaleNeededForMaxWidth = (availableScreenSpace.width - marginLeft)/unscaledW, + scaleNeededForMaxWidth = (availableScreenSpace.width)/unscaledW, lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), - scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10), - startNodeOffsetFromGraphCenter = Math.round((((rootH/2) + (startNodeDimensions.top/currentZoomValue)) - ((graphDimensions.top/currentZoomValue) + (unscaledH/2)))*scaleToFit); + scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10); manualZoom(scaleToFit*100); @@ -204,12 +230,42 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge zoom: scaleToFit }); - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter) + ")scale(" + scaleToFit + ")"); - zoomObj.translate([marginLeft - scaleToFit*marginLeft, windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); + svgGroup.attr("transform", "translate(0," + (windowHeight/2 - (nodeH*scaleToFit/2)) + ")scale(" + scaleToFit + ")"); + zoomObj.translate([0, windowHeight/2 - (nodeH*scaleToFit/2) - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); } function update() { if(scope.dimensionsSet) { + var g = new dagre.graphlib.Graph(); + + g.setGraph({rankdir: 'LR', nodesep: 30, ranksep: 120}); + + g.setDefaultEdgeLabel(function() { return {}; }); + + scope.graphState.arrayOfNodesForChart.forEach((node) => { + if (node.id === 1) { + if (scope.mode === "details") { + g.setNode(node.id, { label: "", width: 25, height: 25 }); + } else { + g.setNode(node.id, { label: "", width: rootW, height: rootH }); + } + } else { + g.setNode(node.id, { label: "", width: nodeW, height: nodeH }); + } + }); + + scope.graphState.arrayOfLinksForChart.forEach((link) => { + g.setEdge(link.source.id, link.target.id); + }); + + dagre.layout(g); + + nodePositionMap = {}; + + g.nodes().forEach((node) => { + nodePositionMap[node] = g.node(node); + }); + let links = svgGroup.selectAll(".WorkflowChart-link") .data(scope.graphState.arrayOfLinksForChart, function(d) { return `${d.source.id}-${d.target.id}`; }); @@ -221,9 +277,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); baseSvg.selectAll(".WorkflowChart-linkPath") + .transition() .attr("class", function(d) { return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; }) + .attr("d", lineData) .attr('stroke', function(d) { let edgeType = d.edgeType; if(edgeType) { @@ -254,15 +312,64 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge linkClasses.push("WorkflowChart-link--active"); } return linkClasses.join(' '); + }) + .attr("points",function(d) { + let x1 = nodePositionMap[d.target.id].x; + let y1 = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); + let x2 = nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].width; + let y2 = normalizeY(nodePositionMap[d.source.id].y) + (nodePositionMap[d.source.id].height/2); + let slope = (y2 - y1)/(x2-x1); + let yIntercept = y1 - slope*x1; + let orthogonalDistance = 8; + + const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + + return [pt1, pt2, pt3, pt4].join(" "); }); baseSvg.selectAll(".WorkflowChart-circleBetweenNodes") .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }); + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("cx", function(d) { + return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; + }) + .attr("cy", function(d) { + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + return yPos; + }); baseSvg.selectAll(".WorkflowChart-betweenNodesIcon") - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }); + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("transform", function(d) { + let translate; + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + translate = "translate(" + (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2 + "," + yPos + ")"; + return translate; + }); // Add any new links let linkEnter = links.enter().append("g") @@ -283,6 +390,22 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }) .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) .call(edit_link) + .attr("points",function(d) { + let x1 = nodePositionMap[d.target.id].x; + let y1 = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); + let x2 = nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].width; + let y2 = normalizeY(nodePositionMap[d.source.id].y) + (nodePositionMap[d.source.id].height/2); + let slope = (y2 - y1)/(x2-x1); + let yIntercept = y1 - slope*x1; + let orthogonalDistance = 8; + + const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + + return [pt1, pt2, pt3, pt4].join(" "); + }) .on("mouseover", function(d) { if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); @@ -290,13 +413,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .classed("WorkflowChart-linkHovering", true); let xPos, yPos, arrowClass; - if (d.source.x === d.target.x) { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 + 45; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 107; arrowClass = 'WorkflowChart-tooltipArrow--down'; } else { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 - 30; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 70; arrowClass = 'WorkflowChart-tooltipArrow--right'; } @@ -337,10 +460,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); // Add entering links in the parent’s old position. - linkEnter.append("line") + linkEnter.insert("path", "g") .attr("class", function(d) { return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; }) + .attr("d", lineData) .call(edit_link) .on("mouseenter", function(d) { if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { @@ -349,13 +473,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .classed("WorkflowChart-linkHovering", true); let xPos, yPos, arrowClass; - if (d.source.x === d.target.x) { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 + 45; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 107; arrowClass = 'WorkflowChart-tooltipArrow--down'; } else { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 - 30; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 70; arrowClass = 'WorkflowChart-tooltipArrow--right'; } @@ -416,6 +540,23 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("cx", function(d) { + return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; + }) + .attr("cy", function(d) { + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + return yPos; + }) .call(add_node_with_child) .on("mouseover", function(d) { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); @@ -436,6 +577,23 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .type("cross") ) .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("transform", function(d) { + let translate; + + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + translate = "translate(" + (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2 + "," + yPos + ")"; + return translate; + }) .call(add_node_with_child) .on("mouseover", function(d) { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); @@ -448,13 +606,6 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .classed("WorkflowChart-addHovering", false); }); - // Create references to all the link elements so that they can be transitioned - // properly in the tick function - let linkLines = svgGroup.selectAll(".WorkflowChart-link line"); - let linkPolygons = svgGroup.selectAll(".WorkflowChart-link polygon"); - let linkAddBetweenCircle = svgGroup.selectAll(".WorkflowChart-link circle"); - let linkAddBetweenIcon = svgGroup.selectAll(".WorkflowChart-betweenNodesIcon"); - let nodes = svgGroup.selectAll('.WorkflowChart-node') .data(scope.graphState.arrayOfNodesForChart, function(d) { return d.id; }); @@ -462,6 +613,15 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge nodes.exit().remove(); // Update existing nodes + baseSvg.selectAll(".WorkflowChart-node") + .transition() + .attr("transform", function (d) { + // Update prior x and prior y + d.px = d.x; + d.py = d.y; + return "translate(" + nodePositionMap[d.id].x + "," + normalizeY(nodePositionMap[d.id].y) + ")"; + }); + baseSvg.selectAll(".WorkflowChart-nodeAddCircle") .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); @@ -640,7 +800,10 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .enter() .append('g') .attr("class", "WorkflowChart-node") - .attr("id", function(d){return "node-" + d.id;}); + .attr("id", function(d){return "node-" + d.id;}) + .attr("transform", function (d) { + return "translate(" + nodePositionMap[d.id].x + "," + normalizeY(nodePositionMap[d.id].y) + ")"; + }); nodeEnter.each(function(d) { let thisNode = d3.select(this); @@ -1064,75 +1227,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }); - // TODO: this - // if(scope.graphState.arrayOfNodesForChart && scope.graphState.arrayOfNodesForChart > 1 && !graphLoaded) { - // zoomToFitChart(); - // } + if(scope.graphState.arrayOfNodesForChart && scope.graphState.arrayOfNodesForChart.length > 1 && !graphLoaded) { + zoomToFitChart(); + } graphLoaded = true; // This will make sure that all the link elements appear before the nodes in the dom - // TODO: i don't think this is working... svgGroup.selectAll(".WorkflowChart-node").order(); - - let tick = () => { - linkLines - .each(function(d) { - d.target.y = scope.graphState.depthMap[d.target.id] * 300; - }) - .attr("x1", function(d) { return d.target.y; }) - .attr("y1", function(d) { return d.target.x + (nodeH/2); }) - .attr("x2", function(d) { return d.source.index === 0 ? (scope.mode === 'details' ? d.source.y + 25 : d.source.y + 60) : (d.source.y + nodeW); }) - .attr("y2", function(d) { return d.source.x + (nodeH/2); }); - - linkPolygons - .attr("points",function(d) { - let x1 = d.target.y; - let y1 = d.target.x + (nodeH/2); - let x2 = d.source.index === 0 ? (d.source.y + 60) : (d.source.y + nodeW); - let y2 = d.source.x + (nodeH/2); - let slope = (y2 - y1)/(x2-x1); - let yIntercept = y1 - slope*x1; - let orthogonalDistance = 8; - - const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - - return [pt1, pt2, pt3, pt4].join(" "); - }); - - linkAddBetweenCircle - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }); - - linkAddBetweenIcon - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }); - - nodes - .attr("transform", function(d) { - return "translate(" + d.y + "," + d.x + ")"; }); - }; - - force - .nodes(scope.graphState.arrayOfNodesForChart) - .links(scope.graphState.arrayOfLinksForChart) - .on("tick", tick) - .start(); } else if(!scope.watchDimensionsSet){ scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ @@ -1178,7 +1280,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function node_click() { this.on("click", function(d) { if(d.id !== scope.graphState.nodeBeingAdded && !scope.readOnly){ - if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget) { + if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget && scope.graphState.addLinkSource !== d.id) { $('.WorkflowChart-potentialLink').remove(); scope.selectNodeForLinking({ nodeToStartLink: d diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index ab2f33e9af..b0fcf857a6 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -571,6 +571,9 @@ export default ['$scope', 'TemplatesService', }; $scope.selectNodeForLinking = (node) => { + if ($scope.nodeConfig) { + $scope.cancelNodeForm(); + } if ($scope.linkConfig) { // This is the second node selected $scope.linkConfig.child = { diff --git a/awx/ui/client/src/vendor.js b/awx/ui/client/src/vendor.js index de496ac02d..b1cc68174a 100644 --- a/awx/ui/client/src/vendor.js +++ b/awx/ui/client/src/vendor.js @@ -38,6 +38,7 @@ require('moment'); require('rrule'); require('sprintf-js'); require('reconnectingwebsocket'); +global.dagre = require('dagre'); // D3 + extensions require('d3'); diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index acd5b97f3b..b6bf415a64 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -3195,6 +3195,15 @@ "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", "integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g=" }, + "dagre": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.2.tgz", + "integrity": "sha512-TEOOGZOkCOgCG7AoUIq64sJ3d21SMv8tyoqteLpX+UsUsS9Qw8iap4hhogXY4oB3r0bbZuAjO0atAilgCmsE0Q==", + "requires": { + "graphlib": "^2.1.5", + "lodash": "^4.17.4" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -6185,6 +6194,14 @@ "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", "dev": true }, + "graphlib": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.5.tgz", + "integrity": "sha512-XvtbqCcw+EM5SqQrIetIKKD+uZVNQtDPD1goIg7K73RuRZtVI5rYMdcCVSHm/AS1sCBZ7vt0p5WgXouucHQaOA==", + "requires": { + "lodash": "^4.11.1" + } + }, "growl": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", diff --git a/awx/ui/package.json b/awx/ui/package.json index facfb83d8d..e5eb180b52 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -117,6 +117,7 @@ "codemirror": "^5.17.0", "components-font-awesome": "^4.6.1", "d3": "^3.5.4", + "dagre": "^0.8.2", "hamsterjs": "^1.1.2", "html-entities": "^1.2.1", "inherits": "^1.0.2",