diff --git a/awx/ui_next/jest.config.js b/awx/ui_next/jest.config.js
index fb24626f6f..a2be1d25ab 100644
--- a/awx/ui_next/jest.config.js
+++ b/awx/ui_next/jest.config.js
@@ -11,6 +11,7 @@ module.exports = {
'\\.(css|scss|less)$': '/__mocks__/styleMock.js',
'^@api(.*)$': '/src/api$1',
'^@components(.*)$': '/src/components$1',
+ '^@constants$': '/src/constants.js',
'^@contexts(.*)$': '/src/contexts$1',
'^@screens(.*)$': '/src/screens$1',
'^@util(.*)$': '/src/util$1',
diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json
index 91b088f656..941399b8f9 100644
--- a/awx/ui_next/package-lock.json
+++ b/awx/ui_next/package-lock.json
@@ -7115,9 +7115,9 @@
"integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ=="
},
"d3-brush": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.3.tgz",
- "integrity": "sha512-v8bbYyCFKjyCzFk/tdWqXwDykY8YWqhXYjcYxfILIit085VZOpj4XJKOMccTsvWxgzSLMJQg5SiqHjslsipEDg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.5.tgz",
+ "integrity": "sha512-rEaJ5gHlgLxXugWjIkolTA0OyMvw8UWU1imYXy1v642XyyswmI1ybKOv05Ft+ewq+TFmdliD3VuK0pRp1VT/5A==",
"requires": {
"d3-dispatch": "1",
"d3-drag": "1",
@@ -7141,9 +7141,9 @@
"integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A=="
},
"d3-color": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.3.0.tgz",
- "integrity": "sha512-NHODMBlj59xPAwl2BDiO2Mog6V+PrGRtBfWKqKRrs9MCqlSkIEb0Z/SfY7jW29ReHTDC/j+vwXhnZcXI3+3fbg=="
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.0.tgz",
+ "integrity": "sha512-TzNPeJy2+iEepfiL92LAAB7fvnp/dV2YwANPVHdDWmYMm23qIJBYww3qT8I8C1wXrmrg4UWs7BKc2tKIgyjzHg=="
},
"d3-contour": {
"version": "1.3.2",
@@ -7154,23 +7154,23 @@
}
},
"d3-dispatch": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.5.tgz",
- "integrity": "sha512-vwKx+lAqB1UuCeklr6Jh1bvC4SZgbSqbkGBLClItFBIYH4vqDJCA7qfoy14lXmJdnBOdxndAMxjCbImJYW7e6g=="
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz",
+ "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA=="
},
"d3-drag": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.4.tgz",
- "integrity": "sha512-ICPurDETFAelF1CTHdIyiUM4PsyZLaM+7oIBhmyP+cuVjze5vDZ8V//LdOFjg0jGnFIZD/Sfmk0r95PSiu78rw==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz",
+ "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==",
"requires": {
"d3-dispatch": "1",
"d3-selection": "1"
}
},
"d3-dsv": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.1.1.tgz",
- "integrity": "sha512-1EH1oRGSkeDUlDRbhsFytAXU6cAmXFzc52YUe6MRlPClmWb85MP1J5x+YJRzya4ynZWnbELdSAvATFW/MbxaXw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz",
+ "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==",
"requires": {
"commander": "2",
"iconv-lite": "0.4",
@@ -7178,9 +7178,9 @@
}
},
"d3-ease": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.5.tgz",
- "integrity": "sha512-Ct1O//ly5y5lFM9YTdu+ygq7LleSgSE4oj7vUt9tPLHUi8VCV7QoizGpdWRWAwCO9LdYzIrQDg97+hGVdsSGPQ=="
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.6.tgz",
+ "integrity": "sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ=="
},
"d3-fetch": {
"version": "1.1.2",
@@ -7202,45 +7202,45 @@
}
},
"d3-format": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.1.tgz",
- "integrity": "sha512-TUswGe6hfguUX1CtKxyG2nymO+1lyThbkS1ifLX0Sr+dOQtAD5gkrffpHnx+yHNKUZ0Bmg5T4AjUQwugPDrm0g=="
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.2.tgz",
+ "integrity": "sha512-gco1Ih54PgMsyIXgttLxEhNy/mXxq8+rLnCb5shQk+P5TsiySrwWU5gpB4zen626J4LIwBxHvDChyA8qDm57ww=="
},
"d3-geo": {
- "version": "1.11.6",
- "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.11.6.tgz",
- "integrity": "sha512-z0J8InXR9e9wcgNtmVnPTj0TU8nhYT6lD/ak9may2PdKqXIeHUr8UbFLoCtrPYNsjv6YaLvSDQVl578k6nm7GA==",
+ "version": "1.11.9",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.11.9.tgz",
+ "integrity": "sha512-9edcH6J3s/Aa3KJITWqFJbyB/8q3mMlA9Fi7z6yy+FAYMnRaxmC7jBhUnsINxVWD14GmqX3DK8uk7nV6/Ekt4A==",
"requires": {
"d3-array": "1"
}
},
"d3-hierarchy": {
- "version": "1.1.8",
- "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.8.tgz",
- "integrity": "sha512-L+GHMSZNwTpiq4rt9GEsNcpLa4M96lXMR8M/nMG9p5hBE0jy6C+3hWtyZMenPQdwla249iJy7Nx0uKt3n+u9+w=="
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz",
+ "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ=="
},
"d3-interpolate": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.3.2.tgz",
- "integrity": "sha512-NlNKGopqaz9qM1PXh9gBF1KSCVh+jSFErrSlD/4hybwoNX/gt1d8CDbDW+3i+5UOHhjC6s6nMvRxcuoMVNgL2w==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
+ "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
"requires": {
"d3-color": "1"
}
},
"d3-path": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.8.tgz",
- "integrity": "sha512-J6EfUNwcMQ+aM5YPOB8ZbgAZu6wc82f/0WFxrxwV6Ll8wBwLaHLKCqQ5Imub02JriCVVdPjgI+6P3a4EWJCxAg=="
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
+ "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
},
"d3-polygon": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.5.tgz",
- "integrity": "sha512-RHhh1ZUJZfhgoqzWWuRhzQJvO7LavchhitSTHGu9oj6uuLFzYZVeBzaWTQ2qSO6bz2w55RMoOCf0MsLCDB6e0w=="
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz",
+ "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ=="
},
"d3-quadtree": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.6.tgz",
- "integrity": "sha512-NUgeo9G+ENQCQ1LsRr2qJg3MQ4DJvxcDNCiohdJGHt5gRhBW6orIB5m5FJ9kK3HNL8g9F4ERVoBzcEwQBfXWVA=="
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz",
+ "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA=="
},
"d3-random": {
"version": "1.1.2",
@@ -7270,40 +7270,40 @@
}
},
"d3-selection": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.0.tgz",
- "integrity": "sha512-EYVwBxQGEjLCKF2pJ4+yrErskDnz5v403qvAid96cNdCMr8rmCYfY5RGzWz24mdIbxmDf6/4EAH+K9xperD5jg=="
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.1.tgz",
+ "integrity": "sha512-BTIbRjv/m5rcVTfBs4AMBLKs4x8XaaLkwm28KWu9S2vKNqXkXt2AH2Qf0sdPZHjFxcWg/YL53zcqAz+3g4/7PA=="
},
"d3-shape": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.5.tgz",
- "integrity": "sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg==",
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
+ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"requires": {
"d3-path": "1"
}
},
"d3-time": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.0.11.tgz",
- "integrity": "sha512-Z3wpvhPLW4vEScGeIMUckDW7+3hWKOQfAWg/U7PlWBnQmeKQ00gCUsTtWSYulrKNA7ta8hJ+xXc6MHrMuITwEw=="
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
+ "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="
},
"d3-time-format": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.1.3.tgz",
- "integrity": "sha512-6k0a2rZryzGm5Ihx+aFMuO1GgelgIz+7HhB4PH4OEndD5q2zGn1mDfRdNrulspOfR6JXkb2sThhDK41CSK85QA==",
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.2.2.tgz",
+ "integrity": "sha512-pweL2Ri2wqMY+wlW/wpkl8T3CUzKAha8S9nmiQlMABab8r5MJN0PD1V4YyRNVaKQfeh4Z0+VO70TLw6ESVOYzw==",
"requires": {
"d3-time": "1"
}
},
"d3-timer": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.9.tgz",
- "integrity": "sha512-rT34J5HnQUHhcLvhSB9GjCkN0Ddd5Y8nCwDBG2u6wQEeYxT/Lf51fTFFkldeib/sE/J0clIe0pnCfs6g/lRbyg=="
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz",
+ "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw=="
},
"d3-transition": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.2.0.tgz",
- "integrity": "sha512-VJ7cmX/FPIPJYuaL2r1o1EMHLttvoIuZhhuAlRoOxDzogV8iQS6jYulDm3xEU3TqL80IZIhI551/ebmCMrkvhw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz",
+ "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==",
"requires": {
"d3-color": "1",
"d3-dispatch": "1",
@@ -10090,11 +10090,11 @@
"dev": true
},
"graphlib": {
- "version": "2.1.7",
- "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.7.tgz",
- "integrity": "sha512-TyI9jIy2J4j0qgPmOOrHTCtpPqJGN/aurBwc6ZT+bRii+di1I+Wv3obRhVrmBEXet+qkMaEX67dXrwsd3QQM6w==",
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
+ "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"requires": {
- "lodash": "^4.17.5"
+ "lodash": "^4.17.15"
}
},
"growly": {
diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js
index f9acea4211..cf033c16ae 100644
--- a/awx/ui_next/src/api/index.js
+++ b/awx/ui_next/src/api/index.js
@@ -22,7 +22,9 @@ import Teams from './models/Teams';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
+import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
import WorkflowJobs from './models/WorkflowJobs';
+import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
const AdHocCommandsAPI = new AdHocCommands();
@@ -49,7 +51,9 @@ const TeamsAPI = new Teams();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
+const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
+const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
export {
@@ -77,6 +81,8 @@ export {
UnifiedJobTemplatesAPI,
UnifiedJobsAPI,
UsersAPI,
+ WorkflowApprovalTemplatesAPI,
WorkflowJobsAPI,
+ WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
};
diff --git a/awx/ui_next/src/api/models/WorkflowApprovalTemplates.js b/awx/ui_next/src/api/models/WorkflowApprovalTemplates.js
new file mode 100644
index 0000000000..83b14784ab
--- /dev/null
+++ b/awx/ui_next/src/api/models/WorkflowApprovalTemplates.js
@@ -0,0 +1,10 @@
+import Base from '../Base';
+
+class WorkflowApprovalTemplates extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/workflow_approval_templates/';
+ }
+}
+
+export default WorkflowApprovalTemplates;
diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
new file mode 100644
index 0000000000..512316a1ab
--- /dev/null
+++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
@@ -0,0 +1,60 @@
+import Base from '../Base';
+
+class WorkflowJobTemplateNodes extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/workflow_job_template_nodes/';
+ }
+
+ createApprovalTemplate(id, data) {
+ return this.http.post(
+ `${this.baseUrl}${id}/create_approval_template/`,
+ data
+ );
+ }
+
+ associateSuccessNode(id, idToAssociate) {
+ return this.http.post(`${this.baseUrl}${id}/success_nodes/`, {
+ id: idToAssociate,
+ });
+ }
+
+ associateFailureNode(id, idToAssociate) {
+ return this.http.post(`${this.baseUrl}${id}/failure_nodes/`, {
+ id: idToAssociate,
+ });
+ }
+
+ associateAlwaysNode(id, idToAssociate) {
+ return this.http.post(`${this.baseUrl}${id}/always_nodes/`, {
+ id: idToAssociate,
+ });
+ }
+
+ disassociateSuccessNode(id, idToDissociate) {
+ return this.http.post(`${this.baseUrl}${id}/success_nodes/`, {
+ id: idToDissociate,
+ disassociate: true,
+ });
+ }
+
+ disassociateFailuresNode(id, idToDissociate) {
+ return this.http.post(`${this.baseUrl}${id}/failure_nodes/`, {
+ id: idToDissociate,
+ disassociate: true,
+ });
+ }
+
+ disassociateAlwaysNode(id, idToDissociate) {
+ return this.http.post(`${this.baseUrl}${id}/always_nodes/`, {
+ id: idToDissociate,
+ disassociate: true,
+ });
+ }
+
+ readCredentials(id) {
+ return this.http.get(`${this.baseUrl}${id}/credentials/`);
+ }
+}
+
+export default WorkflowJobTemplateNodes;
diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplates.js b/awx/ui_next/src/api/models/WorkflowJobTemplates.js
index 07da2531f4..bb0e53f7d5 100644
--- a/awx/ui_next/src/api/models/WorkflowJobTemplates.js
+++ b/awx/ui_next/src/api/models/WorkflowJobTemplates.js
@@ -9,6 +9,10 @@ class WorkflowJobTemplates extends Base {
readNodes(id, params) {
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
}
+
+ createNode(id, data) {
+ return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data);
+ }
}
export default WorkflowJobTemplates;
diff --git a/awx/ui_next/src/api/models/WorkflowJobs.js b/awx/ui_next/src/api/models/WorkflowJobs.js
index 8a7102cc99..87e336e8f5 100644
--- a/awx/ui_next/src/api/models/WorkflowJobs.js
+++ b/awx/ui_next/src/api/models/WorkflowJobs.js
@@ -6,6 +6,10 @@ class WorkflowJobs extends RelaunchMixin(Base) {
super(http);
this.baseUrl = '/api/v2/workflow_jobs/';
}
+
+ readNodes(id, params) {
+ return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
+ }
}
export default WorkflowJobs;
diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
index 2a1189dfb6..edd33d77c0 100644
--- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
+++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
@@ -2,10 +2,10 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { Wizard } from '@patternfly/react-core';
+import SelectableCard from '@components/SelectableCard';
+import Wizard from '@components/Wizard';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
-import SelectableCard from './SelectableCard';
import { TeamsAPI, UsersAPI } from '../../api';
const readUsers = async queryParams =>
diff --git a/awx/ui_next/src/components/AddRole/index.js b/awx/ui_next/src/components/AddRole/index.js
index 806e172146..52e9ec78d4 100644
--- a/awx/ui_next/src/components/AddRole/index.js
+++ b/awx/ui_next/src/components/AddRole/index.js
@@ -1,5 +1,4 @@
export { default as AddResourceRole } from './AddResourceRole';
export { default as CheckboxCard } from './CheckboxCard';
-export { default as SelectableCard } from './SelectableCard';
export { default as SelectResourceStep } from './SelectResourceStep';
export { default as SelectRoleStep } from './SelectRoleStep';
diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx
index c2e3fe2b2c..83533f1168 100644
--- a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx
+++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
-import { string, number } from 'prop-types';
+import { string, node, number } from 'prop-types';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '@components/DetailList';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
@@ -90,7 +90,7 @@ function VariablesDetail({ value, label, rows }) {
}
VariablesDetail.propTypes = {
value: string.isRequired,
- label: string.isRequired,
+ label: node.isRequired,
rows: number,
};
VariablesDetail.defaultProps = {
diff --git a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
index c737c96d44..b1c51a6b8f 100644
--- a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
+++ b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
@@ -1,7 +1,15 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
-import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
+import styled from 'styled-components';
+import {
+ EmptyState as PFEmptyState,
+ EmptyStateBody,
+} from '@patternfly/react-core';
+
+const EmptyState = styled(PFEmptyState)`
+ --pf-c-empty-state--m-lg--MaxWidth: none;
+`;
// TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ className, i18n }) => (
diff --git a/awx/ui_next/src/components/DetailList/Detail.jsx b/awx/ui_next/src/components/DetailList/Detail.jsx
index 10be98d335..e97c20d896 100644
--- a/awx/ui_next/src/components/DetailList/Detail.jsx
+++ b/awx/ui_next/src/components/DetailList/Detail.jsx
@@ -25,8 +25,15 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
`}
`;
-const Detail = ({ label, value, fullWidth, className, dataCy }) => {
- if (!value && typeof value !== 'number') {
+const Detail = ({
+ label,
+ value,
+ fullWidth,
+ className,
+ dataCy,
+ alwaysVisible,
+}) => {
+ if (!value && typeof value !== 'number' && !alwaysVisible) {
return null;
}
@@ -58,10 +65,12 @@ Detail.propTypes = {
label: node.isRequired,
value: node,
fullWidth: bool,
+ alwaysVisible: bool,
};
Detail.defaultProps = {
value: null,
fullWidth: false,
+ alwaysVisible: false,
};
export default Detail;
diff --git a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap
index dbebf79172..42bcf01688 100644
--- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap
+++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap
@@ -72,6 +72,7 @@ exports[` initially renders succesfully 1`] = `
stacked={true}
>
initially renders succesfully 1`] = `
stacked={true}
>
initially renders succesfully 1`] = `
stacked={true}
>
initially renders succesfully 1`] = `
stacked={true}
>
initially renders succesfully 1`] = `
stacked={true}
>
initially renders succesfully 1`] = `
stacked={true}
>
initially renders succesfully 1`] = `
data-pf-content={true}
>
initially renders succesfully 1`] = `
data-pf-content={true}
>
-
-
-
- );
- }
+function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
+ return (
+
+
+
+ {label}
+ {description}
+
+
+ );
}
SelectableCard.propTypes = {
label: PropTypes.string,
+ description: PropTypes.string,
onClick: PropTypes.func.isRequired,
isSelected: PropTypes.bool,
};
SelectableCard.defaultProps = {
label: '',
+ description: '',
isSelected: false,
};
diff --git a/awx/ui_next/src/components/AddRole/SelectableCard.test.jsx b/awx/ui_next/src/components/SelectableCard/SelectableCard.test.jsx
similarity index 100%
rename from awx/ui_next/src/components/AddRole/SelectableCard.test.jsx
rename to awx/ui_next/src/components/SelectableCard/SelectableCard.test.jsx
diff --git a/awx/ui_next/src/components/SelectableCard/index.js b/awx/ui_next/src/components/SelectableCard/index.js
new file mode 100644
index 0000000000..7488713156
--- /dev/null
+++ b/awx/ui_next/src/components/SelectableCard/index.js
@@ -0,0 +1 @@
+export { default } from './SelectableCard';
diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx
index 2727fc67e6..c452c68657 100644
--- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx
+++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx
@@ -10,14 +10,11 @@ import styled from 'styled-components';
import VerticalSeparator from '../VerticalSeparator';
const Split = styled(PFSplit)`
- padding-top: 15px;
- padding-bottom: 5px;
- border-bottom: #ebebeb var(--pf-global--BorderWidth--sm) solid;
+ margin: 20px 0px;
align-items: baseline;
`;
const SplitLabelItem = styled(SplitItem)`
- font-size: 14px;
font-weight: bold;
word-break: initial;
`;
diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.jsx
index dee54ac9c3..d9346758c3 100644
--- a/awx/ui_next/src/components/Sparkline/Sparkline.jsx
+++ b/awx/ui_next/src/components/Sparkline/Sparkline.jsx
@@ -7,7 +7,7 @@ import { Tooltip } from '@patternfly/react-core';
import styled from 'styled-components';
import { t } from '@lingui/macro';
import { formatDateString } from '@util/dates';
-import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
+import { JOB_TYPE_URL_SEGMENTS } from '@constants';
/* eslint-disable react/jsx-pascal-case */
const Link = styled(props => <_Link {...props} />)`
diff --git a/awx/ui_next/src/components/Wizard/Wizard.jsx b/awx/ui_next/src/components/Wizard/Wizard.jsx
new file mode 100644
index 0000000000..99e884baad
--- /dev/null
+++ b/awx/ui_next/src/components/Wizard/Wizard.jsx
@@ -0,0 +1,9 @@
+import { Wizard } from '@patternfly/react-core';
+import styled from 'styled-components';
+
+Wizard.displayName = 'PFWizard';
+export default styled(Wizard)`
+ .pf-c-data-toolbar__content {
+ padding: 0 !important;
+ }
+`;
diff --git a/awx/ui_next/src/components/Wizard/Wizard.test.jsx b/awx/ui_next/src/components/Wizard/Wizard.test.jsx
new file mode 100644
index 0000000000..35e8adf410
--- /dev/null
+++ b/awx/ui_next/src/components/Wizard/Wizard.test.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Wizard from './Wizard';
+
+describe('Wizard', () => {
+ test('renders the expected content', () => {
+ const wrapper = mount(
+ Step 1
}]}
+ />
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/components/Wizard/index.js b/awx/ui_next/src/components/Wizard/index.js
new file mode 100644
index 0000000000..40da120187
--- /dev/null
+++ b/awx/ui_next/src/components/Wizard/index.js
@@ -0,0 +1 @@
+export { default } from './Wizard';
diff --git a/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx
new file mode 100644
index 0000000000..02ec532c95
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import styled from 'styled-components';
+import { node, number } from 'prop-types';
+
+const TooltipContents = styled.div`
+ display: flex;
+`;
+
+const TooltipArrow = styled.div`
+ width: 10px;
+`;
+
+const TooltipArrowOuter = styled.div`
+ border-bottom: 10px solid transparent;
+ border-right: 10px solid #c4c4c4;
+ border-top: 10px solid transparent;
+ height: 0;
+ margin: auto;
+ position: absolute;
+ top: calc(50% - 10px);
+ width: 0;
+`;
+
+const TooltipArrowInner = styled.div`
+ border-bottom: 10px solid transparent;
+ border-right: 10px solid white;
+ border-top: 10px solid transparent;
+ height: 0;
+ left: 2px;
+ margin: auto;
+ position: absolute;
+ top: calc(50% - 10px);
+ width: 0;
+`;
+
+const TooltipActions = styled.div`
+ background-color: white;
+ border-radius: 2px;
+ border: 1px solid #c4c4c4;
+ padding: 5px;
+`;
+
+function WorkflowActionTooltip({ actions, pointX, pointY }) {
+ const tipHeight = 25 * actions.length + 5 * actions.length - 1 + 10;
+ return (
+
+
+
+
+
+
+ {actions}
+
+
+ );
+}
+
+WorkflowActionTooltip.propTypes = {
+ actions: node.isRequired,
+ pointX: number.isRequired,
+ pointY: number.isRequired,
+};
+
+export default WorkflowActionTooltip;
diff --git a/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.test.jsx
new file mode 100644
index 0000000000..aa3c6ba4e4
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.test.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import WorkflowActionTooltip from './WorkflowActionTooltip';
+
+describe('WorkflowActionTooltip', () => {
+ test('successfully mounts', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx
new file mode 100644
index 0000000000..dcb2f4f098
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import styled from 'styled-components';
+import { func } from 'prop-types';
+
+const TooltipItem = styled.div`
+ align-items: center;
+ border-radius: 2px;
+ cursor: pointer;
+ display: flex;
+ font-size: 12px;
+ height: 25px;
+ justify-content: center;
+ width: 25px;
+
+ &:hover {
+ color: white;
+ background-color: #c4c4c4;
+ }
+
+ &:not(:last-of-type) {
+ margin-bottom: 5px;
+ }
+`;
+
+function WorkflowActionTooltipItem({
+ children,
+ onClick,
+ onMouseEnter,
+ onMouseLeave,
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+WorkflowActionTooltipItem.propTypes = {
+ onClick: func,
+ onMouseEnter: func,
+ onMouseLeave: func,
+};
+
+WorkflowActionTooltipItem.defaultProps = {
+ onClick: () => {},
+ onMouseEnter: () => {},
+ onMouseLeave: () => {},
+};
+
+export default WorkflowActionTooltipItem;
diff --git a/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.test.jsx
new file mode 100644
index 0000000000..ed6067a3c0
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.test.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import WorkflowActionTooltipItem from './WorkflowActionTooltipItem';
+
+describe('WorkflowActionTooltipItem', () => {
+ test('successfully mounts', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowHelp.jsx
similarity index 73%
rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelp.jsx
rename to awx/ui_next/src/components/Workflow/WorkflowHelp.jsx
index 4ddd094e40..4b2251a7ce 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelp.jsx
+++ b/awx/ui_next/src/components/Workflow/WorkflowHelp.jsx
@@ -1,29 +1,28 @@
-import React, { Fragment } from 'react';
+import React from 'react';
import styled from 'styled-components';
const Outer = styled.div`
- position: relative;
height: 0;
+ pointer-events: none;
+ position: relative;
`;
const Inner = styled.div`
- position: absolute;
- left: 10px;
- top: 10px;
background-color: #383f44;
- color: white;
- padding: 5px 10px;
border-radius: 2px;
+ color: white;
+ left: 10px;
max-width: 300px;
+ padding: 5px 10px;
+ position: absolute;
+ top: 10px;
`;
function WorkflowHelp({ children }) {
return (
-
-
- {children}
-
-
+
+ {children}
+
);
}
diff --git a/awx/ui_next/src/components/Workflow/WorkflowHelp.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowHelp.test.jsx
new file mode 100644
index 0000000000..1102709889
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowHelp.test.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import WorkflowHelp from './WorkflowHelp';
+
+describe('WorkflowHelp', () => {
+ test('successfully mounts', () => {
+ const wrapper = mount();
+ expect(wrapper).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx b/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx
new file mode 100644
index 0000000000..79951d4bf7
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx
@@ -0,0 +1,133 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import {
+ ExclamationTriangleIcon,
+ PauseIcon,
+ TimesIcon,
+} from '@patternfly/react-icons';
+
+const Wrapper = styled.div`
+ background-color: white;
+ border: 1px solid #c7c7c7;
+ margin-left: 20px;
+ min-width: 100px;
+ position: relative;
+`;
+
+const Header = styled.div`
+ border-bottom: 1px solid #c7c7c7;
+ padding: 10px;
+ position: relative;
+`;
+
+const Legend = styled.ul`
+ padding: 5px 10px;
+
+ li {
+ align-items: center;
+ display: flex;
+ padding: 5px 0px;
+ }
+`;
+
+const NodeTypeLetter = styled.div`
+ background-color: #393f43;
+ border-radius: 50%;
+ color: white;
+ font-size: 10px;
+ height: 20px;
+ line-height: 20px;
+ margin-right: 10px;
+ text-align: center;
+ width: 20px;
+`;
+
+const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)`
+ color: #f0ad4d;
+ height: 20px;
+ margin-right: 10px;
+ width: 20px;
+`;
+
+const Link = styled.div`
+ height: 5px;
+ margin-right: 10px;
+ width: 20px;
+`;
+
+const SuccessLink = styled(Link)`
+ background-color: #5cb85c;
+`;
+
+const FailureLink = styled(Link)`
+ background-color: #d9534f;
+`;
+
+const AlwaysLink = styled(Link)`
+ background-color: #337ab7;
+`;
+
+const Close = styled(TimesIcon)`
+ cursor: pointer;
+ position: absolute;
+ right: 10px;
+ top: 15px;
+`;
+
+function WorkflowLegend({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+
+ return (
+
+
+ {i18n._(t`Legend`)}
+ dispatch({ type: 'TOGGLE_LEGEND' })} />
+
+
+
+ );
+}
+
+export default withI18n()(WorkflowLegend);
diff --git a/awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx
new file mode 100644
index 0000000000..19b27bd1c6
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowLegend from './WorkflowLegend';
+
+describe('WorkflowLegend', () => {
+ test('renders the expected content', () => {
+ const wrapper = mountWithContexts( {}} />);
+ expect(wrapper).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx
new file mode 100644
index 0000000000..5180ab3bba
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import { shape } from 'prop-types';
+
+const GridDL = styled.dl`
+ column-gap: 15px;
+ display: grid;
+ grid-template-columns: max-content;
+ row-gap: 0px;
+ dt {
+ grid-column-start: 1;
+ }
+ dd {
+ grid-column-start: 2;
+ }
+`;
+
+function WorkflowLinkHelp({ link, i18n }) {
+ let linkType;
+ switch (link.linkType) {
+ case 'always':
+ linkType = i18n._(t`Always`);
+ break;
+ case 'success':
+ linkType = i18n._(t`On Success`);
+ break;
+ case 'failure':
+ linkType = i18n._(t`On Failure`);
+ break;
+ default:
+ linkType = '';
+ }
+
+ return (
+
+
+ {i18n._(t`Run`)}
+
+ {linkType}
+
+ );
+}
+
+WorkflowLinkHelp.propTypes = {
+ link: shape().isRequired,
+};
+
+export default withI18n()(WorkflowLinkHelp);
diff --git a/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.test.jsx
new file mode 100644
index 0000000000..8bf8779243
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.test.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowLinkHelp from './WorkflowLinkHelp';
+
+describe('WorkflowLinkHelp', () => {
+ test('successfully mounts', () => {
+ const wrapper = mountWithContexts();
+ expect(wrapper).toHaveLength(1);
+ });
+ test('renders the expected content for an on success link', () => {
+ const link = {
+ linkType: 'success',
+ };
+ const wrapper = mountWithContexts();
+ expect(wrapper.find('#workflow-link-help-type').text()).toBe('On Success');
+ });
+ test('renders the expected content for an on failure link', () => {
+ const link = {
+ linkType: 'failure',
+ };
+ const wrapper = mountWithContexts();
+ expect(wrapper.find('#workflow-link-help-type').text()).toBe('On Failure');
+ });
+ test('renders the expected content for an always link', () => {
+ const link = {
+ linkType: 'always',
+ };
+ const wrapper = mountWithContexts();
+ expect(wrapper.find('#workflow-link-help-type').text()).toBe('Always');
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx
new file mode 100644
index 0000000000..a888640f6a
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t, Trans } from '@lingui/macro';
+import styled from 'styled-components';
+import { ExclamationTriangleIcon } from '@patternfly/react-icons';
+import { shape } from 'prop-types';
+import { secondsToHHMMSS } from '@util/dates';
+
+const GridDL = styled.dl`
+ column-gap: 15px;
+ display: grid;
+ grid-template-columns: max-content;
+ row-gap: 0px;
+ dt {
+ grid-column-start: 1;
+ }
+ dd {
+ grid-column-start: 2;
+ }
+`;
+
+const ResourceDeleted = styled.p`
+ margin-bottom: ${props => (props.job ? '10px' : '0px')};
+`;
+
+const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)`
+ color: #f0ad4d;
+ height: 20px;
+ margin-right: 10px;
+ width: 20px;
+`;
+
+function WorkflowNodeHelp({ node, i18n }) {
+ let nodeType;
+ if (node.unifiedJobTemplate || node.job) {
+ const type = node.unifiedJobTemplate
+ ? node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.type
+ : node.job.type;
+ switch (type) {
+ case 'job_template':
+ case 'job':
+ nodeType = i18n._(t`Job Template`);
+ break;
+ case 'workflow_job_template':
+ case 'workflow_job':
+ nodeType = i18n._(t`Workflow Job Template`);
+ break;
+ case 'project':
+ case 'project_update':
+ nodeType = i18n._(t`Project Update`);
+ break;
+ case 'inventory_source':
+ case 'inventory_update':
+ nodeType = i18n._(t`Inventory Update`);
+ break;
+ case 'workflow_approval_template':
+ case 'workflow_approval':
+ nodeType = i18n._(t`Workflow Approval`);
+ break;
+ default:
+ nodeType = '';
+ }
+ }
+
+ let jobStatus;
+ if (node.job) {
+ switch (node.job.status) {
+ case 'new':
+ jobStatus = i18n._(t`New`);
+ break;
+ case 'pending':
+ jobStatus = i18n._(t`Pending`);
+ break;
+ case 'waiting':
+ jobStatus = i18n._(t`Waiting`);
+ break;
+ case 'running':
+ jobStatus = i18n._(t`Running`);
+ break;
+ case 'successful':
+ jobStatus = i18n._(t`Successful`);
+ break;
+ case 'failed':
+ jobStatus = i18n._(t`Failed`);
+ break;
+ case 'error':
+ jobStatus = i18n._(t`Error`);
+ break;
+ case 'canceled':
+ jobStatus = i18n._(t`Canceled`);
+ break;
+ case 'never updated':
+ jobStatus = i18n._(t`Never Updated`);
+ break;
+ case 'ok':
+ jobStatus = i18n._(t`OK`);
+ break;
+ case 'missing':
+ jobStatus = i18n._(t`Missing`);
+ break;
+ case 'none':
+ jobStatus = i18n._(t`None`);
+ break;
+ case 'updating':
+ jobStatus = i18n._(t`Updating`);
+ break;
+ default:
+ jobStatus = '';
+ }
+ }
+
+ return (
+ <>
+ {!node.unifiedJobTemplate &&
+ (!node.job || node.job.type !== 'workflow_approval') && (
+ <>
+
+
+
+ The resource associated with this node has been deleted.
+
+
+ >
+ )}
+ {node.job && (
+
+
+ {i18n._(t`Name`)}
+
+ {node.job.name}
+
+ {i18n._(t`Type`)}
+
+ {nodeType}
+
+ {i18n._(t`Job Status`)}
+
+ {jobStatus}
+ {typeof node.job.elapsed === 'number' && (
+ <>
+
+ {i18n._(t`Elapsed`)}
+
+
+ {secondsToHHMMSS(node.job.elapsed)}
+
+ >
+ )}
+
+ )}
+ {node.unifiedJobTemplate && !node.job && (
+
+
+ {i18n._(t`Name`)}
+
+ {node.unifiedJobTemplate.name}
+
+ {i18n._(t`Type`)}
+
+ {nodeType}
+
+ )}
+ {node.job && node.job.type !== 'workflow_approval' && (
+ {i18n._(t`Click to view job details`)}
+ )}
+ >
+ );
+}
+
+WorkflowNodeHelp.propTypes = {
+ node: shape().isRequired,
+};
+
+export default withI18n()(WorkflowNodeHelp);
diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx
new file mode 100644
index 0000000000..4c0c94858c
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowNodeHelp from './WorkflowNodeHelp';
+
+describe('WorkflowNodeHelp', () => {
+ test('successfully mounts', () => {
+ const wrapper = mountWithContexts();
+ expect(wrapper).toHaveLength(1);
+ });
+ test('renders the expected content for a completed job template job', () => {
+ const node = {
+ job: {
+ name: 'Foo Job Template',
+ elapsed: 9000,
+ status: 'successful',
+ type: 'job',
+ },
+ unifiedJobTemplate: {
+ name: 'Foo Job Template',
+ unified_job_type: 'job',
+ },
+ };
+ const wrapper = mountWithContexts();
+ expect(wrapper.find('#workflow-node-help-name').text()).toBe(
+ 'Foo Job Template'
+ );
+ expect(wrapper.find('#workflow-node-help-type').text()).toBe(
+ 'Job Template'
+ );
+ expect(wrapper.find('#workflow-node-help-status').text()).toBe(
+ 'Successful'
+ );
+ expect(wrapper.find('#workflow-node-help-elapsed').text()).toBe('02:30:00');
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx
new file mode 100644
index 0000000000..bd3f65dcac
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import styled from 'styled-components';
+import { shape } from 'prop-types';
+import { PauseIcon } from '@patternfly/react-icons';
+
+const NodeTypeLetter = styled.div`
+ background-color: #393f43;
+ border-radius: 50%;
+ color: white;
+ font-size: 10px;
+ line-height: 20px;
+ text-align: center;
+ height: 20px;
+ width: 20px;
+`;
+
+const CenteredPauseIcon = styled(PauseIcon)`
+ vertical-align: middle !important;
+`;
+
+function WorkflowNodeTypeLetter({ node }) {
+ let nodeTypeLetter;
+ if (
+ (node.unifiedJobTemplate &&
+ (node.unifiedJobTemplate.type ||
+ node.unifiedJobTemplate.unified_job_type)) ||
+ (node.job && node.job.type)
+ ) {
+ const ujtType = node.unifiedJobTemplate
+ ? node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type
+ : node.job.type;
+ switch (ujtType) {
+ case 'job_template':
+ case 'job':
+ nodeTypeLetter = 'JT';
+ break;
+ case 'project':
+ case 'project_update':
+ nodeTypeLetter = 'P';
+ break;
+ case 'inventory_source':
+ case 'inventory_update':
+ nodeTypeLetter = 'I';
+ break;
+ case 'workflow_job_template':
+ case 'workflow_job':
+ nodeTypeLetter = 'W';
+ break;
+ case 'workflow_approval_template':
+ case 'workflow_approval':
+ nodeTypeLetter = ;
+ break;
+ default:
+ nodeTypeLetter = '';
+ }
+ }
+
+ return (
+
+
+ {nodeTypeLetter}
+
+
+ );
+}
+
+WorkflowNodeTypeLetter.propTypes = {
+ node: shape().isRequired,
+};
+
+export default WorkflowNodeTypeLetter;
diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx
new file mode 100644
index 0000000000..24313f1f54
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { PauseIcon } from '@patternfly/react-icons';
+import WorkflowNodeTypeLetter from './WorkflowNodeTypeLetter';
+
+describe('WorkflowNodeTypeLetter', () => {
+ test('renders JT when type=job_template', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('JT');
+ });
+ test('renders JT when unified_job_type=job', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('JT');
+ });
+ test('renders P when type=project', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('P');
+ });
+ test('renders P when unified_job_type=project_update', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('P');
+ });
+ test('renders I when type=inventory_source', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('I');
+ });
+ test('renders I when unified_job_type=inventory_update', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('I');
+ });
+ test('renders W when type=workflow_job_template', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('W');
+ });
+ test('renders W when unified_job_type=workflow_job', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.text()).toBe('W');
+ });
+ test('renders puse icon when type=workflow_approval_template', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.containsMatchingElement());
+ });
+ test('renders W when unified_job_type=workflow_approval', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.containsMatchingElement());
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx
new file mode 100644
index 0000000000..a13e628518
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx
@@ -0,0 +1,87 @@
+import React, { useContext, useRef, useState } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import styled from 'styled-components';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { bool, func } from 'prop-types';
+import { PlusIcon } from '@patternfly/react-icons';
+import { constants as wfConstants } from '@components/Workflow/WorkflowUtils';
+import {
+ WorkflowActionTooltip,
+ WorkflowActionTooltipItem,
+} from '@components/Workflow';
+
+const StartG = styled.g`
+ pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
+`;
+
+function WorkflowStartNode({ i18n, onUpdateHelpText, showActionTooltip }) {
+ const ref = useRef(null);
+ const [hovering, setHovering] = useState(false);
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { addingLink, nodePositions } = useContext(WorkflowStateContext);
+
+ const handleNodeMouseEnter = () => {
+ ref.current.parentNode.appendChild(ref.current);
+ setHovering(true);
+ };
+
+ return (
+ setHovering(false)}
+ ref={ref}
+ transform={`translate(${nodePositions[1].x},0)`}
+ >
+
+ {/* TODO: We need to be able to handle translated text here */}
+
+ START
+
+ {showActionTooltip && hovering && (
+ onUpdateHelpText(i18n._(t`Add a new node`))}
+ onMouseLeave={() => onUpdateHelpText(null)}
+ onClick={() => {
+ onUpdateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'START_ADD_NODE', sourceNodeId: 1 });
+ }}
+ >
+
+ ,
+ ]}
+ pointX={wfConstants.rootW}
+ pointY={wfConstants.rootH / 2 + 10}
+ />
+ )}
+
+ );
+}
+
+WorkflowStartNode.propTypes = {
+ showActionTooltip: bool.isRequired,
+ onUpdateHelpText: func,
+};
+
+WorkflowStartNode.defaultProps = {
+ onUpdateHelpText: () => {},
+};
+
+export default withI18n()(WorkflowStartNode);
diff --git a/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx
new file mode 100644
index 0000000000..1079694012
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import WorkflowStartNode from './WorkflowStartNode';
+
+const nodePositions = {
+ 1: {
+ x: 0,
+ y: 0,
+ },
+};
+
+describe('WorkflowStartNode', () => {
+ test('mounts successfully', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+ test('tooltip shown on hover', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper.find('WorkflowActionTooltip')).toHaveLength(0);
+ wrapper.find('WorkflowStartNode').simulate('mouseenter');
+ expect(wrapper.find('WorkflowActionTooltip')).toHaveLength(1);
+ wrapper.find('WorkflowStartNode').simulate('mouseleave');
+ expect(wrapper.find('WorkflowActionTooltip')).toHaveLength(0);
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx
new file mode 100644
index 0000000000..1ff435f824
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx
@@ -0,0 +1,189 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import { func, number } from 'prop-types';
+import { Button, Tooltip } from '@patternfly/react-core';
+import {
+ CaretDownIcon,
+ CaretLeftIcon,
+ CaretRightIcon,
+ CaretUpIcon,
+ DesktopIcon,
+ HomeIcon,
+ MinusIcon,
+ PlusIcon,
+ TimesIcon,
+} from '@patternfly/react-icons';
+
+const Wrapper = styled.div`
+ background-color: white;
+ border: 1px solid #c7c7c7;
+ height: 215px;
+ position: relative;
+`;
+
+const Header = styled.div`
+ border-bottom: 1px solid #c7c7c7;
+ padding: 10px;
+`;
+
+const Pan = styled.div`
+ align-items: center;
+ display: flex;
+`;
+
+const PanCenter = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Tools = styled.div`
+ align-items: center;
+ display: flex;
+ padding: 20px;
+`;
+
+const Close = styled(TimesIcon)`
+ cursor: pointer;
+ position: absolute;
+ right: 10px;
+ top: 15px;
+`;
+
+function WorkflowTools({
+ i18n,
+ onFitGraph,
+ onPan,
+ onPanToMiddle,
+ onZoomChange,
+ zoomPercentage,
+}) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ const zoomIn = () => {
+ const newScale =
+ Math.ceil((zoomPercentage + 10) / 10) * 10 < 200
+ ? Math.ceil((zoomPercentage + 10) / 10) * 10
+ : 200;
+ onZoomChange(newScale / 100);
+ };
+
+ const zoomOut = () => {
+ const newScale =
+ Math.floor((zoomPercentage - 10) / 10) * 10 > 10
+ ? Math.floor((zoomPercentage - 10) / 10) * 10
+ : 10;
+ onZoomChange(newScale / 100);
+ };
+
+ return (
+
+
+ {i18n._(t`Tools`)}
+ dispatch({ type: 'TOGGLE_TOOLS' })} />
+
+
+
+
+
+
+
+
+
+ onZoomChange(parseInt(event.target.value, 10) / 100)
+ }
+ step="10"
+ type="range"
+ value={zoomPercentage}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkflowTools.propTypes = {
+ onFitGraph: func.isRequired,
+ onPan: func.isRequired,
+ onPanToMiddle: func.isRequired,
+ onZoomChange: func.isRequired,
+ zoomPercentage: number.isRequired,
+};
+
+export default withI18n()(WorkflowTools);
diff --git a/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx
new file mode 100644
index 0000000000..7759495d4b
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowTools from './WorkflowTools';
+
+describe('WorkflowTools', () => {
+ test('renders the expected content', () => {
+ const wrapper = mountWithContexts(
+ {}}
+ onFitGraph={() => {}}
+ onPan={() => {}}
+ onPanToMiddle={() => {}}
+ onZoomChange={() => {}}
+ zoomPercentage={100}
+ />
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+ test('clicking zoom/pan buttons passes callback correct values', () => {
+ const pan = jest.fn();
+ const zoomChange = jest.fn();
+ const wrapper = mountWithContexts(
+ {}}
+ onFitGraph={() => {}}
+ onPan={pan}
+ onPanToMiddle={() => {}}
+ onZoomChange={zoomChange}
+ zoomPercentage={95.7}
+ />
+ );
+ wrapper.find('PlusIcon').simulate('click');
+ expect(zoomChange).toHaveBeenCalledWith(1.1);
+ wrapper.find('MinusIcon').simulate('click');
+ expect(zoomChange).toHaveBeenCalledWith(0.8);
+ wrapper.find('CaretLeftIcon').simulate('click');
+ expect(pan).toHaveBeenCalledWith('left');
+ wrapper.find('CaretUpIcon').simulate('click');
+ expect(pan).toHaveBeenCalledWith('up');
+ wrapper.find('CaretRightIcon').simulate('click');
+ expect(pan).toHaveBeenCalledWith('right');
+ wrapper.find('CaretDownIcon').simulate('click');
+ expect(pan).toHaveBeenCalledWith('down');
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/WorkflowUtils.jsx b/awx/ui_next/src/components/Workflow/WorkflowUtils.jsx
new file mode 100644
index 0000000000..6ed9bf903f
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowUtils.jsx
@@ -0,0 +1,196 @@
+/* eslint-disable import/prefer-default-export */
+import * as d3 from 'd3';
+import * as dagre from 'dagre';
+
+const normalizeY = (nodePositions, y) => y - nodePositions[1].y;
+
+export const constants = {
+ nodeW: 180,
+ nodeH: 60,
+ rootW: 72,
+ rootH: 40,
+};
+
+export function getScaleAndOffsetToFit(
+ gBoundingClientRect,
+ svgBoundingClientRect,
+ gBBoxDimensions,
+ currentScale
+) {
+ gBoundingClientRect.height /= currentScale;
+ gBoundingClientRect.width /= currentScale;
+
+ // For some reason the root width needs to be added?
+ gBoundingClientRect.width += constants.rootW;
+
+ const scaleNeededForMaxHeight =
+ svgBoundingClientRect.height / gBoundingClientRect.height;
+ const scaleNeededForMaxWidth =
+ svgBoundingClientRect.width / gBoundingClientRect.width;
+ const lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth);
+
+ let scaleToFit;
+ let yTranslate;
+ if (lowerScale < 0.1 || lowerScale > 2) {
+ scaleToFit = lowerScale < 0.1 ? 0.1 : 2;
+ yTranslate =
+ svgBoundingClientRect.height / 2 - (constants.nodeH * scaleToFit) / 2;
+ } else {
+ scaleToFit = Math.floor(lowerScale * 1000) / 1000;
+ yTranslate =
+ (svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) /
+ 2 -
+ (gBBoxDimensions.y / currentScale) * scaleToFit;
+ }
+
+ return [scaleToFit, yTranslate];
+}
+
+export function generateLine(points) {
+ const line = d3
+ .line()
+ .x(d => {
+ return d.x;
+ })
+ .y(d => {
+ return d.y;
+ });
+
+ return line(points);
+}
+
+export function getLinePoints(link, nodePositions) {
+ const sourceX =
+ nodePositions[link.source.id].x + nodePositions[link.source.id].width + 1;
+ let sourceY =
+ normalizeY(nodePositions, nodePositions[link.source.id].y) +
+ nodePositions[link.source.id].height / 2;
+ const targetX = nodePositions[link.target.id].x - 1;
+ const targetY =
+ normalizeY(nodePositions, nodePositions[link.target.id].y) +
+ nodePositions[link.target.id].height / 2;
+
+ // There's something off with the math on the root node...
+ if (link.source.id === 1) {
+ sourceY += 10;
+ }
+
+ return [
+ {
+ x: sourceX,
+ y: sourceY,
+ },
+ {
+ x: targetX,
+ y: targetY,
+ },
+ ];
+}
+
+export function getLinkOverlayPoints(link, nodePositions) {
+ const sourceX =
+ nodePositions[link.source.id].x + nodePositions[link.source.id].width + 1;
+ let sourceY =
+ normalizeY(nodePositions, nodePositions[link.source.id].y) +
+ nodePositions[link.source.id].height / 2;
+ const targetX = nodePositions[link.target.id].x - 1;
+ const targetY =
+ normalizeY(nodePositions, nodePositions[link.target.id].y) +
+ nodePositions[link.target.id].height / 2;
+
+ // There's something off with the math on the root node...
+ if (link.source.id === 1) {
+ sourceY += 10;
+ }
+ const slope = (targetY - sourceY) / (targetX - sourceX);
+ const yIntercept = targetY - slope * targetX;
+ const orthogonalDistance = 8;
+
+ const pt1 = [
+ targetX,
+ slope * targetX +
+ yIntercept +
+ orthogonalDistance * Math.sqrt(1 + slope * slope),
+ ].join(',');
+ const pt2 = [
+ sourceX,
+ slope * sourceX +
+ yIntercept +
+ orthogonalDistance * Math.sqrt(1 + slope * slope),
+ ].join(',');
+ const pt3 = [
+ sourceX,
+ slope * sourceX +
+ yIntercept -
+ orthogonalDistance * Math.sqrt(1 + slope * slope),
+ ].join(',');
+ const pt4 = [
+ targetX,
+ slope * targetX +
+ yIntercept -
+ orthogonalDistance * Math.sqrt(1 + slope * slope),
+ ].join(',');
+
+ return [pt1, pt2, pt3, pt4].join(' ');
+}
+
+export function layoutGraph(nodes, links) {
+ const g = new dagre.graphlib.Graph();
+ g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 120 });
+
+ // This is needed for Dagre
+ g.setDefaultEdgeLabel(() => {
+ return {};
+ });
+
+ nodes.forEach(node => {
+ if (node.id === 1) {
+ g.setNode(node.id, {
+ label: '',
+ width: constants.rootW,
+ height: constants.rootH,
+ });
+ } else {
+ g.setNode(node.id, {
+ label: '',
+ width: constants.nodeW,
+ height: constants.nodeH,
+ });
+ }
+ });
+
+ links.forEach(link => {
+ g.setEdge(link.source.id, link.target.id);
+ });
+
+ dagre.layout(g);
+
+ return g;
+}
+
+export function getTranslatePointsForZoom(
+ svgBoundingClientRect,
+ currentScaleAndOffset,
+ newScale
+) {
+ const origScale = currentScaleAndOffset.k;
+ const unscaledOffsetX =
+ (currentScaleAndOffset.x +
+ (svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) /
+ 2) /
+ origScale;
+ const unscaledOffsetY =
+ (currentScaleAndOffset.y +
+ (svgBoundingClientRect.height * origScale -
+ svgBoundingClientRect.height) /
+ 2) /
+ origScale;
+ const translateX =
+ unscaledOffsetX * newScale -
+ (newScale * svgBoundingClientRect.width - svgBoundingClientRect.width) / 2;
+ const translateY =
+ unscaledOffsetY * newScale -
+ (newScale * svgBoundingClientRect.height - svgBoundingClientRect.height) /
+ 2;
+ return [translateX, translateY];
+}
diff --git a/awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx
new file mode 100644
index 0000000000..523cf55977
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx
@@ -0,0 +1,225 @@
+import {
+ getScaleAndOffsetToFit,
+ generateLine,
+ getLinePoints,
+ getLinkOverlayPoints,
+ layoutGraph,
+ getTranslatePointsForZoom,
+} from './WorkflowUtils';
+
+describe('getScaleAndOffsetToFit', () => {
+ const gBoundingClientRect = {
+ x: 36,
+ y: 11,
+ width: 798,
+ height: 160,
+ top: 11,
+ right: 834,
+ bottom: 171,
+ left: 36,
+ };
+ const svgBoundingClientRect = {
+ x: 0,
+ y: 56,
+ width: 1680,
+ height: 455,
+ top: 56,
+ right: 1680,
+ bottom: 511,
+ left: 0,
+ };
+ const gBBoxDimensions = {
+ x: 36,
+ y: -45,
+ width: 726,
+ height: 160,
+ };
+ const currentScale = 1;
+ test('returns correct scale and y-offset for zooming the graph to best fit the available space', () => {
+ expect(
+ getScaleAndOffsetToFit(
+ gBoundingClientRect,
+ svgBoundingClientRect,
+ gBBoxDimensions,
+ currentScale
+ )
+ ).toEqual([1.931, 159.91499999999996]);
+ });
+});
+
+describe('generateLine', () => {
+ test('returns correct svg path string', () => {
+ expect(
+ generateLine([
+ {
+ x: 0,
+ y: 0,
+ },
+ {
+ x: 10,
+ y: 10,
+ },
+ ])
+ ).toEqual('M0,0L10,10');
+ expect(
+ generateLine([
+ {
+ x: 900,
+ y: 44,
+ },
+ {
+ x: 5000,
+ y: 359,
+ },
+ ])
+ ).toEqual('M900,44L5000,359');
+ });
+});
+
+describe('getLinePoints', () => {
+ const link = {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ };
+ const nodePositions = {
+ 1: {
+ width: 72,
+ height: 40,
+ x: 36,
+ y: 130,
+ },
+ 2: {
+ width: 180,
+ height: 60,
+ x: 282,
+ y: 40,
+ },
+ };
+ test('returns the correct endpoints of the line', () => {
+ expect(getLinePoints(link, nodePositions)).toEqual([
+ { x: 109, y: 30 },
+ { x: 281, y: -60 },
+ ]);
+ });
+});
+
+describe('getLinkOverlayPoints', () => {
+ const link = {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ };
+ const nodePositions = {
+ 1: {
+ width: 72,
+ height: 40,
+ x: 36,
+ y: 130,
+ },
+ 2: {
+ width: 180,
+ height: 60,
+ x: 282,
+ y: 40,
+ },
+ };
+ test('returns the four points of the polygon that will act as the overlay for the link', () => {
+ expect(getLinkOverlayPoints(link, nodePositions)).toEqual(
+ '281,-50.970992003685446 109,39.02900799631457 109,20.97099200368546 281,-69.02900799631456'
+ );
+ });
+});
+
+describe('layoutGraph', () => {
+ const nodes = [
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ },
+ {
+ id: 3,
+ },
+ {
+ id: 4,
+ },
+ ];
+ const links = [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 4,
+ },
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ },
+ {
+ source: {
+ id: 4,
+ },
+ target: {
+ id: 3,
+ },
+ },
+ ];
+ test('returns the correct dimensions and positions for the nodes', () => {
+ expect(layoutGraph(nodes, links)._nodes).toEqual({
+ 1: { height: 40, label: '', width: 72, x: 36, y: 75 },
+ 2: { height: 60, label: '', width: 180, x: 282, y: 30 },
+ 3: { height: 60, label: '', width: 180, x: 582, y: 75 },
+ 4: { height: 60, label: '', width: 180, x: 282, y: 120 },
+ });
+ });
+});
+
+describe('getTranslatePointsForZoom', () => {
+ const svgBoundingClientRect = {
+ x: 0,
+ y: 56,
+ width: 1680,
+ height: 455,
+ top: 56,
+ right: 1680,
+ bottom: 511,
+ left: 0,
+ };
+ const currentScaleAndOffset = {
+ k: 2,
+ x: 0,
+ y: 167.5,
+ };
+ const newScale = 1.9;
+ test('returns the correct translation point', () => {
+ expect(
+ getTranslatePointsForZoom(
+ svgBoundingClientRect,
+ currentScaleAndOffset,
+ newScale
+ )
+ ).toEqual([42, 170.5]);
+ });
+});
diff --git a/awx/ui_next/src/components/Workflow/index.js b/awx/ui_next/src/components/Workflow/index.js
new file mode 100644
index 0000000000..d3c5d519da
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/index.js
@@ -0,0 +1,11 @@
+export { default as WorkflowActionTooltip } from './WorkflowActionTooltip';
+export {
+ default as WorkflowActionTooltipItem,
+} from './WorkflowActionTooltipItem';
+export { default as WorkflowHelp } from './WorkflowHelp';
+export { default as WorkflowLegend } from './WorkflowLegend';
+export { default as WorkflowLinkHelp } from './WorkflowLinkHelp';
+export { default as WorkflowNodeHelp } from './WorkflowNodeHelp';
+export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter';
+export { default as WorkflowStartNode } from './WorkflowStartNode';
+export { default as WorkflowTools } from './WorkflowTools';
diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.js b/awx/ui_next/src/components/Workflow/workflowReducer.js
new file mode 100644
index 0000000000..05d8af15fb
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/workflowReducer.js
@@ -0,0 +1,609 @@
+import { t } from '@lingui/macro';
+
+export function initReducer() {
+ return {
+ addLinkSourceNode: null,
+ addLinkTargetNode: null,
+ addNodeSource: null,
+ addNodeTarget: null,
+ addingLink: false,
+ contentError: null,
+ isLoading: true,
+ linkToDelete: null,
+ linkToEdit: null,
+ links: [],
+ nextNodeId: 0,
+ nodePositions: null,
+ nodes: [],
+ nodeToDelete: null,
+ nodeToEdit: null,
+ showDeleteAllNodesModal: false,
+ showLegend: false,
+ showTools: false,
+ showUnsavedChangesModal: false,
+ unsavedChanges: false,
+ };
+}
+
+export default function visualizerReducer(state, action) {
+ switch (action.type) {
+ case 'CREATE_LINK':
+ return createLink(state, action.linkType);
+ case 'CREATE_NODE':
+ return createNode(state, action.node);
+ case 'CANCEL_LINK':
+ case 'CANCEL_LINK_MODAL':
+ return cancelLink(state);
+ case 'CANCEL_NODE_MODAL':
+ return {
+ ...state,
+ addNodeSource: null,
+ addNodeTarget: null,
+ nodeToEdit: null,
+ };
+ case 'DELETE_ALL_NODES':
+ return deleteAllNodes(state);
+ case 'DELETE_LINK':
+ return deleteLink(state);
+ case 'DELETE_NODE':
+ return deleteNode(state);
+ case 'GENERATE_NODES_AND_LINKS':
+ return generateNodesAndLinks(state, action.nodes, action.i18n);
+ case 'RESET':
+ return initReducer();
+ case 'SELECT_SOURCE_FOR_LINKING':
+ return selectSourceForLinking(state, action.node);
+ case 'SET_ADD_LINK_TARGET_NODE':
+ return { ...state, addLinkTargetNode: action.value };
+ case 'SET_CONTENT_ERROR':
+ return { ...state, contentError: action.value };
+ case 'SET_IS_LOADING':
+ return { ...state, isLoading: action.value };
+ case 'SET_LINK_TO_DELETE':
+ return { ...state, linkToDelete: action.value };
+ case 'SET_LINK_TO_EDIT':
+ return { ...state, linkToEdit: action.value };
+ case 'SET_NODE_POSITIONS':
+ return { ...state, nodePositions: action.value };
+ case 'SET_NODE_TO_DELETE':
+ return { ...state, nodeToDelete: action.value };
+ case 'SET_NODE_TO_EDIT':
+ return { ...state, nodeToEdit: action.value };
+ case 'SET_NODE_TO_VIEW':
+ return { ...state, nodeToView: action.value };
+ case 'SET_SHOW_DELETE_ALL_NODES_MODAL':
+ return { ...state, showDeleteAllNodesModal: action.value };
+ case 'START_ADD_NODE':
+ return {
+ ...state,
+ addNodeSource: action.sourceNodeId,
+ addNodeTarget: action.targetNodeId || null,
+ };
+ case 'START_DELETE_LINK':
+ return startDeleteLink(state, action.link);
+ case 'TOGGLE_DELETE_ALL_NODES_MODAL':
+ return toggleDeleteAllNodesModal(state);
+ case 'TOGGLE_LEGEND':
+ return toggleLegend(state);
+ case 'TOGGLE_TOOLS':
+ return toggleTools(state);
+ case 'TOGGLE_UNSAVED_CHANGES_MODAL':
+ return toggleUnsavedChangesModal(state);
+ case 'UPDATE_LINK':
+ return updateLink(state, action.linkType);
+ case 'UPDATE_NODE':
+ return updateNode(state, action.node);
+ default:
+ throw new Error(`Unrecognized action type: ${action.type}`);
+ }
+}
+
+function createLink(state, linkType) {
+ const { addLinkSourceNode, addLinkTargetNode, links, nodes } = state;
+ const newLinks = [...links];
+ const newNodes = [...nodes];
+
+ newNodes.forEach(node => {
+ node.isInvalidLinkTarget = false;
+ });
+
+ newLinks.push({
+ source: { id: addLinkSourceNode.id },
+ target: { id: addLinkTargetNode.id },
+ linkType,
+ });
+
+ newLinks.forEach((link, index) => {
+ if (link.source.id === 1 && link.target.id === addLinkTargetNode.id) {
+ newLinks.splice(index, 1);
+ }
+ });
+
+ return {
+ ...state,
+ addLinkSourceNode: null,
+ addLinkTargetNode: null,
+ addingLink: false,
+ linkToEdit: null,
+ links: newLinks,
+ nodes: newNodes,
+ unsavedChanges: true,
+ };
+}
+
+function createNode(state, node) {
+ const { addNodeSource, addNodeTarget, links, nodes, nextNodeId } = state;
+ const newNodes = [...nodes];
+ const newLinks = [...links];
+
+ newNodes.push({
+ id: nextNodeId,
+ unifiedJobTemplate: node.nodeResource,
+ isInvalidLinkTarget: false,
+ });
+
+ // Ensures that root nodes appear to always run
+ // after "START"
+ if (addNodeSource === 1) {
+ node.linkType = 'always';
+ }
+
+ newLinks.push({
+ source: { id: addNodeSource },
+ target: { id: nextNodeId },
+ linkType: node.linkType,
+ });
+
+ if (addNodeTarget) {
+ newLinks.forEach(linkToCompare => {
+ if (
+ linkToCompare.source.id === addNodeSource &&
+ linkToCompare.target.id === addNodeTarget
+ ) {
+ linkToCompare.source = { id: nextNodeId };
+ }
+ });
+ }
+
+ return {
+ ...state,
+ addNodeSource: null,
+ addNodeTarget: null,
+ links: newLinks,
+ nextNodeId: nextNodeId + 1,
+ nodes: newNodes,
+ unsavedChanges: true,
+ };
+}
+
+function cancelLink(state) {
+ const { nodes } = state;
+ const newNodes = [...nodes];
+
+ newNodes.forEach(node => {
+ node.isInvalidLinkTarget = false;
+ });
+
+ return {
+ ...state,
+ addLinkSourceNode: null,
+ addLinkTargetNode: null,
+ addingLink: false,
+ linkToEdit: null,
+ nodes: newNodes,
+ };
+}
+
+function deleteAllNodes(state) {
+ const { nodes } = state;
+ return {
+ ...state,
+ addLinkSourceNode: null,
+ addLinkTargetNode: null,
+ addingLink: false,
+ links: [],
+ nodes: nodes.map(node => {
+ if (node.id !== 1) {
+ node.isDeleted = true;
+ }
+
+ return node;
+ }),
+ showDeleteAllNodesModal: false,
+ unsavedChanges: true,
+ };
+}
+
+function deleteLink(state) {
+ const { links, linkToDelete } = state;
+ const newLinks = [...links];
+
+ for (let i = newLinks.length; i--; ) {
+ const link = newLinks[i];
+
+ if (
+ link.source.id === linkToDelete.source.id &&
+ link.target.id === linkToDelete.target.id
+ ) {
+ newLinks.splice(i, 1);
+ }
+ }
+
+ if (!linkToDelete.isConvergenceLink) {
+ // Add a new link from the start node to the orphaned node
+ newLinks.push({
+ source: {
+ id: 1,
+ },
+ target: {
+ id: linkToDelete.target.id,
+ },
+ linkType: 'always',
+ });
+ }
+
+ return {
+ ...state,
+ links: newLinks,
+ linkToDelete: null,
+ unsavedChanges: true,
+ };
+}
+
+function addLinksFromParentsToChildren(
+ parents,
+ children,
+ newLinks,
+ linkParentMapping
+) {
+ parents.forEach(parentId => {
+ children.forEach(child => {
+ if (parentId === 1) {
+ // We only want to create a link from the start node to this node if it
+ // doesn't have any other parents
+ if (linkParentMapping[child.id].length === 1) {
+ newLinks.push({
+ source: { id: parentId },
+ target: { id: child.id },
+ linkType: 'always',
+ });
+ }
+ } else if (!linkParentMapping[child.id].includes(parentId)) {
+ newLinks.push({
+ source: { id: parentId },
+ target: { id: child.id },
+ linkType: child.linkType,
+ });
+ }
+ });
+ });
+}
+
+function removeLinksFromDeletedNode(
+ nodeId,
+ newLinks,
+ linkParentMapping,
+ children,
+ parents
+) {
+ for (let i = newLinks.length; i--; ) {
+ const link = newLinks[i];
+
+ if (!linkParentMapping[link.target.id]) {
+ linkParentMapping[link.target.id] = [];
+ }
+
+ linkParentMapping[link.target.id].push(link.source.id);
+
+ if (link.source.id === nodeId || link.target.id === nodeId) {
+ if (link.source.id === nodeId) {
+ children.push({ id: link.target.id, linkType: link.linkType });
+ } else if (link.target.id === nodeId) {
+ parents.push(link.source.id);
+ }
+ newLinks.splice(i, 1);
+ }
+ }
+}
+
+function deleteNode(state) {
+ const { links, nodes, nodeToDelete } = state;
+
+ const nodeId = nodeToDelete.id;
+ const newNodes = [...nodes];
+ const newLinks = [...links];
+
+ newNodes.find(node => node.id === nodeToDelete.id).isDeleted = true;
+
+ // Update the links
+ const parents = [];
+ const children = [];
+ const linkParentMapping = {};
+
+ removeLinksFromDeletedNode(
+ nodeId,
+ newLinks,
+ linkParentMapping,
+ children,
+ parents
+ );
+
+ addLinksFromParentsToChildren(parents, children, newLinks, linkParentMapping);
+
+ return {
+ ...state,
+ links: newLinks,
+ nodeToDelete: null,
+ nodes: newNodes,
+ unsavedChanges: true,
+ };
+}
+
+function generateNodes(workflowNodes, i18n) {
+ const allNodeIds = [];
+ const chartNodeIdToIndexMapping = {};
+ const nodeIdToChartNodeIdMapping = {};
+ let nodeIdCounter = 2;
+ const arrayOfNodesForChart = [
+ {
+ id: 1,
+ unifiedJobTemplate: {
+ name: i18n._(t`START`),
+ },
+ },
+ ];
+ workflowNodes.forEach(node => {
+ node.workflowMakerNodeId = nodeIdCounter;
+
+ const nodeObj = {
+ id: nodeIdCounter,
+ originalNodeObject: node,
+ };
+
+ if (node.summary_fields.job) {
+ nodeObj.job = node.summary_fields.job;
+ }
+ if (node.summary_fields.unified_job_template) {
+ nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
+ }
+
+ arrayOfNodesForChart.push(nodeObj);
+ allNodeIds.push(node.id);
+ nodeIdToChartNodeIdMapping[node.id] = node.workflowMakerNodeId;
+ chartNodeIdToIndexMapping[nodeIdCounter] = nodeIdCounter - 1;
+ nodeIdCounter++;
+ });
+
+ return [
+ arrayOfNodesForChart,
+ allNodeIds,
+ nodeIdToChartNodeIdMapping,
+ chartNodeIdToIndexMapping,
+ nodeIdCounter,
+ ];
+}
+
+function generateLinks(
+ workflowNodes,
+ chartNodeIdToIndexMapping,
+ nodeIdToChartNodeIdMapping,
+ arrayOfNodesForChart
+) {
+ const arrayOfLinksForChart = [];
+ const nonRootNodeIds = [];
+ workflowNodes.forEach(node => {
+ const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
+ node.success_nodes.forEach(nodeId => {
+ const targetIndex =
+ chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
+ arrayOfLinksForChart.push({
+ source: arrayOfNodesForChart[sourceIndex],
+ target: arrayOfNodesForChart[targetIndex],
+ linkType: 'success',
+ });
+ nonRootNodeIds.push(nodeId);
+ });
+ node.failure_nodes.forEach(nodeId => {
+ const targetIndex =
+ chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
+ arrayOfLinksForChart.push({
+ source: arrayOfNodesForChart[sourceIndex],
+ target: arrayOfNodesForChart[targetIndex],
+ linkType: 'failure',
+ });
+ nonRootNodeIds.push(nodeId);
+ });
+ node.always_nodes.forEach(nodeId => {
+ const targetIndex =
+ chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
+ arrayOfLinksForChart.push({
+ source: arrayOfNodesForChart[sourceIndex],
+ target: arrayOfNodesForChart[targetIndex],
+ linkType: 'always',
+ });
+ nonRootNodeIds.push(nodeId);
+ });
+ });
+
+ return [arrayOfLinksForChart, nonRootNodeIds];
+}
+
+// TODO: check to make sure passing i18n into this reducer
+// actually works the way we want it to. If not we may
+// have to explore other options
+function generateNodesAndLinks(state, workflowNodes, i18n) {
+ const [
+ arrayOfNodesForChart,
+ allNodeIds,
+ nodeIdToChartNodeIdMapping,
+ chartNodeIdToIndexMapping,
+ nodeIdCounter,
+ ] = generateNodes(workflowNodes, i18n);
+ const [arrayOfLinksForChart, nonRootNodeIds] = generateLinks(
+ workflowNodes,
+ chartNodeIdToIndexMapping,
+ nodeIdToChartNodeIdMapping,
+ arrayOfNodesForChart
+ );
+
+ const uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds));
+
+ const rootNodes = allNodeIds.filter(
+ nodeId => !uniqueNonRootNodeIds.includes(nodeId)
+ );
+
+ rootNodes.forEach(rootNodeId => {
+ const targetIndex =
+ chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[rootNodeId]];
+ arrayOfLinksForChart.push({
+ source: arrayOfNodesForChart[0],
+ target: arrayOfNodesForChart[targetIndex],
+ linkType: 'always',
+ });
+ });
+
+ return {
+ ...state,
+ links: arrayOfLinksForChart,
+ nodes: arrayOfNodesForChart,
+ nextNodeId: nodeIdCounter,
+ };
+}
+
+function selectSourceForLinking(state, sourceNode) {
+ const { links, nodes } = state;
+ const newNodes = [...nodes];
+ const parentMap = {};
+ const invalidLinkTargetIds = [];
+ // Find and mark any ancestors as disabled to prevent cycles
+ links.forEach(link => {
+ // id=1 is our artificial root node so we don't care about that
+ if (link.source.id === 1) {
+ return;
+ }
+ if (link.source.id === sourceNode.id) {
+ // Disables direct children from the add link process
+ invalidLinkTargetIds.push(link.target.id);
+ }
+ if (!parentMap[link.target.id]) {
+ parentMap[link.target.id] = [];
+ }
+ parentMap[link.target.id].push(link.source.id);
+ });
+
+ const getAncestors = id => {
+ if (parentMap[id]) {
+ parentMap[id].forEach(parentId => {
+ invalidLinkTargetIds.push(parentId);
+ getAncestors(parentId);
+ });
+ }
+ };
+
+ getAncestors(sourceNode.id);
+
+ // Filter out the duplicates
+ invalidLinkTargetIds
+ .filter((element, index, array) => index === array.indexOf(element))
+ .forEach(ancestorId => {
+ newNodes.forEach(node => {
+ if (node.id === ancestorId) {
+ node.isInvalidLinkTarget = true;
+ }
+ });
+ });
+
+ return {
+ ...state,
+ addLinkSourceNode: sourceNode,
+ addingLink: true,
+ nodes: newNodes,
+ };
+}
+
+function startDeleteLink(state, link) {
+ const { links } = state;
+ const parentMap = {};
+ links.forEach(existingLink => {
+ if (!parentMap[existingLink.target.id]) {
+ parentMap[existingLink.target.id] = [];
+ }
+ parentMap[existingLink.target.id].push(existingLink.source.id);
+ });
+
+ link.isConvergenceLink = parentMap[link.target.id].length > 1;
+
+ return {
+ ...state,
+ linkToDelete: link,
+ };
+}
+
+function toggleDeleteAllNodesModal(state) {
+ const { showDeleteAllNodesModal } = state;
+ return {
+ ...state,
+ showDeleteAllNodesModal: !showDeleteAllNodesModal,
+ };
+}
+
+function toggleLegend(state) {
+ const { showLegend } = state;
+ return {
+ ...state,
+ showLegend: !showLegend,
+ };
+}
+
+function toggleTools(state) {
+ const { showTools } = state;
+ return {
+ ...state,
+ showTools: !showTools,
+ };
+}
+
+function toggleUnsavedChangesModal(state) {
+ const { showUnsavedChangesModal } = state;
+ return {
+ ...state,
+ showUnsavedChangesModal: !showUnsavedChangesModal,
+ };
+}
+
+function updateLink(state, linkType) {
+ const { linkToEdit, links } = state;
+ const newLinks = [...links];
+
+ newLinks.forEach(link => {
+ if (
+ link.source.id === linkToEdit.source.id &&
+ link.target.id === linkToEdit.target.id
+ ) {
+ link.linkType = linkType;
+ }
+ });
+
+ return {
+ ...state,
+ linkToEdit: null,
+ links: newLinks,
+ unsavedChanges: true,
+ };
+}
+
+function updateNode(state, editedNode) {
+ const { nodeToEdit, nodes } = state;
+ const newNodes = [...nodes];
+
+ const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
+ matchingNode.unifiedJobTemplate = editedNode.nodeResource;
+ matchingNode.isEdited = true;
+
+ return {
+ ...state,
+ nodeToEdit: null,
+ nodes: newNodes,
+ unsavedChanges: true,
+ };
+}
diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.test.js b/awx/ui_next/src/components/Workflow/workflowReducer.test.js
new file mode 100644
index 0000000000..82aa87cf09
--- /dev/null
+++ b/awx/ui_next/src/components/Workflow/workflowReducer.test.js
@@ -0,0 +1,1777 @@
+import workflowReducer, { initReducer } from './workflowReducer';
+
+const defaultState = {
+ addLinkSourceNode: null,
+ addLinkTargetNode: null,
+ addNodeSource: null,
+ addNodeTarget: null,
+ addingLink: false,
+ contentError: null,
+ isLoading: true,
+ linkToDelete: null,
+ linkToEdit: null,
+ links: [],
+ nextNodeId: 0,
+ nodePositions: null,
+ nodes: [],
+ nodeToDelete: null,
+ nodeToEdit: null,
+ showDeleteAllNodesModal: false,
+ showLegend: false,
+ showTools: false,
+ showUnsavedChangesModal: false,
+ unsavedChanges: false,
+};
+
+describe('Workflow reducer', () => {
+ describe('CREATE_LINK', () => {
+ it('should clear the isInvalidLinkTarget flag from all nodes and add new link', () => {
+ const state = {
+ ...defaultState,
+ addLinkSourceNode: { id: 2 },
+ addLinkTargetNode: { id: 4 },
+ addingLink: true,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: true,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: true,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: true,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'CREATE_LINK',
+ linkType: 'always',
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ unsavedChanges: true,
+ });
+ });
+ });
+ describe('CREATE_NODE', () => {
+ it('should add new node and link from the end of the source node when no target node present', () => {
+ const state = {
+ ...defaultState,
+ addNodeSource: 1,
+ isLoading: false,
+ nextNodeId: 2,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'CREATE_NODE',
+ node: {
+ linkType: 'always',
+ nodeResource: {
+ id: 7000,
+ name: 'Foo JT',
+ },
+ },
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 3,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ unifiedJobTemplate: {
+ id: 7000,
+ name: 'Foo JT',
+ },
+ },
+ ],
+ unsavedChanges: true,
+ });
+ });
+ it('should add new node and link between the source and target nodes when target node present', () => {
+ const state = {
+ ...defaultState,
+ addNodeSource: 1,
+ addNodeTarget: 2,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 3,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'CREATE_NODE',
+ node: {
+ linkType: 'always',
+ nodeResource: {
+ id: 7000,
+ name: 'Foo JT',
+ },
+ },
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 4,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ unifiedJobTemplate: {
+ id: 7000,
+ name: 'Foo JT',
+ },
+ },
+ ],
+ unsavedChanges: true,
+ });
+ });
+ });
+ describe('CANCEL_LINK/CANCEL_LINK_MODAL', () => {
+ it('should wipe flags that track the process of adding or editing a link', () => {
+ const state = {
+ ...defaultState,
+ addLinkSourceNode: { id: 2 },
+ addLinkTargetNode: { id: 4 },
+ addingLink: true,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: true,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: true,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: true,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ unsavedChanges: false,
+ };
+ const result = workflowReducer(state, {
+ type: 'CANCEL_LINK',
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ });
+ });
+ });
+ describe('CANCEL_NODE_MODAL', () => {
+ it('should wipe the flags that track the process of adding a node', () => {
+ const state = {
+ ...defaultState,
+ addNodeSource: { id: 1 },
+ isLoading: false,
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'CANCEL_NODE_MODAL',
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ });
+ });
+ it('should wipe the flags that track the process of editing a node', () => {
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ nodeToEdit: {
+ id: 2,
+ },
+ };
+ const result = workflowReducer(state, {
+ type: 'CANCEL_NODE_MODAL',
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ });
+ });
+ });
+ describe('DELETE_ALL_NODES', () => {
+ it('should mark all the non-start nodes as deleted and clear out the links', () => {
+ const state = {
+ ...defaultState,
+ addLinkSourceNode: { id: 2 },
+ addLinkTargetNode: { id: 4 },
+ addingLink: true,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'DELETE_ALL_NODES',
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ isDeleted: true,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ isDeleted: true,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ isDeleted: true,
+ },
+ ],
+ unsavedChanges: true,
+ });
+ });
+ });
+ describe('DELETE_LINK', () => {
+ it('should remove the link and connect the remaining node to start if orphaned', () => {
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ linkToDelete: {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'DELETE_LINK',
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ unsavedChanges: true,
+ });
+ });
+ });
+ describe('DELETE_NODE', () => {
+ it('should remove the mark the node as deleted and re-link any orphaned nodes', () => {
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ nodeToDelete: {
+ id: 3,
+ },
+ };
+ const result = workflowReducer(state, {
+ type: 'DELETE_NODE',
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ isDeleted: true,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ unsavedChanges: true,
+ });
+ });
+ });
+ describe('GENERATE_NODES_AND_LINKS', () => {
+ it('should generate the correct node and link arrays', () => {
+ const result = workflowReducer(defaultState, {
+ type: 'GENERATE_NODES_AND_LINKS',
+ nodes: [
+ {
+ id: 1,
+ success_nodes: [3],
+ failure_nodes: [],
+ always_nodes: [2],
+ summary_fields: {
+ unified_job_template: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ },
+ {
+ id: 2,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 2,
+ name: 'JT 2',
+ },
+ },
+ },
+ {
+ id: 3,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 3,
+ name: 'JT 3',
+ },
+ },
+ },
+ {
+ id: 4,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [2],
+ summary_fields: {
+ unified_job_template: {
+ id: 4,
+ name: 'JT 4',
+ },
+ },
+ },
+ ],
+ i18n: {
+ _: () => {},
+ },
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ links: [
+ {
+ linkType: 'success',
+ source: {
+ id: 2,
+ originalNodeObject: {
+ always_nodes: [2],
+ failure_nodes: [],
+ id: 1,
+ success_nodes: [3],
+ summary_fields: {
+ unified_job_template: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ workflowMakerNodeId: 2,
+ },
+ unifiedJobTemplate: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ target: {
+ id: 4,
+ originalNodeObject: {
+ always_nodes: [],
+ failure_nodes: [],
+ id: 3,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 3,
+ name: 'JT 3',
+ },
+ },
+ workflowMakerNodeId: 4,
+ },
+ unifiedJobTemplate: {
+ id: 3,
+ name: 'JT 3',
+ },
+ },
+ },
+ {
+ linkType: 'always',
+ source: {
+ id: 2,
+ originalNodeObject: {
+ always_nodes: [2],
+ failure_nodes: [],
+ id: 1,
+ success_nodes: [3],
+ summary_fields: {
+ unified_job_template: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ workflowMakerNodeId: 2,
+ },
+ unifiedJobTemplate: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ target: {
+ id: 3,
+ originalNodeObject: {
+ always_nodes: [],
+ failure_nodes: [],
+ id: 2,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 2,
+ name: 'JT 2',
+ },
+ },
+ workflowMakerNodeId: 3,
+ },
+ unifiedJobTemplate: {
+ id: 2,
+ name: 'JT 2',
+ },
+ },
+ },
+ {
+ linkType: 'always',
+ source: {
+ id: 5,
+ originalNodeObject: {
+ always_nodes: [2],
+ failure_nodes: [],
+ id: 4,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 4,
+ name: 'JT 4',
+ },
+ },
+ workflowMakerNodeId: 5,
+ },
+ unifiedJobTemplate: {
+ id: 4,
+ name: 'JT 4',
+ },
+ },
+ target: {
+ id: 3,
+ originalNodeObject: {
+ always_nodes: [],
+ failure_nodes: [],
+ id: 2,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 2,
+ name: 'JT 2',
+ },
+ },
+ workflowMakerNodeId: 3,
+ },
+ unifiedJobTemplate: {
+ id: 2,
+ name: 'JT 2',
+ },
+ },
+ },
+ {
+ linkType: 'always',
+ source: {
+ id: 1,
+ unifiedJobTemplate: {
+ name: undefined,
+ },
+ },
+ target: {
+ id: 2,
+ originalNodeObject: {
+ always_nodes: [2],
+ failure_nodes: [],
+ id: 1,
+ success_nodes: [3],
+ summary_fields: {
+ unified_job_template: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ workflowMakerNodeId: 2,
+ },
+ unifiedJobTemplate: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ },
+ {
+ linkType: 'always',
+ source: {
+ id: 1,
+ unifiedJobTemplate: {
+ name: undefined,
+ },
+ },
+ target: {
+ id: 5,
+ originalNodeObject: {
+ always_nodes: [2],
+ failure_nodes: [],
+ id: 4,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 4,
+ name: 'JT 4',
+ },
+ },
+ workflowMakerNodeId: 5,
+ },
+ unifiedJobTemplate: {
+ id: 4,
+ name: 'JT 4',
+ },
+ },
+ },
+ ],
+ nextNodeId: 6,
+ nodes: [
+ {
+ id: 1,
+ unifiedJobTemplate: {
+ name: undefined,
+ },
+ },
+ {
+ id: 2,
+ originalNodeObject: {
+ always_nodes: [2],
+ failure_nodes: [],
+ id: 1,
+ success_nodes: [3],
+ summary_fields: {
+ unified_job_template: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ workflowMakerNodeId: 2,
+ },
+ unifiedJobTemplate: {
+ id: 1,
+ name: 'JT 1',
+ },
+ },
+ {
+ id: 3,
+ originalNodeObject: {
+ always_nodes: [],
+ failure_nodes: [],
+ id: 2,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 2,
+ name: 'JT 2',
+ },
+ },
+ workflowMakerNodeId: 3,
+ },
+ unifiedJobTemplate: {
+ id: 2,
+ name: 'JT 2',
+ },
+ },
+ {
+ id: 4,
+ originalNodeObject: {
+ always_nodes: [],
+ failure_nodes: [],
+ id: 3,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 3,
+ name: 'JT 3',
+ },
+ },
+ workflowMakerNodeId: 4,
+ },
+ unifiedJobTemplate: {
+ id: 3,
+ name: 'JT 3',
+ },
+ },
+ {
+ id: 5,
+ originalNodeObject: {
+ always_nodes: [2],
+ failure_nodes: [],
+ id: 4,
+ success_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 4,
+ name: 'JT 4',
+ },
+ },
+ workflowMakerNodeId: 5,
+ },
+ unifiedJobTemplate: {
+ id: 4,
+ name: 'JT 4',
+ },
+ },
+ ],
+ });
+ });
+ });
+ describe('RESET', () => {
+ it('should reset the state back to default values', () => {
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ },
+ ],
+ nextNodeId: 3,
+ nodes: [
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'RESET',
+ });
+ expect(result).toEqual(defaultState);
+ });
+ });
+ describe('SELECT_SOURCE_FOR_LINKING', () => {
+ it('should set source node and mark invalid target nodes', () => {
+ const sourceNode = {
+ id: 3,
+ isInvalidLinkTarget: false,
+ };
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 5,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 6,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ sourceNode,
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 5,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'SELECT_SOURCE_FOR_LINKING',
+ node: sourceNode,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ addLinkSourceNode: sourceNode,
+ addingLink: true,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 5,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 6,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ sourceNode,
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 5,
+ isInvalidLinkTarget: true,
+ },
+ ],
+ });
+ });
+ });
+ describe('SET_ADD_LINK_TARGET_NODE', () => {
+ it('should set the state variable', () => {
+ const result = workflowReducer(defaultState, {
+ type: 'SET_ADD_LINK_TARGET_NODE',
+ value: {
+ id: 2,
+ },
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ addLinkTargetNode: {
+ id: 2,
+ },
+ });
+ });
+ });
+ describe('SET_CONTENT_ERROR', () => {
+ it('should set the state variable', () => {
+ const result = workflowReducer(defaultState, {
+ type: 'SET_CONTENT_ERROR',
+ value: new Error('Test Error'),
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ contentError: new Error('Test Error'),
+ });
+ });
+ });
+ describe('SET_IS_LOADING', () => {
+ it('should set the state variable', () => {
+ const result = workflowReducer(defaultState, {
+ type: 'SET_IS_LOADING',
+ value: false,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ isLoading: false,
+ });
+ });
+ });
+ describe('SET_LINK_TO_DELETE', () => {
+ it('should set the state variable', () => {
+ const linkToDelete = {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ };
+ const result = workflowReducer(defaultState, {
+ type: 'SET_LINK_TO_DELETE',
+ value: linkToDelete,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ linkToDelete,
+ });
+ });
+ });
+ describe('SET_LINK_TO_EDIT', () => {
+ it('should set the state variable', () => {
+ const linkToEdit = {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ };
+ const result = workflowReducer(defaultState, {
+ type: 'SET_LINK_TO_EDIT',
+ value: linkToEdit,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ linkToEdit,
+ });
+ });
+ });
+ describe('SET_NODE_POSITIONS', () => {
+ it('should set the state variable', () => {
+ const nodePositions = {
+ label: '',
+ width: 72,
+ height: 40,
+ x: 36,
+ y: 20,
+ };
+ const result = workflowReducer(defaultState, {
+ type: 'SET_NODE_POSITIONS',
+ value: nodePositions,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ nodePositions,
+ });
+ });
+ });
+ describe('SET_NODE_TO_DELETE', () => {
+ it('should set the state variable', () => {
+ const nodeToDelete = {
+ id: 2,
+ };
+ const result = workflowReducer(defaultState, {
+ type: 'SET_NODE_TO_DELETE',
+ value: nodeToDelete,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ nodeToDelete,
+ });
+ });
+ });
+ describe('SET_NODE_TO_EDIT', () => {
+ it('should set the state variable', () => {
+ const nodeToEdit = {
+ id: 2,
+ };
+ const result = workflowReducer(defaultState, {
+ type: 'SET_NODE_TO_EDIT',
+ value: nodeToEdit,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ nodeToEdit,
+ });
+ });
+ });
+ describe('SET_NODE_TO_VIEW', () => {
+ it('should set the state variable', () => {
+ const nodeToView = {
+ id: 2,
+ };
+ const result = workflowReducer(defaultState, {
+ type: 'SET_NODE_TO_VIEW',
+ value: nodeToView,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ nodeToView,
+ });
+ });
+ });
+ describe('START_ADD_NODE', () => {
+ it('should set the source/target node ids to state', () => {
+ const result = workflowReducer(defaultState, {
+ type: 'START_ADD_NODE',
+ sourceNodeId: 44,
+ targetNodeId: 9000,
+ });
+ expect(result).toEqual({
+ ...defaultState,
+ addNodeSource: 44,
+ addNodeTarget: 9000,
+ });
+ });
+ });
+ describe('START_DELETE_LINK', () => {
+ it('should update the link to indicate whether it is a convergence link and update the state variable', () => {
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 5,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 4,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const result = workflowReducer(state, {
+ type: 'START_DELETE_LINK',
+ link: {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'always',
+ },
+ });
+ expect(result).toEqual({
+ ...state,
+ linkToDelete: {
+ source: {
+ id: 3,
+ },
+ target: {
+ id: 4,
+ },
+ isConvergenceLink: true,
+ linkType: 'always',
+ },
+ });
+ });
+ });
+ describe('TOGGLE_DELETE_ALL_NODES_MODAL', () => {
+ it('should toggle the show delete all nodes modal flag', () => {
+ const firstToggleState = workflowReducer(defaultState, {
+ type: 'TOGGLE_DELETE_ALL_NODES_MODAL',
+ });
+ expect(firstToggleState).toEqual({
+ ...defaultState,
+ showDeleteAllNodesModal: true,
+ });
+ const secondToggleState = workflowReducer(firstToggleState, {
+ type: 'TOGGLE_DELETE_ALL_NODES_MODAL',
+ });
+ expect(secondToggleState).toEqual(defaultState);
+ });
+ });
+ describe('TOGGLE_LEGEND', () => {
+ it('should toggle the show legend flag', () => {
+ const firstToggleState = workflowReducer(defaultState, {
+ type: 'TOGGLE_LEGEND',
+ });
+ expect(firstToggleState).toEqual({
+ ...defaultState,
+ showLegend: true,
+ });
+ const secondToggleState = workflowReducer(firstToggleState, {
+ type: 'TOGGLE_LEGEND',
+ });
+ expect(secondToggleState).toEqual(defaultState);
+ });
+ });
+ describe('TOGGLE_TOOLS', () => {
+ it('should toggle the show legend flag', () => {
+ const firstToggleState = workflowReducer(defaultState, {
+ type: 'TOGGLE_TOOLS',
+ });
+ expect(firstToggleState).toEqual({
+ ...defaultState,
+ showTools: true,
+ });
+ const secondToggleState = workflowReducer(firstToggleState, {
+ type: 'TOGGLE_TOOLS',
+ });
+ expect(secondToggleState).toEqual(defaultState);
+ });
+ });
+ describe('TOGGLE_UNSAVED_CHANGES_MODAL', () => {
+ it('should toggle the unsaved changes modal flag', () => {
+ const firstToggleState = workflowReducer(defaultState, {
+ type: 'TOGGLE_UNSAVED_CHANGES_MODAL',
+ });
+ expect(firstToggleState).toEqual({
+ ...defaultState,
+ showUnsavedChangesModal: true,
+ });
+ const secondToggleState = workflowReducer(firstToggleState, {
+ type: 'TOGGLE_UNSAVED_CHANGES_MODAL',
+ });
+ expect(secondToggleState).toEqual(defaultState);
+ });
+ });
+ describe('UPDATE_LINK', () => {
+ it('should update the link type', () => {
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ ],
+ linkToEdit: {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ nextNodeId: 4,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 3,
+ isInvalidLinkTarget: false,
+ },
+ ],
+ };
+ const firstToggleState = workflowReducer(state, {
+ type: 'UPDATE_LINK',
+ linkType: 'success',
+ });
+ expect(firstToggleState).toEqual({
+ ...state,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'success',
+ },
+ ],
+ linkToEdit: null,
+ unsavedChanges: true,
+ });
+ });
+ });
+ describe('UPDATE_NODE', () => {
+ it('should update the node', () => {
+ const state = {
+ ...defaultState,
+ isLoading: false,
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ ],
+ nextNodeId: 3,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isEdited: false,
+ isInvalidLinkTarget: false,
+ unifiedJobTemplate: {
+ id: 703,
+ name: 'Test JT',
+ type: 'job_template',
+ },
+ },
+ ],
+ nodeToEdit: {
+ id: 2,
+ isEdited: false,
+ isInvalidLinkTarget: false,
+ unifiedJobTemplate: {
+ id: 703,
+ name: 'Test JT',
+ type: 'job_template',
+ },
+ },
+ };
+ const firstToggleState = workflowReducer(state, {
+ type: 'UPDATE_NODE',
+ node: {
+ nodeResource: {
+ id: 704,
+ name: 'Other JT',
+ type: 'job_template',
+ },
+ },
+ });
+ expect(firstToggleState).toEqual({
+ ...state,
+ nodes: [
+ {
+ id: 1,
+ isInvalidLinkTarget: false,
+ },
+ {
+ id: 2,
+ isEdited: true,
+ isInvalidLinkTarget: false,
+ unifiedJobTemplate: {
+ id: 704,
+ name: 'Other JT',
+ type: 'job_template',
+ },
+ },
+ ],
+ nodeToEdit: null,
+ unsavedChanges: true,
+ });
+ });
+ });
+ describe('initReducer', () => {
+ it('should init', () => {
+ const state = initReducer();
+ expect(state).toEqual(defaultState);
+ });
+ });
+});
diff --git a/awx/ui_next/src/contexts/Workflow.jsx b/awx/ui_next/src/contexts/Workflow.jsx
new file mode 100644
index 0000000000..d79fd40082
--- /dev/null
+++ b/awx/ui_next/src/contexts/Workflow.jsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+// eslint-disable-next-line import/prefer-default-export
+export const WorkflowDispatchContext = React.createContext(null);
+export const WorkflowStateContext = React.createContext(null);
diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx
index 7975260e8e..14efb2d14e 100644
--- a/awx/ui_next/src/screens/Job/Job.jsx
+++ b/awx/ui_next/src/screens/Job/Job.jsx
@@ -11,7 +11,9 @@ import RoutedTabs from '@components/RoutedTabs';
import JobDetail from './JobDetail';
import JobOutput from './JobOutput';
-import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
+import WorkflowDetail from './WorkflowDetail';
+import { WorkflowOutput } from './WorkflowOutput';
+import { JOB_TYPE_URL_SEGMENTS } from '@constants';
class Job extends Component {
constructor(props) {
@@ -120,33 +122,54 @@ class Job extends Component {
to="/jobs/:type/:id/output"
exact
/>
- {job && [
- }
- />,
- }
- />,
-
- !hasContentLoading && (
-
-
- {i18n._(`View Job Details`)}
-
-
- )
- }
- />,
- ]}
+
+ job &&
+ job.type === 'workflow_job' &&
+ }
+ />
+
+ job &&
+ job.type === 'workflow_job' &&
+ }
+ />
+ {job &&
+ job.type !== 'workflow_job' && [
+ (
+
+ )}
+ />,
+ (
+
+ )}
+ />,
+
+ !hasContentLoading && (
+
+
+ {i18n._(`View Job Details`)}
+
+
+ )
+ }
+ />,
+ ]}
diff --git a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx
index 47acd95865..5cbdec219b 100644
--- a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx
+++ b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx
@@ -17,7 +17,7 @@ import LaunchButton from '@components/LaunchButton';
import { StatusIcon } from '@components/Sparkline';
import { toTitleCase } from '@util/strings';
import { formatDateString } from '@util/dates';
-import { JOB_TYPE_URL_SEGMENTS } from '../../../constants';
+import { JOB_TYPE_URL_SEGMENTS } from '@constants';
const PaddedIcon = styled(StatusIcon)`
margin-right: 20px;
diff --git a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx
index 1dc45adbe8..aeb8ee6efe 100644
--- a/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx
+++ b/awx/ui_next/src/screens/Job/JobTypeRedirect.jsx
@@ -4,7 +4,7 @@ import { PageSection, Card } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { UnifiedJobsAPI } from '@api';
import ContentError from '@components/ContentError';
-import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
+import { JOB_TYPE_URL_SEGMENTS } from '@constants';
const NOT_FOUND = 'not found';
diff --git a/awx/ui_next/src/screens/Job/Jobs.jsx b/awx/ui_next/src/screens/Job/Jobs.jsx
index 35e90816df..7c710614d8 100644
--- a/awx/ui_next/src/screens/Job/Jobs.jsx
+++ b/awx/ui_next/src/screens/Job/Jobs.jsx
@@ -6,7 +6,7 @@ import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
import Job from './Job';
import JobTypeRedirect from './JobTypeRedirect';
import JobList from './JobList/JobList';
-import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
+import { JOB_TYPE_URL_SEGMENTS } from '@constants';
class Jobs extends Component {
constructor(props) {
diff --git a/awx/ui_next/src/screens/Job/WorkflowDetail/WorkflowDetail.jsx b/awx/ui_next/src/screens/Job/WorkflowDetail/WorkflowDetail.jsx
new file mode 100644
index 0000000000..26d0384ab3
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowDetail/WorkflowDetail.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+function WorkflowDetail() {
+ return Workflow Detail!
;
+}
+
+export default WorkflowDetail;
diff --git a/awx/ui_next/src/screens/Job/WorkflowDetail/index.js b/awx/ui_next/src/screens/Job/WorkflowDetail/index.js
new file mode 100644
index 0000000000..3ced22dd95
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowDetail/index.js
@@ -0,0 +1 @@
+export { default } from './WorkflowDetail';
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
new file mode 100644
index 0000000000..b304348cb9
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
@@ -0,0 +1,116 @@
+import React, { useEffect, useReducer } from 'react';
+import { withI18n } from '@lingui/react';
+import styled from 'styled-components';
+import { shape } from 'prop-types';
+import { CardBody as PFCardBody } from '@patternfly/react-core';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { layoutGraph } from '@components/Workflow/WorkflowUtils';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import workflowReducer, {
+ initReducer,
+} from '@components/Workflow/workflowReducer';
+import { WorkflowJobsAPI } from '@api';
+import WorkflowOutputGraph from './WorkflowOutputGraph';
+import WorkflowOutputToolbar from './WorkflowOutputToolbar';
+
+const CardBody = styled(PFCardBody)`
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 240px);
+`;
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-flow: column;
+ height: 100%;
+ position: relative;
+`;
+
+const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
+ const { data } = await WorkflowJobsAPI.readNodes(jobId, {
+ page_size: 200,
+ page: pageNo,
+ });
+
+ if (data.next) {
+ return fetchWorkflowNodes(jobId, pageNo + 1, nodes.concat(data.results));
+ }
+ return nodes.concat(data.results);
+};
+
+function WorkflowOutput({ job, i18n }) {
+ const [state, dispatch] = useReducer(workflowReducer, {}, initReducer);
+ const { contentError, isLoading, links, nodePositions, nodes } = state;
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const workflowNodes = await fetchWorkflowNodes(job.id);
+ dispatch({
+ type: 'GENERATE_NODES_AND_LINKS',
+ nodes: workflowNodes,
+ i18n,
+ });
+ } catch (error) {
+ dispatch({ type: 'SET_CONTENT_ERROR', value: error });
+ } finally {
+ dispatch({ type: 'SET_IS_LOADING', value: false });
+ }
+ }
+ dispatch({ type: 'RESET' });
+ fetchData();
+ }, [job.id, i18n]);
+
+ // Update positions of nodes/links
+ useEffect(() => {
+ if (nodes) {
+ const newNodePositions = {};
+ const g = layoutGraph(nodes, links);
+
+ g.nodes().forEach(node => {
+ newNodePositions[node] = g.node(node);
+ });
+
+ dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions });
+ }
+ }, [job.id, links, nodes]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (contentError) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {nodePositions && }
+
+
+
+
+ );
+}
+
+WorkflowOutput.propTypes = {
+ job: shape().isRequired,
+};
+
+export default withI18n()(WorkflowOutput);
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.test.jsx
new file mode 100644
index 0000000000..39bc048a6a
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.test.jsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { WorkflowJobsAPI } from '@api';
+import WorkflowOutput from './WorkflowOutput';
+
+jest.mock('@api');
+
+const job = {
+ id: 1,
+ name: 'Foo JT',
+ status: 'successful',
+};
+
+const mockWorkflowJobNodes = [
+ {
+ id: 8,
+ success_nodes: [10],
+ failure_nodes: [],
+ always_nodes: [9],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'A Playbook',
+ status: 'successful',
+ type: 'job',
+ },
+ },
+ },
+ {
+ id: 9,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'A Project Update',
+ status: 'successful',
+ type: 'project_update',
+ },
+ },
+ },
+ {
+ id: 10,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'An Inventory Source Sync',
+ status: 'successful',
+ type: 'inventory_update',
+ },
+ },
+ },
+ {
+ id: 11,
+ success_nodes: [9],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ job: {
+ elapsed: 10,
+ id: 14,
+ name: 'Pause',
+ status: 'successful',
+ type: 'workflow_approval',
+ },
+ },
+ },
+];
+
+describe('WorkflowOutput', () => {
+ let wrapper;
+ beforeEach(() => {
+ WorkflowJobsAPI.readNodes.mockResolvedValue({
+ data: {
+ count: mockWorkflowJobNodes.length,
+ results: mockWorkflowJobNodes,
+ },
+ });
+ window.SVGElement.prototype.height = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.width = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.getBBox = () => ({
+ x: 0,
+ y: 0,
+ width: 500,
+ height: 250,
+ });
+
+ window.SVGElement.prototype.getBoundingClientRect = () => ({
+ x: 303,
+ y: 252.359375,
+ width: 1329,
+ height: 259.640625,
+ top: 252.359375,
+ right: 1632,
+ bottom: 512,
+ left: 303,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ delete window.SVGElement.prototype.getBBox;
+ delete window.SVGElement.prototype.getBoundingClientRect;
+ delete window.SVGElement.prototype.height;
+ delete window.SVGElement.prototype.width;
+ });
+
+ test('renders successfully', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError')).toHaveLength(0);
+ expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
+ expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
+ expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
+ });
+
+ test('error shown to user when error thrown fetching workflow job nodes', async () => {
+ WorkflowJobsAPI.readNodes.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError')).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx
new file mode 100644
index 0000000000..40aca8f683
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx
@@ -0,0 +1,212 @@
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import * as d3 from 'd3';
+import {
+ getScaleAndOffsetToFit,
+ getTranslatePointsForZoom,
+} from '@components/Workflow/WorkflowUtils';
+import {
+ WorkflowOutputLink,
+ WorkflowOutputNode,
+} from '@screens/Job/WorkflowOutput';
+import {
+ WorkflowHelp,
+ WorkflowLegend,
+ WorkflowLinkHelp,
+ WorkflowNodeHelp,
+ WorkflowStartNode,
+ WorkflowTools,
+} from '@components/Workflow';
+
+function WorkflowOutputGraph() {
+ const [linkHelp, setLinkHelp] = useState();
+ const [nodeHelp, setNodeHelp] = useState();
+ const [zoomPercentage, setZoomPercentage] = useState(100);
+ const svgRef = useRef(null);
+ const gRef = useRef(null);
+
+ const { links, nodePositions, nodes, showLegend, showTools } = useContext(
+ WorkflowStateContext
+ );
+
+ // This is the zoom function called by using the mousewheel/click and drag
+ const zoom = () => {
+ const translation = [d3.event.transform.x, d3.event.transform.y];
+ d3.select(gRef.current).attr(
+ 'transform',
+ `translate(${translation}) scale(${d3.event.transform.k})`
+ );
+
+ setZoomPercentage(d3.event.transform.k * 100);
+ };
+
+ const handlePan = direction => {
+ const transform = d3.zoomTransform(d3.select(svgRef.current).node());
+
+ let { x: xPos, y: yPos } = transform;
+ const { k: currentScale } = transform;
+
+ switch (direction) {
+ case 'up':
+ yPos -= 50;
+ break;
+ case 'down':
+ yPos += 50;
+ break;
+ case 'left':
+ xPos -= 50;
+ break;
+ case 'right':
+ xPos += 50;
+ break;
+ default:
+ // Throw an error?
+ break;
+ }
+
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity.translate(xPos, yPos).scale(currentScale)
+ );
+ };
+
+ const handlePanToMiddle = () => {
+ const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity
+ .translate(0, svgBoundingClientRect.height / 2 - 30)
+ .scale(1)
+ );
+
+ setZoomPercentage(100);
+ };
+
+ const handleZoomChange = newScale => {
+ const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
+ const currentScaleAndOffset = d3.zoomTransform(
+ d3.select(svgRef.current).node()
+ );
+
+ const [translateX, translateY] = getTranslatePointsForZoom(
+ svgBoundingClientRect,
+ currentScaleAndOffset,
+ newScale
+ );
+
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity.translate(translateX, translateY).scale(newScale)
+ );
+ setZoomPercentage(newScale * 100);
+ };
+
+ const handleFitGraph = () => {
+ const { k: currentScale } = d3.zoomTransform(
+ d3.select(svgRef.current).node()
+ );
+ const gBoundingClientRect = d3
+ .select(gRef.current)
+ .node()
+ .getBoundingClientRect();
+
+ const gBBoxDimensions = d3
+ .select(gRef.current)
+ .node()
+ .getBBox();
+
+ const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
+
+ const [scaleToFit, yTranslate] = getScaleAndOffsetToFit(
+ gBoundingClientRect,
+ svgBoundingClientRect,
+ gBBoxDimensions,
+ currentScale
+ );
+
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
+ );
+
+ setZoomPercentage(scaleToFit * 100);
+ };
+
+ const zoomRef = d3
+ .zoom()
+ .scaleExtent([0.1, 2])
+ .on('zoom', zoom);
+
+ // Initialize the zoom
+ useEffect(() => {
+ d3.select(svgRef.current).call(zoomRef);
+ }, [zoomRef]);
+
+ // Attempt to zoom the graph to fit the available screen space
+ useEffect(() => {
+ handleFitGraph();
+ // We only want this to run once (when the component mounts)
+ // Including handleFitGraph in the deps array will cause this to
+ // run very frequently.
+ // Discussion: https://github.com/facebook/create-react-app/issues/6880
+ // and https://github.com/facebook/react/issues/15865 amongst others
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+ <>
+ {(nodeHelp || linkHelp) && (
+
+ {nodeHelp && }
+ {linkHelp && }
+
+ )}
+
+
+ {showTools && (
+
+ )}
+ {showLegend && }
+
+ >
+ );
+}
+
+export default WorkflowOutputGraph;
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx
new file mode 100644
index 0000000000..19e7e9a976
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx
@@ -0,0 +1,225 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import WorkflowOutputGraph from './WorkflowOutputGraph';
+
+const workflowContext = {
+ links: [
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'success',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 5,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'success',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 5,
+ },
+ linkType: 'success',
+ },
+ ],
+ nodePositions: {
+ 1: { label: '', width: 72, height: 40, x: 36, y: 85 },
+ 2: { label: '', width: 180, height: 60, x: 282, y: 40 },
+ 3: { label: '', width: 180, height: 60, x: 582, y: 130 },
+ 4: { label: '', width: 180, height: 60, x: 582, y: 30 },
+ 5: { label: '', width: 180, height: 60, x: 282, y: 140 },
+ },
+ nodes: [
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ job: {
+ name: 'Foo JT',
+ type: 'job',
+ status: 'successful',
+ elapsed: 60,
+ },
+ },
+ {
+ id: 3,
+ },
+ {
+ id: 4,
+ },
+ {
+ id: 5,
+ },
+ ],
+ showLegend: false,
+ showTools: false,
+};
+
+describe('WorkflowOutputGraph', () => {
+ beforeEach(() => {
+ window.SVGElement.prototype.height = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.width = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.getBBox = () => ({
+ x: 0,
+ y: 0,
+ width: 500,
+ height: 250,
+ });
+
+ window.SVGElement.prototype.getBoundingClientRect = () => ({
+ x: 303,
+ y: 252.359375,
+ width: 1329,
+ height: 259.640625,
+ top: 252.359375,
+ right: 1632,
+ bottom: 512,
+ left: 303,
+ });
+ });
+
+ afterEach(() => {
+ delete window.SVGElement.prototype.getBBox;
+ delete window.SVGElement.prototype.getBoundingClientRect;
+ delete window.SVGElement.prototype.height;
+ delete window.SVGElement.prototype.width;
+ });
+
+ test('mounts successfully', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+
+ test('tools and legend are shown when flags are true', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowLegend')).toHaveLength(1);
+ expect(wrapper.find('WorkflowTools')).toHaveLength(1);
+ });
+
+ test('nodes and links are properly rendered', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
+ expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
+ expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
+ expect(wrapper.find('#link-2-4')).toHaveLength(1);
+ expect(wrapper.find('#link-2-3')).toHaveLength(1);
+ expect(wrapper.find('#link-5-3')).toHaveLength(1);
+ expect(wrapper.find('#link-1-2')).toHaveLength(1);
+ expect(wrapper.find('#link-1-5')).toHaveLength(1);
+ });
+
+ test('proper help text is shown when hovering over links and nodes', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
+ wrapper.find('g#node-2').simulate('mouseenter');
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(1);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Name)).toEqual(
+ true
+ );
+ expect(
+ wrapper.find('WorkflowNodeHelp').containsMatchingElement(Foo JT)
+ ).toEqual(true);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Type)).toEqual(
+ true
+ );
+ expect(
+ wrapper
+ .find('WorkflowNodeHelp')
+ .containsMatchingElement(Job Template)
+ ).toEqual(true);
+ expect(
+ wrapper.find('WorkflowNodeHelp').contains(Job Status)
+ ).toEqual(true);
+ expect(
+ wrapper
+ .find('WorkflowNodeHelp')
+ .containsMatchingElement(Successful)
+ ).toEqual(true);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Elapsed)).toEqual(
+ true
+ );
+ expect(
+ wrapper
+ .find('WorkflowNodeHelp')
+ .containsMatchingElement(00:01:00)
+ ).toEqual(true);
+ wrapper.find('g#node-2').simulate('mouseleave');
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
+ wrapper.find('g#link-2-3').simulate('mouseenter');
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(1);
+ expect(wrapper.find('WorkflowLinkHelp').contains(Run)).toEqual(true);
+ expect(
+ wrapper.find('WorkflowLinkHelp').containsMatchingElement(Always)
+ ).toEqual(true);
+ wrapper.find('g#link-2-3').simulate('mouseleave');
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx
new file mode 100644
index 0000000000..b7ae3028dc
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx
@@ -0,0 +1,76 @@
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import { func, shape } from 'prop-types';
+import {
+ generateLine,
+ getLinePoints,
+ getLinkOverlayPoints,
+} from '@components/Workflow/WorkflowUtils';
+
+function WorkflowOutputLink({ link, mouseEnter, mouseLeave }) {
+ const ref = useRef(null);
+ const [hovering, setHovering] = useState(false);
+ const [pathD, setPathD] = useState();
+ const [pathStroke, setPathStroke] = useState('#CCCCCC');
+ const { nodePositions } = useContext(WorkflowStateContext);
+
+ const handleLinkMouseEnter = () => {
+ ref.current.parentNode.appendChild(ref.current);
+ setHovering(true);
+ mouseEnter();
+ };
+
+ const handleLinkMouseLeave = () => {
+ ref.current.parentNode.prepend(ref.current);
+ setHovering(null);
+ mouseLeave();
+ };
+
+ useEffect(() => {
+ if (link.linkType === 'failure') {
+ setPathStroke('#d9534f');
+ }
+ if (link.linkType === 'success') {
+ setPathStroke('#5cb85c');
+ }
+ if (link.linkType === 'always') {
+ setPathStroke('#337ab7');
+ }
+ }, [link.linkType]);
+
+ useEffect(() => {
+ const linePoints = getLinePoints(link, nodePositions);
+ setPathD(generateLine(linePoints));
+ }, [link, nodePositions]);
+
+ return (
+
+
+
+ mouseEnter()}
+ onMouseLeave={() => mouseLeave()}
+ opacity="0"
+ points={getLinkOverlayPoints(link, nodePositions)}
+ />
+
+ );
+}
+
+WorkflowOutputLink.propTypes = {
+ link: shape().isRequired,
+ mouseEnter: func.isRequired,
+ mouseLeave: func.isRequired,
+};
+
+export default WorkflowOutputLink;
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx
new file mode 100644
index 0000000000..1fe47c070e
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import WorkflowOutputLink from './WorkflowOutputLink';
+
+const link = {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+};
+
+const nodePositions = {
+ 1: {
+ width: 72,
+ height: 40,
+ x: 0,
+ y: 0,
+ },
+ 2: {
+ width: 180,
+ height: 60,
+ x: 282,
+ y: 40,
+ },
+};
+
+describe('WorkflowOutputLink', () => {
+ test('mounts successfully', () => {
+ const wrapper = mount(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx
new file mode 100644
index 0000000000..8b2f841a62
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx
@@ -0,0 +1,137 @@
+import React, { useContext } from 'react';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import { useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import { func, shape } from 'prop-types';
+import { StatusIcon } from '@components/Sparkline';
+import { WorkflowNodeTypeLetter } from '@components/Workflow';
+import { secondsToHHMMSS } from '@util/dates';
+import { constants as wfConstants } from '@components/Workflow/WorkflowUtils';
+
+const NodeG = styled.g`
+ cursor: ${props =>
+ props.job && props.job.type !== 'workflow_approval'
+ ? 'pointer'
+ : 'default'};
+`;
+
+const JobTopLine = styled.div`
+ align-items: center;
+ display: flex;
+ margin-top: 5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ p {
+ margin-left: 10px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+`;
+
+const Elapsed = styled.div`
+ margin-top: 5px;
+ text-align: center;
+
+ span {
+ font-size: 12px;
+ font-weight: bold;
+ background-color: #ededed;
+ padding: 3px 12px;
+ border-radius: 14px;
+ }
+`;
+
+const NodeContents = styled.div`
+ font-size: 13px;
+ padding: 0px 10px;
+`;
+
+const NodeDefaultLabel = styled.p`
+ margin-top: 20px;
+ overflow: hidden;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) {
+ const history = useHistory();
+ const { nodePositions } = useContext(WorkflowStateContext);
+ let borderColor = '#93969A';
+
+ if (node.job) {
+ if (
+ node.job.status === 'failed' ||
+ node.job.status === 'error' ||
+ node.job.status === 'canceled'
+ ) {
+ borderColor = '#d9534f';
+ }
+ if (node.job.status === 'successful' || node.job.status === 'ok') {
+ borderColor = '#5cb85c';
+ }
+ }
+
+ const handleNodeClick = () => {
+ if (node.job && node.job.type !== 'workflow_aproval') {
+ history.push(`/jobs/${node.job.id}/details`);
+ }
+ };
+
+ return (
+
+
+
+
+ {node.job ? (
+ <>
+
+ {node.job.status && }
+ {node.job.name}
+
+ {secondsToHHMMSS(node.job.elapsed)}
+ >
+ ) : (
+
+ {node.unifiedJobTemplate
+ ? node.unifiedJobTemplate.name
+ : i18n._(t`DELETED`)}
+
+ )}
+
+
+ {(node.unifiedJobTemplate || node.job) && (
+
+ )}
+
+ );
+}
+
+WorkflowOutputNode.propTypes = {
+ mouseEnter: func.isRequired,
+ mouseLeave: func.isRequired,
+ node: shape().isRequired,
+};
+
+export default withI18n()(WorkflowOutputNode);
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx
new file mode 100644
index 0000000000..198c01c3d1
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowOutputNode from './WorkflowOutputNode';
+
+const nodeWithJT = {
+ id: 2,
+ job: {
+ elapsed: 7,
+ id: 9000,
+ name: 'Automation JT',
+ status: 'successful',
+ type: 'job',
+ },
+ unifiedJobTemplate: {
+ id: 77,
+ name: 'Automation JT',
+ unified_job_type: 'job',
+ },
+};
+
+const nodeWithoutJT = {
+ id: 2,
+ job: {
+ elapsed: 7,
+ id: 9000,
+ name: 'Automation JT 2',
+ status: 'successful',
+ type: 'job',
+ },
+};
+
+const nodePositions = {
+ 1: {
+ width: 72,
+ height: 40,
+ x: 0,
+ y: 0,
+ },
+ 2: {
+ width: 180,
+ height: 60,
+ x: 282,
+ y: 40,
+ },
+};
+
+describe('WorkflowOutputNode', () => {
+ test('mounts successfully', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+ test('node contents displayed correctly when Job and Job Template exist', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.contains(Automation JT
)).toEqual(true);
+ expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07');
+ });
+ test('node contents displayed correctly when Job Template deleted', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.contains(Automation JT 2
)).toEqual(true);
+ expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07');
+ });
+ test('node contents displayed correctly when Job deleted', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.text()).toBe('DELETED');
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx
new file mode 100644
index 0000000000..26907ab3e4
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx
@@ -0,0 +1,106 @@
+import React, { useContext } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { shape } from 'prop-types';
+import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
+import { CompassIcon, WrenchIcon } from '@patternfly/react-icons';
+import { StatusIcon } from '@components/Sparkline';
+import VerticalSeparator from '@components/VerticalSeparator';
+import styled from 'styled-components';
+
+const Toolbar = styled.div`
+ align-items: center
+ border-bottom: 1px solid grey;
+ display: flex;
+ height: 56px;
+`;
+
+const ToolbarJob = styled.div`
+ align-items: center;
+ display: flex;
+`;
+
+const ToolbarActions = styled.div`
+ align-items: center;
+ display: flex;
+ flex: 1;
+ justify-content: flex-end;
+`;
+
+const Badge = styled(PFBadge)`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ margin-left: 10px;
+`;
+
+const ActionButton = styled(Button)`
+ border: none;
+ margin: 0px 6px;
+ padding: 6px 10px;
+ &:hover {
+ background-color: #0066cc;
+ color: white;
+ }
+
+ &.pf-m-active {
+ background-color: #0066cc;
+ color: white;
+ }
+`;
+
+const StatusIconWithMargin = styled(StatusIcon)`
+ margin-right: 20px;
+`;
+
+function WorkflowOutputToolbar({ i18n, job }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+
+ const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
+
+ const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
+
+ return (
+
+
+
+ {job.name}
+
+
+ {i18n._(t`Total Nodes`)}
+ {totalNodes}
+
+
+ dispatch({ type: 'TOGGLE_LEGEND' })}
+ variant="plain"
+ >
+
+
+
+
+ dispatch({ type: 'TOGGLE_TOOLS' })}
+ variant="plain"
+ >
+
+
+
+
+
+ );
+}
+
+WorkflowOutputToolbar.propTypes = {
+ job: shape().isRequired,
+};
+
+export default withI18n()(WorkflowOutputToolbar);
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx
new file mode 100644
index 0000000000..3523e08f32
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowOutputToolbar from './WorkflowOutputToolbar';
+
+let wrapper;
+const dispatch = jest.fn();
+const job = {
+ id: 1,
+ status: 'successful',
+};
+const workflowContext = {
+ nodes: [],
+ showLegend: false,
+ showTools: false,
+};
+
+describe('WorkflowOutputToolbar', () => {
+ beforeAll(() => {
+ const nodes = [
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ },
+ {
+ id: 3,
+ isDeleted: true,
+ },
+ ];
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Shows correct number of nodes', () => {
+ // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
+ expect(wrapper.find('Badge').text()).toBe('1');
+ });
+
+ test('Toggle Legend button dispatches as expected', () => {
+ wrapper.find('CompassIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_LEGEND' });
+ });
+
+ test('Toggle Tools button dispatches as expected', () => {
+ wrapper.find('WrenchIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_TOOLS' });
+ });
+});
diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/index.js b/awx/ui_next/src/screens/Job/WorkflowOutput/index.js
new file mode 100644
index 0000000000..879db49502
--- /dev/null
+++ b/awx/ui_next/src/screens/Job/WorkflowOutput/index.js
@@ -0,0 +1,5 @@
+export { default as WorkflowOutput } from './WorkflowOutput';
+export { default as WorkflowOutputGraph } from './WorkflowOutputGraph';
+export { default as WorkflowOutputLink } from './WorkflowOutputLink';
+export { default as WorkflowOutputNode } from './WorkflowOutputNode';
+export { default as WorkflowOutputToolbar } from './WorkflowOutputToolbar';
diff --git a/awx/ui_next/src/screens/Template/Templates.test.jsx b/awx/ui_next/src/screens/Template/Templates.test.jsx
index f5b2f4b300..ec7ef416e1 100644
--- a/awx/ui_next/src/screens/Template/Templates.test.jsx
+++ b/awx/ui_next/src/screens/Template/Templates.test.jsx
@@ -1,5 +1,4 @@
import React from 'react';
-
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import Templates from './Templates';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Graph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Graph.jsx
deleted file mode 100644
index dea14d0041..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Graph.jsx
+++ /dev/null
@@ -1,767 +0,0 @@
-import React, { Fragment, useEffect, useRef, useState } from 'react';
-import * as d3 from 'd3';
-import * as dagre from 'dagre';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import styled from 'styled-components';
-import WorkflowHelp from './WorkflowHelp';
-import WorkflowHelpDetails from './WorkflowHelpDetails';
-
-const SVG = styled.svg`
- display: flex;
- height: 100%;
- background-color: #f6f6f6;
-
- .WorkflowChart-tooltip {
- padding-left: 5px;
- }
-
- .WorkflowChart-action {
- height: 25px;
- width: 25px;
- font-size: 12px;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- border-radius: 2px;
- }
-
- .WorkflowChart-action:hover {
- color: white;
- }
-
- .WorkflowChart-action:not(:last-of-type) {
- margin-bottom: 5px;
- }
-
- .WorkflowChart-action--add:hover {
- background-color: #58b957;
- }
-
- .WorkflowChart-action--edit:hover,
- .WorkflowChart-action--link:hover,
- .WorkflowChart-action--details:hover {
- background-color: #0279bc;
- }
-
- .WorkflowChart-action--delete:hover {
- background-color: #d9534f;
- }
-
- .WorkflowChart-tooltipArrows {
- width: 10px;
- }
-
- .WorkflowChart-tooltipArrows--outer {
- position: absolute;
- top: calc(50% - 10px);
- width: 0;
- height: 0;
- border-right: 10px solid #c4c4c4;
- border-top: 10px solid transparent;
- border-bottom: 10px solid transparent;
- margin: auto;
- }
-
- .WorkflowChart-tooltipArrows--inner {
- position: absolute;
- top: calc(50% - 10px);
- left: 6px;
- width: 0;
- height: 0;
- border-right: 10px solid white;
- border-top: 10px solid transparent;
- border-bottom: 10px solid transparent;
- margin: auto;
- }
-
- .WorkflowChart-tooltipActions {
- background-color: white;
- border: 1px solid #c4c4c4;
- border-radius: 2px;
- padding: 5px;
- }
-
- .WorkflowChart-tooltipContents {
- display: flex;
- }
-
- .WorkflowChart-nameText {
- font-size: 13px;
- padding: 0px 10px;
- text-align: center;
- p {
- margin-top: 20px;
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- }
- }
-`;
-
-function Graph({ links, nodes, readOnly, i18n }) {
- const [helpText, setHelpText] = useState();
- const svgRef = useRef(null);
- const gRef = useRef(null);
- const nodeW = 180;
- const nodeH = 60;
- // This needs to be dynamic bc the text can be different lengths in different languages
- const rootW = 72;
- const rootH = 40;
- let currentScale = 1;
-
- // Dagre is going to shift the root node around as nodes are added/removed
- // This function ensures that the user doesn't experience that
- const normalizeY = (nodePositions, y) => {
- return y - nodePositions[1].y;
- };
-
- // This is the zoom function called by using the mousewheel/click and drag
- const zoom = () => {
- const translation = [d3.event.transform.x, d3.event.transform.y];
- d3.select(gRef.current).attr(
- 'transform',
- `translate(${translation}) scale(${d3.event.transform.k})`
- );
- currentScale = d3.event.transform.k;
- };
-
- const zoomRef = d3
- .zoom()
- .scaleExtent([0.1, 2])
- .on('zoom', zoom);
-
- // Initialize the zoom
- useEffect(() => {
- d3.select(svgRef.current).call(zoomRef);
- }, [zoomRef]);
-
- // Draw the graph - this will get triggered whenever
- // nodes or links changes
- useEffect(() => {
- const nodePositions = {};
- const line = d3
- .line()
- .x(d => {
- return d.x;
- })
- .y(d => {
- return d.y;
- });
- const getLinkOverlayPoints = d => {
- const sourceX =
- nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1;
- let sourceY =
- normalizeY(nodePositions, nodePositions[d.source.id].y) +
- nodePositions[d.source.id].height / 2;
- const targetX = nodePositions[d.target.id].x - 1;
- const targetY =
- normalizeY(nodePositions, nodePositions[d.target.id].y) +
- nodePositions[d.target.id].height / 2;
-
- // There's something off with the math on the root node...
- if (d.source.id === 1) {
- sourceY += 10;
- }
- const slope = (targetY - sourceY) / (targetX - sourceX);
- const yIntercept = targetY - slope * targetX;
- const orthogonalDistance = 8;
-
- const pt1 = [
- targetX,
- slope * targetX +
- yIntercept +
- orthogonalDistance * Math.sqrt(1 + slope * slope),
- ].join(',');
- const pt2 = [
- sourceX,
- slope * sourceX +
- yIntercept +
- orthogonalDistance * Math.sqrt(1 + slope * slope),
- ].join(',');
- const pt3 = [
- sourceX,
- slope * sourceX +
- yIntercept -
- orthogonalDistance * Math.sqrt(1 + slope * slope),
- ].join(',');
- const pt4 = [
- targetX,
- slope * targetX +
- yIntercept -
- orthogonalDistance * Math.sqrt(1 + slope * slope),
- ].join(',');
-
- return [pt1, pt2, pt3, pt4].join(' ');
- };
- const lineData = d => {
- const sourceX =
- nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1;
- let sourceY =
- normalizeY(nodePositions, nodePositions[d.source.id].y) +
- nodePositions[d.source.id].height / 2;
- const targetX = nodePositions[d.target.id].x - 1;
- const targetY =
- normalizeY(nodePositions, nodePositions[d.target.id].y) +
- nodePositions[d.target.id].height / 2;
-
- // There's something off with the math on the root node...
- if (d.source.id === 1) {
- sourceY += 10;
- }
-
- return line([
- {
- x: sourceX,
- y: sourceY,
- },
- {
- x: targetX,
- y: targetY,
- },
- ]);
- };
- const svgGroup = d3.select(gRef.current);
-
- const g = new dagre.graphlib.Graph();
-
- g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 120 });
-
- // This is needed for Dagre
- g.setDefaultEdgeLabel(() => {
- return {};
- });
-
- nodes.forEach(node => {
- if (node.id === 1) {
- g.setNode(node.id, { label: '', width: rootW, height: rootH });
- } else {
- g.setNode(node.id, { label: '', width: nodeW, height: nodeH });
- }
- });
-
- links.forEach(link => {
- g.setEdge(link.source.id, link.target.id);
- });
-
- dagre.layout(g);
-
- g.nodes().forEach(node => {
- nodePositions[node] = g.node(node);
- });
-
- const linkRefs = svgGroup
- .selectAll('.WorkflowChart-link')
- .data(links, d => {
- return `${d.source.id}-${d.target.id}`;
- });
-
- // Remove any stale links
- linkRefs.exit().remove();
-
- // Add any new links
- const linkEnter = linkRefs
- .enter()
- .append('g')
- .attr('class', 'WorkflowChart-link')
- .attr('id', d => `link-${d.source.id}-${d.target.id}`)
- .attr('stroke-width', '2px');
-
- linkEnter
- .append('polygon', 'g')
- .attr('class', 'WorkflowChart-linkOverlay')
- .attr('fill', '#E1E1E1')
- .style('opacity', '0')
- .attr('id', d => `link-${d.source.id}-${d.target.id}-overlay`)
- .attr('points', d => getLinkOverlayPoints(d))
- .on('mouseenter', d => {
- setHelpText(d);
- })
- .on('mouseleave', () => {
- setHelpText(null);
- });
-
- // Add entering links in the parent’s old position.
- linkEnter
- .insert('path', 'g')
- .attr('class', 'WorkflowChart-linkPath')
- .attr('d', d => lineData(d))
- .attr('stroke', d => {
- if (d.edgeType) {
- if (d.edgeType === 'failure') {
- return '#d9534f';
- }
- if (d.edgeType === 'success') {
- return '#5cb85c';
- }
- if (d.edgeType === 'always') {
- return '#337ab7';
- }
- }
- return '#D7D7D7';
- });
-
- linkEnter
- .append('polygon', 'g')
- .style('opacity', '0')
- .attr('points', d => getLinkOverlayPoints(d))
- .on('mouseenter', d => {
- setHelpText(d);
- })
- .on('mouseleave', () => {
- setHelpText(null);
- });
-
- linkEnter
- .on('mouseenter', d => {
- d3.select(`#link-${d.source.id}-${d.target.id}`).raise();
- d3.select(`#link-${d.source.id}-${d.target.id}-overlay`).style(
- 'opacity',
- '1'
- );
- if (!readOnly) {
- d3
- .select(`#link-${d.source.id}-${d.target.id}`)
- .append('foreignObject')
- .attr('transform', () => {
- const normalizedSourceY = normalizeY(
- nodePositions,
- nodePositions[d.source.id].y
- );
- const halfSourceHeight = nodePositions[d.source.id].height / 2;
- const normalizedTargetY = normalizeY(
- nodePositions,
- nodePositions[d.target.id].y
- );
- const halfTargetHeight = nodePositions[d.target.id].height / 2;
-
- let yPos =
- (normalizedSourceY +
- halfSourceHeight +
- normalizedTargetY +
- halfTargetHeight) /
- 2;
-
- if (d.source.id === 1) {
- yPos += 4;
- }
-
- yPos -= 34;
-
- return `translate(${(nodePositions[d.source.id].x +
- nodePositions[d.source.id].width +
- nodePositions[d.target.id].x) /
- 2}, ${yPos})`;
- })
- .attr('width', 52)
- .attr('height', 68)
- .attr('class', 'WorkflowChart-tooltip').html(`
-
- `);
-
- d3.select('#node-add-between')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`Add a new node between these two nodes`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- d3.select('#link-edit')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`Edit this link`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- }
- })
- .on('mouseleave', d => {
- d3.select(`#link-${d.source.id}-${d.target.id}`).lower();
- d3.select(`#link-${d.source.id}-${d.target.id}-overlay`).style(
- 'opacity',
- '0'
- );
- if (!readOnly) {
- linkEnter.select('.WorkflowChart-tooltip').remove();
- }
- });
-
- const nodeRefs = svgGroup
- .selectAll('.WorkflowChart-node')
- .data(nodes, d => {
- return d.id;
- });
-
- // Remove any stale nodes
- nodeRefs.exit().remove();
-
- // Add new nodes
- const nodeEnter = nodeRefs
- .enter()
- .append('g')
- .attr('class', 'WorkflowChart-node')
- .attr('id', d => `node-${d.id}`)
- .attr(
- 'transform',
- d =>
- `translate(${nodePositions[d.id].x},${normalizeY(
- nodePositions,
- nodePositions[d.id].y
- )})`
- );
-
- nodeEnter.each((node, i, nodesArray) => {
- const nodeRef = d3.select(nodesArray[i]);
- if (node.id === 1) {
- nodeRef
- .append('rect')
- .attr('width', rootW)
- .attr('height', rootH)
- .attr('y', 10)
- .attr('rx', 2)
- .attr('ry', 2)
- .attr('fill', '#0279BC')
- .attr('class', 'WorkflowChart-rootNode');
- nodeRef
- .append('text')
- .attr('x', 13)
- .attr('y', 30)
- .attr('dy', '.35em')
- .attr('fill', 'white')
- .attr('class', 'WorkflowChart-startText')
- .text('START');
-
- if (!readOnly) {
- nodeRef
- .on('mouseenter', () => {
- nodeRef
- .append('foreignObject')
- .attr('x', rootW)
- .attr('y', 11)
- .attr('width', 52)
- .attr('height', 37)
- .attr('class', 'WorkflowChart-tooltip').html(`
-
- `);
- d3.select('#node-add')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`Add a new node`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- })
- .on('mouseleave', () => {
- nodeRef.select('.WorkflowChart-tooltip').remove();
- });
- }
- } else {
- nodeRef
- .append('rect')
- .attr('width', nodeW)
- .attr('height', nodeH)
- .attr('rx', 2)
- .attr('ry', 2)
- .attr('stroke', '#93969A')
- .attr('stroke-width', '2px')
- .attr('fill', '#FFFFFF')
- .attr('class', d => {
- let classString = 'WorkflowChart-rect';
- classString += !(d.unifiedJobTemplate && d.unifiedJobTemplate.name)
- ? ' WorkflowChart-dashedNode'
- : '';
- return classString;
- });
-
- nodeRef
- .append('foreignObject')
- .attr('width', nodeW)
- .attr('height', nodeH)
- .attr('class', 'WorkflowChart-nameText')
- .html(
- d =>
- `${
- d.unifiedJobTemplate
- ? d.unifiedJobTemplate.name
- : i18n._(t`DELETED`)
- }
`
- );
-
- nodeRef
- .append('rect')
- .attr('width', nodeW)
- .attr('height', nodeH)
- .style('opacity', '0')
- .on('mouseenter', d => {
- setHelpText(d);
- })
- .on('mouseleave', () => {
- setHelpText(null);
- });
-
- nodeRef
- .append('circle')
- .attr('cy', nodeH)
- .attr('r', 10)
- .attr('class', 'WorkflowChart-nodeTypeCircle')
- .attr('fill', '#393F43')
- .style('display', d => (d.unifiedJobTemplate ? null : 'none'));
-
- nodeRef
- .append('text')
- .attr('y', nodeH)
- .attr('dy', '.35em')
- .attr('text-anchor', 'middle')
- .attr('fill', '#FFFFFF')
- .attr('class', 'WorkflowChart-nodeTypeLetter')
- .text(d => {
- let nodeTypeLetter;
- if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) {
- switch (d.unifiedJobTemplate.type) {
- case 'job_template':
- nodeTypeLetter = 'JT';
- break;
- case 'project':
- nodeTypeLetter = 'P';
- break;
- case 'inventory_source':
- nodeTypeLetter = 'I';
- break;
- case 'workflow_job_template':
- nodeTypeLetter = 'W';
- break;
- default:
- nodeTypeLetter = '';
- }
- } else if (
- d.unifiedJobTemplate &&
- d.unifiedJobTemplate.unified_job_type
- ) {
- switch (d.unifiedJobTemplate.unified_job_type) {
- case 'job':
- nodeTypeLetter = 'JT';
- break;
- case 'project_update':
- nodeTypeLetter = 'P';
- break;
- case 'inventory_update':
- nodeTypeLetter = 'I';
- break;
- case 'workflow_job':
- nodeTypeLetter = 'W';
- break;
- default:
- nodeTypeLetter = '';
- }
- }
- return nodeTypeLetter;
- })
- .style('font-size', '10px')
- .style('display', d => {
- return d.unifiedJobTemplate &&
- d.unifiedJobTemplate.type !== 'workflow_approval_template' &&
- d.unifiedJobTemplate.unified_job_type !== 'workflow_approval'
- ? null
- : 'none';
- });
-
- nodeRef
- .on('mouseenter', () => {
- nodeRef.select('.WorkflowChart-rect').attr('stroke', '#007ABC');
- nodeRef.raise();
- if (readOnly) {
- nodeRef
- .append('foreignObject')
- .attr('x', nodeW)
- .attr('y', 11)
- .attr('width', 52)
- .attr('height', 37)
- .attr('class', 'WorkflowChart-tooltip').html(`
-
- `);
- } else {
- nodeRef
- .append('foreignObject')
- .attr('x', nodeW)
- .attr('y', -49)
- .attr('width', 52)
- .attr('height', 157)
- .attr('class', 'WorkflowChart-tooltip').html(`
-
- `);
- d3.select('#node-add')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`Add a new node`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- d3.select('#node-edit')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`Edit this node`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- d3.select('#node-link')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`Link to an available node`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- d3.select('#node-delete')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`Remove this node`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- }
-
- d3.select('#node-details')
- .on('mouseenter', () => {
- setHelpText(i18n._(t`View node details`));
- })
- .on('mouseleave', () => {
- setHelpText(null);
- })
- .on('click', () => {});
- })
- .on('mouseleave', () => {
- nodeRef.select('.WorkflowChart-rect').attr('stroke', '#93969A');
- nodeRef.select('.WorkflowChart-tooltip').remove();
- });
- }
- });
-
- // This will make sure that all the link elements appear before the nodes in the dom
- svgGroup.selectAll('.WorkflowChart-node').order();
- }, [links, nodes, readOnly, i18n]);
-
- // Attempt to zoom the graph to fit the available screen space
- useEffect(() => {
- // TODO: try to figure out this start node width thing...
- const startNodeWidth = 60;
- const gDimensions = d3
- .select(gRef.current)
- .node()
- .getBoundingClientRect();
-
- const pageHeight = window.innerHeight - 50;
- const pageWidth = window.innerWidth;
-
- // For some reason the start node isn't accounted for in the width... add it
- gDimensions.width += startNodeWidth * currentScale;
-
- const scaleNeededForMaxHeight =
- pageHeight / (gDimensions.height / currentScale);
- const scaleNeededForMaxWidth =
- pageWidth / (gDimensions.width / currentScale);
- const lowerScale = Math.min(
- scaleNeededForMaxHeight,
- scaleNeededForMaxWidth
- );
-
- let scaleToFit;
- if (lowerScale < 0.5 || lowerScale > 2) {
- scaleToFit = lowerScale;
- } else {
- scaleToFit = Math.floor(lowerScale * 1000) / 1000;
- }
-
- d3.select(svgRef.current).call(
- zoomRef.transform,
- d3.zoomIdentity
- .translate(0, pageHeight / 2 - (nodeH * scaleToFit) / 2)
- .scale(scaleToFit)
- );
- // We only want this to run once (when the component mounts)
- // but this rule will throw a warning if we don't include
- // things like height, width, currentScale in the array
- // of deps. Including them will cause this hook to fire
- // as those deps change.
- // Discussion: https://github.com/facebook/create-react-app/issues/6880
- // and https://github.com/facebook/react/issues/15865 amongst others
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return (
-
- {helpText && helpText !== '' && (
-
- {typeof helpText === 'string' && {helpText}}
- {typeof helpText === 'object' && }
-
- )}
-
-
- );
-}
-
-export default withI18n()(Graph);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx
new file mode 100644
index 0000000000..9947656801
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx
@@ -0,0 +1,46 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { Button } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import AlertModal from '@components/AlertModal';
+
+function DeleteAllNodesModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ return (
+ dispatch({ type: 'DELETE_ALL_NODES' })}
+ >
+ {i18n._(t`Remove`)}
+ ,
+ ,
+ ]}
+ isOpen
+ onClose={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
+ title={i18n._(t`Remove All Nodes`)}
+ variant="danger"
+ >
+
+ {i18n._(
+ t`Are you sure you want to remove all the nodes in this workflow?`
+ )}
+
+
+ );
+}
+
+export default withI18n()(DeleteAllNodesModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.test.jsx
new file mode 100644
index 0000000000..45f426755d
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.test.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import DeleteAllNodesModal from './DeleteAllNodesModal';
+
+let wrapper;
+const dispatch = jest.fn();
+
+describe('DeleteAllNodesModal', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Delete All button dispatches as expected', () => {
+ wrapper.find('button#confirm-delete-all-nodes').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'DELETE_ALL_NODES',
+ });
+ });
+
+ test('Cancel button dispatches as expected', () => {
+ wrapper.find('button#cancel-delete-all-nodes').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'TOGGLE_DELETE_ALL_NODES_MODAL',
+ });
+ });
+
+ test('Close button dispatches as expected', () => {
+ wrapper.find('TimesIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'TOGGLE_DELETE_ALL_NODES_MODAL',
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkAddModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkAddModal.jsx
new file mode 100644
index 0000000000..c3b707fe5e
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkAddModal.jsx
@@ -0,0 +1,22 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import LinkModal from './LinkModal';
+
+function LinkAddModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ return (
+
+ {i18n._(t`Add Link`)}
+
+ }
+ onConfirm={linkType => dispatch({ type: 'CREATE_LINK', linkType })}
+ />
+ );
+}
+
+export default withI18n()(LinkAddModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkAddModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkAddModal.test.jsx
new file mode 100644
index 0000000000..bb68a69161
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkAddModal.test.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import LinkAddModal from './LinkAddModal';
+
+const dispatch = jest.fn();
+
+const workflowContext = {
+ linkToEdit: null,
+};
+
+describe('LinkAddModal', () => {
+ test('Confirm button dispatches as expected', () => {
+ const wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ wrapper.find('button#link-confirm').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'CREATE_LINK',
+ linkType: 'success',
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx
new file mode 100644
index 0000000000..216ecb71e3
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx
@@ -0,0 +1,56 @@
+import React, { Fragment, useContext } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { Button } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import AlertModal from '@components/AlertModal';
+
+function LinkDeleteModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { linkToDelete } = useContext(WorkflowStateContext);
+ return (
+ dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+ {i18n._(t`Are you sure you want to remove this link?`)}
+ {!linkToDelete.isConvergenceLink && (
+
+
+
+ {i18n._(
+ t`Removing this link will orphan the rest of the branch and cause it to be executed immediately on launch.`
+ )}
+
+
+ )}
+
+ );
+}
+
+export default withI18n()(LinkDeleteModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.test.jsx
new file mode 100644
index 0000000000..4cb5b775e6
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import LinkDeleteModal from './LinkDeleteModal';
+
+let wrapper;
+const dispatch = jest.fn();
+
+const workflowContext = {
+ linkToDelete: {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+};
+
+describe('LinkDeleteModal', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Confirm button dispatches as expected', () => {
+ wrapper.find('button#confirm-link-removal').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'DELETE_LINK',
+ });
+ });
+
+ test('Cancel button dispatches as expected', () => {
+ wrapper.find('button#cancel-link-removal').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_LINK_TO_DELETE',
+ value: null,
+ });
+ });
+
+ test('Close button dispatches as expected', () => {
+ wrapper.find('TimesIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_LINK_TO_DELETE',
+ value: null,
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkEditModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkEditModal.jsx
new file mode 100644
index 0000000000..f6f265527e
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkEditModal.jsx
@@ -0,0 +1,22 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import LinkModal from './LinkModal';
+
+function LinkEditModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ return (
+
+ {i18n._(t`Edit Link`)}
+
+ }
+ onConfirm={linkType => dispatch({ type: 'UPDATE_LINK', linkType })}
+ />
+ );
+}
+
+export default withI18n()(LinkEditModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkEditModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkEditModal.test.jsx
new file mode 100644
index 0000000000..a3fc316a69
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkEditModal.test.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import LinkEditModal from './LinkEditModal';
+
+const dispatch = jest.fn();
+
+const workflowContext = {
+ linkToEdit: {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+};
+
+describe('LinkEditModal', () => {
+ test('Confirm button dispatches as expected', () => {
+ const wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ wrapper.find('button#link-confirm').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'UPDATE_LINK',
+ linkType: 'always',
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.jsx
new file mode 100644
index 0000000000..28d3d7837e
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.jsx
@@ -0,0 +1,81 @@
+import React, { useContext, useState } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { Button, FormGroup, Modal } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { func } from 'prop-types';
+import AnsibleSelect from '@components/AnsibleSelect';
+
+function LinkModal({ header, i18n, onConfirm }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { linkToEdit } = useContext(WorkflowStateContext);
+ const [linkType, setLinkType] = useState(
+ linkToEdit ? linkToEdit.linkType : 'success'
+ );
+ return (
+ dispatch({ type: 'CANCEL_LINK_MODAL' })}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+
+ {
+ setLinkType(value);
+ }}
+ />
+
+
+ );
+}
+
+LinkModal.propTypes = {
+ onConfirm: func.isRequired,
+};
+
+export default withI18n()(LinkModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.test.jsx
new file mode 100644
index 0000000000..96bb8d92a0
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkModal.test.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import LinkModal from './LinkModal';
+
+const dispatch = jest.fn();
+const onConfirm = jest.fn();
+let wrapper;
+
+describe('LinkModal', () => {
+ describe('Adding new link', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Dropdown defaults to success when adding new link', () => {
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('success');
+ });
+
+ test('Cancel button dispatches as expected', () => {
+ wrapper.find('button#link-cancel').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'CANCEL_LINK_MODAL',
+ });
+ });
+
+ test('Close button dispatches as expected', () => {
+ wrapper.find('TimesIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'CANCEL_LINK_MODAL',
+ });
+ });
+
+ test('Confirm button passes callback correct link type after changing dropdown', () => {
+ act(() => {
+ wrapper.find('AnsibleSelect').prop('onChange')(null, 'always');
+ });
+ wrapper.find('button#link-confirm').simulate('click');
+ expect(onConfirm).toHaveBeenCalledWith('always');
+ });
+ });
+ describe('Editing existing link', () => {
+ test('Dropdown defaults to existing link type when editing link', () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('failure');
+ wrapper.unmount();
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/index.js
new file mode 100644
index 0000000000..2ec7da9d96
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/index.js
@@ -0,0 +1,4 @@
+export { default as LinkDeleteModal } from './LinkDeleteModal';
+export { default as LinkAddModal } from './LinkAddModal';
+export { default as LinkEditModal } from './LinkEditModal';
+export { default as LinkModal } from './LinkModal';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx
new file mode 100644
index 0000000000..1af0278e72
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx
@@ -0,0 +1,33 @@
+import React, { useContext } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import NodeModal from './NodeModal';
+
+function NodeAddModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { addNodeSource } = useContext(WorkflowStateContext);
+
+ const addNode = (resource, linkType) => {
+ dispatch({
+ type: 'CREATE_NODE',
+ node: {
+ linkType,
+ nodeResource: resource,
+ },
+ });
+ };
+
+ return (
+
+ );
+}
+
+export default withI18n()(NodeAddModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx
new file mode 100644
index 0000000000..bf2b3052d5
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.test.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import NodeAddModal from './NodeAddModal';
+
+const dispatch = jest.fn();
+
+const nodeResource = {
+ id: 448,
+ type: 'job_template',
+ name: 'Test JT',
+};
+
+const workflowContext = {
+ addNodeSource: 2,
+};
+
+describe('NodeAddModal', () => {
+ test('Node modal confirmation dispatches as expected', () => {
+ const wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ act(() => {
+ wrapper.find('NodeModal').prop('onSave')(nodeResource, 'success');
+ });
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'CREATE_NODE',
+ node: {
+ linkType: 'success',
+ nodeResource,
+ },
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx
new file mode 100644
index 0000000000..1032b85c91
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx
@@ -0,0 +1,56 @@
+import React, { Fragment, useContext } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { Button } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import AlertModal from '@components/AlertModal';
+
+function NodeDeleteModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { nodeToDelete } = useContext(WorkflowStateContext);
+ return (
+ dispatch({ type: 'SET_NODE_TO_DELETE', value: null })}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+ {nodeToDelete && nodeToDelete.unifiedJobTemplate ? (
+
+ {i18n._(t`Are you sure you want to remove the node below:`)}
+
+
+ {nodeToDelete.unifiedJobTemplate.name}
+
+
+ ) : (
+ {i18n._(t`Are you sure you want to remove this node?`)}
+ )}
+
+ );
+}
+
+export default withI18n()(NodeDeleteModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.test.jsx
new file mode 100644
index 0000000000..94224fa451
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.test.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import NodeDeleteModal from './NodeDeleteModal';
+
+let wrapper;
+const dispatch = jest.fn();
+
+describe('NodeDeleteModal', () => {
+ describe('Node with unified job template', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Mounts successfully', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ test('Confirm button dispatches as expected', () => {
+ wrapper.find('button#confirm-node-removal').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'DELETE_NODE',
+ });
+ });
+
+ test('Cancel button dispatches as expected', () => {
+ wrapper.find('button#cancel-node-removal').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_DELETE',
+ value: null,
+ });
+ });
+
+ test('Close button dispatches as expected', () => {
+ wrapper.find('TimesIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_DELETE',
+ value: null,
+ });
+ });
+ });
+ describe('Node without unified job template', () => {
+ test('Mounts successfully', () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ expect(wrapper.length).toBe(1);
+ wrapper.unmount();
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx
new file mode 100644
index 0000000000..28e92e63c2
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx
@@ -0,0 +1,28 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import NodeModal from './NodeModal';
+
+function NodeEditModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+
+ const updateNode = resource => {
+ dispatch({
+ type: 'UPDATE_NODE',
+ node: {
+ nodeResource: resource,
+ },
+ });
+ };
+
+ return (
+
+ );
+}
+
+export default withI18n()(NodeEditModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx
new file mode 100644
index 0000000000..f851cc1f1e
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.test.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import NodeEditModal from './NodeEditModal';
+
+const dispatch = jest.fn();
+
+const nodeResource = {
+ id: 448,
+ type: 'job_template',
+ name: 'Test JT',
+};
+
+const workflowContext = {
+ nodeToEdit: {
+ id: 4,
+ unifiedJobTemplate: {
+ id: 30,
+ name: 'Foo JT',
+ type: 'job_template',
+ },
+ },
+};
+
+describe('NodeEditModal', () => {
+ test('Node modal confirmation dispatches as expected', () => {
+ const wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ act(() => {
+ wrapper.find('NodeModal').prop('onSave')(nodeResource);
+ });
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'UPDATE_NODE',
+ node: {
+ nodeResource,
+ },
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx
new file mode 100644
index 0000000000..fafd574f6a
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx
@@ -0,0 +1,218 @@
+import React, { useContext, useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { bool, node, func } from 'prop-types';
+import {
+ Button,
+ WizardContextConsumer,
+ WizardFooter,
+} from '@patternfly/react-core';
+import Wizard from '@components/Wizard';
+import { NodeTypeStep } from './NodeTypeStep';
+import { RunStep, NodeNextButton } from '.';
+
+function NodeModal({ askLinkType, i18n, onSave, title }) {
+ const history = useHistory();
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { nodeToEdit } = useContext(WorkflowStateContext);
+
+ let defaultApprovalDescription = '';
+ let defaultApprovalName = '';
+ let defaultApprovalTimeout = 0;
+ let defaultNodeResource = null;
+ let defaultNodeType = 'job_template';
+ if (nodeToEdit && nodeToEdit.unifiedJobTemplate) {
+ if (
+ nodeToEdit &&
+ nodeToEdit.unifiedJobTemplate &&
+ (nodeToEdit.unifiedJobTemplate.type ||
+ nodeToEdit.unifiedJobTemplate.unified_job_type)
+ ) {
+ const ujtType =
+ nodeToEdit.unifiedJobTemplate.type ||
+ nodeToEdit.unifiedJobTemplate.unified_job_type;
+ switch (ujtType) {
+ case 'job_template':
+ case 'job':
+ defaultNodeType = 'job_template';
+ defaultNodeResource = nodeToEdit.unifiedJobTemplate;
+ break;
+ case 'project':
+ case 'project_update':
+ defaultNodeType = 'project_sync';
+ defaultNodeResource = nodeToEdit.unifiedJobTemplate;
+ break;
+ case 'inventory_source':
+ case 'inventory_update':
+ defaultNodeType = 'inventory_source_sync';
+ defaultNodeResource = nodeToEdit.unifiedJobTemplate;
+ break;
+ case 'workflow_job_template':
+ case 'workflow_job':
+ defaultNodeType = 'workflow_job_template';
+ defaultNodeResource = nodeToEdit.unifiedJobTemplate;
+ break;
+ case 'workflow_approval_template':
+ case 'workflow_approval':
+ defaultNodeType = 'approval';
+ defaultApprovalName = nodeToEdit.unifiedJobTemplate.name;
+ defaultApprovalDescription =
+ nodeToEdit.unifiedJobTemplate.description;
+ defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout;
+ break;
+ default:
+ }
+ }
+ }
+ const [approvalDescription, setApprovalDescription] = useState(
+ defaultApprovalDescription
+ );
+ const [approvalName, setApprovalName] = useState(defaultApprovalName);
+ const [approvalTimeout, setApprovalTimeout] = useState(
+ defaultApprovalTimeout
+ );
+ const [linkType, setLinkType] = useState('success');
+ const [nodeResource, setNodeResource] = useState(defaultNodeResource);
+ const [nodeType, setNodeType] = useState(defaultNodeType);
+ const [triggerNext, setTriggerNext] = useState(0);
+
+ const clearQueryParams = () => {
+ const parts = history.location.search.replace(/^\?/, '').split('&');
+ const otherParts = parts.filter(param =>
+ /^!(job_templates\.|projects\.|inventory_sources\.|workflow_job_templates\.)/.test(
+ param
+ )
+ );
+ history.replace(`${history.location.pathname}?${otherParts.join('&')}`);
+ };
+
+ const handleSaveNode = () => {
+ clearQueryParams();
+
+ const resource =
+ nodeType === 'approval'
+ ? {
+ description: approvalDescription,
+ name: approvalName,
+ timeout: approvalTimeout,
+ type: 'workflow_approval_template',
+ }
+ : nodeResource;
+
+ onSave(resource, askLinkType ? linkType : null);
+ };
+
+ const handleCancel = () => {
+ clearQueryParams();
+ dispatch({ type: 'CANCEL_NODE_MODAL' });
+ };
+
+ const handleNodeTypeChange = newNodeType => {
+ setNodeType(newNodeType);
+ setNodeResource(null);
+ setApprovalName('');
+ setApprovalDescription('');
+ setApprovalTimeout(0);
+ };
+
+ const steps = [
+ ...(askLinkType
+ ? [
+ {
+ name: i18n._(t`Run Type`),
+ key: 'run_type',
+ component: (
+
+ ),
+ enableNext: linkType !== null,
+ },
+ ]
+ : []),
+ {
+ name: i18n._(t`Node Type`),
+ key: 'node_resource',
+ enableNext:
+ (nodeType !== 'approval' && nodeResource !== null) ||
+ (nodeType === 'approval' && approvalName !== ''),
+ component: (
+
+ ),
+ },
+ ];
+
+ steps.forEach((step, n) => {
+ step.id = n + 1;
+ });
+
+ const CustomFooter = (
+
+
+ {({ activeStep, onNext, onBack }) => (
+ <>
+ setTriggerNext(triggerNext + 1)}
+ buttonText={
+ activeStep.key === 'node_resource'
+ ? i18n._(t`Save`)
+ : i18n._(t`Next`)
+ }
+ />
+ {activeStep && activeStep.id !== 1 && (
+
+ )}
+
+ >
+ )}
+
+
+ );
+
+ const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title;
+
+ return (
+
+ );
+}
+
+NodeModal.propTypes = {
+ askLinkType: bool.isRequired,
+ onSave: func.isRequired,
+ title: node.isRequired,
+};
+
+export default withI18n()(NodeModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx
new file mode 100644
index 0000000000..5cd9840b57
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.test.jsx
@@ -0,0 +1,414 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import {
+ InventorySourcesAPI,
+ JobTemplatesAPI,
+ ProjectsAPI,
+ WorkflowJobTemplatesAPI,
+} from '@api';
+import NodeModal from './NodeModal';
+
+jest.mock('@api/models/InventorySources');
+jest.mock('@api/models/JobTemplates');
+jest.mock('@api/models/Projects');
+jest.mock('@api/models/WorkflowJobTemplates');
+
+let wrapper;
+const dispatch = jest.fn();
+const onSave = jest.fn();
+
+describe('NodeModal', () => {
+ beforeAll(() => {
+ JobTemplatesAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Job Template',
+ type: 'job_template',
+ url: '/api/v2/job_templates/1',
+ },
+ ],
+ },
+ });
+ ProjectsAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Project',
+ type: 'project',
+ url: '/api/v2/projects/1',
+ },
+ ],
+ },
+ });
+ InventorySourcesAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Inventory Source',
+ type: 'inventory_source',
+ url: '/api/v2/inventory_sources/1',
+ },
+ ],
+ },
+ });
+ WorkflowJobTemplatesAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Workflow Job Template',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/1',
+ },
+ ],
+ },
+ });
+ });
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+ describe('Add new node', () => {
+ beforeEach(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Can successfully create a new job template node', async () => {
+ act(() => {
+ wrapper.find('#link-type-always').simulate('click');
+ });
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ wrapper.update();
+ wrapper.find('DataListRadio').simulate('click');
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ expect(onSave).toBeCalledWith(
+ {
+ id: 1,
+ name: 'Test Job Template',
+ type: 'job_template',
+ url: '/api/v2/job_templates/1',
+ },
+ 'always'
+ );
+ });
+
+ test('Can successfully create a new project sync node', async () => {
+ act(() => {
+ wrapper.find('#link-type-failure').simulate('click');
+ });
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('AnsibleSelect').prop('onChange')(null, 'project_sync');
+ });
+ wrapper.update();
+ wrapper.find('DataListRadio').simulate('click');
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ expect(onSave).toBeCalledWith(
+ {
+ id: 1,
+ name: 'Test Project',
+ type: 'project',
+ url: '/api/v2/projects/1',
+ },
+ 'failure'
+ );
+ });
+
+ test('Can successfully create a new inventory source sync node', async () => {
+ act(() => {
+ wrapper.find('#link-type-failure').simulate('click');
+ });
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('AnsibleSelect').prop('onChange')(
+ null,
+ 'inventory_source_sync'
+ );
+ });
+ wrapper.update();
+ wrapper.find('DataListRadio').simulate('click');
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ expect(onSave).toBeCalledWith(
+ {
+ id: 1,
+ name: 'Test Inventory Source',
+ type: 'inventory_source',
+ url: '/api/v2/inventory_sources/1',
+ },
+ 'failure'
+ );
+ });
+
+ test('Can successfully create a new workflow job template node', async () => {
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('AnsibleSelect').prop('onChange')(
+ null,
+ 'workflow_job_template'
+ );
+ });
+ wrapper.update();
+ wrapper.find('DataListRadio').simulate('click');
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ expect(onSave).toBeCalledWith(
+ {
+ id: 1,
+ name: 'Test Workflow Job Template',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/1',
+ },
+ 'success'
+ );
+ });
+
+ test('Can successfully create a new approval template node', async () => {
+ act(() => {
+ wrapper.find('#link-type-always').simulate('click');
+ });
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('AnsibleSelect').prop('onChange')(null, 'approval');
+ });
+ wrapper.update();
+
+ await act(async () => {
+ wrapper.find('input#approval-name').simulate('change', {
+ target: { value: 'Test Approval', name: 'name' },
+ });
+ wrapper.find('input#approval-description').simulate('change', {
+ target: { value: 'Test Approval Description', name: 'description' },
+ });
+ wrapper.find('input#approval-timeout-minutes').simulate('change', {
+ target: { value: 5, name: 'timeoutMinutes' },
+ });
+ });
+
+ // Updating the minutes and seconds is split to avoid a race condition.
+ // They both update the same state variable in the parent so triggering
+ // them syncronously creates flakey test results.
+ await act(async () => {
+ wrapper.find('input#approval-timeout-seconds').simulate('change', {
+ target: { value: 30, name: 'timeoutSeconds' },
+ });
+ });
+ wrapper.update();
+
+ expect(wrapper.find('input#approval-name').prop('value')).toBe(
+ 'Test Approval'
+ );
+ expect(wrapper.find('input#approval-description').prop('value')).toBe(
+ 'Test Approval Description'
+ );
+ expect(wrapper.find('input#approval-timeout-minutes').prop('value')).toBe(
+ 5
+ );
+ expect(wrapper.find('input#approval-timeout-seconds').prop('value')).toBe(
+ 30
+ );
+
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ expect(onSave).toBeCalledWith(
+ {
+ description: 'Test Approval Description',
+ name: 'Test Approval',
+ timeout: 330,
+ type: 'workflow_approval_template',
+ },
+ 'always'
+ );
+ });
+
+ test('Cancel button dispatches as expected', () => {
+ wrapper.find('button#cancel-node-modal').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'CANCEL_NODE_MODAL',
+ });
+ });
+ });
+ describe('Edit existing node', () => {
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ test('Can successfully change project sync node to workflow approval node', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync');
+ await act(async () => {
+ wrapper.find('AnsibleSelect').prop('onChange')(null, 'approval');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('input#approval-name').simulate('change', {
+ target: { value: 'Test Approval', name: 'name' },
+ });
+ wrapper.find('input#approval-description').simulate('change', {
+ target: { value: 'Test Approval Description', name: 'description' },
+ });
+ wrapper.find('input#approval-timeout-minutes').simulate('change', {
+ target: { value: 5, name: 'timeoutMinutes' },
+ });
+ });
+
+ // Updating the minutes and seconds is split to avoid a race condition.
+ // They both update the same state variable in the parent so triggering
+ // them syncronously creates flakey test results.
+ await act(async () => {
+ wrapper.find('input#approval-timeout-seconds').simulate('change', {
+ target: { value: 30, name: 'timeoutSeconds' },
+ });
+ });
+ wrapper.update();
+
+ expect(wrapper.find('input#approval-name').prop('value')).toBe(
+ 'Test Approval'
+ );
+ expect(wrapper.find('input#approval-description').prop('value')).toBe(
+ 'Test Approval Description'
+ );
+ expect(wrapper.find('input#approval-timeout-minutes').prop('value')).toBe(
+ 5
+ );
+ expect(wrapper.find('input#approval-timeout-seconds').prop('value')).toBe(
+ 30
+ );
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+
+ expect(onSave).toBeCalledWith(
+ {
+ description: 'Test Approval Description',
+ name: 'Test Approval',
+ timeout: 330,
+ type: 'workflow_approval_template',
+ },
+ null
+ );
+ });
+
+ test('Can successfully change approval node to workflow job template node', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval');
+ await act(async () => {
+ wrapper.find('AnsibleSelect').prop('onChange')(
+ null,
+ 'workflow_job_template'
+ );
+ });
+ wrapper.update();
+ wrapper.find('DataListRadio').simulate('click');
+ await act(async () => {
+ wrapper.find('button#next-node-modal').simulate('click');
+ });
+ expect(onSave).toBeCalledWith(
+ {
+ id: 1,
+ name: 'Test Workflow Job Template',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/1',
+ },
+ null
+ );
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx
new file mode 100644
index 0000000000..43ae2b681e
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx
@@ -0,0 +1,40 @@
+import React, { useEffect } from 'react';
+import { func, number, shape, string } from 'prop-types';
+import { Button } from '@patternfly/react-core';
+
+function NodeNextButton({
+ activeStep,
+ buttonText,
+ onClick,
+ onNext,
+ triggerNext,
+}) {
+ useEffect(() => {
+ if (!triggerNext) {
+ return;
+ }
+ onNext();
+ }, [onNext, triggerNext]);
+
+ return (
+
+ );
+}
+
+NodeNextButton.propTypes = {
+ activeStep: shape().isRequired,
+ buttonText: string.isRequired,
+ onClick: func.isRequired,
+ onNext: func.isRequired,
+ triggerNext: number.isRequired,
+};
+
+export default NodeNextButton;
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.test.jsx
new file mode 100644
index 0000000000..8a254db9b1
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.test.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import NodeNextButton from './NodeNextButton';
+
+const activeStep = {
+ name: 'Node Type',
+ key: 'node_resource',
+ enableNext: true,
+ component: {},
+ id: 1,
+};
+const buttonText = 'Next';
+const onClick = jest.fn();
+const onNext = jest.fn();
+const triggerNext = 0;
+let wrapper;
+
+describe('NodeNextButton', () => {
+ beforeAll(() => {
+ wrapper = mount(
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Button text matches', () => {
+ expect(wrapper.find('button').text()).toBe(buttonText);
+ });
+
+ test('Clicking button makes expected callback', () => {
+ wrapper.find('button').simulate('click');
+ expect(onClick).toBeCalledWith(activeStep);
+ });
+
+ test('onNext triggered when triggerNext counter incrimented', () => {
+ wrapper.setProps({ triggerNext: 1 });
+ expect(onNext).toBeCalled();
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx
new file mode 100644
index 0000000000..a4abf420c5
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx
@@ -0,0 +1,114 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { func, shape } from 'prop-types';
+import { InventorySourcesAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('inventory_sources', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function InventorySourcesList({
+ history,
+ i18n,
+ nodeResource,
+ onUpdateNodeResource,
+}) {
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [inventorySources, setInventorySources] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setInventorySources([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await InventorySourcesAPI.read(params);
+ setInventorySources(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ onUpdateNodeResource(row)}
+ qsConfig={QS_CONFIG}
+ showPageSizeOptions={false}
+ renderItem={item => (
+ onUpdateNodeResource(item)}
+ onDeselect={() => onUpdateNodeResource(null)}
+ isRadio
+ />
+ )}
+ renderToolbar={props => }
+ toolbarSearchColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ isDefault: true,
+ },
+ {
+ name: i18n._(t`Source`),
+ key: 'source',
+ options: [
+ [``, i18n._(t`Manual`)],
+ [`file`, i18n._(t`File, Directory or Script`)],
+ [`scm`, i18n._(t`Sourced from a Project`)],
+ [`ec2`, i18n._(t`Amazon EC2`)],
+ [`gce`, i18n._(t`Google Compute Engine`)],
+ [`azure_rm`, i18n._(t`Microsoft Azure Resource Manager`)],
+ [`vmware`, i18n._(t`VMware vCenter`)],
+ [`satellite6`, i18n._(t`Red Hat Satellite 6`)],
+ [`cloudforms`, i18n._(t`Red Hat CloudForms`)],
+ [`openstack`, i18n._(t`OpenStack`)],
+ [`rhv`, i18n._(t`Red Hat Virtualization`)],
+ [`tower`, i18n._(t`Ansible Tower`)],
+ [`custom`, i18n._(t`Custom Script`)],
+ ],
+ },
+ ]}
+ toolbarSortColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ },
+ ]}
+ />
+ );
+}
+
+InventorySourcesList.propTypes = {
+ nodeResource: shape(),
+ onUpdateNodeResource: func.isRequired,
+};
+
+InventorySourcesList.defaultProps = {
+ nodeResource: null,
+};
+
+export default withI18n()(withRouter(InventorySourcesList));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.test.jsx
new file mode 100644
index 0000000000..d09cf92ae1
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.test.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { InventorySourcesAPI } from '@api';
+import InventorySourcesList from './InventorySourcesList';
+
+jest.mock('@api/models/InventorySources');
+
+const nodeResource = {
+ id: 1,
+ name: 'Test Inventory Source',
+ unified_job_type: 'workflow_approval',
+};
+const onUpdateNodeResource = jest.fn();
+
+describe('InventorySourcesList', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
+ InventorySourcesAPI.read.mockResolvedValueOnce({
+ data: {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'Test Inventory Source',
+ type: 'inventory_source',
+ url: '/api/v2/inventory_sources/1',
+ },
+ {
+ id: 2,
+ name: 'Test Inventory Source 2',
+ type: 'inventory_source',
+ url: '/api/v2/inventory_sources/2',
+ },
+ ],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('CheckboxListItem[name="Test Inventory Source"]').props()
+ .isSelected
+ ).toBe(true);
+ expect(
+ wrapper.find('CheckboxListItem[name="Test Inventory Source 2"]').props()
+ .isSelected
+ ).toBe(false);
+ wrapper
+ .find('CheckboxListItem[name="Test Inventory Source 2"]')
+ .simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 2,
+ name: 'Test Inventory Source 2',
+ type: 'inventory_source',
+ url: '/api/v2/inventory_sources/2',
+ });
+ });
+ test('Error shown when read() request errors', async () => {
+ InventorySourcesAPI.read.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx
new file mode 100644
index 0000000000..bf7695f966
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx
@@ -0,0 +1,109 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { func, shape } from 'prop-types';
+import { JobTemplatesAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('job_templates', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function JobTemplatesList({
+ i18n,
+ history,
+ nodeResource,
+ onUpdateNodeResource,
+}) {
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [jobTemplates, setJobTemplates] = useState([]);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setJobTemplates([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await JobTemplatesAPI.read(params, {
+ role_level: 'execute_role',
+ });
+ setJobTemplates(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ onUpdateNodeResource(row)}
+ qsConfig={QS_CONFIG}
+ renderItem={item => (
+ onUpdateNodeResource(item)}
+ onDeselect={() => onUpdateNodeResource(null)}
+ isRadio
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ toolbarSearchColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ isDefault: true,
+ },
+ {
+ name: i18n._(t`Playbook name`),
+ key: 'playbook',
+ },
+ {
+ name: i18n._(t`Created By (Username)`),
+ key: 'created_by__username',
+ },
+ {
+ name: i18n._(t`Modified By (Username)`),
+ key: 'modified_by__username',
+ },
+ ]}
+ toolbarSortColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ },
+ ]}
+ />
+ );
+}
+
+JobTemplatesList.propTypes = {
+ nodeResource: shape(),
+ onUpdateNodeResource: func.isRequired,
+};
+
+JobTemplatesList.defaultProps = {
+ nodeResource: null,
+};
+
+export default withI18n()(withRouter(JobTemplatesList));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx
new file mode 100644
index 0000000000..d5d8097313
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.test.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { JobTemplatesAPI } from '@api';
+import JobTemplatesList from './JobTemplatesList';
+
+jest.mock('@api/models/JobTemplates');
+
+const nodeResource = {
+ id: 1,
+ name: 'Test Job Template',
+ unified_job_type: 'job',
+};
+const onUpdateNodeResource = jest.fn();
+
+describe('JobTemplatesList', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
+ JobTemplatesAPI.read.mockResolvedValueOnce({
+ data: {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'Test Job Template',
+ type: 'job_template',
+ url: '/api/v2/job_templates/1',
+ },
+ {
+ id: 2,
+ name: 'Test Job Template 2',
+ type: 'job_template',
+ url: '/api/v2/job_templates/2',
+ },
+ ],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
+ .isSelected
+ ).toBe(true);
+ expect(
+ wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
+ .isSelected
+ ).toBe(false);
+ wrapper
+ .find('CheckboxListItem[name="Test Job Template 2"]')
+ .simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 2,
+ name: 'Test Job Template 2',
+ type: 'job_template',
+ url: '/api/v2/job_templates/2',
+ });
+ });
+ test('Error shown when read() request errors', async () => {
+ JobTemplatesAPI.read.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx
new file mode 100644
index 0000000000..a8a3f73fee
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx
@@ -0,0 +1,279 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t, Trans } from '@lingui/macro';
+import { func, number, shape, string } from 'prop-types';
+import styled from 'styled-components';
+import { Formik, Field } from 'formik';
+import { Form, FormGroup, TextInput } from '@patternfly/react-core';
+import FormRow from '@components/FormRow';
+import AnsibleSelect from '@components/AnsibleSelect';
+import VerticalSeperator from '@components/VerticalSeparator';
+import InventorySourcesList from './InventorySourcesList';
+import JobTemplatesList from './JobTemplatesList';
+import ProjectsList from './ProjectsList';
+import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
+
+const Divider = styled.div`
+ height: 1px;
+ background-color: var(--pf-global--Color--light-300);
+ border: 0;
+ flex-shrink: 0;
+`;
+
+const TimeoutInput = styled(TextInput)`
+ width: 200px;
+ :not(:first-of-type) {
+ margin-left: 20px;
+ }
+`;
+TimeoutInput.displayName = 'TimeoutInput';
+
+const TimeoutLabel = styled.p`
+ margin-left: 10px;
+`;
+
+function NodeTypeStep({
+ description,
+ i18n,
+ name,
+ nodeResource,
+ nodeType,
+ timeout,
+ onUpdateDescription,
+ onUpdateName,
+ onUpdateNodeResource,
+ onUpdateNodeType,
+ onUpdateTimeout,
+}) {
+ return (
+ <>
+
+
{i18n._(t`Node Type`)}
+
+
+
{
+ onUpdateNodeType(val);
+ }}
+ />
+
+
+
+ {nodeType === 'job_template' && (
+
+ )}
+ {nodeType === 'project_sync' && (
+
+ )}
+ {nodeType === 'inventory_source_sync' && (
+
+ )}
+ {nodeType === 'workflow_job_template' && (
+
+ )}
+ {nodeType === 'approval' && (
+
+ {() => (
+
+ )}
+
+ )}
+ >
+ );
+}
+
+NodeTypeStep.propTypes = {
+ description: string,
+ name: string,
+ nodeResource: shape(),
+ nodeType: string,
+ timeout: number,
+ onUpdateDescription: func.isRequired,
+ onUpdateName: func.isRequired,
+ onUpdateNodeResource: func.isRequired,
+ onUpdateNodeType: func.isRequired,
+ onUpdateTimeout: func.isRequired,
+};
+
+NodeTypeStep.defaultProps = {
+ description: '',
+ name: '',
+ nodeResource: null,
+ nodeType: 'job_template',
+ timeout: 0,
+};
+
+export default withI18n()(NodeTypeStep);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx
new file mode 100644
index 0000000000..c4a1306fb4
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.test.jsx
@@ -0,0 +1,239 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import {
+ InventorySourcesAPI,
+ JobTemplatesAPI,
+ ProjectsAPI,
+ WorkflowJobTemplatesAPI,
+} from '@api';
+import NodeTypeStep from './NodeTypeStep';
+
+jest.mock('@api/models/InventorySources');
+jest.mock('@api/models/JobTemplates');
+jest.mock('@api/models/Projects');
+jest.mock('@api/models/WorkflowJobTemplates');
+
+const onUpdateDescription = jest.fn();
+const onUpdateName = jest.fn();
+const onUpdateNodeResource = jest.fn();
+const onUpdateNodeType = jest.fn();
+const onUpdateTimeout = jest.fn();
+
+describe('NodeTypeStep', () => {
+ beforeAll(() => {
+ JobTemplatesAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Job Template',
+ type: 'job_template',
+ url: '/api/v2/job_templates/1',
+ },
+ ],
+ },
+ });
+ ProjectsAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Project',
+ type: 'project',
+ url: '/api/v2/projects/1',
+ },
+ ],
+ },
+ });
+ InventorySourcesAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Inventory Source',
+ type: 'inventory_source',
+ url: '/api/v2/inventory_sources/1',
+ },
+ ],
+ },
+ });
+ WorkflowJobTemplatesAPI.read.mockResolvedValue({
+ data: {
+ count: 1,
+ results: [
+ {
+ id: 1,
+ name: 'Test Workflow Job Template',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/1',
+ },
+ ],
+ },
+ });
+ });
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+ test('It shows the job template list by default', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template');
+ expect(wrapper.find('JobTemplatesList').length).toBe(1);
+ wrapper.find('DataListRadio').simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 1,
+ name: 'Test Job Template',
+ type: 'job_template',
+ url: '/api/v2/job_templates/1',
+ });
+ });
+ test('It shows the project list when node type is project sync', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync');
+ expect(wrapper.find('ProjectsList').length).toBe(1);
+ wrapper.find('DataListRadio').simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 1,
+ name: 'Test Project',
+ type: 'project',
+ url: '/api/v2/projects/1',
+ });
+ });
+ test('It shows the inventory source list when node type is inventory source sync', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
+ 'inventory_source_sync'
+ );
+ expect(wrapper.find('InventorySourcesList').length).toBe(1);
+ wrapper.find('DataListRadio').simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 1,
+ name: 'Test Inventory Source',
+ type: 'inventory_source',
+ url: '/api/v2/inventory_sources/1',
+ });
+ });
+ test('It shows the workflow job template list when node type is workflow job template', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
+ 'workflow_job_template'
+ );
+ expect(wrapper.find('WorkflowJobTemplatesList').length).toBe(1);
+ wrapper.find('DataListRadio').simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 1,
+ name: 'Test Workflow Job Template',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/1',
+ });
+ });
+ test('It shows the approval form fields when node type is approval', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval');
+ expect(wrapper.find('input#approval-name').length).toBe(1);
+ expect(wrapper.find('input#approval-description').length).toBe(1);
+ expect(wrapper.find('input#approval-timeout-minutes').length).toBe(1);
+ expect(wrapper.find('input#approval-timeout-seconds').length).toBe(1);
+
+ await act(async () => {
+ wrapper.find('input#approval-name').simulate('change', {
+ target: { value: 'Test Approval', name: 'name' },
+ });
+ });
+
+ expect(onUpdateName).toHaveBeenCalledWith('Test Approval');
+
+ await act(async () => {
+ wrapper.find('input#approval-description').simulate('change', {
+ target: { value: 'Test Approval Description', name: 'description' },
+ });
+ });
+
+ expect(onUpdateDescription).toHaveBeenCalledWith(
+ 'Test Approval Description'
+ );
+
+ await act(async () => {
+ wrapper.find('input#approval-timeout-minutes').simulate('change', {
+ target: { value: 5, name: 'timeoutMinutes' },
+ });
+ });
+
+ expect(onUpdateTimeout).toHaveBeenCalledWith(300);
+
+ await act(async () => {
+ wrapper.find('input#approval-timeout-seconds').simulate('change', {
+ target: { value: 30, name: 'timeoutSeconds' },
+ });
+ });
+
+ expect(onUpdateTimeout).toHaveBeenCalledWith(330);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx
new file mode 100644
index 0000000000..4ba28da12f
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx
@@ -0,0 +1,113 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { func, shape } from 'prop-types';
+import { ProjectsAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('projects', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function ProjectsList({ history, i18n, nodeResource, onUpdateNodeResource }) {
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [projects, setProjects] = useState([]);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setProjects([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await ProjectsAPI.read(params);
+ setProjects(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ onUpdateNodeResource(row)}
+ qsConfig={QS_CONFIG}
+ renderItem={item => (
+ onUpdateNodeResource(item)}
+ onDeselect={() => onUpdateNodeResource(null)}
+ isRadio
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ toolbarSearchColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ isDefault: true,
+ },
+ {
+ name: i18n._(t`Type`),
+ key: 'type',
+ options: [
+ [``, i18n._(t`Manual`)],
+ [`git`, i18n._(t`Git`)],
+ [`hg`, i18n._(t`Mercurial`)],
+ [`svn`, i18n._(t`Subversion`)],
+ [`insights`, i18n._(t`Red Hat Insights`)],
+ ],
+ },
+ {
+ name: i18n._(t`SCM URL`),
+ key: 'scm_url',
+ },
+ {
+ name: i18n._(t`Modified By (Username)`),
+ key: 'modified_by__username',
+ },
+ {
+ name: i18n._(t`Created By (Username)`),
+ key: 'created_by__username',
+ },
+ ]}
+ toolbarSortColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ },
+ ]}
+ />
+ );
+}
+
+ProjectsList.propTypes = {
+ nodeResource: shape(),
+ onUpdateNodeResource: func.isRequired,
+};
+
+ProjectsList.defaultProps = {
+ nodeResource: null,
+};
+
+export default withI18n()(withRouter(ProjectsList));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.test.jsx
new file mode 100644
index 0000000000..be4b588ce2
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.test.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { ProjectsAPI } from '@api';
+import ProjectsList from './ProjectsList';
+
+jest.mock('@api/models/Projects');
+
+const nodeResource = {
+ id: 1,
+ name: 'Test Project',
+ unified_job_type: 'project_update',
+};
+const onUpdateNodeResource = jest.fn();
+
+describe('ProjectsList', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
+ ProjectsAPI.read.mockResolvedValueOnce({
+ data: {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'Test Project',
+ type: 'project',
+ url: '/api/v2/projects/1',
+ },
+ {
+ id: 2,
+ name: 'Test Project 2',
+ type: 'project',
+ url: '/api/v2/projects/2',
+ },
+ ],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('CheckboxListItem[name="Test Project"]').props().isSelected
+ ).toBe(true);
+ expect(
+ wrapper.find('CheckboxListItem[name="Test Project 2"]').props().isSelected
+ ).toBe(false);
+ wrapper.find('CheckboxListItem[name="Test Project 2"]').simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 2,
+ name: 'Test Project 2',
+ type: 'project',
+ url: '/api/v2/projects/2',
+ });
+ });
+ test('Error shown when read() request errors', async () => {
+ ProjectsAPI.read.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx
new file mode 100644
index 0000000000..05a0d15e9c
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx
@@ -0,0 +1,113 @@
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { func, shape } from 'prop-types';
+import { WorkflowJobTemplatesAPI } from '@api';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import PaginatedDataList from '@components/PaginatedDataList';
+import DataListToolbar from '@components/DataListToolbar';
+import CheckboxListItem from '@components/CheckboxListItem';
+
+const QS_CONFIG = getQSConfig('workflow_job_templates', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function WorkflowJobTemplatesList({
+ history,
+ i18n,
+ nodeResource,
+ onUpdateNodeResource,
+}) {
+ const [count, setCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]);
+
+ useEffect(() => {
+ (async () => {
+ setIsLoading(true);
+ setWorkflowJobTemplates([]);
+ setCount(0);
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ try {
+ const { data } = await WorkflowJobTemplatesAPI.read(params, {
+ role_level: 'execute_role',
+ });
+ setWorkflowJobTemplates(data.results);
+ setCount(data.count);
+ } catch (err) {
+ setError(err);
+ } finally {
+ setIsLoading(false);
+ }
+ })();
+ }, [history.location]);
+
+ return (
+ onUpdateNodeResource(row)}
+ qsConfig={QS_CONFIG}
+ renderItem={item => (
+ onUpdateNodeResource(item)}
+ onDeselect={() => onUpdateNodeResource(null)}
+ isRadio
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ toolbarSearchColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ isDefault: true,
+ },
+ {
+ name: i18n._(t`Organization (Name)`),
+ key: 'organization__name',
+ },
+ {
+ name: i18n._(t`Inventory (Name)`),
+ key: 'inventory__name',
+ },
+ {
+ name: i18n._(t`Created By (Username)`),
+ key: 'created_by__username',
+ },
+ {
+ name: i18n._(t`Modified By (Username)`),
+ key: 'modified_by__username',
+ },
+ ]}
+ toolbarSortColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ },
+ ]}
+ />
+ );
+}
+
+WorkflowJobTemplatesList.propTypes = {
+ nodeResource: shape(),
+ onUpdateNodeResource: func.isRequired,
+};
+
+WorkflowJobTemplatesList.defaultProps = {
+ nodeResource: null,
+};
+
+export default withI18n()(withRouter(WorkflowJobTemplatesList));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.test.jsx
new file mode 100644
index 0000000000..69b63dd7d9
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.test.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { WorkflowJobTemplatesAPI } from '@api';
+import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
+
+jest.mock('@api/models/WorkflowJobTemplates');
+
+const nodeResource = {
+ id: 1,
+ name: 'Test Workflow Job Template',
+ unified_job_type: 'workflow_job',
+};
+const onUpdateNodeResource = jest.fn();
+
+describe('WorkflowJobTemplatesList', () => {
+ let wrapper;
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
+ WorkflowJobTemplatesAPI.read.mockResolvedValueOnce({
+ data: {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'Test Workflow Job Template',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/1',
+ },
+ {
+ id: 2,
+ name: 'Test Workflow Job Template 2',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/2',
+ },
+ ],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper
+ .find('CheckboxListItem[name="Test Workflow Job Template"]')
+ .props().isSelected
+ ).toBe(true);
+ expect(
+ wrapper
+ .find('CheckboxListItem[name="Test Workflow Job Template 2"]')
+ .props().isSelected
+ ).toBe(false);
+ wrapper
+ .find('CheckboxListItem[name="Test Workflow Job Template 2"]')
+ .simulate('click');
+ expect(onUpdateNodeResource).toHaveBeenCalledWith({
+ id: 2,
+ name: 'Test Workflow Job Template 2',
+ type: 'workflow_job_template',
+ url: '/api/v2/workflow_job_templates/2',
+ });
+ });
+ test('Error shown when read() request errors', async () => {
+ WorkflowJobTemplatesAPI.read.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/index.js
new file mode 100644
index 0000000000..7864636d38
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/index.js
@@ -0,0 +1,7 @@
+export { default as InventorySourcesList } from './InventorySourcesList';
+export { default as JobTemplatesList } from './JobTemplatesList';
+export { default as NodeTypeStep } from './NodeTypeStep';
+export { default as ProjectsList } from './ProjectsList';
+export {
+ default as WorkflowJobTemplatesList,
+} from './WorkflowJobTemplatesList';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx
new file mode 100644
index 0000000000..27000d1185
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx
@@ -0,0 +1,21 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { Modal } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+
+function NodeViewModal({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ return (
+ dispatch({ type: 'SET_NODE_TO_VIEW', value: null })}
+ >
+ Coming soon :)
+
+ );
+}
+
+export default withI18n()(NodeViewModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx
new file mode 100644
index 0000000000..fa1a9fc82f
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.test.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import NodeViewModal from './NodeViewModal';
+
+let wrapper;
+const dispatch = jest.fn();
+
+describe('NodeViewModal', () => {
+ test('Close button dispatches as expected', () => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ wrapper.find('TimesIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_VIEW',
+ value: null,
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx
new file mode 100644
index 0000000000..1555f04754
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import { func, string } from 'prop-types';
+import { Title } from '@patternfly/react-core';
+import SelectableCard from '@components/SelectableCard';
+
+const Grid = styled.div`
+ display: grid;
+ grid-auto-rows: 100px;
+ grid-gap: 20px;
+ grid-template-columns: 33% 33% 33%;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ margin: 20px 0px;
+ width: 100%;
+`;
+
+function RunStep({ i18n, linkType, onUpdateLinkType }) {
+ return (
+ <>
+
+ {i18n._(t`Run`)}
+
+
+ {i18n._(
+ t`Specify the conditions under which this node should be executed`
+ )}
+
+
+ onUpdateLinkType('success')}
+ />
+ onUpdateLinkType('failure')}
+ />
+ onUpdateLinkType('always')}
+ />
+
+ >
+ );
+}
+
+RunStep.propTypes = {
+ linkType: string.isRequired,
+ onUpdateLinkType: func.isRequired,
+};
+
+export default withI18n()(RunStep);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx
new file mode 100644
index 0000000000..84f1cec07f
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.test.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import RunStep from './RunStep';
+
+let wrapper;
+const linkType = 'always';
+const onUpdateLinkType = jest.fn();
+
+describe('RunStep', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Default selected card matches default link type when present', () => {
+ expect(wrapper.find('#link-type-success').props().isSelected).toBe(false);
+ expect(wrapper.find('#link-type-failure').props().isSelected).toBe(false);
+ expect(wrapper.find('#link-type-always').props().isSelected).toBe(true);
+ });
+
+ test('Clicking success card makes expected callback', () => {
+ wrapper.find('#link-type-success').simulate('click');
+ expect(onUpdateLinkType).toHaveBeenCalledWith('success');
+ });
+
+ test('Clicking failure card makes expected callback', () => {
+ wrapper.find('#link-type-failure').simulate('click');
+ expect(onUpdateLinkType).toHaveBeenCalledWith('failure');
+ });
+
+ test('Clicking always card makes expected callback', () => {
+ wrapper.find('#link-type-always').simulate('click');
+ expect(onUpdateLinkType).toHaveBeenCalledWith('always');
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/index.js
new file mode 100644
index 0000000000..6dc89d09e5
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/index.js
@@ -0,0 +1,7 @@
+export { default as NodeAddModal } from './NodeAddModal';
+export { default as NodeDeleteModal } from './NodeDeleteModal';
+export { default as NodeEditModal } from './NodeEditModal';
+export { default as NodeModal } from './NodeModal';
+export { default as NodeNextButton } from './NodeNextButton';
+export { default as NodeViewModal } from './NodeViewModal';
+export { default as RunStep } from './RunStep';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx
new file mode 100644
index 0000000000..e594c7a570
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx
@@ -0,0 +1,52 @@
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { Button, Modal } from '@patternfly/react-core';
+import { withI18n } from '@lingui/react';
+import { t, Trans } from '@lingui/macro';
+import { func } from 'prop-types';
+
+function UnsavedChangesModal({ i18n, onSaveAndExit, onExit }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+ return (
+ dispatch({ type: 'TOGGLE_UNSAVED_CHANGES_MODAL' })}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+
+
+ Are you sure you want to exit the Workflow Creator without saving your
+ changes?
+
+
+
+ );
+}
+
+UnsavedChangesModal.propTypes = {
+ onExit: func.isRequired,
+ onSaveAndExit: func.isRequired,
+};
+
+export default withI18n()(UnsavedChangesModal);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.test.jsx
new file mode 100644
index 0000000000..01b5e59780
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.test.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import UnsavedChangesModal from './UnsavedChangesModal';
+
+let wrapper;
+const dispatch = jest.fn();
+const onSaveAndExit = jest.fn();
+const onExit = jest.fn();
+
+describe('UnsavedChangesModal', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Exit Without Saving button dispatches as expected', () => {
+ wrapper.find('button#confirm-exit-without-saving').simulate('click');
+ expect(onExit).toHaveBeenCalled();
+ });
+
+ test('Save and Exit button dispatches as expected', () => {
+ wrapper.find('button#confirm-save-and-exit').simulate('click');
+ expect(onSaveAndExit).toHaveBeenCalled();
+ });
+
+ test('Close button dispatches as expected', () => {
+ wrapper.find('TimesIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'TOGGLE_UNSAVED_CHANGES_MODAL',
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/index.js
new file mode 100644
index 0000000000..2c05fbee79
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/index.js
@@ -0,0 +1,2 @@
+export { default as DeleteAllNodesModal } from './DeleteAllNodesModal';
+export { default as UnsavedChangesModal } from './UnsavedChangesModal';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Toolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Toolbar.jsx
deleted file mode 100644
index dee2e8130b..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Toolbar.jsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-import { withRouter } from 'react-router-dom';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { Badge as PFBadge, Button } from '@patternfly/react-core';
-import {
- BookIcon,
- CompassIcon,
- DownloadIcon,
- RocketIcon,
- TimesIcon,
- TrashAltIcon,
- WrenchIcon,
-} from '@patternfly/react-icons';
-import VerticalSeparator from '@components/VerticalSeparator';
-import styled from 'styled-components';
-
-const Badge = styled(PFBadge)`
- align-items: center;
- display: flex;
- justify-content: center;
- margin-left: 10px;
-`;
-
-function Toolbar({ history, i18n, template }) {
- const handleVisualizerCancel = () => {
- history.push(`/templates/workflow_job_template/${template.id}/details`);
- };
-
- return (
-
-
-
- {i18n._(t`Workflow Visualizer`)}
-
- {template.name}
-
-
-
{i18n._(t`Total Nodes`)}
-
0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export default withI18n()(withRouter(Toolbar));
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx
index fbd2dde245..754d0765e3 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx
@@ -1,175 +1,413 @@
-import React, { useState, useEffect } from 'react';
+import React, { useEffect, useReducer } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
import styled from 'styled-components';
+import { shape } from 'prop-types';
+import { layoutGraph } from '@components/Workflow/WorkflowUtils';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
-import Graph from './Graph';
-import StartScreen from './StartScreen';
-import Toolbar from './Toolbar';
-import { WorkflowJobTemplatesAPI } from '@api';
+import workflowReducer from '@components/Workflow/workflowReducer';
+import { DeleteAllNodesModal, UnsavedChangesModal } from './Modals';
+import {
+ LinkAddModal,
+ LinkDeleteModal,
+ LinkEditModal,
+} from './Modals/LinkModals';
+import {
+ NodeAddModal,
+ NodeEditModal,
+ NodeDeleteModal,
+ NodeViewModal,
+} from './Modals/NodeModals';
+import VisualizerGraph from './VisualizerGraph';
+import VisualizerStartScreen from './VisualizerStartScreen';
+import VisualizerToolbar from './VisualizerToolbar';
+import {
+ WorkflowApprovalTemplatesAPI,
+ WorkflowJobTemplateNodesAPI,
+ WorkflowJobTemplatesAPI,
+} from '@api';
const CenteredContent = styled.div`
+ align-items: center;
display: flex;
flex-flow: column;
height: 100%;
- align-items: center;
justify-content: center;
`;
-const VisualizerLayout = styled.div`
+const Wrapper = styled.div`
display: flex;
flex-flow: column;
height: 100%;
`;
-const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => {
- try {
- const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
- page_size: 200,
- page: pageNo,
- });
- if (data.next) {
- return await fetchWorkflowNodes(
- templateId,
- pageNo + 1,
- nodes.concat(data.results)
- );
- }
- return nodes.concat(data.results);
- } catch (error) {
- throw error;
+const fetchWorkflowNodes = async (
+ templateId,
+ pageNo = 1,
+ workflowNodes = []
+) => {
+ const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, {
+ page_size: 200,
+ page: pageNo,
+ });
+ if (data.next) {
+ return fetchWorkflowNodes(
+ templateId,
+ pageNo + 1,
+ workflowNodes.concat(data.results)
+ );
}
+ return workflowNodes.concat(data.results);
};
function Visualizer({ template, i18n }) {
- const [contentError, setContentError] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [graphLinks, setGraphLinks] = useState([]);
- // We'll also need to store the original set of nodes...
- const [graphNodes, setGraphNodes] = useState([]);
+ const history = useHistory();
+ const [state, dispatch] = useReducer(workflowReducer, {
+ addLinkSourceNode: null,
+ addLinkTargetNode: null,
+ addNodeSource: null,
+ addNodeTarget: null,
+ addingLink: false,
+ contentError: null,
+ isLoading: true,
+ linkToDelete: null,
+ linkToEdit: null,
+ links: [],
+ nextNodeId: 0,
+ nodePositions: null,
+ nodeToDelete: null,
+ nodeToEdit: null,
+ nodeToView: null,
+ nodes: [],
+ showDeleteAllNodesModal: false,
+ showLegend: false,
+ showTools: false,
+ showUnsavedChangesModal: false,
+ unsavedChanges: false,
+ });
+
+ const {
+ addLinkSourceNode,
+ addLinkTargetNode,
+ addNodeSource,
+ contentError,
+ isLoading,
+ linkToDelete,
+ linkToEdit,
+ links,
+ nodeToDelete,
+ nodeToEdit,
+ nodeToView,
+ nodes,
+ showDeleteAllNodesModal,
+ showUnsavedChangesModal,
+ unsavedChanges,
+ } = state;
+
+ const handleVisualizerClose = () => {
+ if (unsavedChanges) {
+ dispatch({ type: 'TOGGLE_UNSAVED_CHANGES_MODAL' });
+ } else {
+ history.push(`/templates/workflow_job_template/${template.id}/details`);
+ }
+ };
+
+ const associateNodes = (newLinks, originalLinkMap) => {
+ const associateNodeRequests = [];
+ newLinks.forEach(link => {
+ switch (link.linkType) {
+ case 'success':
+ associateNodeRequests.push(
+ WorkflowJobTemplateNodesAPI.associateSuccessNode(
+ originalLinkMap[link.source.id].id,
+ originalLinkMap[link.target.id].id
+ )
+ );
+ break;
+ case 'failure':
+ associateNodeRequests.push(
+ WorkflowJobTemplateNodesAPI.associateFailureNode(
+ originalLinkMap[link.source.id].id,
+ originalLinkMap[link.target.id].id
+ )
+ );
+ break;
+ case 'always':
+ associateNodeRequests.push(
+ WorkflowJobTemplateNodesAPI.associateAlwaysNode(
+ originalLinkMap[link.source.id].id,
+ originalLinkMap[link.target.id].id
+ )
+ );
+ break;
+ default:
+ }
+ });
+
+ return associateNodeRequests;
+ };
+
+ const disassociateNodes = (originalLinkMap, deletedNodeIds, linkMap) => {
+ const disassociateNodeRequests = [];
+ Object.keys(originalLinkMap).forEach(key => {
+ const node = originalLinkMap[key];
+ node.success_nodes.forEach(successNodeId => {
+ if (
+ !deletedNodeIds.includes(successNodeId) &&
+ (!linkMap[node.id] ||
+ !linkMap[node.id][successNodeId] ||
+ linkMap[node.id][successNodeId] !== 'success')
+ ) {
+ disassociateNodeRequests.push(
+ WorkflowJobTemplateNodesAPI.disassociateSuccessNode(
+ node.id,
+ successNodeId
+ )
+ );
+ }
+ });
+ node.failure_nodes.forEach(failureNodeId => {
+ if (
+ !deletedNodeIds.includes(failureNodeId) &&
+ (!linkMap[node.id] ||
+ !linkMap[node.id][failureNodeId] ||
+ linkMap[node.id][failureNodeId] !== 'failure')
+ ) {
+ disassociateNodeRequests.push(
+ WorkflowJobTemplateNodesAPI.disassociateFailuresNode(
+ node.id,
+ failureNodeId
+ )
+ );
+ }
+ });
+ node.always_nodes.forEach(alwaysNodeId => {
+ if (
+ !deletedNodeIds.includes(alwaysNodeId) &&
+ (!linkMap[node.id] ||
+ !linkMap[node.id][alwaysNodeId] ||
+ linkMap[node.id][alwaysNodeId] !== 'always')
+ ) {
+ disassociateNodeRequests.push(
+ WorkflowJobTemplateNodesAPI.disassociateAlwaysNode(
+ node.id,
+ alwaysNodeId
+ )
+ );
+ }
+ });
+ });
+
+ return disassociateNodeRequests;
+ };
+
+ const generateLinkMapAndNewLinks = originalLinkMap => {
+ const linkMap = {};
+ const newLinks = [];
+
+ links.forEach(link => {
+ if (link.source.id !== 1) {
+ const realLinkSourceId = originalLinkMap[link.source.id].id;
+ const realLinkTargetId = originalLinkMap[link.target.id].id;
+ if (!linkMap[realLinkSourceId]) {
+ linkMap[realLinkSourceId] = {};
+ }
+ linkMap[realLinkSourceId][realLinkTargetId] = link.linkType;
+ switch (link.linkType) {
+ case 'success':
+ if (
+ !originalLinkMap[link.source.id].success_nodes.includes(
+ originalLinkMap[link.target.id].id
+ )
+ ) {
+ newLinks.push(link);
+ }
+ break;
+ case 'failure':
+ if (
+ !originalLinkMap[link.source.id].failure_nodes.includes(
+ originalLinkMap[link.target.id].id
+ )
+ ) {
+ newLinks.push(link);
+ }
+ break;
+ case 'always':
+ if (
+ !originalLinkMap[link.source.id].always_nodes.includes(
+ originalLinkMap[link.target.id].id
+ )
+ ) {
+ newLinks.push(link);
+ }
+ break;
+ default:
+ }
+ }
+ });
+
+ return [linkMap, newLinks];
+ };
+
+ const handleVisualizerSave = async () => {
+ const nodeRequests = [];
+ const approvalTemplateRequests = [];
+ const originalLinkMap = {};
+ const deletedNodeIds = [];
+ nodes.forEach(node => {
+ // node with id=1 is the artificial start node
+ if (node.id === 1) {
+ return;
+ }
+ if (node.originalNodeObject && !node.isDeleted) {
+ const {
+ id,
+ success_nodes,
+ failure_nodes,
+ always_nodes,
+ } = node.originalNodeObject;
+ originalLinkMap[node.id] = {
+ id,
+ success_nodes,
+ failure_nodes,
+ always_nodes,
+ };
+ }
+ if (node.isDeleted && node.originalNodeObject) {
+ deletedNodeIds.push(node.originalNodeObject.id);
+ nodeRequests.push(
+ WorkflowJobTemplateNodesAPI.destroy(node.originalNodeObject.id)
+ );
+ } else if (!node.isDeleted && !node.originalNodeObject) {
+ if (node.unifiedJobTemplate.type === 'workflow_approval_template') {
+ nodeRequests.push(
+ WorkflowJobTemplatesAPI.createNode(template.id, {}).then(
+ ({ data }) => {
+ node.originalNodeObject = data;
+ originalLinkMap[node.id] = {
+ id: data.id,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ };
+ approvalTemplateRequests.push(
+ WorkflowJobTemplateNodesAPI.createApprovalTemplate(data.id, {
+ name: node.unifiedJobTemplate.name,
+ description: node.unifiedJobTemplate.description,
+ timeout: node.unifiedJobTemplate.timeout,
+ })
+ );
+ }
+ )
+ );
+ } else {
+ nodeRequests.push(
+ WorkflowJobTemplatesAPI.createNode(template.id, {
+ unified_job_template: node.unifiedJobTemplate.id,
+ }).then(({ data }) => {
+ node.originalNodeObject = data;
+ originalLinkMap[node.id] = {
+ id: data.id,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ };
+ })
+ );
+ }
+ } else if (node.isEdited) {
+ if (
+ node.unifiedJobTemplate &&
+ (node.unifiedJobTemplate.unified_job_type === 'workflow_approval' ||
+ node.unifiedJobTemplate.type === 'workflow_approval_template')
+ ) {
+ if (
+ node.originalNodeObject.summary_fields.unified_job_template
+ .unified_job_type === 'workflow_approval'
+ ) {
+ approvalTemplateRequests.push(
+ WorkflowApprovalTemplatesAPI.update(
+ node.originalNodeObject.summary_fields.unified_job_template.id,
+ {
+ name: node.unifiedJobTemplate.name,
+ description: node.unifiedJobTemplate.description,
+ timeout: node.unifiedJobTemplate.timeout,
+ }
+ )
+ );
+ } else {
+ approvalTemplateRequests.push(
+ WorkflowJobTemplateNodesAPI.createApprovalTemplate(
+ node.originalNodeObject.id,
+ {
+ name: node.unifiedJobTemplate.name,
+ description: node.unifiedJobTemplate.description,
+ timeout: node.unifiedJobTemplate.timeout,
+ }
+ )
+ );
+ }
+ } else {
+ nodeRequests.push(
+ WorkflowJobTemplateNodesAPI.update(node.originalNodeObject.id, {
+ unified_job_template: node.unifiedJobTemplate.id,
+ })
+ );
+ }
+ }
+ });
+
+ await Promise.all(nodeRequests);
+ // Creating approval templates needs to happen after the node has been created
+ // since we reference the node in the approval template request.
+ await Promise.all(approvalTemplateRequests);
+ const [linkMap, newLinks] = generateLinkMapAndNewLinks(originalLinkMap);
+ await Promise.all(
+ disassociateNodes(originalLinkMap, deletedNodeIds, linkMap)
+ );
+ await Promise.all(associateNodes(newLinks, originalLinkMap));
+
+ history.push(`/templates/workflow_job_template/${template.id}/details`);
+ };
useEffect(() => {
- const buildGraphArrays = nodes => {
- const nonRootNodeIds = [];
- const allNodeIds = [];
- const arrayOfLinksForChart = [];
- const nodeIdToChartNodeIdMapping = {};
- const chartNodeIdToIndexMapping = {};
- const nodeRef = {};
- let nodeIdCounter = 1;
- const arrayOfNodesForChart = [
- {
- id: nodeIdCounter,
- unifiedJobTemplate: {
- name: i18n._(t`START`),
- },
- type: 'node',
- },
- ];
- nodeIdCounter++;
- // Assign each node an ID - 0 is reserved for the start node. We need to
- // make sure that we have an ID on every node including new nodes so the
- // ID returned by the api won't do
- nodes.forEach(node => {
- node.workflowMakerNodeId = nodeIdCounter;
- nodeRef[nodeIdCounter] = {
- originalNodeObject: node,
- };
-
- const nodeObj = {
- index: nodeIdCounter - 1,
- id: nodeIdCounter,
- type: 'node',
- };
-
- if (node.summary_fields.job) {
- nodeObj.job = node.summary_fields.job;
- }
- if (node.summary_fields.unified_job_template) {
- nodeRef[nodeIdCounter].unifiedJobTemplate =
- node.summary_fields.unified_job_template;
- nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
- }
-
- arrayOfNodesForChart.push(nodeObj);
- allNodeIds.push(node.id);
- nodeIdToChartNodeIdMapping[node.id] = node.workflowMakerNodeId;
- chartNodeIdToIndexMapping[nodeIdCounter] = nodeIdCounter - 1;
- nodeIdCounter++;
- });
-
- nodes.forEach(node => {
- const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
- node.success_nodes.forEach(nodeId => {
- const targetIndex =
- chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
- arrayOfLinksForChart.push({
- source: arrayOfNodesForChart[sourceIndex],
- target: arrayOfNodesForChart[targetIndex],
- edgeType: 'success',
- type: 'link',
- });
- nonRootNodeIds.push(nodeId);
- });
- node.failure_nodes.forEach(nodeId => {
- const targetIndex =
- chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
- arrayOfLinksForChart.push({
- source: arrayOfNodesForChart[sourceIndex],
- target: arrayOfNodesForChart[targetIndex],
- edgeType: 'failure',
- type: 'link',
- });
- nonRootNodeIds.push(nodeId);
- });
- node.always_nodes.forEach(nodeId => {
- const targetIndex =
- chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
- arrayOfLinksForChart.push({
- source: arrayOfNodesForChart[sourceIndex],
- target: arrayOfNodesForChart[targetIndex],
- edgeType: 'always',
- type: 'link',
- });
- nonRootNodeIds.push(nodeId);
- });
- });
-
- const uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds));
-
- const rootNodes = allNodeIds.filter(
- nodeId => !uniqueNonRootNodeIds.includes(nodeId)
- );
-
- rootNodes.forEach(rootNodeId => {
- const targetIndex =
- chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[rootNodeId]];
- arrayOfLinksForChart.push({
- source: arrayOfNodesForChart[0],
- target: arrayOfNodesForChart[targetIndex],
- edgeType: 'always',
- type: 'link',
- });
- });
-
- setGraphNodes(arrayOfNodesForChart);
- setGraphLinks(arrayOfLinksForChart);
- };
-
async function fetchData() {
try {
- const nodes = await fetchWorkflowNodes(template.id);
- buildGraphArrays(nodes);
+ const workflowNodes = await fetchWorkflowNodes(template.id);
+ dispatch({
+ type: 'GENERATE_NODES_AND_LINKS',
+ nodes: workflowNodes,
+ i18n,
+ });
} catch (error) {
- setContentError(error);
+ dispatch({ type: 'SET_CONTENT_ERROR', value: error });
} finally {
- setIsLoading(false);
+ dispatch({ type: 'SET_IS_LOADING', value: false });
}
}
fetchData();
}, [template.id, i18n]);
+ // Update positions of nodes/links
+ useEffect(() => {
+ if (nodes) {
+ const newNodePositions = {};
+ const nonDeletedNodes = nodes.filter(node => !node.isDeleted);
+ const g = layoutGraph(nonDeletedNodes, links);
+
+ g.nodes().forEach(node => {
+ newNodePositions[node] = g.node(node);
+ });
+
+ dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions });
+ }
+ }, [links, nodes]);
+
if (isLoading) {
return (
@@ -187,19 +425,47 @@ function Visualizer({ template, i18n }) {
}
return (
-
-
- {graphLinks.length > 0 ? (
-
- ) : (
-
- )}
-
+
+
+
+
+ {links.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ {nodeToDelete && }
+ {linkToDelete && }
+ {linkToEdit && }
+ {addLinkSourceNode && addLinkTargetNode && }
+ {addNodeSource && }
+ {nodeToEdit && }
+ {showUnsavedChangesModal && (
+
+ history.push(
+ `/templates/workflow_job_template/${template.id}/details`
+ )
+ }
+ onSaveAndExit={() => handleVisualizerSave()}
+ />
+ )}
+ {showDeleteAllNodesModal && }
+ {nodeToView && }
+
+
);
}
+Visualizer.propTypes = {
+ template: shape().isRequired,
+};
+
export default withI18n()(Visualizer);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx
new file mode 100644
index 0000000000..fb497bb0ee
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.test.jsx
@@ -0,0 +1,231 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { WorkflowJobTemplateNodesAPI, WorkflowJobTemplatesAPI } from '@api';
+import Visualizer from './Visualizer';
+
+jest.mock('@api');
+
+const template = {
+ id: 1,
+ name: 'Foo WFJT',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ start: true,
+ schedule: true,
+ copy: true,
+ },
+ },
+};
+
+const mockWorkflowNodes = [
+ {
+ id: 8,
+ success_nodes: [10],
+ failure_nodes: [],
+ always_nodes: [9],
+ summary_fields: {
+ unified_job_template: {
+ id: 14,
+ name: 'A Playbook',
+ type: 'job_template',
+ },
+ },
+ },
+ {
+ id: 9,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 14,
+ name: 'A Project Update',
+ type: 'project',
+ },
+ },
+ },
+ {
+ id: 10,
+ success_nodes: [],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ elapsed: 10,
+ name: 'An Inventory Source Sync',
+ type: 'inventory_source',
+ },
+ },
+ },
+ {
+ id: 11,
+ success_nodes: [9],
+ failure_nodes: [],
+ always_nodes: [],
+ summary_fields: {
+ unified_job_template: {
+ id: 14,
+ name: 'Pause',
+ type: 'workflow_approval_template',
+ },
+ },
+ },
+];
+
+describe('Visualizer', () => {
+ let wrapper;
+ beforeAll(() => {
+ WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
+ data: {
+ count: mockWorkflowNodes.length,
+ results: mockWorkflowNodes,
+ },
+ });
+ window.SVGElement.prototype.height = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.width = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.getBBox = () => ({
+ x: 0,
+ y: 0,
+ width: 500,
+ height: 250,
+ });
+
+ window.SVGElement.prototype.getBoundingClientRect = () => ({
+ x: 303,
+ y: 252.359375,
+ width: 1329,
+ height: 259.640625,
+ top: 252.359375,
+ right: 1632,
+ bottom: 512,
+ left: 303,
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ delete window.SVGElement.prototype.getBBox;
+ delete window.SVGElement.prototype.getBoundingClientRect;
+ delete window.SVGElement.prototype.height;
+ delete window.SVGElement.prototype.width;
+ });
+
+ test('Renders successfully', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError')).toHaveLength(0);
+ expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
+ expect(wrapper.find('VisualizerNode')).toHaveLength(4);
+ expect(wrapper.find('VisualizerLink')).toHaveLength(5);
+ });
+
+ test('Successfully deletes all nodes', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('DeleteAllNodesModal').length).toBe(0);
+ wrapper.find('TrashAltIcon').simulate('click');
+ expect(wrapper.find('DeleteAllNodesModal').length).toBe(1);
+ wrapper.find('button#confirm-delete-all-nodes').simulate('click');
+ expect(wrapper.find('VisualizerStartScreen')).toHaveLength(1);
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(8);
+ expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(9);
+ expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(10);
+ expect(WorkflowJobTemplateNodesAPI.destroy).toHaveBeenCalledWith(11);
+ });
+
+ test('Successfully changes link type', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('LinkEditModal').length).toBe(0);
+ wrapper.find('g#link-2-3').simulate('mouseenter');
+ wrapper.find('#link-edit').simulate('click');
+ expect(wrapper.find('LinkEditModal').length).toBe(1);
+ act(() => {
+ wrapper
+ .find('LinkEditModal')
+ .find('AnsibleSelect')
+ .prop('onChange')(null, 'success');
+ });
+ wrapper.find('button#link-confirm').simulate('click');
+ expect(wrapper.find('LinkEditModal').length).toBe(0);
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ expect(
+ WorkflowJobTemplateNodesAPI.disassociateAlwaysNode
+ ).toHaveBeenCalledWith(8, 9);
+ expect(
+ WorkflowJobTemplateNodesAPI.associateSuccessNode
+ ).toHaveBeenCalledWith(8, 9);
+ });
+
+ test('Start Screen shown when no nodes are present', async () => {
+ WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
+ data: {
+ count: 0,
+ results: [],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('VisualizerStartScreen')).toHaveLength(1);
+ expect(
+ wrapper.find('ActionButton#visualizer-toggle-tools').props().isDisabled
+ ).toBe(true);
+ expect(
+ wrapper.find('ActionButton#visualizer-toggle-legend').props().isDisabled
+ ).toBe(true);
+ });
+
+ test('Error shown to user when error thrown fetching workflow nodes', async () => {
+ WorkflowJobTemplatesAPI.readNodes.mockRejectedValue(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError')).toHaveLength(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx
new file mode 100644
index 0000000000..0a8238455b
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx
@@ -0,0 +1,342 @@
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import { bool } from 'prop-types';
+import * as d3 from 'd3';
+import {
+ getScaleAndOffsetToFit,
+ constants as wfConstants,
+ getTranslatePointsForZoom,
+} from '@components/Workflow/WorkflowUtils';
+import {
+ WorkflowHelp,
+ WorkflowLegend,
+ WorkflowLinkHelp,
+ WorkflowNodeHelp,
+ WorkflowStartNode,
+ WorkflowTools,
+} from '@components/Workflow';
+import {
+ VisualizerLink,
+ VisualizerNode,
+} from '@screens/Template/WorkflowJobTemplateVisualizer';
+
+const PotentialLink = styled.polyline`
+ pointer-events: none;
+`;
+
+const WorkflowSVG = styled.svg`
+ background-color: #f6f6f6;
+ display: flex;
+ height: 100%;
+`;
+
+function VisualizerGraph({ i18n, readOnly }) {
+ const [helpText, setHelpText] = useState(null);
+ const [linkHelp, setLinkHelp] = useState();
+ const [nodeHelp, setNodeHelp] = useState();
+ const [zoomPercentage, setZoomPercentage] = useState(100);
+ const svgRef = useRef(null);
+ const gRef = useRef(null);
+
+ const {
+ addLinkSourceNode,
+ addingLink,
+ links,
+ nodePositions,
+ nodes,
+ showLegend,
+ showTools,
+ } = useContext(WorkflowStateContext);
+
+ const dispatch = useContext(WorkflowDispatchContext);
+
+ const drawPotentialLinkToNode = node => {
+ if (node.id !== addLinkSourceNode.id) {
+ const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
+ const sourceNodeY =
+ nodePositions[addLinkSourceNode.id].y - nodePositions[1].y;
+ const targetNodeX = nodePositions[node.id].x;
+ const targetNodeY = nodePositions[node.id].y - nodePositions[1].y;
+ const startX = sourceNodeX + wfConstants.nodeW;
+ const startY = sourceNodeY + wfConstants.nodeH / 2;
+ const finishX = targetNodeX;
+ const finishY = targetNodeY + wfConstants.nodeH / 2;
+
+ d3.select('#workflow-potentialLink')
+ .attr('points', `${startX},${startY} ${finishX},${finishY}`)
+ .raise();
+ }
+ };
+
+ const handleBackgroundClick = () => {
+ setHelpText(null);
+ dispatch({ type: 'CANCEL_LINK' });
+ };
+
+ const drawPotentialLinkToCursor = e => {
+ const currentTransform = d3.zoomTransform(d3.select(gRef.current).node());
+ const rect = e.target.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+ const sourceNodeX = nodePositions[addLinkSourceNode.id].x;
+ const sourceNodeY =
+ nodePositions[addLinkSourceNode.id].y - nodePositions[1].y;
+ const startX = sourceNodeX + wfConstants.nodeW;
+ const startY = sourceNodeY + wfConstants.nodeH / 2;
+
+ d3.select('#workflow-potentialLink')
+ .attr(
+ 'points',
+ `${startX},${startY} ${mouseX / currentTransform.k -
+ currentTransform.x / currentTransform.k},${mouseY /
+ currentTransform.k -
+ currentTransform.y / currentTransform.k}`
+ )
+ .raise();
+ };
+
+ // This is the zoom function called by using the mousewheel/click and drag
+ const zoom = () => {
+ const translation = [d3.event.transform.x, d3.event.transform.y];
+ d3.select(gRef.current).attr(
+ 'transform',
+ `translate(${translation}) scale(${d3.event.transform.k})`
+ );
+
+ setZoomPercentage(d3.event.transform.k * 100);
+ };
+
+ const handlePan = direction => {
+ const transform = d3.zoomTransform(d3.select(svgRef.current).node());
+
+ let { x: xPos, y: yPos } = transform;
+ const { k: currentScale } = transform;
+
+ switch (direction) {
+ case 'up':
+ yPos -= 50;
+ break;
+ case 'down':
+ yPos += 50;
+ break;
+ case 'left':
+ xPos -= 50;
+ break;
+ case 'right':
+ xPos += 50;
+ break;
+ default:
+ // Throw an error?
+ break;
+ }
+
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity.translate(xPos, yPos).scale(currentScale)
+ );
+ };
+
+ const handlePanToMiddle = () => {
+ const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity
+ .translate(0, svgBoundingClientRect.height / 2 - 30)
+ .scale(1)
+ );
+
+ setZoomPercentage(100);
+ };
+
+ const handleZoomChange = newScale => {
+ const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
+ const currentScaleAndOffset = d3.zoomTransform(
+ d3.select(svgRef.current).node()
+ );
+
+ const [translateX, translateY] = getTranslatePointsForZoom(
+ svgBoundingClientRect,
+ currentScaleAndOffset,
+ newScale
+ );
+
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity.translate(translateX, translateY).scale(newScale)
+ );
+ setZoomPercentage(newScale * 100);
+ };
+
+ const handleFitGraph = () => {
+ const { k: currentScale } = d3.zoomTransform(
+ d3.select(svgRef.current).node()
+ );
+ const gBoundingClientRect = d3
+ .select(gRef.current)
+ .node()
+ .getBoundingClientRect();
+
+ const gBBoxDimensions = d3
+ .select(gRef.current)
+ .node()
+ .getBBox();
+
+ const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
+
+ const [scaleToFit, yTranslate] = getScaleAndOffsetToFit(
+ gBoundingClientRect,
+ svgBoundingClientRect,
+ gBBoxDimensions,
+ currentScale
+ );
+
+ d3.select(svgRef.current).call(
+ zoomRef.transform,
+ d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
+ );
+
+ setZoomPercentage(scaleToFit * 100);
+ };
+
+ const zoomRef = d3
+ .zoom()
+ .scaleExtent([0.1, 2])
+ .on('zoom', zoom);
+
+ // Initialize the zoom
+ useEffect(() => {
+ d3.select(svgRef.current).call(zoomRef);
+ }, [zoomRef]);
+
+ // Attempt to zoom the graph to fit the available screen space
+ useEffect(() => {
+ handleFitGraph();
+ // We only want this to run once (when the component mounts)
+ // Including handleFitGraph in the deps array will cause this to
+ // run very frequently.
+ // Discussion: https://github.com/facebook/create-react-app/issues/6880
+ // and https://github.com/facebook/react/issues/15865 amongst others
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+ <>
+ {(helpText || nodeHelp || linkHelp) && (
+
+ {helpText && {helpText}
}
+ {nodeHelp && }
+ {linkHelp && }
+
+ )}
+
+
+
+
+
+
+ drawPotentialLinkToCursor(e),
+ onMouseOver: () =>
+ setHelpText(
+ i18n._(
+ t`Click an available node to create a new link. Click outside the graph to cancel.`
+ )
+ ),
+ onMouseOut: () => setHelpText(null),
+ onClick: () => handleBackgroundClick(),
+ })}
+ />
+
+ {nodePositions && [
+ ,
+ links.map(link => {
+ if (
+ nodePositions[link.source.id] &&
+ nodePositions[link.target.id]
+ ) {
+ return (
+ setLinkHelp(newLinkHelp)}
+ updateHelpText={newHelpText => setHelpText(newHelpText)}
+ />
+ );
+ }
+ return null;
+ }),
+ nodes.map(node => {
+ if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) {
+ return (
+ setHelpText(newHelpText)}
+ updateNodeHelp={newNodeHelp => setNodeHelp(newNodeHelp)}
+ {...(addingLink && {
+ onMouseOver: () => drawPotentialLinkToNode(node),
+ })}
+ />
+ );
+ }
+ return null;
+ }),
+ ]}
+ {addingLink && (
+
+ )}
+
+
+
+ {showTools && (
+
+ )}
+ {showLegend && }
+
+ >
+ );
+}
+
+VisualizerGraph.propTypes = {
+ readOnly: bool.isRequired,
+};
+
+export default withI18n()(VisualizerGraph);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.test.jsx
new file mode 100644
index 0000000000..40921aeeca
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.test.jsx
@@ -0,0 +1,226 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { WorkflowStateContext } from '@contexts/Workflow';
+import VisualizerGraph from './VisualizerGraph';
+
+const workflowContext = {
+ links: [
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 2,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 1,
+ },
+ target: {
+ id: 5,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 4,
+ },
+ linkType: 'success',
+ },
+ {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'always',
+ },
+ {
+ source: {
+ id: 5,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'success',
+ },
+ ],
+ nodePositions: {
+ 1: { label: '', width: 72, height: 40, x: 36, y: 85 },
+ 2: { label: '', width: 180, height: 60, x: 282, y: 40 },
+ 3: { label: '', width: 180, height: 60, x: 582, y: 130 },
+ 4: { label: '', width: 180, height: 60, x: 582, y: 30 },
+ 5: { label: '', width: 180, height: 60, x: 282, y: 140 },
+ },
+ nodes: [
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ unifiedJobTemplate: {
+ name: 'Foo JT',
+ type: 'job_template',
+ },
+ },
+ {
+ id: 3,
+ },
+ {
+ id: 4,
+ },
+ {
+ id: 5,
+ },
+ ],
+ showLegend: false,
+ showTools: false,
+};
+
+describe('VisualizerGraph', () => {
+ beforeAll(() => {
+ window.SVGElement.prototype.height = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.width = {
+ baseVal: {
+ value: 100,
+ },
+ };
+ window.SVGElement.prototype.getBBox = () => ({
+ x: 0,
+ y: 0,
+ width: 500,
+ height: 250,
+ });
+
+ window.SVGElement.prototype.getBoundingClientRect = () => ({
+ x: 303,
+ y: 252.359375,
+ width: 1329,
+ height: 259.640625,
+ top: 252.359375,
+ right: 1632,
+ bottom: 512,
+ left: 303,
+ });
+ });
+
+ afterAll(() => {
+ delete window.SVGElement.prototype.getBBox;
+ delete window.SVGElement.prototype.getBoundingClientRect;
+ delete window.SVGElement.prototype.height;
+ delete window.SVGElement.prototype.width;
+ });
+
+ test('mounts successfully', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+
+ test('tools and legend are shown when flags are true', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowLegend')).toHaveLength(1);
+ expect(wrapper.find('WorkflowTools')).toHaveLength(1);
+ });
+
+ test('nodes and links are properly rendered', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
+ expect(wrapper.find('VisualizerNode')).toHaveLength(4);
+ expect(wrapper.find('VisualizerLink')).toHaveLength(5);
+ expect(wrapper.find('g#link-2-4')).toHaveLength(1);
+ expect(wrapper.find('g#link-2-3')).toHaveLength(1);
+ expect(wrapper.find('g#link-5-3')).toHaveLength(1);
+ expect(wrapper.find('g#link-1-2')).toHaveLength(1);
+ expect(wrapper.find('g#link-1-5')).toHaveLength(1);
+ });
+
+ test('proper help text is shown when hovering over nodes', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
+ wrapper
+ .find('g#node-2')
+ .find('foreignObject')
+ .first()
+ .simulate('mouseenter');
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(1);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Name)).toEqual(
+ true
+ );
+ expect(
+ wrapper.find('WorkflowNodeHelp').containsMatchingElement(Foo JT)
+ ).toEqual(true);
+ expect(wrapper.find('WorkflowNodeHelp').contains(Type)).toEqual(
+ true
+ );
+ expect(
+ wrapper
+ .find('WorkflowNodeHelp')
+ .containsMatchingElement(Job Template)
+ ).toEqual(true);
+ wrapper
+ .find('g#node-2')
+ .find('foreignObject')
+ .first()
+ .simulate('mouseleave');
+ expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
+ });
+
+ test('proper help text is shown when hovering over links', () => {
+ const wrapper = mountWithContexts(
+
+ );
+
+ wrapper.find('#link-2-3-overlay').simulate('mouseenter');
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(1);
+ expect(wrapper.find('WorkflowLinkHelp').contains(Run)).toEqual(true);
+ expect(
+ wrapper.find('WorkflowLinkHelp').containsMatchingElement(Always)
+ ).toEqual(true);
+ wrapper.find('#link-2-3-overlay').simulate('mouseleave');
+ expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx
new file mode 100644
index 0000000000..b046f22056
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx
@@ -0,0 +1,165 @@
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import styled from 'styled-components';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { bool, func, shape } from 'prop-types';
+import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons';
+import {
+ generateLine,
+ getLinePoints,
+ getLinkOverlayPoints,
+} from '@components/Workflow/WorkflowUtils';
+import {
+ WorkflowActionTooltip,
+ WorkflowActionTooltipItem,
+} from '@components/Workflow';
+
+const LinkG = styled.g`
+ pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
+`;
+
+function VisualizerLink({
+ i18n,
+ link,
+ updateLinkHelp,
+ readOnly,
+ updateHelpText,
+}) {
+ const ref = useRef(null);
+ const [hovering, setHovering] = useState(false);
+ const [pathD, setPathD] = useState();
+ const [pathStroke, setPathStroke] = useState('#CCCCCC');
+ const [tooltipX, setTooltipX] = useState();
+ const [tooltipY, setTooltipY] = useState();
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { addingLink, nodePositions } = useContext(WorkflowStateContext);
+
+ const addNodeAction = (
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({
+ type: 'START_ADD_NODE',
+ sourceNodeId: link.source.id,
+ targetNodeId: link.target.id,
+ });
+ }}
+ onMouseEnter={() =>
+ updateHelpText(i18n._(t`Add a new node between these two nodes`))
+ }
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+
+ );
+
+ const tooltipActions =
+ link.source.id === 1
+ ? [addNodeAction]
+ : [
+ addNodeAction,
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'SET_LINK_TO_EDIT', value: link });
+ }}
+ onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+ ,
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'START_DELETE_LINK', link });
+ }}
+ onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+ ,
+ ];
+
+ const handleLinkMouseEnter = () => {
+ ref.current.parentNode.appendChild(ref.current);
+ setHovering(true);
+ };
+
+ const handleLinkMouseLeave = () => {
+ ref.current.parentNode.prepend(ref.current);
+ setHovering(null);
+ };
+
+ useEffect(() => {
+ if (link.linkType === 'failure') {
+ setPathStroke('#d9534f');
+ }
+ if (link.linkType === 'success') {
+ setPathStroke('#5cb85c');
+ }
+ if (link.linkType === 'always') {
+ setPathStroke('#337ab7');
+ }
+ }, [link.linkType]);
+
+ useEffect(() => {
+ const linePoints = getLinePoints(link, nodePositions);
+ setPathD(generateLine(linePoints));
+ setTooltipX((linePoints[0].x + linePoints[1].x) / 2);
+ setTooltipY((linePoints[0].y + linePoints[1].y) / 2);
+ }, [link, nodePositions]);
+
+ return (
+
+
+
+ updateLinkHelp(link)}
+ onMouseLeave={() => updateLinkHelp(null)}
+ opacity="0"
+ points={getLinkOverlayPoints(link, nodePositions)}
+ />
+ {!readOnly && hovering && (
+
+ )}
+
+ );
+}
+
+VisualizerLink.propTypes = {
+ link: shape().isRequired,
+ readOnly: bool.isRequired,
+ updateHelpText: func.isRequired,
+ updateLinkHelp: func.isRequired,
+};
+
+export default withI18n()(VisualizerLink);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.test.jsx
new file mode 100644
index 0000000000..affdf306ab
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.test.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import VisualizerLink from './VisualizerLink';
+
+const link = {
+ source: {
+ id: 2,
+ },
+ target: {
+ id: 3,
+ },
+ linkType: 'success',
+};
+
+const mockedContext = {
+ addingLink: false,
+ nodePositions: {
+ 1: {
+ width: 72,
+ height: 40,
+ x: 0,
+ y: 0,
+ },
+ 2: {
+ width: 180,
+ height: 60,
+ x: 282,
+ y: 40,
+ },
+ 3: {
+ width: 180,
+ height: 60,
+ x: 564,
+ y: 40,
+ },
+ },
+};
+
+const dispatch = jest.fn();
+const updateHelpText = jest.fn();
+const updateLinkHelp = jest.fn();
+
+describe('VisualizerLink', () => {
+ let wrapper;
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Displays action tooltip on hover and updates help text on hover', () => {
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ wrapper
+ .find('g')
+ .first()
+ .simulate('mouseenter');
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(1);
+ expect(wrapper.find('WorkflowActionTooltipItem').length).toBe(3);
+ wrapper
+ .find('g')
+ .first()
+ .simulate('mouseleave');
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ wrapper
+ .find('#link-2-3-overlay')
+ .first()
+ .simulate('mouseenter');
+ expect(updateLinkHelp).toHaveBeenCalledWith(link);
+ wrapper
+ .find('#link-2-3-overlay')
+ .first()
+ .simulate('mouseleave');
+ expect(updateLinkHelp).toHaveBeenCalledWith(null);
+ });
+
+ test('Add Node tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper
+ .find('g')
+ .first()
+ .simulate('mouseenter');
+ wrapper.find('#link-add-node').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith(
+ 'Add a new node between these two nodes'
+ );
+ wrapper.find('#link-add-node').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#link-add-node').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'START_ADD_NODE',
+ sourceNodeId: 2,
+ targetNodeId: 3,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+
+ test('Edit tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper
+ .find('g')
+ .first()
+ .simulate('mouseenter');
+ wrapper.find('#link-edit').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith('Edit this link');
+ wrapper.find('#link-edit').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#link-edit').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_LINK_TO_EDIT',
+ value: link,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+
+ test('Delete tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper
+ .find('g')
+ .first()
+ .simulate('mouseenter');
+ wrapper.find('#link-delete').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith('Delete this link');
+ wrapper.find('#link-delete').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#link-delete').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'START_DELETE_LINK',
+ link,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx
new file mode 100644
index 0000000000..423e347b18
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx
@@ -0,0 +1,233 @@
+import React, { useContext, useRef, useState } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import styled from 'styled-components';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { bool, func, shape } from 'prop-types';
+import {
+ InfoIcon,
+ LinkIcon,
+ PencilAltIcon,
+ PlusIcon,
+ TrashAltIcon,
+} from '@patternfly/react-icons';
+import { constants as wfConstants } from '@components/Workflow/WorkflowUtils';
+import {
+ WorkflowActionTooltip,
+ WorkflowActionTooltipItem,
+ WorkflowNodeTypeLetter,
+} from '@components/Workflow';
+
+const NodeG = styled.g`
+ pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')};
+ cursor: ${props => (props.job ? 'pointer' : 'default')};
+`;
+
+const NodeContents = styled.div`
+ font-size: 13px;
+ padding: 0px 10px;
+ background-color: ${props =>
+ props.isInvalidLinkTarget ? '#D7D7D7' : '#FFFFFF'};
+`;
+
+const NodeResourceName = styled.p`
+ margin-top: 20px;
+ overflow: hidden;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+NodeResourceName.displayName = 'NodeResourceName';
+
+function VisualizerNode({
+ i18n,
+ node,
+ onMouseOver,
+ readOnly,
+ updateHelpText,
+ updateNodeHelp,
+}) {
+ const ref = useRef(null);
+ const [hovering, setHovering] = useState(false);
+ const dispatch = useContext(WorkflowDispatchContext);
+ const { addingLink, addLinkSourceNode, nodePositions } = useContext(
+ WorkflowStateContext
+ );
+ const isAddLinkSourceNode =
+ addLinkSourceNode && addLinkSourceNode.id === node.id;
+
+ const handleNodeMouseEnter = () => {
+ ref.current.parentNode.appendChild(ref.current);
+ setHovering(true);
+ if (addingLink) {
+ updateHelpText(
+ node.isInvalidLinkTarget
+ ? i18n._(
+ t`Invalid link target. Unable to link to children or ancestor nodes. Graph cycles are not supported.`
+ )
+ : i18n._(t`Click to create a new link to this node.`)
+ );
+ onMouseOver(node);
+ }
+ };
+
+ const handleNodeMouseLeave = () => {
+ setHovering(false);
+ if (addingLink) {
+ updateHelpText(null);
+ }
+ };
+
+ const handleNodeClick = () => {
+ if (addingLink && !node.isInvalidLinkTarget && !isAddLinkSourceNode) {
+ dispatch({ type: 'SET_ADD_LINK_TARGET_NODE', value: node });
+ }
+ };
+
+ const viewDetailsAction = (
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'SET_NODE_TO_VIEW', value: node });
+ }}
+ onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+
+ );
+
+ const tooltipActions = readOnly
+ ? [viewDetailsAction]
+ : [
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'START_ADD_NODE', sourceNodeId: node.id });
+ }}
+ onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+ ,
+ viewDetailsAction,
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'SET_NODE_TO_EDIT', value: node });
+ }}
+ onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+ ,
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'SELECT_SOURCE_FOR_LINKING', node });
+ }}
+ onMouseEnter={() =>
+ updateHelpText(i18n._(t`Link to an available node`))
+ }
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+ ,
+ {
+ updateHelpText(null);
+ setHovering(false);
+ dispatch({ type: 'SET_NODE_TO_DELETE', value: node });
+ }}
+ onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
+ onMouseLeave={() => updateHelpText(null)}
+ >
+
+ ,
+ ];
+
+ return (
+
+
+ updateNodeHelp(node),
+ onMouseLeave: () => updateNodeHelp(null),
+ })}
+ onClick={() => handleNodeClick()}
+ width="178"
+ x="1"
+ y="1"
+ >
+
+
+ {node.unifiedJobTemplate
+ ? node.unifiedJobTemplate.name
+ : i18n._(t`DELETED`)}
+
+
+
+ {node.unifiedJobTemplate && }
+ {hovering && !addingLink && (
+
+ )}
+
+ );
+}
+
+VisualizerNode.propTypes = {
+ node: shape().isRequired,
+ onMouseOver: func,
+ readOnly: bool.isRequired,
+ updateHelpText: func.isRequired,
+ updateNodeHelp: func.isRequired,
+};
+
+VisualizerNode.defaultProps = {
+ onMouseOver: () => {},
+};
+
+export default withI18n()(VisualizerNode);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.test.jsx
new file mode 100644
index 0000000000..4f351bca04
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.test.jsx
@@ -0,0 +1,230 @@
+import React from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import VisualizerNode from './VisualizerNode';
+
+const mockedContext = {
+ addingLink: false,
+ addLinkSourceNode: null,
+ nodePositions: {
+ 1: {
+ width: 72,
+ height: 40,
+ x: 0,
+ y: 0,
+ },
+ 2: {
+ width: 180,
+ height: 60,
+ x: 282,
+ y: 40,
+ },
+ },
+};
+
+const nodeWithJT = {
+ id: 2,
+ unifiedJobTemplate: {
+ id: 77,
+ name: 'Automation JT',
+ type: 'job_template',
+ },
+};
+
+const dispatch = jest.fn();
+const updateHelpText = jest.fn();
+const updateNodeHelp = jest.fn();
+
+describe('VisualizerNode', () => {
+ describe('Node with unified job template', () => {
+ let wrapper;
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('Displays unified job template name inside node', () => {
+ expect(wrapper.find('NodeResourceName').text()).toBe('Automation JT');
+ });
+ test('Displays action tooltip on hover and updates help text on hover', () => {
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ wrapper.find('VisualizerNode').simulate('mouseenter');
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(1);
+ expect(wrapper.find('WorkflowActionTooltipItem').length).toBe(5);
+ wrapper.find('VisualizerNode').simulate('mouseleave');
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ wrapper
+ .find('foreignObject')
+ .first()
+ .simulate('mouseenter');
+ expect(updateNodeHelp).toHaveBeenCalledWith(nodeWithJT);
+ wrapper
+ .find('foreignObject')
+ .first()
+ .simulate('mouseleave');
+ expect(updateNodeHelp).toHaveBeenCalledWith(null);
+ });
+
+ test('Add tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper.find('VisualizerNode').simulate('mouseenter');
+ wrapper.find('#node-add').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith('Add a new node');
+ wrapper.find('#node-add').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#node-add').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'START_ADD_NODE',
+ sourceNodeId: 2,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+
+ test('Edit tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper.find('VisualizerNode').simulate('mouseenter');
+ wrapper.find('#node-edit').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith('Edit this node');
+ wrapper.find('#node-edit').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#node-edit').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_EDIT',
+ value: nodeWithJT,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+
+ test('Details tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper.find('VisualizerNode').simulate('mouseenter');
+ wrapper.find('#node-details').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith('View node details');
+ wrapper.find('#node-details').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#node-details').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_VIEW',
+ value: nodeWithJT,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+
+ test('Link tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper.find('VisualizerNode').simulate('mouseenter');
+ wrapper.find('#node-link').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith('Link to an available node');
+ wrapper.find('#node-link').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#node-link').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SELECT_SOURCE_FOR_LINKING',
+ node: nodeWithJT,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+
+ test('Delete tooltip action hover/click updates help text and dispatches properly', () => {
+ wrapper.find('VisualizerNode').simulate('mouseenter');
+ wrapper.find('#node-delete').simulate('mouseenter');
+ expect(updateHelpText).toHaveBeenCalledWith('Delete this node');
+ wrapper.find('#node-delete').simulate('mouseleave');
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ wrapper.find('#node-delete').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_NODE_TO_DELETE',
+ value: nodeWithJT,
+ });
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ });
+ });
+ describe('Node actions while adding a new link', () => {
+ let wrapper;
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('Displays correct help text when hovering over node while adding link', () => {
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ wrapper.find('VisualizerNode').simulate('mouseenter');
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ expect(updateHelpText).toHaveBeenCalledWith(
+ 'Click to create a new link to this node.'
+ );
+ wrapper.find('VisualizerNode').simulate('mouseleave');
+ expect(wrapper.find('WorkflowActionTooltip').length).toBe(0);
+ expect(updateHelpText).toHaveBeenCalledWith(null);
+ });
+ test('Dispatches properly when node is clicked', () => {
+ wrapper
+ .find('foreignObject')
+ .first()
+ .simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_ADD_LINK_TARGET_NODE',
+ value: nodeWithJT,
+ });
+ });
+ });
+ describe('Node without unified job template', () => {
+ test('Displays DELETED text inside node when unified job template is missing', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ expect(wrapper.find('NodeResourceName').text()).toBe('DELETED');
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/StartScreen.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx
similarity index 64%
rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/StartScreen.jsx
rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx
index 8a13cd707a..d51596f618 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/StartScreen.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx
@@ -1,4 +1,5 @@
-import React from 'react';
+import React, { useContext } from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button as PFButton } from '@patternfly/react-core';
@@ -14,28 +15,35 @@ const Button = styled(PFButton)`
`;
const StartPanel = styled.div`
- padding: 60px 80px;
- border: 1px solid #c7c7c7;
background-color: white;
- color: var(--pf-global--Color--200);
+ border: 1px solid #c7c7c7;
+ padding: 60px 80px;
text-align: center;
`;
const StartPanelWrapper = styled.div`
- display: flex;
align-items: center;
- justify-content: center;
- height: 100%;
background-color: #f6f6f6;
+ display: flex;
+ height: 100%;
+ justify-content: center;
`;
-function StartScreen({ i18n }) {
+function VisualizerStartScreen({ i18n }) {
+ const dispatch = useContext(WorkflowDispatchContext);
return (
{i18n._(t`Please click the Start button to begin.`)}
-
@@ -44,4 +52,4 @@ function StartScreen({ i18n }) {
);
}
-export default withI18n()(StartScreen);
+export default withI18n()(VisualizerStartScreen);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.test.jsx
new file mode 100644
index 0000000000..bc3b6fd38f
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.test.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { WorkflowDispatchContext } from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import VisualizerStartScreen from './VisualizerStartScreen';
+
+const dispatch = jest.fn();
+
+describe('VisualizerStartScreen', () => {
+ test('dispatches properly when start button clicked', () => {
+ const wrapper = mountWithContexts(
+
+
+
+ );
+ expect(wrapper).toHaveLength(1);
+ wrapper.find('Button').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'START_ADD_NODE',
+ sourceNodeId: 1,
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx
new file mode 100644
index 0000000000..4712c7b481
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx
@@ -0,0 +1,141 @@
+import React, { useContext } from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { func, shape } from 'prop-types';
+import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
+import {
+ BookIcon,
+ CompassIcon,
+ RocketIcon,
+ TimesIcon,
+ TrashAltIcon,
+ WrenchIcon,
+} from '@patternfly/react-icons';
+import VerticalSeparator from '@components/VerticalSeparator';
+import styled from 'styled-components';
+
+const Badge = styled(PFBadge)`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ margin-left: 10px;
+`;
+
+const ActionButton = styled(Button)`
+ padding: 6px 10px;
+ margin: 0px 6px;
+ border: none;
+ &:hover {
+ background-color: #0066cc;
+ color: white;
+ }
+
+ &.pf-m-active {
+ background-color: #0066cc;
+ color: white;
+ }
+`;
+ActionButton.displayName = 'ActionButton';
+
+function VisualizerToolbar({ i18n, onClose, onSave, template }) {
+ const dispatch = useContext(WorkflowDispatchContext);
+
+ const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
+
+ const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
+
+ return (
+
+ );
+}
+
+VisualizerToolbar.propTypes = {
+ onClose: func.isRequired,
+ onSave: func.isRequired,
+ template: shape().isRequired,
+};
+
+export default withI18n()(VisualizerToolbar);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.test.jsx
new file mode 100644
index 0000000000..c0699d36c1
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.test.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import {
+ WorkflowDispatchContext,
+ WorkflowStateContext,
+} from '@contexts/Workflow';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import VisualizerToolbar from './VisualizerToolbar';
+
+let wrapper;
+const close = jest.fn();
+const dispatch = jest.fn();
+const save = jest.fn();
+const template = {
+ id: 1,
+ name: 'Test JT',
+};
+const workflowContext = {
+ nodes: [],
+ showLegend: false,
+ showTools: false,
+};
+
+describe('VisualizerToolbar', () => {
+ beforeAll(() => {
+ const nodes = [
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ },
+ {
+ id: 3,
+ isDeleted: true,
+ },
+ ];
+ wrapper = mountWithContexts(
+
+
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('Shows correct number of nodes', () => {
+ // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored
+ expect(wrapper.find('Badge').text()).toBe('1');
+ });
+
+ test('Toggle Legend button dispatches as expected', () => {
+ wrapper.find('CompassIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_LEGEND' });
+ });
+
+ test('Toggle Tools button dispatches as expected', () => {
+ wrapper.find('WrenchIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_TOOLS' });
+ });
+
+ test('Delete All button dispatches as expected', () => {
+ wrapper.find('TrashAltIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_SHOW_DELETE_ALL_NODES_MODAL',
+ value: true,
+ });
+ });
+
+ test('Delete All button dispatches as expected', () => {
+ wrapper.find('TrashAltIcon').simulate('click');
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_SHOW_DELETE_ALL_NODES_MODAL',
+ value: true,
+ });
+ });
+
+ test('Save button calls expected function', () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ expect(save).toHaveBeenCalled();
+ });
+
+ test('Close button calls expected function', () => {
+ wrapper.find('TimesIcon').simulate('click');
+ expect(close).toHaveBeenCalled();
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelpDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelpDetails.jsx
deleted file mode 100644
index 6080de3af4..0000000000
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelpDetails.jsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import React, { Fragment } from 'react';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import styled from 'styled-components';
-
-const GridDL = styled.dl`
- display: grid;
- grid-template-columns: max-content;
- column-gap: 15px;
- row-gap: 0px;
-
- dt {
- grid-column-start: 1;
- }
-
- dd {
- grid-column-start: 2;
- }
-`;
-
-function WorkflowHelpDetails({ d, i18n }) {
- const rows = [];
-
- if (d.type === 'link') {
- let linkType;
- switch (d.edgeType) {
- case 'always':
- linkType = i18n._(t`Always`);
- break;
- case 'success':
- linkType = i18n._(t`On Success`);
- break;
- case 'failure':
- linkType = i18n._(t`On Failure`);
- break;
- default:
- linkType = '';
- }
-
- rows.push({
- label: i18n._(t`Run`),
- value: linkType,
- });
- } else if (d.type === 'node') {
- if (d.unifiedJobTemplate) {
- rows.push({
- label: i18n._(t`Name`),
- value: d.unifiedJobTemplate.name,
- });
-
- let nodeType;
- switch (d.unifiedJobTemplate.unified_job_type) {
- case 'job':
- nodeType = i18n._(t`Job Template`);
- break;
- case 'workflow_job':
- nodeType = i18n._(t`Workflow Job Template`);
- break;
- case 'project_update':
- nodeType = i18n._(t`Project Update`);
- break;
- case 'inventory_update':
- nodeType = i18n._(t`Inventory Update`);
- break;
- case 'workflow_approval':
- nodeType = i18n._(t`Workflow Approval`);
- break;
- default:
- nodeType = '';
- }
-
- rows.push({
- label: i18n._(t`Type`),
- value: nodeType,
- });
- } else {
- // todo: this scenario (deleted)
- }
- }
-
- return (
-
- {rows.map(row => (
-
-
- {row.label}
-
- {row.value}
-
- ))}
-
- );
-}
-
-export default withI18n()(WorkflowHelpDetails);
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js
index f7f95d4961..0d7871e690 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js
@@ -1,6 +1,6 @@
export { default as Visualizer } from './Visualizer';
-export { default as Toolbar } from './Toolbar';
-export { default as Graph } from './Graph';
-export { default as StartScreen } from './StartScreen';
-export { default as WorkflowHelp } from './WorkflowHelp';
-export { default as WorkflowHelpDetails } from './WorkflowHelpDetails';
+export { default as VisualizerGraph } from './VisualizerGraph';
+export { default as VisualizerLink } from './VisualizerLink';
+export { default as VisualizerNode } from './VisualizerNode';
+export { default as VisualizerStartScreen } from './VisualizerStartScreen';
+export { default as VisualizerToolbar } from './VisualizerToolbar';
diff --git a/awx/ui_next/src/util/dates.jsx b/awx/ui_next/src/util/dates.jsx
index 644c896562..87c362414a 100644
--- a/awx/ui_next/src/util/dates.jsx
+++ b/awx/ui_next/src/util/dates.jsx
@@ -4,3 +4,7 @@ import { getLanguage } from './language';
export function formatDateString(dateString, lang = getLanguage(navigator)) {
return new Date(dateString).toLocaleString(lang);
}
+
+export function secondsToHHMMSS(seconds) {
+ return new Date(seconds * 1000).toISOString().substr(11, 8);
+}
diff --git a/awx/ui_next/webpack.config.js b/awx/ui_next/webpack.config.js
index e726078ffb..7f07d77c3e 100644
--- a/awx/ui_next/webpack.config.js
+++ b/awx/ui_next/webpack.config.js
@@ -60,6 +60,7 @@ module.exports = {
alias: {
'@api': path.join(SRC_PATH, 'api'),
'@components': path.join(SRC_PATH, 'components'),
+ '@constants': path.join(SRC_PATH, 'constants.js'),
'@contexts': path.join(SRC_PATH, 'contexts'),
'@screens': path.join(SRC_PATH, 'screens'),
'@types': path.join(SRC_PATH, 'types'),