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' })} /> +
+ +
  • + JT + {i18n._(t`Job Template`)} +
  • +
  • + W + {i18n._(t`Workflow`)} +
  • +
  • + I + {i18n._(t`Inventory Sync`)} +
  • +
  • + P + {i18n._(t`Project Sync`)} +
  • +
  • + + + + {i18n._(t`Approval`)} +
  • +
  • + + {i18n._(t`Warning`)} +
  • +
  • + + {i18n._(t`On Success`)} +
  • +
  • + + {i18n._(t`On Failure`)} +
  • +
  • + + {i18n._(t`Always`)} +
  • +
    +
    + ); +} + +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`)} +
    + +
    + ); +} + +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 && } + + )} + + + {nodePositions && [ + , + links.map(link => ( + setLinkHelp(link)} + mouseLeave={() => setLinkHelp(null)} + /> + )), + nodes.map(node => { + if (node.id > 1) { + return ( + setNodeHelp(node)} + mouseLeave={() => setNodeHelp(null)} + node={node} + /> + ); + } + return null; + }), + ]} + + +
    + {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( + + + {}} + mouseLeave={() => {}} + /> + + + ); + 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( + + + {}} + mouseLeave={() => {}} + node={nodeWithJT} + /> + + + ); + expect(wrapper).toHaveLength(1); + }); + test('node contents displayed correctly when Job and Job Template exist', () => { + const wrapper = mountWithContexts( + + + {}} + mouseLeave={() => {}} + node={nodeWithJT} + /> + + + ); + 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( + + + {}} + mouseLeave={() => {}} + node={nodeWithoutJT} + /> + + + ); + 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( + + + {}} + mouseLeave={() => {}} + node={{ id: 2 }} + /> + + + ); + 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' && ( + + {() => ( +
    + + + {({ field, form }) => { + const isValid = + form && + (!form.touched[field.name] || !form.errors[field.name]); + + return ( + + { + onUpdateName(evt.target.value); + field.onChange(evt); + }} + /> + + ); + }} + + + + + {({ field }) => ( + + { + onUpdateDescription(evt.target.value); + field.onChange(evt); + }} + /> + + )} + + + + +
    + + {({ field, form }) => ( + <> + { + if ( + !evt.target.value || + evt.target.value === '' + ) { + evt.target.value = 0; + } + onUpdateTimeout( + Number(evt.target.value) * 60 + + Number(form.values.timeoutSeconds) + ); + field.onChange(evt); + }} + /> + + min + + + )} + + + {({ field, form }) => ( + <> + { + if ( + !evt.target.value || + evt.target.value === '' + ) { + evt.target.value = 0; + } + onUpdateTimeout( + Number(evt.target.value) + + Number(form.values.timeoutMinutes) * 60 + ); + field.onChange(evt); + }} + /> + + sec + + + )} + +
    +
    +
    +
    + )} +
    + )} + + ); +} + +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( + + + + {}} + mouseLeave={() => {}} + node={nodeWithJT} + readOnly={false} + updateHelpText={updateHelpText} + updateNodeHelp={updateNodeHelp} + /> + + + + ); + }); + 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( + + + + {}} + mouseLeave={() => {}} + node={nodeWithJT} + readOnly={false} + updateHelpText={updateHelpText} + updateNodeHelp={updateNodeHelp} + /> + + + + ); + }); + 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( + + + {}} + mouseLeave={() => {}} + node={{ + id: 2, + }} + readOnly={false} + updateHelpText={() => {}} + updateNodeHelp={() => {}} + /> + + + ); + 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 ( +
    +
    +
    + {template.name} +
    +
    +
    {i18n._(t`Total Nodes`)}
    + + {totalNodes} + + + + 0 && showLegend} + isDisabled={totalNodes === 0} + onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })} + variant="plain" + > + + + + + 0 && showTools} + isDisabled={totalNodes === 0} + onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })} + variant="plain" + > + + + + + + + + + + + + dispatch({ + type: 'SET_SHOW_DELETE_ALL_NODES_MODAL', + value: true, + }) + } + variant="plain" + > + + + + + + + +
    +
    +
    + ); +} + +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'),