From ca478ac88098dd655a3279c7a260c7d95228a2a8 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 16 Dec 2019 09:57:55 -0500 Subject: [PATCH 01/46] Render workflow results. Extensive refactors of workflow components in general. --- awx/ui_next/jest.config.js | 1 + awx/ui_next/package-lock.json | 122 +-- awx/ui_next/src/api/models/WorkflowJobs.js | 4 + .../src/components/Sparkline/Sparkline.jsx | 2 +- .../Workflow/WorkflowActionTooltip.jsx | 62 ++ .../Workflow/WorkflowActionTooltipItem.jsx | 41 + .../Workflow}/WorkflowHelp.jsx | 11 +- .../components/Workflow/WorkflowLinkHelp.jsx | 45 + .../components/Workflow/WorkflowNodeHelp.jsx | 130 +++ .../Workflow/WorkflowNodeTypeLetter.jsx | 68 ++ awx/ui_next/src/components/Workflow/index.js | 8 + awx/ui_next/src/screens/Job/Job.jsx | 20 +- .../src/screens/Job/JobDetail/JobDetail.jsx | 10 + .../src/screens/Job/JobList/JobListItem.jsx | 2 +- .../src/screens/Job/JobTypeRedirect.jsx | 2 +- awx/ui_next/src/screens/Job/Jobs.jsx | 2 +- .../Job/WorkflowDetail/WorkflowDetail.jsx | 7 + .../src/screens/Job/WorkflowDetail/index.js | 1 + .../Job/WorkflowOutput/WorkflowOutput.jsx | 222 +++++ .../WorkflowOutput/WorkflowOutputGraph.jsx | 97 +++ .../Job/WorkflowOutput/WorkflowOutputLink.jsx | 40 + .../Job/WorkflowOutput/WorkflowOutputNode.jsx | 136 ++++ .../WorkflowOutputStartNode.jsx | 23 + .../src/screens/Job/WorkflowOutput/index.js | 5 + .../WorkflowJobTemplateVisualizer/Graph.jsx | 767 ------------------ .../Modals/NodeDeleteModal.jsx | 48 ++ .../Visualizer.jsx | 130 ++- .../VisualizerGraph.jsx | 118 +++ .../VisualizerLink.jsx | 136 ++++ .../VisualizerNode.jsx | 148 ++++ .../VisualizerStartNode.jsx | 63 ++ ...rtScreen.jsx => VisualizerStartScreen.jsx} | 0 .../{Toolbar.jsx => VisualizerToolbar.jsx} | 0 .../WorkflowHelpDetails.jsx | 95 --- .../WorkflowJobTemplateVisualizer/index.js | 11 +- awx/ui_next/src/util/dates.jsx | 4 + awx/ui_next/src/util/workflow.jsx | 174 ++++ awx/ui_next/webpack.config.js | 1 + 38 files changed, 1798 insertions(+), 958 deletions(-) create mode 100644 awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx rename awx/ui_next/src/{screens/Template/WorkflowJobTemplateVisualizer => components/Workflow}/WorkflowHelp.jsx (73%) create mode 100644 awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx create mode 100644 awx/ui_next/src/components/Workflow/index.js create mode 100644 awx/ui_next/src/screens/Job/WorkflowDetail/WorkflowDetail.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowDetail/index.js create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/index.js delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Graph.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/{StartScreen.jsx => VisualizerStartScreen.jsx} (100%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/{Toolbar.jsx => VisualizerToolbar.jsx} (100%) delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelpDetails.jsx create mode 100644 awx/ui_next/src/util/workflow.jsx diff --git a/awx/ui_next/jest.config.js b/awx/ui_next/jest.config.js index fb24626f6f..25f11b8f92 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/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/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/Workflow/WorkflowActionTooltip.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx new file mode 100644 index 0000000000..aa3a626578 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import styled from 'styled-components'; + +const TooltipContents = styled.div` + display: flex; +`; + +const TooltipArrows = styled.div` + width: 10px; +`; + +const TooltipArrowOuter = styled.div` + 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; +`; + +const TooltipArrowInner = styled.div` + position: absolute; + top: calc(50% - 10px); + left: 2px; + width: 0; + height: 0; + border-right: 10px solid white; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + margin: auto; +`; + +const TooltipActions = styled.div` + background-color: white; + border: 1px solid #c4c4c4; + border-radius: 2px; + padding: 5px; +`; + +function WorkflowActionTooltip({ actions, pointX, pointY }) { + const tipHeight = 25 * actions.length + 5 * actions.length - 1 + 10; + return ( + + + + + + + {actions} + + + ); +} + +export default WorkflowActionTooltip; 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..571b749ae7 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import styled from 'styled-components'; + +const TooltipItem = styled.div` + height: 25px; + width: 25px; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 2px; + + &:hover { + color: white; + background-color: #c4c4c4; + } + + &:not(:last-of-type) { + margin-bottom: 5px; + } +`; + +function WorkflowActionTooltip({ + children, + onMouseEnter, + onMouseLeave, + onClick, +}) { + return ( + + {children} + + ); +} + +export default WorkflowActionTooltip; 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..a69ed75844 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/WorkflowHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowHelp.jsx @@ -1,9 +1,10 @@ -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; `; const Inner = styled.div` @@ -19,11 +20,9 @@ const Inner = styled.div` function WorkflowHelp({ children }) { return ( - - - {children} - - + + {children} + ); } 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..3cd00b7aae --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx @@ -0,0 +1,45 @@ +import React 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 WorkflowLinkHelp({ link, i18n }) { + let linkType; + switch (link.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 = ''; + } + + return ( + +
+ {i18n._(t`Run`)} +
+
{linkType}
+
+ ); +} + +export default withI18n()(WorkflowLinkHelp); 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..8522c0bff8 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx @@ -0,0 +1,130 @@ +import React, { Fragment } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { secondsToHHMMSS } from '@util/dates'; + +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 WorkflowNodeHelp({ node, i18n }) { + let nodeType; + if (node.unifiedJobTemplate) { + switch (node.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 = ''; + } + } + + 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 && ( + +
+ {i18n._(t`Name`)} +
+
{node.unifiedJobTemplate.name}
+
+ {i18n._(t`Type`)} +
+
{nodeType}
+
+ )} + {node.job && ( + +
+ {i18n._(t`Job Status`)} +
+
{jobStatus}
+ {node.job.elapsed && ( + +
+ {i18n._(t`Elapsed`)} +
+
{secondsToHHMMSS(node.job.elapsed)}
+
+ )} +
+ )} +
+ {node.job && ( +

{i18n._(t`Click to view job details`)}

+ )} +
+ ); +} + +export default withI18n()(WorkflowNodeHelp); 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..557f32f202 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import styled from 'styled-components'; +import { PauseIcon } from '@patternfly/react-icons'; + +const NodeTypeLetter = styled.foreignObject` + font-size: 10px; + color: white; + text-align: center; + line-height: 20px; + background-color: #393f43; + border-radius: 50%; +`; + +function WorkflowNodeTypeLetter({ node }) { + let nodeTypeLetter; + if (node.unifiedJobTemplate && node.unifiedJobTemplate.type) { + switch (node.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; + case 'workflow_approval_template': + nodeTypeLetter = ; + break; + default: + nodeTypeLetter = ''; + } + } else if ( + node.unifiedJobTemplate && + node.unifiedJobTemplate.unified_job_type + ) { + switch (node.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; + case 'workflow_approval': + nodeTypeLetter = ; + break; + default: + nodeTypeLetter = ''; + } + } + + return ( + + {nodeTypeLetter} + + ); +} + +export default WorkflowNodeTypeLetter; 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..66cc6ef332 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/index.js @@ -0,0 +1,8 @@ +export { default as WorkflowHelp } from './WorkflowHelp'; +export { default as WorkflowLinkHelp } from './WorkflowLinkHelp'; +export { default as WorkflowNodeHelp } from './WorkflowNodeHelp'; +export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter'; +export { default as WorkflowActionTooltip } from './WorkflowActionTooltip'; +export { + default as WorkflowActionTooltipItem, +} from './WorkflowActionTooltipItem'; diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx index 7975260e8e..92018314bb 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) { @@ -124,12 +126,24 @@ class Job extends Component { } + render={() => + match.params.type === 'workflow' ? ( + + ) : ( + + ) + } />, } + render={() => + match.params.type === 'workflow' ? ( + + ) : ( + + ) + } />, :not(:first-child) { + margin-left: 20px; + } +`; const VariablesInput = styled(_VariablesInput)` .pf-c-form__label { 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..cee04e61cf --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -0,0 +1,222 @@ +import React, { useState, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { CardBody as PFCardBody } from '@patternfly/react-core'; +import { layoutGraph } from '@util/workflow'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { WorkflowJobsAPI } from '@api'; +import WorkflowOutputGraph from './WorkflowOutputGraph'; + +const CardBody = styled(PFCardBody)` + height: calc(100vh - 240px); + display: flex; + flex-direction: column; +`; + +const Toolbar = styled.div` + height: 50px; + background-color: grey; +`; + +const Wrapper = styled.div` + display: flex; + flex-flow: column; + height: 100%; +`; + +const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => { + try { + const { data } = await WorkflowJobsAPI.readNodes(jobId, { + page_size: 200, + page: pageNo, + }); + if (data.next) { + return await fetchWorkflowNodes( + jobId, + pageNo + 1, + nodes.concat(data.results) + ); + } + return nodes.concat(data.results); + } catch (error) { + throw error; + } +}; + +function WorkflowOutput({ job, i18n }) { + const [contentError, setContentError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [graphLinks, setGraphLinks] = useState([]); + const [graphNodes, setGraphNodes] = useState([]); + const [nodePositions, setNodePositions] = useState(null); + + 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(job.id); + buildGraphArrays(nodes); + } catch (error) { + setContentError(error); + } finally { + setIsLoading(false); + } + } + fetchData(); + }, [job.id, job.unified_job_template, i18n]); + + // Update positions of nodes/links + useEffect(() => { + if (graphNodes) { + const newNodePositions = {}; + const g = layoutGraph(graphNodes, graphLinks); + + g.nodes().forEach(node => { + newNodePositions[node] = g.node(node); + }); + + setNodePositions(newNodePositions); + } + }, [graphLinks, graphNodes]); + + if (isLoading) { + return ( + + + + ); + } + + if (contentError) { + return ( + + + + ); + } + + return ( + + + Toolbar + {nodePositions && ( + + )} + + + ); +} + +export default withI18n()(WorkflowOutput); 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..04112d149d --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -0,0 +1,97 @@ +import React, { Fragment, useEffect, useRef, useState } from 'react'; +import * as d3 from 'd3'; +import { WorkflowHelp, WorkflowNodeHelp } from '@components/Workflow'; +import { calcZoomAndFit } from '@util/workflow'; +import { + WorkflowOutputLink, + WorkflowOutputNode, + WorkflowOutputStartNode, +} from '@screens/Job/WorkflowOutput'; + +function WorkflowOutputGraph({ links, nodes, nodePositions }) { + const [nodeHelp, setNodeHelp] = useState(); + const svgRef = useRef(null); + const gRef = useRef(null); + + // 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})` + ); + }; + + 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(() => { + const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current); + + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) + ); + // We only want this to run once (when the component mounts) + // Including zoomRef.transform 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 && ( + + + + )} + + + {nodePositions && [ + , + links.map(link => ( + + )), + nodes.map(node => { + if (node.id > 1) { + return ( + setNodeHelp(node)} + mouseLeave={() => setNodeHelp(null)} + /> + ); + } + return null; + }), + ]} + + + + ); +} + +export default WorkflowOutputGraph; 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..bc9dde7874 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx @@ -0,0 +1,40 @@ +import React, { useEffect, useState } from 'react'; +import { generateLine, getLinePoints } from '@util/workflow'; + +function WorkflowOutputLink({ link, nodePositions }) { + const [pathD, setPathD] = useState(); + const [pathStroke, setPathStroke] = useState('#CCCCCC'); + + useEffect(() => { + if (link.edgeType === 'failure') { + setPathStroke('#d9534f'); + } + if (link.edgeType === 'success') { + setPathStroke('#5cb85c'); + } + if (link.edgeType === 'always') { + setPathStroke('#337ab7'); + } + }, [link.edgeType]); + + useEffect(() => { + const linePoints = getLinePoints(link, nodePositions); + setPathD(generateLine(linePoints)); + }, [link, nodePositions]); + + return ( + + + + ); +} + +export default WorkflowOutputLink; 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..a8749e60e8 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -0,0 +1,136 @@ +import React, { Fragment } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { StatusIcon } from '@components/Sparkline'; +import { WorkflowNodeTypeLetter } from '@components/Workflow'; +import { secondsToHHMMSS } from '@util/dates'; +import { JOB_TYPE_URL_SEGMENTS } from '@constants'; +import { constants as wfConstants } from '@util/workflow'; + +const NodeG = styled.g` + cursor: ${props => (props.job ? 'pointer' : 'default')}; +`; + +const JobTopLine = styled.div` + display: flex; + align-items: center; + margin-top: 5px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + p { + margin-left: 10px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } +`; + +const Elapsed = styled.div` + text-align: center; + margin-top: 5px; + + span { + font-size: 12px; + font-weight: bold; + background-color: #ededed; + padding: 3px 12px; + border-radius: 14px; + } +`; + +const NodeContents = styled.foreignObject` + font-size: 13px; + padding: 0px 10px; +`; + +const NodeDefaultLabel = styled.p` + margin-top: 20px; + text-align: center; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +`; + +function WorkflowOutputNode({ + node, + nodePositions, + mouseEnter, + mouseLeave, + i18n, +}) { + 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) { + window.open( + `/#/jobs/${JOB_TYPE_URL_SEGMENTS[node.job.type]}/${node.job.id}`, + '_blank' + ); + } + }; + + return ( + + + + {node.job ? ( + + + +

+ {node.unifiedJobTemplate + ? node.unifiedJobTemplate.name + : i18n._(t`DELETED`)} +

+
+ + {secondsToHHMMSS(node.job.elapsed)} + +
+ ) : ( + + {node.unifiedJobTemplate + ? node.unifiedJobTemplate.name + : i18n._(t`DELETED`)} + + )} +
+ + {node.unifiedJobTemplate && } +
+ ); +} + +export default withI18n()(WorkflowOutputNode); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx new file mode 100644 index 0000000000..0101200732 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { constants as wfConstants } from '@util/workflow'; + +function WorkflowOutputStartNode({ nodePositions }) { + return ( + + + {/* TODO: Translate this...? */} + + START + + + ); +} + +export default WorkflowOutputStartNode; 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..6580c4f7d3 --- /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 WorkflowOutputStartNode } from './WorkflowOutputStartNode'; 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/NodeDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx new file mode 100644 index 0000000000..fd89907c68 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx @@ -0,0 +1,48 @@ +import React, { Fragment } from 'react'; +import { Button } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import AlertModal from '@components/AlertModal'; + +function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { + return ( + onConfirm()} + > + {i18n._(t`Remove`)} + , + , + ]} + > + {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/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index fbd2dde245..6c3bb30994 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -1,12 +1,14 @@ -import React, { useState, useEffect } from 'react'; +import React, { Fragment, useState, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import { layoutGraph } from '@util/workflow'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; -import Graph from './Graph'; -import StartScreen from './StartScreen'; -import Toolbar from './Toolbar'; +import NodeDeleteModal from './Modals/NodeDeleteModal'; +import VisualizerGraph from './VisualizerGraph'; +import VisualizerStartScreen from './VisualizerStartScreen'; +import VisualizerToolbar from './VisualizerToolbar'; import { WorkflowJobTemplatesAPI } from '@api'; const CenteredContent = styled.div` @@ -17,7 +19,7 @@ const CenteredContent = styled.div` justify-content: center; `; -const VisualizerLayout = styled.div` +const Wrapper = styled.div` display: flex; flex-flow: column; height: 100%; @@ -48,6 +50,77 @@ function Visualizer({ template, i18n }) { const [graphLinks, setGraphLinks] = useState([]); // We'll also need to store the original set of nodes... const [graphNodes, setGraphNodes] = useState([]); + const [nodePositions, setNodePositions] = useState(null); + const [nodeToDelete, setNodeToDelete] = useState(null); + + const deleteNode = () => { + const nodeId = nodeToDelete.id; + const newGraphNodes = [...graphNodes]; + const newGraphLinks = [...graphLinks]; + + // Remove the node from the array + for (let i = newGraphNodes.length; i--; ) { + if (newGraphNodes[i].id === nodeId) { + newGraphNodes.splice(i, 1); + i = 0; + } + } + + // Update the links + const parents = []; + const children = []; + const linkParentMapping = {}; + + // Remove any links that reference this node + for (let i = newGraphLinks.length; i--; ) { + const link = newGraphLinks[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, edgeType: link.edgeType }); + } else if (link.target.id === nodeId) { + parents.push(link.source.id); + } + newGraphLinks.splice(i, 1); + } + } + + // Add the new links + 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) { + newGraphLinks.push({ + source: { id: parentId }, + target: { id: child.id }, + edgeType: 'always', + type: 'link', + }); + } + } else if (!linkParentMapping[child.id].includes(parentId)) { + newGraphLinks.push({ + source: { id: parentId }, + target: { id: child.id }, + edgeType: child.edgeType, + type: 'link', + }); + } + }); + }); + // need to track that this node has been deleted if it's not new + + setNodeToDelete(null); + setGraphNodes(newGraphNodes); + setGraphLinks(newGraphLinks); + }; useEffect(() => { const buildGraphArrays = nodes => { @@ -170,6 +243,20 @@ function Visualizer({ template, i18n }) { fetchData(); }, [template.id, i18n]); + // Update positions of nodes/links + useEffect(() => { + if (graphNodes) { + const newNodePositions = {}; + const g = layoutGraph(graphNodes, graphLinks); + + g.nodes().forEach(node => { + newNodePositions[node] = g.node(node); + }); + + setNodePositions(newNodePositions); + } + }, [graphLinks, graphNodes]); + if (isLoading) { return ( @@ -187,18 +274,27 @@ function Visualizer({ template, i18n }) { } return ( - - - {graphLinks.length > 0 ? ( - - ) : ( - - )} - + + + + {graphLinks.length > 0 ? ( + + ) : ( + + )} + + setNodeToDelete(null)} + /> + ); } 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..76e528cab4 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -0,0 +1,118 @@ +import React, { Fragment, useEffect, useRef, useState } from 'react'; +import * as d3 from 'd3'; +import { calcZoomAndFit } from '@util/workflow'; +import { + WorkflowHelp, + WorkflowLinkHelp, + WorkflowNodeHelp, +} from '@components/Workflow'; +import { + VisualizerLink, + VisualizerNode, + VisualizerStartNode, +} from '@screens/Template/WorkflowJobTemplateVisualizer'; + +function VizualizerGraph({ + links, + nodes, + readOnly, + nodePositions, + onDeleteNodeClick, +}) { + const [helpText, setHelpText] = useState(null); + const [nodeHelp, setNodeHelp] = useState(); + const [linkHelp, setLinkHelp] = useState(); + const svgRef = useRef(null); + const gRef = useRef(null); + + // 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})` + ); + }; + + 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(() => { + const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current); + + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) + ); + // We only want this to run once (when the component mounts) + // Including zoomRef.transform 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 && } +
+ )} + + + {nodePositions && [ + , + links.map(link => ( + + )), + nodes.map(node => { + if (node.id > 1) { + return ( + + ); + } + return null; + }), + ]} + + +
+ ); +} + +export default VizualizerGraph; 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..38b452f033 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons'; +import { + generateLine, + getLinkOverlayPoints, + getLinePoints, +} from '@util/workflow'; +import { + WorkflowActionTooltip, + WorkflowActionTooltipItem, +} from '@components/Workflow'; + +function VisualizerLink({ + link, + nodePositions, + readOnly, + updateHelpText, + updateLinkHelp, + i18n, +}) { + const [hovering, setHovering] = useState(false); + const [pathD, setPathD] = useState(); + const [pathStroke, setPathStroke] = useState('#CCCCCC'); + const [tooltipX, setTooltipX] = useState(); + const [tooltipY, setTooltipY] = useState(); + + const addNodeAction = ( + + updateHelpText(i18n._(t`Add a new node between these two nodes`)) + } + onMouseLeave={() => updateHelpText(null)} + > + + + ); + + const tooltipActions = + link.source.id === 1 + ? [addNodeAction] + : [ + addNodeAction, + updateHelpText(i18n._(t`Edit this link`))} + onMouseLeave={() => updateHelpText(null)} + > + + , + updateHelpText(i18n._(t`Delete this link`))} + onMouseLeave={() => updateHelpText(null)} + > + + , + ]; + + const handleLinkMouseEnter = () => { + const linkEl = document.getElementById( + `link-${link.source.id}-${link.target.id}` + ); + linkEl.parentNode.appendChild(linkEl); + setHovering(true); + }; + + const handleLinkMouseLeave = () => { + const linkEl = document.getElementById( + `link-${link.source.id}-${link.target.id}` + ); + linkEl.parentNode.prepend(linkEl); + setHovering(null); + }; + + useEffect(() => { + if (link.edgeType === 'failure') { + setPathStroke('#d9534f'); + } + if (link.edgeType === 'success') { + setPathStroke('#5cb85c'); + } + if (link.edgeType === 'always') { + setPathStroke('#337ab7'); + } + }, [link.edgeType]); + + 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)} + /> + {!readOnly && hovering && ( + + )} + + ); +} + +export default withI18n()(VisualizerLink); 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..80ce5161d2 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + InfoIcon, + LinkIcon, + PencilAltIcon, + PlusIcon, + TrashAltIcon, +} from '@patternfly/react-icons'; +import { constants as wfConstants } from '@util/workflow'; +import { + WorkflowActionTooltip, + WorkflowActionTooltipItem, + WorkflowNodeTypeLetter, +} from '@components/Workflow'; + +// dont need this in this component +const NodeG = styled.g` + cursor: ${props => (props.job ? 'pointer' : 'default')}; +`; + +const NodeContents = styled.foreignObject` + font-size: 13px; + padding: 0px 10px; +`; + +const NodeDefaultLabel = styled.p` + margin-top: 20px; + text-align: center; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +`; + +function VisualizerNode({ + node, + nodePositions, + updateHelpText, + updateNodeHelp, + readOnly, + i18n, + onDeleteNodeClick, +}) { + const [hovering, setHovering] = useState(false); + + const handleNodeMouseEnter = () => { + const nodeEl = document.getElementById(`node-${node.id}`); + nodeEl.parentNode.appendChild(nodeEl); + setHovering(true); + }; + + const viewDetailsAction = ( + updateHelpText(i18n._(t`View node details`))} + onMouseLeave={() => updateHelpText(null)} + > + + + ); + + const tooltipActions = readOnly + ? [viewDetailsAction] + : [ + updateHelpText(i18n._(t`Add a new node`))} + onMouseLeave={() => updateHelpText(null)} + > + + , + viewDetailsAction, + updateHelpText(i18n._(t`Edit this node`))} + onMouseLeave={() => updateHelpText(null)} + > + + , + + updateHelpText(i18n._(t`Link to an available node`)) + } + onMouseLeave={() => updateHelpText(null)} + > + + , + updateHelpText(i18n._(t`Delete this node`))} + onMouseLeave={() => updateHelpText(null)} + onClick={() => onDeleteNodeClick(node)} + > + + , + ]; + + return ( + setHovering(false)} + > + + updateNodeHelp(node)} + onMouseLeave={() => updateNodeHelp(null)} + > + + {node.unifiedJobTemplate + ? node.unifiedJobTemplate.name + : i18n._(t`DELETED`)} + + + {node.unifiedJobTemplate && } + {hovering && ( + + )} + + ); +} + +export default withI18n()(VisualizerNode); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx new file mode 100644 index 0000000000..8ded33b189 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { constants as wfConstants } from '@util/workflow'; +import { + WorkflowActionTooltip, + WorkflowActionTooltipItem, +} from '@components/Workflow'; + +function VisualizerStartNode({ + updateHelpText, + nodePositions, + readOnly, + i18n, +}) { + const [hovering, setHovering] = useState(false); + + const handleNodeMouseEnter = () => { + const nodeEl = document.getElementById('node-1'); + nodeEl.parentNode.appendChild(nodeEl); + setHovering(true); + }; + + return ( + setHovering(false)} + > + + {/* TODO: We need to be able to handle translated text here */} + + START + + {!readOnly && hovering && ( + updateHelpText(i18n._(t`Add a new node`))} + onMouseLeave={() => updateHelpText(null)} + > + + , + ]} + /> + )} + + ); +} + +export default withI18n()(VisualizerStartNode); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/StartScreen.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/StartScreen.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Toolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Toolbar.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx 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..c593ae9701 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js @@ -1,6 +1,7 @@ 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 VisualizerToolbar } from './VisualizerToolbar'; +export { default as VisualizerGraph } from './VisualizerGraph'; +export { default as VisualizerStartScreen } from './VisualizerStartScreen'; +export { default as VisualizerStartNode } from './VisualizerStartNode'; +export { default as VisualizerLink } from './VisualizerLink'; +export { default as VisualizerNode } from './VisualizerNode'; 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/src/util/workflow.jsx b/awx/ui_next/src/util/workflow.jsx new file mode 100644 index 0000000000..63191237ad --- /dev/null +++ b/awx/ui_next/src/util/workflow.jsx @@ -0,0 +1,174 @@ +/* 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 calcZoomAndFit(gRef) { + const gBoundingClientRect = d3 + .select(gRef) + .node() + .getBoundingClientRect(); + + const gBBoxDimensions = d3 + .select(gRef) + .node() + .getBBox(); + + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + + // 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 * 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(d, nodePositions) { + 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(' '); +} + +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; +} 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'), From de55af6ae6416f85dfaf4cec08b450e296d471f4 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 13 Jan 2020 11:13:40 -0500 Subject: [PATCH 02/46] Fully functioning workflow editor without read-only view modal and without prompting. --- awx/ui_next/src/api/index.js | 6 + .../api/models/WorkflowApprovalTemplates.js | 10 + .../api/models/WorkflowJobTemplateNodes.js | 56 ++ .../src/api/models/WorkflowJobTemplates.js | 4 + .../components/AddRole/AddResourceRole.jsx | 2 +- awx/ui_next/src/components/AddRole/index.js | 1 - .../HorizontalSeparator.jsx | 14 + .../HorizontalSeparator.test.jsx | 11 + .../components/HorizontalSeparator/index.js | 1 + .../SelectableCard.jsx | 48 +- .../SelectableCard.test.jsx | 0 .../src/components/SelectableCard/index.js | 1 + .../components/Workflow/WorkflowNodeHelp.jsx | 9 +- .../Workflow/WorkflowNodeTypeLetter.jsx | 33 +- .../Modals/DeleteAllNodesModal.jsx | 42 ++ .../Modals/LinkDeleteModal.jsx | 48 ++ .../Modals/LinkModal.jsx | 72 ++ .../Modals/NodeDeleteModal.jsx | 2 +- .../Modals/NodeModal/ApprovalPreviewStep.jsx | 49 ++ .../NodeModal/InventorySyncPreviewStep.jsx | 39 + .../NodeModal/JobTemplatePreviewStep.jsx | 185 +++++ .../Modals/NodeModal/NodeApprovalStep.jsx | 161 ++++ .../Modals/NodeModal/NodeModal.jsx | 310 ++++++++ .../Modals/NodeModal/NodeNextButton.jsx | 26 + .../Modals/NodeModal/NodeResourceStep.jsx | 120 +++ .../Modals/NodeModal/NodeTypeStep.jsx | 105 +++ .../NodeModal/ProjectSyncPreviewStep.jsx | 39 + .../WorkflowJobTemplatePreviewStep.jsx | 43 ++ .../Modals/UnsavedChangesModal.jsx | 43 ++ .../Visualizer.jsx | 713 ++++++++++++++++-- .../VisualizerGraph.jsx | 283 ++++++- .../VisualizerKey.jsx | 116 +++ .../VisualizerLink.jsx | 21 +- .../VisualizerNode.jsx | 76 +- .../VisualizerStartNode.jsx | 20 +- .../VisualizerStartScreen.jsx | 9 +- .../VisualizerToolbar.jsx | 92 ++- .../VisualizerTools.jsx | 122 +++ .../WorkflowJobTemplateVisualizer/index.js | 2 + awx/ui_next/src/util/workflow.jsx | 34 +- 40 files changed, 2799 insertions(+), 169 deletions(-) create mode 100644 awx/ui_next/src/api/models/WorkflowApprovalTemplates.js create mode 100644 awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js create mode 100644 awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx create mode 100644 awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx create mode 100644 awx/ui_next/src/components/HorizontalSeparator/index.js rename awx/ui_next/src/components/{AddRole => SelectableCard}/SelectableCard.jsx (59%) rename awx/ui_next/src/components/{AddRole => SelectableCard}/SelectableCard.test.jsx (100%) create mode 100644 awx/ui_next/src/components/SelectableCard/index.js create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx 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..dfb434c831 --- /dev/null +++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js @@ -0,0 +1,56 @@ +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, + }); + } +} + +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/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2a1189dfb6..8576e18bbe 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import { Wizard } from '@patternfly/react-core'; import SelectResourceStep from './SelectResourceStep'; import SelectRoleStep from './SelectRoleStep'; -import SelectableCard from './SelectableCard'; +import { SelectableCard } from '@components/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/HorizontalSeparator/HorizontalSeparator.jsx b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx new file mode 100644 index 0000000000..b1646660fe --- /dev/null +++ b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Separator = styled.div` + width: 100%; + height: 1px; + margin-top: 20px; + margin-bottom: 20px; + background-color: #d7d7d7; +`; + +const HorizontalSeparator = () => ; + +export default HorizontalSeparator; diff --git a/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx new file mode 100644 index 0000000000..c02794494b --- /dev/null +++ b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import HorizontalSeparator from './HorizontalSeparator'; + +describe('HorizontalSeparator', () => { + test('renders the expected content', () => { + const wrapper = mount(); + expect(wrapper).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/components/HorizontalSeparator/index.js b/awx/ui_next/src/components/HorizontalSeparator/index.js new file mode 100644 index 0000000000..7f9fe23413 --- /dev/null +++ b/awx/ui_next/src/components/HorizontalSeparator/index.js @@ -0,0 +1 @@ +export { default } from './HorizontalSeparator'; diff --git a/awx/ui_next/src/components/AddRole/SelectableCard.jsx b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx similarity index 59% rename from awx/ui_next/src/components/AddRole/SelectableCard.jsx rename to awx/ui_next/src/components/SelectableCard/SelectableCard.jsx index 475af3d2ce..a1dfa28ae3 100644 --- a/awx/ui_next/src/components/AddRole/SelectableCard.jsx +++ b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; @@ -12,7 +12,6 @@ const SelectableItem = styled.div` ? 'var(--pf-global--active-color--100)' : 'var(--pf-global--BorderColor--200)'}; margin-right: 20px; - font-weight: bold; display: flex; cursor: pointer; `; @@ -24,31 +23,31 @@ const Indicator = styled.div` props.isSelected ? 'var(--pf-global--active-color--100)' : null}; `; -const Label = styled.div` - display: flex; - flex: 1; - align-items: center; - padding: 20px; +const Contents = styled.div` + padding: 10px 20px; `; -class SelectableCard extends Component { - render() { - const { label, onClick, isSelected, dataCy } = this.props; +const Description = styled.p` + font-size: 14px; +`; - return ( - - - - - ); - } +function SelectableCard({ label, description, onClick, isSelected, dataCy }) { + return ( + + + + {label} + {description} + + + ); } SelectableCard.propTypes = { @@ -59,6 +58,7 @@ SelectableCard.propTypes = { 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..1253d7447c --- /dev/null +++ b/awx/ui_next/src/components/SelectableCard/index.js @@ -0,0 +1 @@ +export { default as SelectableCard } from './SelectableCard'; diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx index 8522c0bff8..17524b720e 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx @@ -20,19 +20,26 @@ const GridDL = styled.dl` function WorkflowNodeHelp({ node, i18n }) { let nodeType; if (node.unifiedJobTemplate) { - switch (node.unifiedJobTemplate.unified_job_type) { + const type = + node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.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; diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx index 557f32f202..013c1ed88d 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx @@ -13,43 +13,30 @@ const NodeTypeLetter = styled.foreignObject` function WorkflowNodeTypeLetter({ node }) { let nodeTypeLetter; - if (node.unifiedJobTemplate && node.unifiedJobTemplate.type) { - switch (node.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; - case 'workflow_approval_template': - nodeTypeLetter = ; - break; - default: - nodeTypeLetter = ''; - } - } else if ( + if ( node.unifiedJobTemplate && - node.unifiedJobTemplate.unified_job_type + (node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type) ) { - switch (node.unifiedJobTemplate.unified_job_type) { + const ujtType = + node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_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; 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..86a19f586f --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import AlertModal from '@components/AlertModal'; + +function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { + return ( + onConfirm()} + > + {i18n._(t`Remove`)} + , + , + ]} + > +

+ {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/LinkDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx new file mode 100644 index 0000000000..db8222b8b8 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx @@ -0,0 +1,48 @@ +import React, { Fragment } from 'react'; +import { Button } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import AlertModal from '@components/AlertModal'; + +function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) { + return ( + onConfirm()} + > + {i18n._(t`Remove`)} + , + , + ]} + > +

{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/LinkModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx new file mode 100644 index 0000000000..6c76c80e4f --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { Button, Modal } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { FormGroup } from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; + +function LinkModal({ + i18n, + header, + onCancel, + onConfirm, + edgeType = 'success', +}) { + const [newEdgeType, setNewEdgeType] = useState(edgeType); + return ( + onConfirm(newEdgeType)} + > + {i18n._(t`Save`)} + , + , + ]} + > + + { + setNewEdgeType(value); + }} + /> + + + ); +} + +export default withI18n()(LinkModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx index fd89907c68..5d98bc2550 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx @@ -8,7 +8,7 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { return ( + + {i18n._(t`Approval Node`)} + + + + + + + + + + ); +} + +export default withI18n()(ApprovalPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx new file mode 100644 index 0000000000..d385169117 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Title } from '@patternfly/react-core'; +import { DetailList, Detail } from '@components/DetailList'; +import HorizontalSeparator from '@components/HorizontalSeparator'; + +function InventorySyncPreviewStep({ i18n, inventorySource, linkType }) { + let linkTypeValue; + + switch (linkType) { + case 'success': + linkTypeValue = i18n._(t`On Success`); + break; + case 'failure': + linkTypeValue = i18n._(t`On Failure`); + break; + case 'always': + linkTypeValue = i18n._(t`Always`); + break; + default: + break; + } + + return ( +
+ + {i18n._(t`Inventory Sync Node`)} + + + + + + +
+ ); +} + +export default withI18n()(InventorySyncPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx new file mode 100644 index 0000000000..c04daa8e58 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Title } from '@patternfly/react-core'; +import { DetailList, Detail } from '@components/DetailList'; +import HorizontalSeparator from '@components/HorizontalSeparator'; + +function JobTemplatePreviewStep({ i18n, jobTemplate, linkType }) { + let linkTypeValue; + + switch (linkType) { + case 'success': + linkTypeValue = i18n._(t`On Success`); + break; + case 'failure': + linkTypeValue = i18n._(t`On Failure`); + break; + case 'always': + linkTypeValue = i18n._(t`Always`); + break; + default: + break; + } + + return ( +
+ + {i18n._(t`Job Template Node`)} + + + + + + {/* + + {summary_fields.inventory ? ( + + ) : ( + !ask_inventory_on_launch && + renderMissingDataDetail(i18n._(t`Inventory`)) + )} + {summary_fields.project ? ( + + {summary_fields.project + ? summary_fields.project.name + : i18n._(t`Deleted`)} + + } + /> + ) : ( + renderMissingDataDetail(i18n._(t`Project`)) + )} + + + + + + {createdBy && ( + + )} + {modifiedBy && ( + + )} + + + {host_config_key && ( + + + + + )} + {renderOptionsField && ( + + )} + {summary_fields.credentials && + summary_fields.credentials.length > 0 && ( + + {summary_fields.credentials.map(c => ( + + ))} + + } + /> + )} + {summary_fields.labels && summary_fields.labels.results.length > 0 && ( + + {summary_fields.labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + {instanceGroups.length > 0 && ( + + {instanceGroups.map(ig => ( + + {ig.name} + + ))} + + } + /> + )} + {job_tags && job_tags.length > 0 && ( + + {job_tags.split(',').map(jobTag => ( + + {jobTag} + + ))} + + } + /> + )} + {skip_tags && skip_tags.length > 0 && ( + + {skip_tags.split(',').map(skipTag => ( + + {skipTag} + + ))} + + } + /> + )} */} + + +
+ ); +} + +export default withI18n()(JobTemplatePreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx new file mode 100644 index 0000000000..b6905a077c --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { Formik, Field } from 'formik'; +import { Form, FormGroup, TextInput, Title } from '@patternfly/react-core'; +import FormRow from '@components/FormRow'; +import HorizontalSeparator from '@components/HorizontalSeparator'; + +const TimeoutInput = styled(TextInput)` + width: 200px; + :not(:first-of-type) { + margin-left: 20px; + } +`; + +const TimeoutLabel = styled.p` + margin-left: 10px; +`; + +function NodeApprovalStep({ + i18n, + name, + updateName, + description, + updateDescription, + timeout = 0, + updateTimeout, +}) { + return ( +
+ + {i18n._(t`Approval Node`)} + + + ( +
+ + { + const isValid = + form && + (!form.touched[field.name] || !form.errors[field.name]); + + return ( + + { + updateName(value); + field.onChange(event); + }} + autoFocus + /> + + ); + }} + /> + + + ( + + { + updateDescription(value); + field.onChange(event); + }} + /> + + )} + /> + + + +
+ ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) * 60 + + Number(form.values.timeoutSeconds) + ); + field.onChange(event); + }} + /> + min + + )} + /> + ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) + + Number(form.values.timeoutMinutes) * 60 + ); + field.onChange(event); + }} + /> + sec + + )} + /> +
+
+
+
+ )} + /> +
+ ); +} + +export default withI18n()(NodeApprovalStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx new file mode 100644 index 0000000000..777e73d7fa --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx @@ -0,0 +1,310 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Button, + Wizard, + WizardContextConsumer, + WizardFooter, +} from '@patternfly/react-core'; +import NodeResourceStep from './NodeResourceStep'; +import NodeTypeStep from './NodeTypeStep'; +import NodeNextButton from './NodeNextButton'; +import NodeApprovalStep from './NodeApprovalStep'; +import ApprovalPreviewStep from './ApprovalPreviewStep'; +import JobTemplatePreviewStep from './JobTemplatePreviewStep'; +import InventorySyncPreviewStep from './InventorySyncPreviewStep'; +import ProjectSyncPreviewStep from './ProjectSyncPreviewStep'; +import WorkflowJobTemplatePreviewStep from './WorkflowJobTemplatePreviewStep'; + +import { + JobTemplatesAPI, + ProjectsAPI, + InventorySourcesAPI, + WorkflowJobTemplatesAPI, +} from '@api'; + +const readInventorySources = async queryParams => + InventorySourcesAPI.read(queryParams); +const readJobTemplates = async queryParams => + JobTemplatesAPI.read(queryParams, { role_level: 'execute_role' }); +const readProjects = async queryParams => ProjectsAPI.read(queryParams); +const readWorkflowJobTemplates = async queryParams => + WorkflowJobTemplatesAPI.read(queryParams, { role_level: 'execute_role' }); + +function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { + let defaultNodeType = 'job_template'; + let defaultNodeResource = null; + let defaultApprovalName = ''; + let defaultApprovalDescription = ''; + let defaultApprovalTimeout = 0; + if (node && node.unifiedJobTemplate) { + if ( + node && + node.unifiedJobTemplate && + (node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type) + ) { + const ujtType = + node.unifiedJobTemplate.type || + node.unifiedJobTemplate.unified_job_type; + switch (ujtType) { + case 'job_template': + case 'job': + defaultNodeType = 'job_template'; + defaultNodeResource = node.unifiedJobTemplate; + break; + case 'project': + case 'project_update': + defaultNodeType = 'project_sync'; + defaultNodeResource = node.unifiedJobTemplate; + break; + case 'inventory_source': + case 'inventory_update': + defaultNodeType = 'inventory_source_sync'; + defaultNodeResource = node.unifiedJobTemplate; + break; + case 'workflow_job_template': + case 'workflow_job': + defaultNodeType = 'workflow_job_template'; + defaultNodeResource = node.unifiedJobTemplate; + break; + case 'workflow_approval_template': + case 'workflow_approval': + defaultNodeType = 'approval'; + defaultApprovalName = node.unifiedJobTemplate.name; + defaultApprovalDescription = node.unifiedJobTemplate.description; + defaultApprovalTimeout = node.unifiedJobTemplate.timeout; + break; + default: + } + } + } + const [nodeType, setNodeType] = useState(defaultNodeType); + const [linkType, setLinkType] = useState('success'); + const [nodeResource, setNodeResource] = useState(defaultNodeResource); + const [showApprovalStep, setShowApprovalStep] = useState( + defaultNodeType === 'approval' + ); + const [showResourceStep, setShowResourceStep] = useState( + defaultNodeResource ? true : false + ); + const [showPreviewStep, setShowPreviewStep] = useState( + defaultNodeType === 'approval' || defaultNodeResource ? true : false + ); + const [triggerNext, setTriggerNext] = useState(0); + const [approvalName, setApprovalName] = useState(defaultApprovalName); + const [approvalDescription, setApprovalDescription] = useState( + defaultApprovalDescription + ); + const [approvalTimeout, setApprovalTimeout] = useState( + defaultApprovalTimeout + ); + + const handleSaveNode = () => { + const resource = + nodeType === 'approval' + ? { + name: approvalName, + description: approvalDescription, + timeout: approvalTimeout, + type: 'workflow_approval_template', + } + : nodeResource; + + // TODO: pick edgeType or linkType and be consistent across all files. + + onSave({ + nodeType, + edgeType: linkType, + nodeResource: resource, + }); + }; + + const resourceSearch = queryParams => { + switch (nodeType) { + case 'inventory_source_sync': + return readInventorySources(queryParams); + case 'job_template': + return readJobTemplates(queryParams); + case 'project_sync': + return readProjects(queryParams); + case 'workflow_job_template': + return readWorkflowJobTemplates(queryParams); + default: + throw new Error(i18n._(t`Missing node type`)); + } + }; + + const handleNextClick = activeStep => { + if (activeStep.key === 'node_type') { + if ( + [ + 'inventory_source_sync', + 'job_template', + 'project_sync', + 'workflow_job_template', + ].includes(nodeType) + ) { + setShowApprovalStep(false); + setShowResourceStep(true); + } else if (nodeType === 'approval') { + setShowResourceStep(false); + setShowApprovalStep(true); + } + setShowPreviewStep(true); + } + setTriggerNext(triggerNext + 1); + }; + + const handleNodeTypeChange = newNodeType => { + setNodeType(newNodeType); + setShowResourceStep(false); + setShowApprovalStep(false); + setShowPreviewStep(false); + setNodeResource(null); + setApprovalName(''); + setApprovalDescription(''); + setApprovalTimeout(0); + }; + + const steps = [ + { + name: node ? i18n._(t`Node Type`) : i18n._(t`Run/Node Type`), + key: 'node_type', + component: ( + + ), + enableNext: nodeType !== null, + }, + ...(showResourceStep + ? [ + { + name: i18n._(t`Select Node Resource`), + key: 'node_resource', + enableNext: nodeResource !== null, + component: ( + + ), + }, + ] + : []), + ...(showApprovalStep + ? [ + { + name: i18n._(t`Configure Approval`), + key: 'approval', + component: ( + + ), + enableNext: approvalName !== '', + }, + ] + : []), + ...(showPreviewStep + ? [ + { + name: i18n._(t`Preview`), + key: 'preview', + component: ( + <> + {nodeType === 'approval' && ( + + )} + {nodeType === 'job_template' && ( + + )} + {nodeType === 'inventory_source_sync' && ( + + )} + {nodeType === 'project_sync' && ( + + )} + {nodeType === 'workflow_job_template' && ( + + )} + + ), + enableNext: true, + }, + ] + : []), + ]; + + steps.forEach((step, n) => { + step.id = n + 1; + }); + + const CustomFooter = ( + + + {({ activeStep, onNext, onBack, onClose }) => ( + <> + + {activeStep && activeStep.id !== 1 && ( + + )} + + + )} + + + ); + + return ( + + ); +} + +export default withI18n()(NodeModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx new file mode 100644 index 0000000000..0d617ec821 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx @@ -0,0 +1,26 @@ +import React, { useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; + +function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) { + useEffect(() => { + if (!triggerNext) { + return; + } + onNext(); + }, [triggerNext]); + + return ( + + ); +} + +export default withI18n()(NodeNextButton); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx new file mode 100644 index 0000000000..623dae669c --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx @@ -0,0 +1,120 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { Title } from '@patternfly/react-core'; +import PaginatedDataList from '@components/PaginatedDataList'; +import DataListToolbar from '@components/DataListToolbar'; +import CheckboxListItem from '@components/CheckboxListItem'; +import SelectedList from '@components/SelectedList'; + +const QS_CONFIG = getQSConfig('node_resource', { + page: 1, + page_size: 5, + order_by: 'name', +}); + +function NodeTypeStep({ + i18n, + search, + nodeType, + nodeResource, + updateNodeResource, +}) { + const [contentError, setContentError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [rowCount, setRowCount] = useState(0); + const [rows, setRows] = useState([]); + + let headerText = ''; + + switch (nodeType) { + case 'inventory_source_sync': + headerText = i18n._(t`Inventory Sources`); + break; + case 'job_template': + headerText = i18n._(t`Job Templates`); + break; + case 'project_sync': + headerText = i18n._(t`Projects`); + break; + case 'workflow_job_template': + headerText = i18n._(t`Workflow Job Templates`); + break; + default: + break; + } + + const fetchRows = queryString => { + const params = parseQueryString(QS_CONFIG, queryString); + return search(params); + }; + + useEffect(() => { + async function fetchData() { + try { + const { + data: { count, results }, + } = await fetchRows(location.node_resource); + + setRows(results); + setRowCount(count); + } catch (error) { + setContentError(error); + } finally { + setIsLoading(false); + } + } + fetchData(); + }, [location]); + + return ( + + + {headerText} + +

{i18n._(t`Select a resource to be executed from the list below.`)}

+ {nodeResource && ( + updateNodeResource(null)} + selected={[nodeResource]} + /> + )} + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
+ ); +} + +export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx new file mode 100644 index 0000000000..6d0e84fd34 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { Title } from '@patternfly/react-core'; +import { SelectableCard } from '@components/SelectableCard'; + +const Grid = styled.div` + display: grid; + grid-template-columns: 33% 33% 33%; + grid-gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + grid-auto-rows: 100px; + width: 100%; + margin: 20px 0px; +`; + +function NodeTypeStep({ + i18n, + nodeType, + updateNodeType, + linkType, + updateLinkType, + askLinkType, +}) { + return ( +
+ {askLinkType && ( + <> + + {i18n._(t`Run`)} + +

+ {i18n._( + t`Specify the conditions under which this node should be executed` + )} +

+ + updateLinkType('success')} + /> + updateLinkType('failure')} + /> + updateLinkType('always')} + /> + + + )} + + {i18n._(t`Node Type`)} + + + updateNodeType('job_template')} + /> + updateNodeType('workflow_job_template')} + /> + updateNodeType('project_sync')} + /> + updateNodeType('inventory_source_sync')} + /> + updateNodeType('approval')} + /> + +
+ ); +} + +export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx new file mode 100644 index 0000000000..596e6eb905 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Title } from '@patternfly/react-core'; +import { DetailList, Detail } from '@components/DetailList'; +import HorizontalSeparator from '@components/HorizontalSeparator'; + +function ProjectPreviewStep({ i18n, project, linkType }) { + let linkTypeValue; + + switch (linkType) { + case 'success': + linkTypeValue = i18n._(t`On Success`); + break; + case 'failure': + linkTypeValue = i18n._(t`On Failure`); + break; + case 'always': + linkTypeValue = i18n._(t`Always`); + break; + default: + break; + } + + return ( +
+ + {i18n._(t`Project Sync Node`)} + + + + + + +
+ ); +} + +export default withI18n()(ProjectPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx new file mode 100644 index 0000000000..d016248c62 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Title } from '@patternfly/react-core'; +import { DetailList, Detail } from '@components/DetailList'; +import HorizontalSeparator from '@components/HorizontalSeparator'; + +function WorkflowJobTemplatePreviewStep({ + i18n, + workflowJobTemplate, + linkType, +}) { + let linkTypeValue; + + switch (linkType) { + case 'success': + linkTypeValue = i18n._(t`On Success`); + break; + case 'failure': + linkTypeValue = i18n._(t`On Failure`); + break; + case 'always': + linkTypeValue = i18n._(t`Always`); + break; + default: + break; + } + + return ( +
+ + {i18n._(t`Workflow Job Template Node`)} + + + + + + +
+ ); +} + +export default withI18n()(WorkflowJobTemplatePreviewStep); 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..caab1b23c3 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Button, Modal } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; + +function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) { + return ( + + {i18n._(t`Exit`)} + , + , + ]} + > +

+ + Are you sure you want to exit the Workflow Creator without saving your + changes? + +

+
+ ); +} + +export default withI18n()(UnsavedChangesModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 6c3bb30994..ef3041dbbf 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -1,15 +1,26 @@ import React, { Fragment, useState, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core'; import { layoutGraph } from '@util/workflow'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; +import DeleteAllNodesModal from './Modals/DeleteAllNodesModal'; +import LinkModal from './Modals/LinkModal'; +import LinkDeleteModal from './Modals/LinkDeleteModal'; +import NodeModal from './Modals/NodeModal/NodeModal'; import NodeDeleteModal from './Modals/NodeDeleteModal'; import VisualizerGraph from './VisualizerGraph'; import VisualizerStartScreen from './VisualizerStartScreen'; import VisualizerToolbar from './VisualizerToolbar'; -import { WorkflowJobTemplatesAPI } from '@api'; +import UnsavedChangesModal from './Modals/UnsavedChangesModal'; +import { + WorkflowApprovalTemplatesAPI, + WorkflowJobTemplatesAPI, + WorkflowJobTemplateNodesAPI, +} from '@api'; const CenteredContent = styled.div` display: flex; @@ -25,7 +36,11 @@ const Wrapper = styled.div` height: 100%; `; -const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => { +const fetchWorkflowNodes = async ( + templateId, + pageNo = 1, + workflowNodes = [] +) => { try { const { data } = await WorkflowJobTemplatesAPI.readNodes(templateId, { page_size: 200, @@ -35,36 +50,111 @@ const fetchWorkflowNodes = async (templateId, pageNo = 1, nodes = []) => { return await fetchWorkflowNodes( templateId, pageNo + 1, - nodes.concat(data.results) + workflowNodes.concat(data.results) ); } - return nodes.concat(data.results); + return workflowNodes.concat(data.results); } catch (error) { throw error; } }; -function Visualizer({ template, i18n }) { +function Visualizer({ history, 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 [links, setLinks] = useState([]); + const [nodes, setNodes] = useState([]); + const [linkToDelete, setLinkToDelete] = useState(null); + const [linkToEdit, setLinkToEdit] = useState(null); const [nodePositions, setNodePositions] = useState(null); const [nodeToDelete, setNodeToDelete] = useState(null); + const [nodeToEdit, setNodeToEdit] = useState(null); + const [addingLink, setAddingLink] = useState(false); + const [addLinkSourceNode, setAddLinkSourceNode] = useState(null); + const [addLinkTargetNode, setAddLinkTargetNode] = useState(null); + const [addNodeSource, setAddNodeSource] = useState(null); + const [addNodeTarget, setAddNodeTarget] = useState(null); + const [nextNodeId, setNextNodeId] = useState(0); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false); + const [showDeleteAllNodesModal, setShowDeleteAllNodesModal] = useState(false); + const [showKey, setShowKey] = useState(false); + const [showTools, setShowTools] = useState(false); + + const startAddNode = (sourceNodeId, targetNodeId = null) => { + setAddNodeSource(sourceNodeId); + setAddNodeTarget(targetNodeId); + }; + + const finishAddingNode = newNode => { + const newNodes = [...nodes]; + const newLinks = [...links]; + newNodes.push({ + id: nextNodeId, + type: 'node', + unifiedJobTemplate: newNode.nodeResource, + }); + + // Ensures that root nodes appear to always run + // after "START" + if (addNodeSource === 1) { + newNode.edgeType = 'always'; + } + + newLinks.push({ + source: { id: addNodeSource }, + target: { id: nextNodeId }, + edgeType: newNode.edgeType, + type: 'link', + }); + if (addNodeTarget) { + newLinks.forEach(linkToCompare => { + if ( + linkToCompare.source.id === addNodeSource && + linkToCompare.target.id === addNodeTarget + ) { + linkToCompare.source = { id: nextNodeId }; + } + }); + } + if (!unsavedChanges) { + setUnsavedChanges(true); + } + setAddNodeSource(null); + setAddNodeTarget(null); + setNextNodeId(nextNodeId + 1); + setNodes(newNodes); + setLinks(newLinks); + }; + + const startEditNode = nodeToEdit => { + setNodeToEdit(nodeToEdit); + }; + + const finishEditingNode = editedNode => { + const newNodes = [...nodes]; + const matchingNode = newNodes.find(node => node.id === nodeToEdit.id); + matchingNode.unifiedJobTemplate = editedNode.nodeResource; + matchingNode.isEdited = true; + if (!unsavedChanges) { + setUnsavedChanges(true); + } + setNodeToEdit(null); + setNodes(newNodes); + }; + + const cancelNodeForm = () => { + setAddNodeSource(null); + setAddNodeTarget(null); + setNodeToEdit(null); + }; const deleteNode = () => { const nodeId = nodeToDelete.id; - const newGraphNodes = [...graphNodes]; - const newGraphLinks = [...graphLinks]; + const newNodes = [...nodes]; + const newLinks = [...links]; - // Remove the node from the array - for (let i = newGraphNodes.length; i--; ) { - if (newGraphNodes[i].id === nodeId) { - newGraphNodes.splice(i, 1); - i = 0; - } - } + newNodes.find(node => node.id === nodeToDelete.id).isDeleted = true; // Update the links const parents = []; @@ -72,8 +162,8 @@ function Visualizer({ template, i18n }) { const linkParentMapping = {}; // Remove any links that reference this node - for (let i = newGraphLinks.length; i--; ) { - const link = newGraphLinks[i]; + for (let i = newLinks.length; i--; ) { + const link = newLinks[i]; if (!linkParentMapping[link.target.id]) { linkParentMapping[link.target.id] = []; @@ -87,7 +177,7 @@ function Visualizer({ template, i18n }) { } else if (link.target.id === nodeId) { parents.push(link.source.id); } - newGraphLinks.splice(i, 1); + newLinks.splice(i, 1); } } @@ -98,7 +188,7 @@ function Visualizer({ template, i18n }) { // 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) { - newGraphLinks.push({ + newLinks.push({ source: { id: parentId }, target: { id: child.id }, edgeType: 'always', @@ -106,7 +196,7 @@ function Visualizer({ template, i18n }) { }); } } else if (!linkParentMapping[child.id].includes(parentId)) { - newGraphLinks.push({ + newLinks.push({ source: { id: parentId }, target: { id: child.id }, edgeType: child.edgeType, @@ -117,51 +207,483 @@ function Visualizer({ template, i18n }) { }); // need to track that this node has been deleted if it's not new + if (!unsavedChanges) { + setUnsavedChanges(true); + } setNodeToDelete(null); - setGraphNodes(newGraphNodes); - setGraphLinks(newGraphLinks); + setNodes(newNodes); + setLinks(newLinks); + }; + + const updateLink = edgeType => { + const newLinks = [...links]; + newLinks.forEach(link => { + if ( + link.source.id === linkToEdit.source.id && + link.target.id === linkToEdit.target.id + ) { + link.edgeType = edgeType; + } + }); + + if (!unsavedChanges) { + setUnsavedChanges(true); + } + setLinkToEdit(null); + setLinks(newLinks); + }; + + const startDeleteLink = link => { + let parentMap = {}; + links.forEach(link => { + if (!parentMap[link.target.id]) { + parentMap[link.target.id] = []; + } + parentMap[link.target.id].push(link.source.id); + }); + + link.isConvergenceLink = parentMap[link.target.id].length > 1; + + setLinkToDelete(link); + }; + + const deleteLink = () => { + 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, + }, + edgeType: 'always', + type: 'link', + }); + } + + if (!unsavedChanges) { + setUnsavedChanges(true); + } + setLinkToDelete(null); + setLinks(newLinks); + }; + + const selectSourceNodeForLinking = sourceNode => { + const newNodes = [...nodes]; + let parentMap = {}; + let 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) { + 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); + } + }); + + let 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; + } + }); + }); + + setAddLinkSourceNode(sourceNode); + setAddingLink(true); + setNodes(newNodes); + }; + + const selectTargetNodeForLinking = targetNode => { + setAddLinkTargetNode(targetNode); + }; + + const addLink = edgeType => { + const newLinks = [...links]; + const newNodes = [...nodes]; + + newNodes.forEach(node => { + node.isInvalidLinkTarget = false; + }); + + newLinks.push({ + source: { id: addLinkSourceNode.id }, + target: { id: addLinkTargetNode.id }, + edgeType, + type: 'link', + }); + + newLinks.forEach((link, index) => { + if (link.source.id === 1 && link.target.id === addLinkTargetNode.id) { + newLinks.splice(index, 1); + } + }); + + if (!unsavedChanges) { + setUnsavedChanges(true); + } + setAddLinkSourceNode(null); + setAddLinkTargetNode(null); + setAddingLink(false); + setLinks(newLinks); + }; + + const cancelNodeLink = () => { + const newNodes = [...nodes]; + + newNodes.forEach(node => { + node.isInvalidLinkTarget = false; + }); + + setAddLinkSourceNode(null); + setAddLinkTargetNode(null); + setAddingLink(false); + setNodes(newNodes); + }; + + const deleteAllNodes = () => { + setAddLinkSourceNode(null); + setAddLinkTargetNode(null); + setAddingLink(false); + setNodes( + nodes.map(node => { + if (node.id !== 1) { + node.isDeleted = true; + } + + return node; + }) + ); + setLinks([]); + setShowDeleteAllNodesModal(false); + }; + + const handleVisualizerClose = () => { + if (unsavedChanges) { + setShowUnsavedChangesModal(true); + } else { + history.push(`/templates/workflow_job_template/${template.id}/details`); + } + }; + + const handleVisualizerSave = async () => { + const nodeRequests = []; + const approvalTemplateRequests = []; + const originalLinkMap = {}; + const deletedNodeIds = []; + nodes.forEach(node => { + 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.id !== 1) { + // node with id=1 is the artificial start node + 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, + }) + ); + } + } + } + }); + + // TODO: error handling? + await Promise.all(nodeRequests); + await Promise.all(approvalTemplateRequests); + + const associateRequests = []; + const disassociateRequests = []; + 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.edgeType; + switch (link.edgeType) { + 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: + } + } + }); + + for (const [nodeId, node] of Object.entries(originalLinkMap)) { + node.success_nodes.forEach(successNodeId => { + if ( + !deletedNodeIds.includes(successNodeId) && + (!linkMap[node.id] || + !linkMap[node.id][successNodeId] || + linkMap[node.id][successNodeId] !== 'success') + ) { + disassociateRequests.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') + ) { + disassociateRequests.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') + ) { + disassociateRequests.push( + WorkflowJobTemplateNodesAPI.disassociateAlwaysNode( + node.id, + alwaysNodeId + ) + ); + } + }); + } + + // TODO: error handling? + await Promise.all(disassociateRequests); + + newLinks.forEach(link => { + switch (link.edgeType) { + case 'success': + associateRequests.push( + WorkflowJobTemplateNodesAPI.associateSuccessNode( + originalLinkMap[link.source.id].id, + originalLinkMap[link.target.id].id + ) + ); + break; + case 'failure': + associateRequests.push( + WorkflowJobTemplateNodesAPI.associateFailureNode( + originalLinkMap[link.source.id].id, + originalLinkMap[link.target.id].id + ) + ); + break; + case 'always': + associateRequests.push( + WorkflowJobTemplateNodesAPI.associateAlwaysNode( + originalLinkMap[link.source.id].id, + originalLinkMap[link.target.id].id + ) + ); + break; + default: + } + }); + + // TODO: error handling? + await Promise.all(associateRequests); + + // Some nodes (both new and edited) are going to need a followup request to + // either create or update an approval job template. This has to happen + // after the node has been created + history.push(`/templates/workflow_job_template/${template.id}/details`); }; useEffect(() => { - const buildGraphArrays = nodes => { + const buildGraphArrays = workflowNodes => { const nonRootNodeIds = []; const allNodeIds = []; const arrayOfLinksForChart = []; const nodeIdToChartNodeIdMapping = {}; const chartNodeIdToIndexMapping = {}; - const nodeRef = {}; - let nodeIdCounter = 1; const arrayOfNodesForChart = [ { - id: nodeIdCounter, + id: 1, unifiedJobTemplate: { name: i18n._(t`START`), }, type: 'node', }, ]; - nodeIdCounter++; - // Assign each node an ID - 0 is reserved for the start node. We need to + let nodeIdCounter = 2; + // Assign each node an ID - 1 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 => { + workflowNodes.forEach(node => { node.workflowMakerNodeId = nodeIdCounter; - nodeRef[nodeIdCounter] = { - originalNodeObject: node, - }; const nodeObj = { - index: nodeIdCounter - 1, id: nodeIdCounter, type: 'node', + originalNodeObject: 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; } @@ -172,7 +694,7 @@ function Visualizer({ template, i18n }) { nodeIdCounter++; }); - nodes.forEach(node => { + workflowNodes.forEach(node => { const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId]; node.success_nodes.forEach(nodeId => { const targetIndex = @@ -226,14 +748,15 @@ function Visualizer({ template, i18n }) { }); }); - setGraphNodes(arrayOfNodesForChart); - setGraphLinks(arrayOfLinksForChart); + setNodes(arrayOfNodesForChart); + setLinks(arrayOfLinksForChart); + setNextNodeId(nodeIdCounter); }; async function fetchData() { try { - const nodes = await fetchWorkflowNodes(template.id); - buildGraphArrays(nodes); + const workflowNodes = await fetchWorkflowNodes(template.id); + buildGraphArrays(workflowNodes); } catch (error) { setContentError(error); } finally { @@ -245,9 +768,10 @@ function Visualizer({ template, i18n }) { // Update positions of nodes/links useEffect(() => { - if (graphNodes) { + if (nodes) { const newNodePositions = {}; - const g = layoutGraph(graphNodes, graphLinks); + const nonDeletedNodes = nodes.filter(node => !node.isDeleted); + const g = layoutGraph(nonDeletedNodes, links); g.nodes().forEach(node => { newNodePositions[node] = g.node(node); @@ -255,7 +779,7 @@ function Visualizer({ template, i18n }) { setNodePositions(newNodePositions); } - }, [graphLinks, graphNodes]); + }, [links, nodes]); if (isLoading) { return ( @@ -276,17 +800,38 @@ function Visualizer({ template, i18n }) { return ( - - {graphLinks.length > 0 ? ( + setShowDeleteAllNodesModal(true)} + onKeyToggle={() => setShowKey(!showKey)} + keyShown={showKey} + onToolsToggle={() => setShowTools(!showTools)} + toolsShown={showTools} + /> + {links.length > 0 ? ( ) : ( - + )} setNodeToDelete(null)} /> + {linkToDelete && ( + setLinkToDelete(null)} + /> + )} + {linkToEdit && ( + + {/* todo: make title match mockups (display: flex) */} + {i18n._(t`Edit Link`)} + + } + onConfirm={updateLink} + onCancel={() => setLinkToEdit(null)} + edgeType={linkToEdit.edgeType} + /> + )} + {addLinkSourceNode && addLinkTargetNode && ( + + {/* todo: make title match mockups (display: flex) */} + {i18n._(t`Add Link`)} + + } + onConfirm={addLink} + onCancel={cancelNodeLink} + /> + )} + {addNodeSource && ( + cancelNodeForm()} + onSave={finishAddingNode} + /> + )} + {nodeToEdit && ( + cancelNodeForm()} + onSave={finishEditingNode} + /> + )} + {showUnsavedChangesModal && ( + setShowUnsavedChangesModal(false)} + onExit={() => + history.push( + `/templates/workflow_job_template/${template.id}/details` + ) + } + onSaveAndExit={() => handleVisualizerSave()} + /> + )} + {showDeleteAllNodesModal && ( + setShowDeleteAllNodesModal(false)} + onConfirm={() => deleteAllNodes()} + /> + )} ); } -export default withI18n()(Visualizer); +export default withI18n()(withRouter(Visualizer)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index 76e528cab4..b9eda4f24e 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -1,6 +1,13 @@ -import React, { Fragment, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; import * as d3 from 'd3'; -import { calcZoomAndFit } from '@util/workflow'; +import { + calcZoomAndFit, + constants as wfConstants, + getZoomTranslate, +} from '@util/workflow'; import { WorkflowHelp, WorkflowLinkHelp, @@ -10,21 +17,103 @@ import { VisualizerLink, VisualizerNode, VisualizerStartNode, + VisualizerKey, + VisualizerTools, } from '@screens/Template/WorkflowJobTemplateVisualizer'; -function VizualizerGraph({ +const PotentialLink = styled.polyline` + pointer-events: none; +`; + +const WorkflowSVG = styled.svg` + display: flex; + height: 100%; + background-color: #f6f6f6; +`; + +// const KeyWrapper = styled.div` +// position: absolute; +// right: 20px; +// top: 76px; +// `; + +// const ToolsWrapper = styled.div` +// position: absolute; +// right: 200px; +// top: 76px; +// `; + +function VisualizerGraph({ links, nodes, readOnly, nodePositions, onDeleteNodeClick, + onAddNodeClick, + onEditNodeClick, + onLinkEditClick, + onDeleteLinkClick, + onStartAddLinkClick, + onConfirmAddLinkClick, + onCancelAddLinkClick, + addingLink, + addLinkSourceNode, + showKey, + showTools, + i18n, }) { const [helpText, setHelpText] = useState(null); const [nodeHelp, setNodeHelp] = useState(); const [linkHelp, setLinkHelp] = useState(); + const [zoomPercentage, setZoomPercentage] = useState(100); const svgRef = useRef(null); const gRef = useRef(null); + 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); + onCancelAddLinkClick(); + }; + + 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]; @@ -32,6 +121,74 @@ function VizualizerGraph({ 'transform', `translate(${translation}) scale(${d3.event.transform.k})` ); + + setZoomPercentage(d3.event.transform.k * 100); + }; + + const handlePan = direction => { + let { x: xPos, y: yPos, k: currentScale } = d3.zoomTransform( + d3.select(svgRef.current).node() + ); + + 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 svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity + .translate(0, svgBoundingClientRect.height / 2 - 30) + .scale(1) + ); + + setZoomPercentage(100); + }; + + const handleZoomChange = newScale => { + const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale); + + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity.translate(translateX, translateY).scale(newScale) + ); + setZoomPercentage(newScale * 100); + }; + + const handleFitGraph = () => { + const [scaleToFit, yTranslate] = calcZoomAndFit( + gRef.current, + svgRef.current + ); + + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) + ); + + setZoomPercentage(scaleToFit * 100); }; const zoomRef = d3 @@ -46,12 +203,17 @@ function VizualizerGraph({ // Attempt to zoom the graph to fit the available screen space useEffect(() => { - const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current); + const [scaleToFit, yTranslate] = calcZoomAndFit( + gRef.current, + svgRef.current + ); d3.select(svgRef.current).call( zoomRef.transform, d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) ); + + setZoomPercentage(scaleToFit * 100); // We only want this to run once (when the component mounts) // Including zoomRef.transform in the deps array will cause this to // run very frequently. @@ -61,7 +223,7 @@ function VizualizerGraph({ }, []); return ( - + <> {(helpText || nodeHelp || linkHelp) && ( {helpText &&

{helpText}

} @@ -69,11 +231,38 @@ function VizualizerGraph({ {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 => ( - - )), + links.map(link => { + if ( + nodePositions[link.source.id] && + nodePositions[link.target.id] + ) { + return ( + + ); + } + return null; + }), nodes.map(node => { - if (node.id > 1) { + if (node.id > 1 && nodePositions[node.id] && !node.isDeleted) { return ( drawPotentialLinkToNode(node), + })} /> ); } return null; }), ]} + {addingLink && ( + + )} - -
+ +
+ {showTools && ( + + )} + {showKey && } +
+ ); } -export default VizualizerGraph; +export default withI18n()(VisualizerGraph); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx new file mode 100644 index 0000000000..74bed8a577 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { ExclamationTriangleIcon, PauseIcon } from '@patternfly/react-icons'; + +const Wrapper = styled.div` + border: 1px solid #c7c7c7; + background-color: white; + min-width: 100px; + margin-left: 20px; +`; + +const Header = styled.div` + padding: 10px; + border-bottom: 1px solid #c7c7c7; +`; + +const Key = styled.ul` + padding: 5px 10px; + + li { + padding: 5px 0px; + display: flex; + align-items: center; + } +`; + +const NodeTypeLetter = styled.div` + font-size: 10px; + color: white; + text-align: center; + line-height: 20px; + background-color: #393f43; + border-radius: 50%; + height: 20px; + width: 20px; + margin-right: 10px; +`; + +const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)` + color: #f0ad4d; + margin-right: 10px; + height: 20px; + width: 20px; +`; + +const Link = styled.div` + height: 5px; + width: 20px; + margin-right: 10px; +`; + +const SuccessLink = styled(Link)` + background-color: #5cb85c; +`; + +const FailureLink = styled(Link)` + background-color: #d9534f; +`; + +const AlwaysLink = styled(Link)` + background-color: #337ab7; +`; + +function VisualizerKey({ i18n }) { + return ( + +
+ {i18n._(t`Key`)} +
+ +
  • + 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()(VisualizerKey); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index 38b452f033..1887012634 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons'; @@ -12,6 +13,10 @@ import { WorkflowActionTooltipItem, } from '@components/Workflow'; +const LinkG = styled.g` + pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')}; +`; + function VisualizerLink({ link, nodePositions, @@ -19,6 +24,10 @@ function VisualizerLink({ updateHelpText, updateLinkHelp, i18n, + onLinkEditClick, + onDeleteLinkClick, + addingLink, + onAddNodeClick, }) { const [hovering, setHovering] = useState(false); const [pathD, setPathD] = useState(); @@ -30,6 +39,11 @@ function VisualizerLink({ { + updateHelpText(null); + setHovering(false); + onAddNodeClick(link.source.id, link.target.id); + }} onMouseEnter={() => updateHelpText(i18n._(t`Add a new node between these two nodes`)) } @@ -49,6 +63,7 @@ function VisualizerLink({ key="edit" onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => onLinkEditClick(link)} > , @@ -57,6 +72,7 @@ function VisualizerLink({ key="delete" onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => onDeleteLinkClick(link)} > , @@ -98,11 +114,12 @@ function VisualizerLink({ }, [link, nodePositions]); return ( - )} - + ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index 80ce5161d2..fa7f85d855 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -16,14 +16,16 @@ import { WorkflowNodeTypeLetter, } from '@components/Workflow'; -// dont need this in this component const NodeG = styled.g` + pointer-events: ${props => (props.noPointerEvents ? 'none' : 'initial')}; cursor: ${props => (props.job ? 'pointer' : 'default')}; `; const NodeContents = styled.foreignObject` font-size: 13px; padding: 0px 10px; + background-color: ${props => + props.isInvalidLinkTarget ? '#D7D7D7' : '#FFFFFF'}; `; const NodeDefaultLabel = styled.p` @@ -42,6 +44,13 @@ function VisualizerNode({ readOnly, i18n, onDeleteNodeClick, + onStartAddLinkClick, + onConfirmAddLinkClick, + addingLink, + onMouseOver, + isAddLinkSourceNode, + onAddNodeClick, + onEditNodeClick, }) { const [hovering, setHovering] = useState(false); @@ -49,6 +58,29 @@ function VisualizerNode({ const nodeEl = document.getElementById(`node-${node.id}`); nodeEl.parentNode.appendChild(nodeEl); 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) { + onConfirmAddLinkClick(node); + } }; const viewDetailsAction = ( @@ -70,6 +102,11 @@ function VisualizerNode({ key="add" onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onAddNodeClick(node.id); + }} > , @@ -79,6 +116,11 @@ function VisualizerNode({ key="edit" onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onEditNodeClick(node); + }} > , @@ -89,6 +131,11 @@ function VisualizerNode({ updateHelpText(i18n._(t`Link to an available node`)) } onMouseLeave={() => updateHelpText(null)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onStartAddLinkClick(node); + }} > , @@ -97,7 +144,11 @@ function VisualizerNode({ key="delete" onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))} onMouseLeave={() => updateHelpText(null)} - onClick={() => onDeleteNodeClick(node)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onDeleteNodeClick(node); + }} > , @@ -109,23 +160,32 @@ function VisualizerNode({ transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id] .y - nodePositions[1].y})`} job={node.job} + noPointerEvents={isAddLinkSourceNode} onMouseEnter={handleNodeMouseEnter} - onMouseLeave={() => setHovering(false)} + onMouseLeave={handleNodeMouseLeave} > updateNodeHelp(node)} - onMouseLeave={() => updateNodeHelp(null)} + isInvalidLinkTarget={node.isInvalidLinkTarget} + {...(!addingLink && { + onMouseEnter: () => updateNodeHelp(node), + onMouseLeave: () => updateNodeHelp(null), + })} + onClick={() => handleNodeClick()} > {node.unifiedJobTemplate @@ -134,7 +194,7 @@ function VisualizerNode({ {node.unifiedJobTemplate && } - {hovering && ( + {hovering && !addingLink && ( (props.ignorePointerEvents ? 'none' : 'auto')}; +`; + function VisualizerStartNode({ updateHelpText, nodePositions, readOnly, i18n, + addingLink, + onAddNodeClick, }) { const [hovering, setHovering] = useState(false); @@ -22,11 +30,12 @@ function VisualizerStartNode({ }; return ( - setHovering(false)} + ignorePointerEvents={addingLink} > updateHelpText(i18n._(t`Add a new node`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onAddNodeClick(1); + }} > - + , ]} /> )} - + ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx index 8a13cd707a..ea3e156264 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx @@ -17,7 +17,6 @@ const StartPanel = styled.div` padding: 60px 80px; border: 1px solid #c7c7c7; background-color: white; - color: var(--pf-global--Color--200); text-align: center; `; @@ -29,13 +28,17 @@ const StartPanelWrapper = styled.div` background-color: #f6f6f6; `; -function StartScreen({ i18n }) { +function StartScreen({ i18n, onStartClick }) { return (

    {i18n._(t`Please click the Start button to begin.`)}

    -
    diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx index dee2e8130b..c7ad6e08b7 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx @@ -1,8 +1,7 @@ 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 { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core'; import { BookIcon, CompassIcon, @@ -22,10 +21,34 @@ const Badge = styled(PFBadge)` margin-left: 10px; `; -function Toolbar({ history, i18n, template }) { - const handleVisualizerCancel = () => { - history.push(`/templates/workflow_job_template/${template.id}/details`); - }; +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; + } +`; + +function Toolbar({ + i18n, + template, + onClose, + onSave, + nodes = [], + onDeleteAllClick, + onKeyToggle, + keyShown, + onToolsToggle, + toolsShown, +}) { + const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1; return (
    @@ -52,35 +75,54 @@ function Toolbar({ history, i18n, template }) { }} >
    {i18n._(t`Total Nodes`)}
    - 0 + {totalNodes} - - - - - - + + + + + + - @@ -90,4 +132,4 @@ function Toolbar({ history, i18n, template }) { ); } -export default withI18n()(withRouter(Toolbar)); +export default withI18n()(Toolbar); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx new file mode 100644 index 0000000000..1be36d1851 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { Tooltip } from '@patternfly/react-core'; +import { + CaretDownIcon, + CaretLeftIcon, + CaretRightIcon, + CaretUpIcon, + DesktopIcon, + HomeIcon, + MinusIcon, + PlusIcon, +} from '@patternfly/react-icons'; + +const Wrapper = styled.div` + border: 1px solid #c7c7c7; + background-color: white; + height: 135px; +`; + +const Header = styled.div` + padding: 10px; + border-bottom: 1px solid #c7c7c7; +`; + +const Pan = styled.div` + display: flex; + align-items: center; +`; + +const PanCenter = styled.div` + display: flex; + flex-direction: column; +`; + +const Tools = styled.div` + display: flex; + align-items: center; + padding: 20px; +`; + +function VisualizerTools({ + i18n, + zoomPercentage, + onZoomChange, + onFitGraph, + onPan, + onPanToMiddle, +}) { + 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`)} +
    + + + onFitGraph()} css="margin-right: 30px;" /> + + + zoomOut()} css="margin-right: 10px;" /> + + onZoomChange(parseInt(event.target.value) / 100)} + > + + zoomIn()} css="margin: 0px 25px 0px 10px;" /> + + + + onPan('left')} /> + + + + onPan('up')} /> + + + onPanToMiddle()} /> + + + onPan('down')} /> + + + + onPan('right')} /> + + + +
    + ); +} + +export default withI18n()(VisualizerTools); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js index c593ae9701..7cff003e08 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js @@ -5,3 +5,5 @@ export { default as VisualizerStartScreen } from './VisualizerStartScreen'; export { default as VisualizerStartNode } from './VisualizerStartNode'; export { default as VisualizerLink } from './VisualizerLink'; export { default as VisualizerNode } from './VisualizerNode'; +export { default as VisualizerKey } from './VisualizerKey'; +export { default as VisualizerTools } from './VisualizerTools'; diff --git a/awx/ui_next/src/util/workflow.jsx b/awx/ui_next/src/util/workflow.jsx index 63191237ad..a914aeda80 100644 --- a/awx/ui_next/src/util/workflow.jsx +++ b/awx/ui_next/src/util/workflow.jsx @@ -11,12 +11,16 @@ export const constants = { rootH: 40, }; -export function calcZoomAndFit(gRef) { +export function calcZoomAndFit(gRef, svgRef) { + const { k: currentScale } = d3.zoomTransform(d3.select(svgRef).node()); const gBoundingClientRect = d3 .select(gRef) .node() .getBoundingClientRect(); + gBoundingClientRect.height = gBoundingClientRect.height / currentScale; + gBoundingClientRect.width = gBoundingClientRect.width / currentScale; + const gBBoxDimensions = d3 .select(gRef) .node() @@ -45,7 +49,7 @@ export function calcZoomAndFit(gRef) { yTranslate = (svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) / 2 - - gBBoxDimensions.y * scaleToFit; + (gBBoxDimensions.y / currentScale) * scaleToFit; } return [scaleToFit, yTranslate]; @@ -172,3 +176,29 @@ export function layoutGraph(nodes, links) { return g; } + +export function getZoomTranslate(svgRef, newScale) { + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const current = d3.zoomTransform(d3.select(svgRef).node()); + const origScale = current.k; + const unscaledOffsetX = + (current.x + + (svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) / + 2) / + origScale; + const unscaledOffsetY = + (current.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]; +} From 50ba4f97594f797dbfc126358425573a4c755380 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 16 Jan 2020 15:37:35 -0500 Subject: [PATCH 03/46] Halfway implemented node details. Still need to handle cases where the user has edited the node and cases where the node is brand new. --- .../api/models/WorkflowJobTemplateNodes.js | 4 + .../components/AddRole/AddResourceRole.jsx | 2 +- .../CodeMirrorInput/VariablesDetail.jsx | 6 +- .../ContentLoading/ContentLoading.jsx | 10 +- .../src/components/DetailList/Detail.jsx | 13 +- .../components/SelectedList/SelectedList.jsx | 5 +- awx/ui_next/src/components/Wizard/Wizard.jsx | 9 + .../src/components/Wizard/Wizard.test.jsx | 10 + awx/ui_next/src/components/Wizard/index.js | 1 + .../Modals/NodeModal/ApprovalPreviewStep.jsx | 49 -- .../NodeModal/InventorySyncPreviewStep.jsx | 39 -- .../NodeModal/JobTemplatePreviewStep.jsx | 185 ------ .../Modals/NodeModal/NodeApprovalStep.jsx | 161 ----- .../Modals/NodeModal/NodeModal.jsx | 230 ++----- .../Modals/NodeModal/NodeNextButton.jsx | 11 +- .../Modals/NodeModal/NodeResourceStep.jsx | 120 ---- .../Modals/NodeModal/NodeTypeStep.jsx | 105 ---- .../NodeTypeStep/InventorySourcesList.jsx | 81 +++ .../NodeTypeStep/JobTemplatesList.jsx | 78 +++ .../NodeModal/NodeTypeStep/NodeTypeStep.jsx | 244 ++++++++ .../NodeModal/NodeTypeStep/ProjectsList.jsx | 76 +++ .../NodeTypeStep/WorkflowJobTemplatesList.jsx | 83 +++ .../NodeModal/ProjectSyncPreviewStep.jsx | 39 -- .../Modals/NodeModal/RunStep.jsx | 59 ++ .../WorkflowJobTemplatePreviewStep.jsx | 43 -- .../Modals/NodeViewModal/ApprovalDetails.jsx | 26 + .../InventorySourceSyncDetails.jsx | 165 +++++ .../NodeViewModal/JobTemplateDetails.jsx | 564 ++++++++++++++++++ .../Modals/NodeViewModal/NodeViewModal.jsx | 38 ++ .../NodeViewModal/ProjectSyncDetails.jsx | 141 +++++ .../WorkflowJobTemplateDetails.jsx | 129 ++++ .../Visualizer.jsx | 6 + .../VisualizerGraph.jsx | 14 +- .../VisualizerNode.jsx | 6 + 34 files changed, 1822 insertions(+), 930 deletions(-) create mode 100644 awx/ui_next/src/components/Wizard/Wizard.jsx create mode 100644 awx/ui_next/src/components/Wizard/Wizard.test.jsx create mode 100644 awx/ui_next/src/components/Wizard/index.js delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js index dfb434c831..512316a1ab 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js @@ -51,6 +51,10 @@ class WorkflowJobTemplateNodes extends Base { disassociate: true, }); } + + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); + } } export default WorkflowJobTemplateNodes; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 8576e18bbe..2f6bfc227e 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 SelectResourceStep from './SelectResourceStep'; import SelectRoleStep from './SelectRoleStep'; import { SelectableCard } from '@components/SelectableCard'; +import { Wizard } from '@components/Wizard'; import { TeamsAPI, UsersAPI } from '../../api'; const readUsers = async queryParams => diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx index c2e3fe2b2c..2dae4512d5 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'; @@ -21,7 +21,7 @@ function getValueAsMode(value, mode) { return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value); } -function VariablesDetail({ value, label, rows }) { +function VariablesDetail({ value = '---', label, rows }) { const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE); const [currentValue, setCurrentValue] = useState(value || '---'); const [error, setError] = useState(null); @@ -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/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/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..00fcbd4b51 --- /dev/null +++ b/awx/ui_next/src/components/Wizard/Wizard.test.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Wizard from './Wizard'; + +describe('Wizard', () => { + test('renders the expected content', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); +}); 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..f07d6622b0 --- /dev/null +++ b/awx/ui_next/src/components/Wizard/index.js @@ -0,0 +1 @@ +export { default as Wizard } from './Wizard'; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx deleted file mode 100644 index b261008c25..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Title } from '@patternfly/react-core'; -import { DetailList, Detail } from '@components/DetailList'; -import HorizontalSeparator from '@components/HorizontalSeparator'; - -function ApprovalPreviewStep({ i18n, name, description, timeout, linkType }) { - let linkTypeValue; - - switch (linkType) { - case 'on_success': - linkTypeValue = i18n._(t`On Success`); - break; - case 'on_failure': - linkTypeValue = i18n._(t`On Failure`); - break; - case 'always': - linkTypeValue = i18n._(t`Always`); - break; - default: - break; - } - - let timeoutValue = i18n._(t`None`); - - if (timeout) { - const minutes = Math.floor(timeout / 60); - const seconds = timeout - minutes * 60; - timeoutValue = i18n._(t`${minutes}min ${seconds}sec`); - } - - return ( -
    - - {i18n._(t`Approval Node`)} - - - - - - - - -
    - ); -} - -export default withI18n()(ApprovalPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx deleted file mode 100644 index d385169117..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Title } from '@patternfly/react-core'; -import { DetailList, Detail } from '@components/DetailList'; -import HorizontalSeparator from '@components/HorizontalSeparator'; - -function InventorySyncPreviewStep({ i18n, inventorySource, linkType }) { - let linkTypeValue; - - switch (linkType) { - case 'success': - linkTypeValue = i18n._(t`On Success`); - break; - case 'failure': - linkTypeValue = i18n._(t`On Failure`); - break; - case 'always': - linkTypeValue = i18n._(t`Always`); - break; - default: - break; - } - - return ( -
    - - {i18n._(t`Inventory Sync Node`)} - - - - - - -
    - ); -} - -export default withI18n()(InventorySyncPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx deleted file mode 100644 index c04daa8e58..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx +++ /dev/null @@ -1,185 +0,0 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Title } from '@patternfly/react-core'; -import { DetailList, Detail } from '@components/DetailList'; -import HorizontalSeparator from '@components/HorizontalSeparator'; - -function JobTemplatePreviewStep({ i18n, jobTemplate, linkType }) { - let linkTypeValue; - - switch (linkType) { - case 'success': - linkTypeValue = i18n._(t`On Success`); - break; - case 'failure': - linkTypeValue = i18n._(t`On Failure`); - break; - case 'always': - linkTypeValue = i18n._(t`Always`); - break; - default: - break; - } - - return ( -
    - - {i18n._(t`Job Template Node`)} - - - - - - {/* - - {summary_fields.inventory ? ( - - ) : ( - !ask_inventory_on_launch && - renderMissingDataDetail(i18n._(t`Inventory`)) - )} - {summary_fields.project ? ( - - {summary_fields.project - ? summary_fields.project.name - : i18n._(t`Deleted`)} - - } - /> - ) : ( - renderMissingDataDetail(i18n._(t`Project`)) - )} - - - - - - {createdBy && ( - - )} - {modifiedBy && ( - - )} - - - {host_config_key && ( - - - - - )} - {renderOptionsField && ( - - )} - {summary_fields.credentials && - summary_fields.credentials.length > 0 && ( - - {summary_fields.credentials.map(c => ( - - ))} - - } - /> - )} - {summary_fields.labels && summary_fields.labels.results.length > 0 && ( - - {summary_fields.labels.results.map(l => ( - - {l.name} - - ))} - - } - /> - )} - {instanceGroups.length > 0 && ( - - {instanceGroups.map(ig => ( - - {ig.name} - - ))} - - } - /> - )} - {job_tags && job_tags.length > 0 && ( - - {job_tags.split(',').map(jobTag => ( - - {jobTag} - - ))} - - } - /> - )} - {skip_tags && skip_tags.length > 0 && ( - - {skip_tags.split(',').map(skipTag => ( - - {skipTag} - - ))} - - } - /> - )} */} - - -
    - ); -} - -export default withI18n()(JobTemplatePreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx deleted file mode 100644 index b6905a077c..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx +++ /dev/null @@ -1,161 +0,0 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import styled from 'styled-components'; -import { Formik, Field } from 'formik'; -import { Form, FormGroup, TextInput, Title } from '@patternfly/react-core'; -import FormRow from '@components/FormRow'; -import HorizontalSeparator from '@components/HorizontalSeparator'; - -const TimeoutInput = styled(TextInput)` - width: 200px; - :not(:first-of-type) { - margin-left: 20px; - } -`; - -const TimeoutLabel = styled.p` - margin-left: 10px; -`; - -function NodeApprovalStep({ - i18n, - name, - updateName, - description, - updateDescription, - timeout = 0, - updateTimeout, -}) { - return ( -
    - - {i18n._(t`Approval Node`)} - - - ( -
    - - { - const isValid = - form && - (!form.touched[field.name] || !form.errors[field.name]); - - return ( - - { - updateName(value); - field.onChange(event); - }} - autoFocus - /> - - ); - }} - /> - - - ( - - { - updateDescription(value); - field.onChange(event); - }} - /> - - )} - /> - - - -
    - ( - <> - { - if (!value || value === '') { - value = 0; - } - updateTimeout( - Number(value) * 60 + - Number(form.values.timeoutSeconds) - ); - field.onChange(event); - }} - /> - min - - )} - /> - ( - <> - { - if (!value || value === '') { - value = 0; - } - updateTimeout( - Number(value) + - Number(form.values.timeoutMinutes) * 60 - ); - field.onChange(event); - }} - /> - sec - - )} - /> -
    -
    -
    -
    - )} - /> -
    - ); -} - -export default withI18n()(NodeApprovalStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx index 777e73d7fa..ed7bd6d546 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx @@ -1,38 +1,26 @@ import React, { useState } from 'react'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Button, - Wizard, WizardContextConsumer, WizardFooter, } from '@patternfly/react-core'; -import NodeResourceStep from './NodeResourceStep'; -import NodeTypeStep from './NodeTypeStep'; +import NodeTypeStep from './NodeTypeStep/NodeTypeStep'; +import RunStep from './RunStep'; import NodeNextButton from './NodeNextButton'; -import NodeApprovalStep from './NodeApprovalStep'; -import ApprovalPreviewStep from './ApprovalPreviewStep'; -import JobTemplatePreviewStep from './JobTemplatePreviewStep'; -import InventorySyncPreviewStep from './InventorySyncPreviewStep'; -import ProjectSyncPreviewStep from './ProjectSyncPreviewStep'; -import WorkflowJobTemplatePreviewStep from './WorkflowJobTemplatePreviewStep'; +import { Wizard } from '@components/Wizard'; -import { - JobTemplatesAPI, - ProjectsAPI, - InventorySourcesAPI, - WorkflowJobTemplatesAPI, -} from '@api'; - -const readInventorySources = async queryParams => - InventorySourcesAPI.read(queryParams); -const readJobTemplates = async queryParams => - JobTemplatesAPI.read(queryParams, { role_level: 'execute_role' }); -const readProjects = async queryParams => ProjectsAPI.read(queryParams); -const readWorkflowJobTemplates = async queryParams => - WorkflowJobTemplatesAPI.read(queryParams, { role_level: 'execute_role' }); - -function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { +function NodeModal({ + history, + i18n, + title, + onClose, + onSave, + node, + askLinkType, +}) { let defaultNodeType = 'job_template'; let defaultNodeResource = null; let defaultApprovalName = ''; @@ -82,15 +70,6 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { const [nodeType, setNodeType] = useState(defaultNodeType); const [linkType, setLinkType] = useState('success'); const [nodeResource, setNodeResource] = useState(defaultNodeResource); - const [showApprovalStep, setShowApprovalStep] = useState( - defaultNodeType === 'approval' - ); - const [showResourceStep, setShowResourceStep] = useState( - defaultNodeResource ? true : false - ); - const [showPreviewStep, setShowPreviewStep] = useState( - defaultNodeType === 'approval' || defaultNodeResource ? true : false - ); const [triggerNext, setTriggerNext] = useState(0); const [approvalName, setApprovalName] = useState(defaultApprovalName); const [approvalDescription, setApprovalDescription] = useState( @@ -100,7 +79,19 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { defaultApprovalTimeout ); + 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.push(`${history.location.pathname}?${otherParts.join('&')}`); + }; + const handleSaveNode = () => { + clearQueryParams(); + const resource = nodeType === 'approval' ? { @@ -120,47 +111,13 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { }); }; - const resourceSearch = queryParams => { - switch (nodeType) { - case 'inventory_source_sync': - return readInventorySources(queryParams); - case 'job_template': - return readJobTemplates(queryParams); - case 'project_sync': - return readProjects(queryParams); - case 'workflow_job_template': - return readWorkflowJobTemplates(queryParams); - default: - throw new Error(i18n._(t`Missing node type`)); - } - }; - - const handleNextClick = activeStep => { - if (activeStep.key === 'node_type') { - if ( - [ - 'inventory_source_sync', - 'job_template', - 'project_sync', - 'workflow_job_template', - ].includes(nodeType) - ) { - setShowApprovalStep(false); - setShowResourceStep(true); - } else if (nodeType === 'approval') { - setShowResourceStep(false); - setShowApprovalStep(true); - } - setShowPreviewStep(true); - } - setTriggerNext(triggerNext + 1); + const handleCancel = () => { + clearQueryParams(); + onClose(); }; const handleNodeTypeChange = newNodeType => { setNodeType(newNodeType); - setShowResourceStep(false); - setShowApprovalStep(false); - setShowPreviewStep(false); setNodeResource(null); setApprovalName(''); setApprovalDescription(''); @@ -168,101 +125,39 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { }; const steps = [ + ...(askLinkType + ? [ + { + name: i18n._(t`Run Type`), + key: 'run_type', + component: ( + + ), + enableNext: linkType !== null, + }, + ] + : []), { - name: node ? i18n._(t`Node Type`) : i18n._(t`Run/Node Type`), - key: 'node_type', + name: i18n._(t`Node Type`), + key: 'node_resource', + enableNext: + (nodeType !== 'approval' && nodeResource !== null) || + (nodeType === 'approval' && approvalName !== ''), component: ( ), - enableNext: nodeType !== null, }, - ...(showResourceStep - ? [ - { - name: i18n._(t`Select Node Resource`), - key: 'node_resource', - enableNext: nodeResource !== null, - component: ( - - ), - }, - ] - : []), - ...(showApprovalStep - ? [ - { - name: i18n._(t`Configure Approval`), - key: 'approval', - component: ( - - ), - enableNext: approvalName !== '', - }, - ] - : []), - ...(showPreviewStep - ? [ - { - name: i18n._(t`Preview`), - key: 'preview', - component: ( - <> - {nodeType === 'approval' && ( - - )} - {nodeType === 'job_template' && ( - - )} - {nodeType === 'inventory_source_sync' && ( - - )} - {nodeType === 'project_sync' && ( - - )} - {nodeType === 'workflow_job_template' && ( - - )} - - ), - enableNext: true, - }, - ] - : []), ]; steps.forEach((step, n) => { @@ -272,20 +167,25 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { const CustomFooter = ( - {({ activeStep, onNext, onBack, onClose }) => ( + {({ activeStep, onNext, onBack }) => ( <> setTriggerNext(triggerNext + 1)} + buttonText={ + activeStep.key === 'node_resource' + ? i18n._(t`Save`) + : i18n._(t`Next`) + } /> {activeStep && activeStep.id !== 1 && ( )} - @@ -294,17 +194,19 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { ); + const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title; + return ( ); } -export default withI18n()(NodeModal); +export default withI18n()(withRouter(NodeModal)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx index 0d617ec821..a941cb33da 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx @@ -3,7 +3,14 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Button } from '@patternfly/react-core'; -function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) { +function NodeNextButton({ + i18n, + activeStep, + onNext, + triggerNext, + onClick, + buttonText, +}) { useEffect(() => { if (!triggerNext) { return; @@ -18,7 +25,7 @@ function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) { onClick={() => onClick(activeStep)} isDisabled={!activeStep.enableNext} > - {activeStep.key === 'preview' ? i18n._(t`Save`) : i18n._(t`Next`)} + {buttonText} ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx deleted file mode 100644 index 623dae669c..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { Fragment, useEffect, useState } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { getQSConfig, parseQueryString } from '@util/qs'; -import { Title } from '@patternfly/react-core'; -import PaginatedDataList from '@components/PaginatedDataList'; -import DataListToolbar from '@components/DataListToolbar'; -import CheckboxListItem from '@components/CheckboxListItem'; -import SelectedList from '@components/SelectedList'; - -const QS_CONFIG = getQSConfig('node_resource', { - page: 1, - page_size: 5, - order_by: 'name', -}); - -function NodeTypeStep({ - i18n, - search, - nodeType, - nodeResource, - updateNodeResource, -}) { - const [contentError, setContentError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [rowCount, setRowCount] = useState(0); - const [rows, setRows] = useState([]); - - let headerText = ''; - - switch (nodeType) { - case 'inventory_source_sync': - headerText = i18n._(t`Inventory Sources`); - break; - case 'job_template': - headerText = i18n._(t`Job Templates`); - break; - case 'project_sync': - headerText = i18n._(t`Projects`); - break; - case 'workflow_job_template': - headerText = i18n._(t`Workflow Job Templates`); - break; - default: - break; - } - - const fetchRows = queryString => { - const params = parseQueryString(QS_CONFIG, queryString); - return search(params); - }; - - useEffect(() => { - async function fetchData() { - try { - const { - data: { count, results }, - } = await fetchRows(location.node_resource); - - setRows(results); - setRowCount(count); - } catch (error) { - setContentError(error); - } finally { - setIsLoading(false); - } - } - fetchData(); - }, [location]); - - return ( - - - {headerText} - -

    {i18n._(t`Select a resource to be executed from the list below.`)}

    - {nodeResource && ( - updateNodeResource(null)} - selected={[nodeResource]} - /> - )} - ( - updateNodeResource(item)} - onDeselect={() => updateNodeResource(null)} - isRadio={true} - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} - /> -
    - ); -} - -export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx deleted file mode 100644 index 6d0e84fd34..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import styled from 'styled-components'; -import { Title } from '@patternfly/react-core'; -import { SelectableCard } from '@components/SelectableCard'; - -const Grid = styled.div` - display: grid; - grid-template-columns: 33% 33% 33%; - grid-gap: 20px; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - grid-auto-rows: 100px; - width: 100%; - margin: 20px 0px; -`; - -function NodeTypeStep({ - i18n, - nodeType, - updateNodeType, - linkType, - updateLinkType, - askLinkType, -}) { - return ( -
    - {askLinkType && ( - <> - - {i18n._(t`Run`)} - -

    - {i18n._( - t`Specify the conditions under which this node should be executed` - )} -

    - - updateLinkType('success')} - /> - updateLinkType('failure')} - /> - updateLinkType('always')} - /> - - - )} - - {i18n._(t`Node Type`)} - - - updateNodeType('job_template')} - /> - updateNodeType('workflow_job_template')} - /> - updateNodeType('project_sync')} - /> - updateNodeType('inventory_source_sync')} - /> - updateNodeType('approval')} - /> - -
    - ); -} - -export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx new file mode 100644 index 0000000000..7cb0db0347 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx @@ -0,0 +1,81 @@ +import React, { useState, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +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({ + i18n, + history, + nodeResource, + updateNodeResource, +}) { + const [inventorySources, setInventorySources] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + 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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(InventorySourcesList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx new file mode 100644 index 0000000000..01633cb1d5 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx @@ -0,0 +1,78 @@ +import React, { useState, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +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, updateNodeResource }) { + const [jobTemplates, setJobTemplates] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + 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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(JobTemplatesList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx new file mode 100644 index 0000000000..8052571bc1 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { Formik, Field } from 'formik'; +import { Form, FormGroup, TextInput } from '@patternfly/react-core'; +import { Divider } from '@patternfly/react-core/dist/esm/experimental'; +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 TimeoutInput = styled(TextInput)` + width: 200px; + :not(:first-of-type) { + margin-left: 20px; + } +`; + +const TimeoutLabel = styled.p` + margin-left: 10px; +`; + +function NodeTypeStep({ + i18n, + nodeType = 'job_template', + updateNodeType, + nodeResource, + updateNodeResource, + name, + updateName, + description, + updateDescription, + timeout = 0, + updateTimeout, +}) { + return ( + <> +
    + {i18n._(t`Node Type`)} + +
    + { + updateNodeType(val); + }} + /> +
    +
    + + {nodeType === 'job_template' && ( + + )} + {nodeType === 'project_sync' && ( + + )} + {nodeType === 'inventory_source_sync' && ( + + )} + {nodeType === 'workflow_job_template' && ( + + )} + {nodeType === 'approval' && ( + ( +
    + + { + const isValid = + form && + (!form.touched[field.name] || !form.errors[field.name]); + + return ( + + { + updateName(value); + field.onChange(event); + }} + autoFocus + /> + + ); + }} + /> + + + ( + + { + updateDescription(value); + field.onChange(event); + }} + /> + + )} + /> + + + +
    + ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) * 60 + + Number(form.values.timeoutSeconds) + ); + field.onChange(event); + }} + /> + min + + )} + /> + ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) + + Number(form.values.timeoutMinutes) * 60 + ); + field.onChange(event); + }} + /> + sec + + )} + /> +
    +
    +
    +
    + )} + /> + )} + + ); +} + +export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx new file mode 100644 index 0000000000..5b428f98fe --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx @@ -0,0 +1,76 @@ +import React, { useState, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +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({ i18n, history, nodeResource, updateNodeResource }) { + const [projects, setProjects] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + 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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(ProjectsList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx new file mode 100644 index 0000000000..c988dd34dd --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +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({ + i18n, + history, + nodeResource, + updateNodeResource, +}) { + const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + 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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(WorkflowJobTemplatesList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx deleted file mode 100644 index 596e6eb905..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Title } from '@patternfly/react-core'; -import { DetailList, Detail } from '@components/DetailList'; -import HorizontalSeparator from '@components/HorizontalSeparator'; - -function ProjectPreviewStep({ i18n, project, linkType }) { - let linkTypeValue; - - switch (linkType) { - case 'success': - linkTypeValue = i18n._(t`On Success`); - break; - case 'failure': - linkTypeValue = i18n._(t`On Failure`); - break; - case 'always': - linkTypeValue = i18n._(t`Always`); - break; - default: - break; - } - - return ( -
    - - {i18n._(t`Project Sync Node`)} - - - - - - -
    - ); -} - -export default withI18n()(ProjectPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx new file mode 100644 index 0000000000..ce65f27cdd --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { Title } from '@patternfly/react-core'; +import { SelectableCard } from '@components/SelectableCard'; + +const Grid = styled.div` + display: grid; + grid-template-columns: 33% 33% 33%; + grid-gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + grid-auto-rows: 100px; + width: 100%; + margin: 20px 0px; +`; + +function RunStep({ i18n, linkType, updateLinkType }) { + return ( + <> + + {i18n._(t`Run`)} + +

    + {i18n._( + t`Specify the conditions under which this node should be executed` + )} +

    + + updateLinkType('success')} + /> + updateLinkType('failure')} + /> + updateLinkType('always')} + /> + + + ); +} + +export default withI18n()(RunStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx deleted file mode 100644 index d016248c62..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Title } from '@patternfly/react-core'; -import { DetailList, Detail } from '@components/DetailList'; -import HorizontalSeparator from '@components/HorizontalSeparator'; - -function WorkflowJobTemplatePreviewStep({ - i18n, - workflowJobTemplate, - linkType, -}) { - let linkTypeValue; - - switch (linkType) { - case 'success': - linkTypeValue = i18n._(t`On Success`); - break; - case 'failure': - linkTypeValue = i18n._(t`On Failure`); - break; - case 'always': - linkTypeValue = i18n._(t`Always`); - break; - default: - break; - } - - return ( -
    - - {i18n._(t`Workflow Job Template Node`)} - - - - - - -
    - ); -} - -export default withI18n()(WorkflowJobTemplatePreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx new file mode 100644 index 0000000000..a99413f471 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { DetailList, Detail } from '@components/DetailList'; + +function ApprovalDetails({ i18n, node }) { + const { name, description, timeout } = node.unifiedJobTemplate; + + let timeoutValue = i18n._(t`None`); + + if (timeout) { + const minutes = Math.floor(timeout / 60); + const seconds = timeout - minutes * 60; + timeoutValue = i18n._(t`${minutes}min ${seconds}sec`); + } + return ( + + + + + + + ); +} + +export default withI18n()(ApprovalDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx new file mode 100644 index 0000000000..510d9e591a --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; +import { InventorySourcesAPI } from '@api'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { DetailList, Detail } from '@components/DetailList'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import { CredentialChip } from '@components/Chip'; + +function InventorySourceSyncDetails({ i18n, node }) { + const [inventorySource, setInventorySource] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [noReadAccess, setNoReadAccess] = useState(false); + const [contentError, setContentError] = useState(null); + const [optionsActions, setOptionsActions] = useState(null); + + useEffect(() => { + async function fetchInventorySource() { + try { + const [ + { data }, + { + data: { actions }, + }, + ] = await Promise.all([ + InventorySourcesAPI.readDetail(node.unifiedJobTemplate.id), + InventorySourcesAPI.readOptions(), + ]); + setInventorySource(data); + setOptionsActions(actions); + } catch (err) { + if (err.response.status === 403) { + setNoReadAccess(true); + } else { + setContentError(err); + } + } finally { + setIsLoading(false); + } + } + fetchInventorySource(); + }, []); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

    + + Your account does not have read access to this inventory source so + the displayed details will be limited. + +

    +
    + + + + + + + ); + } + + const { + custom_virtualenv, + description, + group_by, + instance_filters, + name, + source, + source_path, + source_regions, + source_script, + source_vars, + summary_fields, + timeout, + verbosity, + } = inventorySource; + + let sourceValue = ''; + let verbosityValue = ''; + + optionsActions.GET.source.choices.forEach(choice => { + if (choice[0] === source) { + sourceValue = choice[1]; + } + }); + + optionsActions.GET.verbosity.choices.forEach(choice => { + if (choice[0] === verbosity) { + verbosityValue = choice[1]; + } + }); + + return ( + + + + + {summary_fields.inventory && ( + + )} + {summary_fields.credential && ( + + } + /> + )} + + + + {/* this should probably be tags built from OPTIONS*/} + + + {/* this should probably be tags built from OPTIONS */} + + + + + + + ); +} + +export default withI18n()(InventorySourceSyncDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx new file mode 100644 index 0000000000..f93b277b15 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx @@ -0,0 +1,564 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; +import jsyaml from 'js-yaml'; +import styled from 'styled-components'; +import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '@api'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { DetailList, Detail } from '@components/DetailList'; +import { ChipGroup, Chip, CredentialChip } from '@components/Chip'; +import { VariablesDetail } from '@components/CodeMirrorInput'; + +const Overridden = styled.div` + color: var(--pf-global--warning-color--100); +`; + +function JobTemplateDetails({ i18n, node }) { + const [jobTemplate, setJobTemplate] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [noReadAccess, setNoReadAccess] = useState(false); + const [contentError, setContentError] = useState(null); + const [optionsActions, setOptionsActions] = useState(null); + const [instanceGroups, setInstanceGroups] = useState([]); + const [nodeCredentials, setNodeCredentials] = useState([]); + const [launchConf, setLaunchConf] = useState(null); + + useEffect(() => { + async function fetchJobTemplate() { + try { + const [ + { data }, + { + data: { results: instanceGroups }, + }, + { data: launchConf }, + { + data: { actions }, + }, + { + data: { results: nodeCredentials }, + }, + ] = await Promise.all([ + JobTemplatesAPI.readDetail(node.unifiedJobTemplate.id), + JobTemplatesAPI.readInstanceGroups(node.unifiedJobTemplate.id), + JobTemplatesAPI.readLaunch(node.unifiedJobTemplate.id), + JobTemplatesAPI.readOptions(), + WorkflowJobTemplateNodesAPI.readCredentials( + node.originalNodeObject.id + ), + ]); + setJobTemplate(data); + setInstanceGroups(instanceGroups); + setLaunchConf(launchConf); + setOptionsActions(actions); + setNodeCredentials(nodeCredentials); + } catch (err) { + if (err.response.status === 403) { + setNoReadAccess(true); + } else { + setContentError(err); + } + } finally { + setIsLoading(false); + } + } + fetchJobTemplate(); + }, []); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

    + + Your account does not have read access to this job template so the + displayed details will be limited. + +

    +
    + + + + + + + ); + } + + const { + job_type: nodeJobType, + limit: nodeLimit, + scm_branch: nodeScmBranch, + inventory: nodeInventory, + verbosity: nodeVerbosity, + job_tags: nodeJobTags, + skip_tags: nodeSkipTags, + diff_mode: nodeDiffMode, + extra_data: nodeExtraData, + summary_fields: nodeSummaryFields, + } = node.originalNodeObject; + + let { + ask_job_type_on_launch, + ask_limit_on_launch, + ask_scm_branch_on_launch, + ask_inventory_on_launch, + ask_verbosity_on_launch, + ask_tags_on_launch, + ask_skip_tags_on_launch, + ask_diff_mode_on_launch, + ask_credential_on_launch, + ask_variables_on_launch, + description, + diff_mode, + extra_vars, + forks, + host_config_key, + job_slice_count, + job_tags, + job_type, + name, + limit, + playbook, + skip_tags, + timeout, + summary_fields, + verbosity, + scm_branch, + inventory, + } = jobTemplate; + + const jobTypeOverridden = + ask_job_type_on_launch && nodeJobType !== null && job_type !== nodeJobType; + const limitOverridden = + ask_limit_on_launch && nodeLimit !== null && limit !== nodeLimit; + const scmBranchOverridden = + ask_scm_branch_on_launch && + nodeScmBranch !== null && + scm_branch !== nodeScmBranch; + const inventoryOverridden = + ask_inventory_on_launch && + nodeInventory !== null && + inventory !== nodeInventory; + const verbosityOverridden = + ask_verbosity_on_launch && + nodeVerbosity !== null && + verbosity !== nodeVerbosity; + const jobTagsOverridden = + ask_tags_on_launch && nodeJobTags !== null && job_tags !== nodeJobTags; + const skipTagsOverridden = + ask_skip_tags_on_launch && + nodeSkipTags !== null && + skip_tags !== nodeSkipTags; + const diffModeOverridden = + ask_diff_mode_on_launch && + nodeDiffMode !== null && + diff_mode !== nodeDiffMode; + const credentialOverridden = + ask_credential_on_launch && nodeCredentials.length > 0; + let variablesOverridden = false; + let variablesToShow = extra_vars; + + const deepObjectMatch = (obj1, obj2) => { + if (obj1 === obj2) { + return true; + } + + if ( + obj1 === null || + obj2 === null || + typeof obj1 !== 'object' || + typeof obj2 !== 'object' + ) { + return false; + } + + const obj1Keys = Object.keys(obj1); + const obj2Keys = Object.keys(obj2); + + if (obj1Keys.length !== obj2Keys.length) { + return false; + } + + for (let key of obj1Keys) { + if (!obj2Keys.includes(key) || !deepObjectMatch(obj1[key], obj2[key])) { + return false; + } + } + + return true; + }; + + if (ask_variables_on_launch || launchConf.survey_enabled) { + // we need to check to see if the extra vars are different from the defaults + // but we'll need to do some normalization. Convert both to JSON objects + // and then compare. + + let jsonifiedExtraVars = {}; + let jsonifiedExtraData = {}; + + // extra_vars has to be a string + if (typeof extra_vars === 'string') { + if ( + extra_vars === '{}' || + extra_vars === 'null' || + extra_vars === '' || + extra_vars === '""' + ) { + jsonifiedExtraVars = {}; + } else { + try { + // try to turn the string into json + jsonifiedExtraVars = JSON.parse(extra_vars); + } catch (jsonParseError) { + try { + // do safeLoad, which well error if not valid yaml + jsonifiedExtraVars = jsyaml.safeLoad(extra_vars); + } catch (yamlLoadError) { + setContentError(yamlLoadError); + } + } + } + } else { + setContentError( + Error(i18n._(t`Error parsing extra variables from the job template`)) + ); + } + + // extra_data on a node can be either a string or an object... + if (typeof nodeExtraData === 'string') { + if ( + nodeExtraData === '{}' || + nodeExtraData === 'null' || + nodeExtraData === '' || + nodeExtraData === '""' + ) { + jsonifiedExtraData = {}; + } else { + try { + // try to turn the string into json + jsonifiedExtraData = JSON.parse(nodeExtraData); + } catch (error) { + try { + // do safeLoad, which well error if not valid yaml + jsonifiedExtraData = jsyaml.safeLoad(nodeExtraData); + } catch (yamlLoadError) { + setContentError(yamlLoadError); + } + } + } + } else if (typeof nodeExtraData === 'object') { + jsonifiedExtraData = nodeExtraData; + } else { + setContentError( + Error(i18n._(t`Error parsing extra variables from the node`)) + ); + } + + if (!deepObjectMatch(jsonifiedExtraVars, jsonifiedExtraData)) { + variablesOverridden = true; + variablesToShow = jsyaml.safeDump( + Object.assign(jsonifiedExtraVars, jsonifiedExtraData) + ); + } + } + + let credentialsToShow = summary_fields.credentials; + + if (credentialOverridden) { + credentialsToShow = [...nodeCredentials]; + + // adds vault_id to the credentials we get back from + // fetching the JT + launchConf.defaults.credentials.forEach(launchCred => { + if (launchCred.vault_id) { + summary_fields.credentials[ + summary_fields.credentials.findIndex( + defaultCred => defaultCred.id === launchCred.id + ) + ].vault_id = launchCred.vault_id; + } + }); + + summary_fields.credentials.forEach(defaultCred => { + if ( + !nodeCredentials.some( + overrideCredential => + (defaultCred.kind === overrideCredential.kind && + (!defaultCred.vault_id && !overrideCredential.inputs.vault_id)) || + (defaultCred.vault_id && + overrideCredential.inputs.vault_id && + defaultCred.vault_id === overrideCredential.inputs.vault_id) + ) + ) { + credentialsToShow.push(defaultCred); + } + }); + } + + let verbosityToShow = ''; + + optionsActions.GET.verbosity.choices.forEach(choice => { + if ( + verbosityOverridden + ? choice[0] === nodeVerbosity + : choice[0] === verbosity + ) { + verbosityToShow = choice[1]; + } + }); + + const jobTagsToShow = jobTagsOverridden ? nodeJobTags : job_tags; + const skipTagsToShow = skipTagsOverridden ? nodeSkipTags : skip_tags; + + return ( + <> + + + + + * {i18n._(t`Job Type`)} + ) : ( + i18n._(t`Job Type`) + ) + } + value={jobTypeOverridden ? nodeJobType : job_type} + /> + * {i18n._(t`Inventory`)} + ) : ( + i18n._(t`Inventory`) + ) + } + value={ + inventoryOverridden + ? nodeSummaryFields.inventory.name + : summary_fields.inventory.name + } + alwaysVisible={inventoryOverridden} + /> + {summary_fields.project && ( + + )} + * {i18n._(t`SCM Branch`)} + ) : ( + i18n._(t`SCM Branch`) + ) + } + value={scmBranchOverridden ? nodeScmBranch : scm_branch} + alwaysVisible={scmBranchOverridden} + /> + + + * {i18n._(t`Limit`)} + ) : ( + i18n._(t`Limit`) + ) + } + value={limitOverridden ? nodeLimit : limit} + alwaysVisible={limitOverridden} + /> + * {i18n._(t`Verbosity`)} + ) : ( + i18n._(t`Verbosity`) + ) + } + value={verbosityToShow} + /> + + * {i18n._(t`Show Changes`)} + ) : ( + i18n._(t`Show Changes`) + ) + } + value={ + (diffModeOverridden + ? nodeDiffMode + : diff_mode) + ? i18n._(t`On`) + : i18n._(t`Off`) + } + /> + + {host_config_key && ( + <> + + + + )} + * {i18n._(t`Credentials`)} + ) : ( + i18n._(t`Credentials`) + ) + } + value={ + credentialsToShow.length > 0 && ( + + {credentialsToShow.map(c => ( + + ))} + + ) + } + alwaysVisible={credentialOverridden} + /> + {summary_fields.labels && summary_fields.labels.results.length > 0 && ( + + {summary_fields.labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + {instanceGroups.length > 0 && ( + + {instanceGroups.map(ig => ( + + {ig.name} + + ))} + + } + /> + )} + * {i18n._(t`Job Tags`)} + ) : ( + i18n._(t`Job Tags`) + ) + } + value={ + jobTagsOverridden.length > 0 && ( + + {jobTagsToShow.split(',').map(jobTag => ( + + {jobTag} + + ))} + + ) + } + alwaysVisible={jobTagsOverridden} + /> + * {i18n._(t`Skip Tags`)} + ) : ( + i18n._(t`Skip Tags`) + ) + } + value={ + skipTagsToShow.length > 0 && ( + + {skipTagsToShow.split(',').map(skipTag => ( + + {skipTag} + + ))} + + ) + } + alwaysVisible={skipTagsOverridden} + /> + * {i18n._(t`Variables`)} + ) : ( + i18n._(t`Variables`) + ) + } + value={variablesToShow} + rows={4} + /> + + {(jobTypeOverridden || + limitOverridden || + scmBranchOverridden || + inventoryOverridden || + verbosityOverridden || + jobTagsOverridden || + skipTagsOverridden || + diffModeOverridden || + credentialOverridden || + variablesOverridden) && ( + <> +
    + + + + * Values for these fields differ from the job template's default + + + + + )} + + ); +} + +export default withI18n()(JobTemplateDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx new file mode 100644 index 0000000000..887740d804 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Modal } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import ApprovalDetails from './ApprovalDetails'; +import InventorySourceSyncDetails from './InventorySourceSyncDetails'; +import JobTemplateDetails from './JobTemplateDetails'; +import ProjectSyncDetails from './ProjectSyncDetails'; +import WorkflowJobTemplateDetails from './WorkflowJobTemplateDetails'; + +function NodeViewModal({ i18n, onClose, node }) { + return ( + + {(node.unifiedJobTemplate.type === 'job_template' || node.unifiedJobTemplate.unified_job_type === 'job') && ( + + )} + {(node.unifiedJobTemplate.type === 'workflow_approval_template' || node.unifiedJobTemplate.unified_job_type) === 'workflow_approval' && ( + + )} + {(node.unifiedJobTemplate.type === 'project' || node.unifiedJobTemplate.unified_job_type === 'project_update') && ( + + )} + {(node.unifiedJobTemplate.type === 'inventory_source' || node.unifiedJobTemplate.unified_job_type === 'inventory_update') && ( + + )} + {(node.unifiedJobTemplate.type === 'workflow_job_template' || node.unifiedJobTemplate.unified_job_type === 'workflow_job') && ( + + )} + + ); +} + +export default withI18n()(NodeViewModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx new file mode 100644 index 0000000000..759c612940 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx @@ -0,0 +1,141 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; +import { ProjectsAPI } from '@api'; +import { Config } from '@contexts/Config'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { DetailList, Detail } from '@components/DetailList'; +import { CredentialChip } from '@components/Chip'; +import { toTitleCase } from '@util/strings'; + +function ProjectSyncDetails({ i18n, node }) { + const [project, setProject] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [noReadAccess, setNoReadAccess] = useState(false); + const [contentError, setContentError] = useState(null); + + useEffect(() => { + async function fetchProject() { + try { + const { data } = await ProjectsAPI.readDetail( + node.unifiedJobTemplate.id + ); + setProject(data); + } catch (err) { + if (err.response.status === 403) { + setNoReadAccess(true); + } else { + setContentError(err); + } + } finally { + setIsLoading(false); + } + } + fetchProject(); + }, []); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

    + + Your account does not have read access to this project so the + displayed details will be limited. + +

    +
    + + + + + + + ); + } + + const { + custom_virtualenv, + description, + local_path, + name, + scm_branch, + scm_refspec, + scm_type, + scm_update_cache_timeout, + scm_url, + summary_fields, + } = project; + + return ( + + + + + {summary_fields.organization && ( + + )} + + + + + {summary_fields.credential && ( + + } + /> + )} + + + + {({ project_base_dir }) => ( + + )} + + + + ); +} + +export default withI18n()(ProjectSyncDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx new file mode 100644 index 0000000000..5142fd21d3 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; +import { WorkflowJobTemplatesAPI } from '@api'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import { DetailList, Detail } from '@components/DetailList'; +import { ChipGroup, Chip } from '@components/Chip'; +import { VariablesDetail } from '@components/CodeMirrorInput'; + +function WorkflowJobTemplateDetails({ i18n, node }) { + const [workflowJobTemplate, setWorkflowJobTemplate] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [noReadAccess, setNoReadAccess] = useState(false); + const [contentError, setContentError] = useState(null); + + useEffect(() => { + async function fetchWorkflowJobTemplate() { + try { + const { data } = await WorkflowJobTemplatesAPI.readDetail( + node.unifiedJobTemplate.id + ); + setWorkflowJobTemplate(data); + } catch (err) { + if (err.response.status === 403) { + setNoReadAccess(true); + } else { + setContentError(err); + } + } finally { + setIsLoading(false); + } + } + fetchWorkflowJobTemplate(); + }, []); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

    + + Your account does not have read access to this workflow job template + so the displayed details will be limited. + +

    +
    + + + + + + + ); + } + + const { + description, + extra_vars, + limit, + name, + scm_branch, + summary_fields, + } = workflowJobTemplate; + + return ( + + + + + {summary_fields.organization && ( + + )} + {summary_fields.inventory && ( + + )} + + + {summary_fields.labels && summary_fields.labels.results.length > 0 && ( + + {summary_fields.labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + + + ); +} + +export default withI18n()(WorkflowJobTemplateDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index ef3041dbbf..0f2c222d08 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -16,6 +16,7 @@ import VisualizerGraph from './VisualizerGraph'; import VisualizerStartScreen from './VisualizerStartScreen'; import VisualizerToolbar from './VisualizerToolbar'; import UnsavedChangesModal from './Modals/UnsavedChangesModal'; +import NodeViewModal from './Modals/NodeViewModal/NodeViewModal'; import { WorkflowApprovalTemplatesAPI, WorkflowJobTemplatesAPI, @@ -69,6 +70,7 @@ function Visualizer({ history, template, i18n }) { const [nodePositions, setNodePositions] = useState(null); const [nodeToDelete, setNodeToDelete] = useState(null); const [nodeToEdit, setNodeToEdit] = useState(null); + const [nodeToView, setNodeToView] = useState(null); const [addingLink, setAddingLink] = useState(false); const [addLinkSourceNode, setAddLinkSourceNode] = useState(null); const [addLinkTargetNode, setAddLinkTargetNode] = useState(null); @@ -825,6 +827,7 @@ function Visualizer({ history, template, i18n }) { onStartAddLinkClick={selectSourceNodeForLinking} onConfirmAddLinkClick={selectTargetNodeForLinking} onCancelAddLinkClick={cancelNodeLink} + onViewNodeClick={setNodeToView} addingLink={addingLink} addLinkSourceNode={addLinkSourceNode} showKey={showKey} @@ -905,6 +908,9 @@ function Visualizer({ history, template, i18n }) { onConfirm={() => deleteAllNodes()} /> )} + {nodeToView && ( + setNodeToView(null)} /> + )} ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index b9eda4f24e..93a4457d15 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -31,18 +31,6 @@ const WorkflowSVG = styled.svg` background-color: #f6f6f6; `; -// const KeyWrapper = styled.div` -// position: absolute; -// right: 20px; -// top: 76px; -// `; - -// const ToolsWrapper = styled.div` -// position: absolute; -// right: 200px; -// top: 76px; -// `; - function VisualizerGraph({ links, nodes, @@ -56,6 +44,7 @@ function VisualizerGraph({ onStartAddLinkClick, onConfirmAddLinkClick, onCancelAddLinkClick, + onViewNodeClick, addingLink, addLinkSourceNode, showKey, @@ -310,6 +299,7 @@ function VisualizerGraph({ onDeleteNodeClick={onDeleteNodeClick} onStartAddLinkClick={onStartAddLinkClick} onConfirmAddLinkClick={onConfirmAddLinkClick} + onViewNodeClick={onViewNodeClick} addingLink={addingLink} isAddLinkSourceNode={ addLinkSourceNode && addLinkSourceNode.id === node.id diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index fa7f85d855..3ce9aaf22a 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -51,6 +51,7 @@ function VisualizerNode({ isAddLinkSourceNode, onAddNodeClick, onEditNodeClick, + onViewNodeClick, }) { const [hovering, setHovering] = useState(false); @@ -89,6 +90,11 @@ function VisualizerNode({ key="details" onMouseEnter={() => updateHelpText(i18n._(t`View node details`))} onMouseLeave={() => updateHelpText(null)} + onClick={() => { + updateHelpText(null); + setHovering(false); + onViewNodeClick(node); + }} > From 1d84d03566dd6f5520f143351cbb5f2cd4512bbc Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 20 Jan 2020 15:25:07 -0500 Subject: [PATCH 04/46] Stabilized workflow visualizer and output point. Workflow jobs can be viewed and workflows can be built (without jt prompting). --- .../components/AddRole/AddResourceRole.jsx | 4 +- .../ResourceAccessListItem.test.jsx.snap | 8 + .../SelectableCard/SelectableCard.jsx | 1 + .../src/components/SelectableCard/index.js | 2 +- .../src/components/Wizard/Wizard.test.jsx | 9 +- awx/ui_next/src/components/Wizard/index.js | 2 +- .../Workflow/WorkflowActionTooltip.jsx | 31 +- .../Workflow/WorkflowActionTooltipItem.jsx | 33 +- .../src/components/Workflow/WorkflowHelp.jsx | 12 +- .../Workflow/WorkflowKey.jsx} | 28 +- .../components/Workflow/WorkflowLinkHelp.jsx | 9 +- .../components/Workflow/WorkflowNodeHelp.jsx | 7 +- .../Workflow/WorkflowNodeTypeLetter.jsx | 13 +- .../Workflow/WorkflowTools.jsx} | 37 +- awx/ui_next/src/components/Workflow/index.js | 10 +- .../src/screens/Job/JobDetail/JobDetail.jsx | 10 - .../Job/WorkflowOutput/WorkflowOutput.jsx | 69 ++- .../WorkflowOutput/WorkflowOutputGraph.jsx | 119 +++- .../Job/WorkflowOutput/WorkflowOutputLink.jsx | 14 +- .../Job/WorkflowOutput/WorkflowOutputNode.jsx | 42 +- .../WorkflowOutputStartNode.jsx | 13 +- .../WorkflowOutput/WorkflowOutputToolbar.jsx | 90 +++ .../src/screens/Job/WorkflowOutput/index.js | 1 + .../src/screens/Template/Templates.test.jsx | 1 - .../Modals/DeleteAllNodesModal.jsx | 14 +- .../Modals/LinkDeleteModal.jsx | 15 +- .../Modals/LinkModal.jsx | 33 +- .../Modals/NodeDeleteModal.jsx | 7 + .../Modals/NodeModal/NodeModal.jsx | 100 ++-- .../Modals/NodeModal/NodeNextButton.jsx | 20 +- .../NodeTypeStep/InventorySourcesList.jsx | 47 +- .../NodeTypeStep/JobTemplatesList.jsx | 45 +- .../NodeModal/NodeTypeStep/NodeTypeStep.jsx | 69 ++- .../NodeModal/NodeTypeStep/ProjectsList.jsx | 47 +- .../NodeTypeStep/WorkflowJobTemplatesList.jsx | 47 +- .../Modals/NodeModal/NodeTypeStep/index.js | 7 + .../Modals/NodeModal/RunStep.jsx | 16 +- .../Modals/NodeModal/index.js | 3 + .../Modals/NodeViewModal.jsx | 25 + .../Modals/NodeViewModal/ApprovalDetails.jsx | 26 - .../InventorySourceSyncDetails.jsx | 165 ----- .../NodeViewModal/JobTemplateDetails.jsx | 564 ------------------ .../Modals/NodeViewModal/NodeViewModal.jsx | 38 -- .../NodeViewModal/ProjectSyncDetails.jsx | 141 ----- .../WorkflowJobTemplateDetails.jsx | 129 ---- .../Modals/UnsavedChangesModal.jsx | 12 +- .../Modals/index.js | 6 + .../Visualizer.jsx | 207 +++---- .../VisualizerGraph.jsx | 120 ++-- .../VisualizerLink.jsx | 47 +- .../VisualizerNode.jsx | 85 ++- .../VisualizerStartNode.jsx | 31 +- .../VisualizerStartScreen.jsx | 21 +- .../VisualizerToolbar.jsx | 68 +-- .../WorkflowJobTemplateVisualizer/index.js | 8 +- awx/ui_next/src/util/workflow.jsx | 4 +- 56 files changed, 1088 insertions(+), 1644 deletions(-) rename awx/ui_next/src/{screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx => components/Workflow/WorkflowKey.jsx} (96%) rename awx/ui_next/src/{screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx => components/Workflow/WorkflowTools.jsx} (87%) create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/index.js create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/index.js create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/index.js diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2f6bfc227e..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 SelectableCard from '@components/SelectableCard'; +import Wizard from '@components/Wizard'; import SelectResourceStep from './SelectResourceStep'; import SelectRoleStep from './SelectRoleStep'; -import { SelectableCard } from '@components/SelectableCard'; -import { Wizard } from '@components/Wizard'; import { TeamsAPI, UsersAPI } from '../../api'; const readUsers = async queryParams => 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} > { test('renders the expected content', () => { - const wrapper = mount(); - expect(wrapper).toMatchSnapshot(); + 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 index f07d6622b0..40da120187 100644 --- a/awx/ui_next/src/components/Wizard/index.js +++ b/awx/ui_next/src/components/Wizard/index.js @@ -1 +1 @@ -export { default as Wizard } from './Wizard'; +export { default } from './Wizard'; diff --git a/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx index aa3a626578..8946461d63 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx @@ -1,5 +1,6 @@ import React from 'react'; import styled from 'styled-components'; +import { node, number } from 'prop-types'; const TooltipContents = styled.div` display: flex; @@ -10,32 +11,32 @@ const TooltipArrows = styled.div` `; 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; - height: 0; - border-right: 10px solid #c4c4c4; - border-top: 10px solid transparent; - border-bottom: 10px solid transparent; - margin: auto; `; const TooltipArrowInner = styled.div` - position: absolute; - top: calc(50% - 10px); - left: 2px; - width: 0; - height: 0; + border-bottom: 10px solid transparent; border-right: 10px solid white; border-top: 10px solid transparent; - border-bottom: 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: 1px solid #c4c4c4; border-radius: 2px; + border: 1px solid #c4c4c4; padding: 5px; `; @@ -59,4 +60,10 @@ function WorkflowActionTooltip({ actions, pointX, pointY }) { ); } +WorkflowActionTooltip.propTypes = { + actions: node.isRequired, + pointX: number.isRequired, + pointY: number.isRequired, +}; + export default WorkflowActionTooltip; diff --git a/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx index 571b749ae7..dcb2f4f098 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.jsx @@ -1,15 +1,16 @@ import React from 'react'; import styled from 'styled-components'; +import { func } from 'prop-types'; const TooltipItem = styled.div` - height: 25px; - width: 25px; - font-size: 12px; - display: flex; align-items: center; - justify-content: center; - cursor: pointer; border-radius: 2px; + cursor: pointer; + display: flex; + font-size: 12px; + height: 25px; + justify-content: center; + width: 25px; &:hover { color: white; @@ -21,21 +22,33 @@ const TooltipItem = styled.div` } `; -function WorkflowActionTooltip({ +function WorkflowActionTooltipItem({ children, + onClick, onMouseEnter, onMouseLeave, - onClick, }) { return ( {children} ); } -export default WorkflowActionTooltip; +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/WorkflowHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowHelp.jsx index a69ed75844..4b2251a7ce 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowHelp.jsx @@ -2,20 +2,20 @@ 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 }) { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx b/awx/ui_next/src/components/Workflow/WorkflowKey.jsx similarity index 96% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx rename to awx/ui_next/src/components/Workflow/WorkflowKey.jsx index 74bed8a577..e0e75dd995 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerKey.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowKey.jsx @@ -5,50 +5,50 @@ import styled from 'styled-components'; import { ExclamationTriangleIcon, PauseIcon } from '@patternfly/react-icons'; const Wrapper = styled.div` - border: 1px solid #c7c7c7; background-color: white; - min-width: 100px; + border: 1px solid #c7c7c7; margin-left: 20px; + min-width: 100px; `; const Header = styled.div` - padding: 10px; border-bottom: 1px solid #c7c7c7; + padding: 10px; `; const Key = styled.ul` padding: 5px 10px; li { - padding: 5px 0px; - display: flex; align-items: center; + display: flex; + padding: 5px 0px; } `; const NodeTypeLetter = styled.div` - font-size: 10px; - color: white; - text-align: center; - line-height: 20px; background-color: #393f43; border-radius: 50%; + color: white; + font-size: 10px; height: 20px; - width: 20px; + line-height: 20px; margin-right: 10px; + text-align: center; + width: 20px; `; const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)` color: #f0ad4d; - margin-right: 10px; height: 20px; + margin-right: 10px; width: 20px; `; const Link = styled.div` height: 5px; - width: 20px; margin-right: 10px; + width: 20px; `; const SuccessLink = styled(Link)` @@ -63,7 +63,7 @@ const AlwaysLink = styled(Link)` background-color: #337ab7; `; -function VisualizerKey({ i18n }) { +function WorkflowKey({ i18n }) { return (
    @@ -113,4 +113,4 @@ function VisualizerKey({ i18n }) { ); } -export default withI18n()(VisualizerKey); +export default withI18n()(WorkflowKey); diff --git a/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx index 3cd00b7aae..4252351798 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx @@ -2,11 +2,12 @@ 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; - column-gap: 15px; row-gap: 0px; dt { grid-column-start: 1; @@ -18,7 +19,7 @@ const GridDL = styled.dl` function WorkflowLinkHelp({ link, i18n }) { let linkType; - switch (link.edgeType) { + switch (link.linkType) { case 'always': linkType = i18n._(t`Always`); break; @@ -42,4 +43,8 @@ function WorkflowLinkHelp({ link, i18n }) { ); } +WorkflowLinkHelp.propTypes = { + link: shape().isRequired, +}; + export default withI18n()(WorkflowLinkHelp); diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx index 17524b720e..07d56c623b 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx @@ -2,12 +2,13 @@ import React, { Fragment } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import { shape } from 'prop-types'; import { secondsToHHMMSS } from '@util/dates'; const GridDL = styled.dl` + column-gap: 15px; display: grid; grid-template-columns: max-content; - column-gap: 15px; row-gap: 0px; dt { grid-column-start: 1; @@ -134,4 +135,8 @@ function WorkflowNodeHelp({ node, i18n }) { ); } +WorkflowNodeHelp.propTypes = { + node: shape().isRequired, +}; + export default withI18n()(WorkflowNodeHelp); diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx index 013c1ed88d..eb2364503d 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx @@ -1,14 +1,15 @@ import React from 'react'; import styled from 'styled-components'; +import { shape } from 'prop-types'; import { PauseIcon } from '@patternfly/react-icons'; const NodeTypeLetter = styled.foreignObject` - font-size: 10px; - color: white; - text-align: center; - line-height: 20px; background-color: #393f43; border-radius: 50%; + color: white; + font-size: 10px; + line-height: 20px; + text-align: center; `; function WorkflowNodeTypeLetter({ node }) { @@ -52,4 +53,8 @@ function WorkflowNodeTypeLetter({ node }) { ); } +WorkflowNodeTypeLetter.propTypes = { + node: shape().isRequired, +}; + export default WorkflowNodeTypeLetter; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx similarity index 87% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx rename to awx/ui_next/src/components/Workflow/WorkflowTools.jsx index 1be36d1851..a1aa6f4c08 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerTools.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import { func, number } from 'prop-types'; import { Tooltip } from '@patternfly/react-core'; import { CaretDownIcon, @@ -15,19 +16,19 @@ import { } from '@patternfly/react-icons'; const Wrapper = styled.div` - border: 1px solid #c7c7c7; background-color: white; + border: 1px solid #c7c7c7; height: 135px; `; const Header = styled.div` - padding: 10px; border-bottom: 1px solid #c7c7c7; + padding: 10px; `; const Pan = styled.div` - display: flex; align-items: center; + display: flex; `; const PanCenter = styled.div` @@ -36,18 +37,18 @@ const PanCenter = styled.div` `; const Tools = styled.div` - display: flex; align-items: center; + display: flex; padding: 20px; `; -function VisualizerTools({ +function WorkflowTools({ i18n, - zoomPercentage, - onZoomChange, onFitGraph, onPan, onPanToMiddle, + onZoomChange, + zoomPercentage, }) { const zoomIn = () => { const newScale = @@ -81,14 +82,16 @@ function VisualizerTools({ zoomOut()} css="margin-right: 10px;" /> + onZoomChange(parseInt(event.target.value, 10) / 100) + } step="10" - onChange={event => onZoomChange(parseInt(event.target.value) / 100)} - > + type="range" + value={zoomPercentage} + /> zoomIn()} css="margin: 0px 25px 0px 10px;" /> @@ -119,4 +122,12 @@ function VisualizerTools({ ); } -export default withI18n()(VisualizerTools); +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/index.js b/awx/ui_next/src/components/Workflow/index.js index 66cc6ef332..c0adfbe7c1 100644 --- a/awx/ui_next/src/components/Workflow/index.js +++ b/awx/ui_next/src/components/Workflow/index.js @@ -1,8 +1,10 @@ -export { default as WorkflowHelp } from './WorkflowHelp'; -export { default as WorkflowLinkHelp } from './WorkflowLinkHelp'; -export { default as WorkflowNodeHelp } from './WorkflowNodeHelp'; -export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter'; export { default as WorkflowActionTooltip } from './WorkflowActionTooltip'; export { default as WorkflowActionTooltipItem, } from './WorkflowActionTooltipItem'; +export { default as WorkflowHelp } from './WorkflowHelp'; +export { default as WorkflowKey } from './WorkflowKey'; +export { default as WorkflowLinkHelp } from './WorkflowLinkHelp'; +export { default as WorkflowNodeHelp } from './WorkflowNodeHelp'; +export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter'; +export { default as WorkflowTools } from './WorkflowTools'; diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index 2db9a79ee6..d93ee7d457 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -25,16 +25,6 @@ import { InventoriesAPI, AdHocCommandsAPI, } from '@api'; -import { JOB_TYPE_URL_SEGMENTS } from '@constants'; - -const ActionButtonWrapper = styled.div` - display: flex; - justify-content: flex-end; - margin-top: 20px; - & > :not(:first-child) { - margin-left: 20px; - } -`; const VariablesInput = styled(_VariablesInput)` .pf-c-form__label { diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx index cee04e61cf..9783d9ea7b 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -2,64 +2,60 @@ import React, { useState, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import { shape } from 'prop-types'; import { CardBody as PFCardBody } from '@patternfly/react-core'; import { layoutGraph } from '@util/workflow'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import { WorkflowJobsAPI } from '@api'; import WorkflowOutputGraph from './WorkflowOutputGraph'; +import WorkflowOutputToolbar from './WorkflowOutputToolbar'; const CardBody = styled(PFCardBody)` - height: calc(100vh - 240px); display: flex; flex-direction: column; -`; - -const Toolbar = styled.div` - height: 50px; - background-color: grey; + height: calc(100vh - 240px); `; const Wrapper = styled.div` display: flex; flex-flow: column; height: 100%; + position: relative; `; const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => { - try { - const { data } = await WorkflowJobsAPI.readNodes(jobId, { - page_size: 200, - page: pageNo, - }); - if (data.next) { - return await fetchWorkflowNodes( - jobId, - pageNo + 1, - nodes.concat(data.results) - ); - } - return nodes.concat(data.results); - } catch (error) { - throw error; + 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 [contentError, setContentError] = useState(null); - const [isLoading, setIsLoading] = useState(true); const [graphLinks, setGraphLinks] = useState([]); const [graphNodes, setGraphNodes] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [nodePositions, setNodePositions] = useState(null); + const [showKey, setShowKey] = useState(false); + const [showTools, setShowTools] = useState(false); useEffect(() => { const buildGraphArrays = nodes => { - const nonRootNodeIds = []; const allNodeIds = []; const arrayOfLinksForChart = []; - const nodeIdToChartNodeIdMapping = {}; const chartNodeIdToIndexMapping = {}; + const nodeIdToChartNodeIdMapping = {}; const nodeRef = {}; + const nonRootNodeIds = []; let nodeIdCounter = 1; const arrayOfNodesForChart = [ { @@ -110,7 +106,7 @@ function WorkflowOutput({ job, i18n }) { arrayOfLinksForChart.push({ source: arrayOfNodesForChart[sourceIndex], target: arrayOfNodesForChart[targetIndex], - edgeType: 'success', + linkType: 'success', type: 'link', }); nonRootNodeIds.push(nodeId); @@ -121,7 +117,7 @@ function WorkflowOutput({ job, i18n }) { arrayOfLinksForChart.push({ source: arrayOfNodesForChart[sourceIndex], target: arrayOfNodesForChart[targetIndex], - edgeType: 'failure', + linkType: 'failure', type: 'link', }); nonRootNodeIds.push(nodeId); @@ -132,7 +128,7 @@ function WorkflowOutput({ job, i18n }) { arrayOfLinksForChart.push({ source: arrayOfNodesForChart[sourceIndex], target: arrayOfNodesForChart[targetIndex], - edgeType: 'always', + linkType: 'always', type: 'link', }); nonRootNodeIds.push(nodeId); @@ -151,7 +147,7 @@ function WorkflowOutput({ job, i18n }) { arrayOfLinksForChart.push({ source: arrayOfNodesForChart[0], target: arrayOfNodesForChart[targetIndex], - edgeType: 'always', + linkType: 'always', type: 'link', }); }); @@ -206,12 +202,21 @@ function WorkflowOutput({ job, i18n }) { return ( - Toolbar + setShowKey(!showKey)} + onToolsToggle={() => setShowTools(!showTools)} + toolsShown={showTools} + /> {nodePositions && ( )} @@ -219,4 +224,8 @@ function WorkflowOutput({ job, i18n }) { ); } +WorkflowOutput.propTypes = { + job: shape().isRequired, +}; + export default withI18n()(WorkflowOutput); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 04112d149d..89aff5467d 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -1,15 +1,28 @@ import React, { Fragment, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; -import { WorkflowHelp, WorkflowNodeHelp } from '@components/Workflow'; -import { calcZoomAndFit } from '@util/workflow'; +import { arrayOf, bool, shape } from 'prop-types'; +import { calcZoomAndFit, getZoomTranslate } from '@util/workflow'; import { WorkflowOutputLink, WorkflowOutputNode, WorkflowOutputStartNode, } from '@screens/Job/WorkflowOutput'; +import { + WorkflowHelp, + WorkflowKey, + WorkflowNodeHelp, + WorkflowTools, +} from '@components/Workflow'; -function WorkflowOutputGraph({ links, nodes, nodePositions }) { +function WorkflowOutputGraph({ + links, + nodePositions, + nodes, + showKey, + showTools, +}) { const [nodeHelp, setNodeHelp] = useState(); + const [zoomPercentage, setZoomPercentage] = useState(100); const svgRef = useRef(null); const gRef = useRef(null); @@ -20,6 +33,75 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) { '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 svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity + .translate(0, svgBoundingClientRect.height / 2 - 30) + .scale(1) + ); + + setZoomPercentage(100); + }; + + const handleZoomChange = newScale => { + const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale); + + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity.translate(translateX, translateY).scale(newScale) + ); + setZoomPercentage(newScale * 100); + }; + + const handleFitGraph = () => { + const [scaleToFit, yTranslate] = calcZoomAndFit( + gRef.current, + svgRef.current + ); + + d3.select(svgRef.current).call( + zoomRef.transform, + d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) + ); + + setZoomPercentage(scaleToFit * 100); }; const zoomRef = d3 @@ -34,12 +116,17 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) { // Attempt to zoom the graph to fit the available screen space useEffect(() => { - const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current); + const [scaleToFit, yTranslate] = calcZoomAndFit( + gRef.current, + svgRef.current + ); d3.select(svgRef.current).call( zoomRef.transform, d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) ); + + setZoomPercentage(scaleToFit * 100); // We only want this to run once (when the component mounts) // Including zoomRef.transform in the deps array will cause this to // run very frequently. @@ -78,10 +165,10 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) { return ( setNodeHelp(node)} mouseLeave={() => setNodeHelp(null)} + node={node} + nodePositions={nodePositions} /> ); } @@ -90,8 +177,28 @@ function WorkflowOutputGraph({ links, nodes, nodePositions }) { ]} +
    + {showTools && ( + + )} + {showKey && } +
    ); } +WorkflowOutputGraph.propTypes = { + links: arrayOf(shape()).isRequired, + nodePositions: shape().isRequired, + nodes: arrayOf(shape()).isRequired, + showKey: bool.isRequired, + showTools: bool.isRequired, +}; + export default WorkflowOutputGraph; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx index bc9dde7874..fdfc91d6cd 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { shape } from 'prop-types'; import { generateLine, getLinePoints } from '@util/workflow'; function WorkflowOutputLink({ link, nodePositions }) { @@ -6,16 +7,16 @@ function WorkflowOutputLink({ link, nodePositions }) { const [pathStroke, setPathStroke] = useState('#CCCCCC'); useEffect(() => { - if (link.edgeType === 'failure') { + if (link.linkType === 'failure') { setPathStroke('#d9534f'); } - if (link.edgeType === 'success') { + if (link.linkType === 'success') { setPathStroke('#5cb85c'); } - if (link.edgeType === 'always') { + if (link.linkType === 'always') { setPathStroke('#337ab7'); } - }, [link.edgeType]); + }, [link.linkType]); useEffect(() => { const linePoints = getLinePoints(link, nodePositions); @@ -37,4 +38,9 @@ function WorkflowOutputLink({ link, nodePositions }) { ); } +WorkflowOutputLink.propTypes = { + link: shape().isRequired, + nodePositions: shape().isRequired, +}; + export default WorkflowOutputLink; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx index a8749e60e8..69ebd533aa 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -1,11 +1,12 @@ import React, { Fragment } from 'react'; +import { withRouter } 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 { JOB_TYPE_URL_SEGMENTS } from '@constants'; import { constants as wfConstants } from '@util/workflow'; const NodeG = styled.g` @@ -13,12 +14,12 @@ const NodeG = styled.g` `; const JobTopLine = styled.div` - display: flex; align-items: center; + display: flex; margin-top: 5px; - white-space: nowrap; - text-overflow: ellipsis; overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; p { margin-left: 10px; @@ -29,8 +30,8 @@ const JobTopLine = styled.div` `; const Elapsed = styled.div` - text-align: center; margin-top: 5px; + text-align: center; span { font-size: 12px; @@ -48,18 +49,19 @@ const NodeContents = styled.foreignObject` const NodeDefaultLabel = styled.p` margin-top: 20px; - text-align: center; - white-space: nowrap; - text-overflow: ellipsis; overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; `; function WorkflowOutputNode({ - node, - nodePositions, + history, + i18n, mouseEnter, mouseLeave, - i18n, + node, + nodePositions, }) { let borderColor = '#93969A'; @@ -78,10 +80,7 @@ function WorkflowOutputNode({ const handleNodeClick = () => { if (node.job) { - window.open( - `/#/jobs/${JOB_TYPE_URL_SEGMENTS[node.job.type]}/${node.job.id}`, - '_blank' - ); + history.push(`/jobs/${node.job.id}/details`); } }; @@ -96,13 +95,13 @@ function WorkflowOutputNode({ onMouseLeave={mouseLeave} > {node.job ? ( @@ -133,4 +132,11 @@ function WorkflowOutputNode({ ); } -export default withI18n()(WorkflowOutputNode); +WorkflowOutputNode.propTypes = { + mouseEnter: func.isRequired, + mouseLeave: func.isRequired, + node: shape().isRequired, + nodePositions: shape().isRequired, +}; + +export default withI18n()(withRouter(WorkflowOutputNode)); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx index 0101200732..5b6fd79ee8 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx @@ -1,18 +1,19 @@ import React from 'react'; +import { shape } from 'prop-types'; import { constants as wfConstants } from '@util/workflow'; function WorkflowOutputStartNode({ nodePositions }) { return ( - {/* TODO: Translate this...? */} + {/* TODO: We need to be able to handle translated text here */} START @@ -20,4 +21,8 @@ function WorkflowOutputStartNode({ nodePositions }) { ); } +WorkflowOutputStartNode.propTypes = { + nodePositions: shape().isRequired, +}; + export default WorkflowOutputStartNode; 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..c3f0350796 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { arrayOf, bool, func, 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 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; + } +`; + +function WorkflowOutputToolbar({ + i18n, + job, + keyShown, + nodes, + onKeyToggle, + onToolsToggle, + toolsShown, +}) { + const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1; + + return ( +
    +
    + + {job.name} +
    +
    +
    {i18n._(t`Total Nodes`)}
    + {totalNodes} + + + + + + + + + + + +
    +
    + ); +} + +WorkflowOutputToolbar.propTypes = { + job: shape().isRequired, + keyShown: bool.isRequired, + nodes: arrayOf(shape()), + onKeyToggle: func.isRequired, + onToolsToggle: func.isRequired, + toolsShown: bool.isRequired, +}; + +WorkflowOutputToolbar.defaultProps = { + nodes: [], +}; + +export default withI18n()(WorkflowOutputToolbar); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/index.js b/awx/ui_next/src/screens/Job/WorkflowOutput/index.js index 6580c4f7d3..f3020d0679 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/index.js +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/index.js @@ -3,3 +3,4 @@ export { default as WorkflowOutputGraph } from './WorkflowOutputGraph'; export { default as WorkflowOutputLink } from './WorkflowOutputLink'; export { default as WorkflowOutputNode } from './WorkflowOutputNode'; export { default as WorkflowOutputStartNode } from './WorkflowOutputStartNode'; +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/Modals/DeleteAllNodesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx index 86a19f586f..727f41f9e0 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx @@ -2,15 +2,12 @@ import React from 'react'; import { Button } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { func } from 'prop-types'; import AlertModal from '@components/AlertModal'; function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { return ( , ]} + isOpen + onClose={onCancel} + title={i18n._(t`Remove All Nodes`)} + variant="danger" >

    {i18n._( @@ -39,4 +40,9 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { ); } +DeleteAllNodesModal.propTypes = { + onCancel: func.isRequired, + onConfirm: func.isRequired, +}; + export default withI18n()(DeleteAllNodesModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx index db8222b8b8..390940937d 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx @@ -2,6 +2,7 @@ import React, { Fragment } from 'react'; import { Button } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { func, shape } from 'prop-types'; import AlertModal from '@components/AlertModal'; function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) { @@ -13,18 +14,18 @@ function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) { onClose={onCancel} actions={[ , , @@ -45,4 +46,10 @@ function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) { ); } +LinkDeleteModal.propTypes = { + linkToDelete: shape().isRequired, + onCancel: func.isRequired, + onConfirm: func.isRequired, +}; + export default withI18n()(LinkDeleteModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx index 6c76c80e4f..1e9c486e44 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModal.jsx @@ -1,23 +1,17 @@ import React, { useState } from 'react'; -import { Button, Modal } from '@patternfly/react-core'; +import { Button, FormGroup, Modal } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { FormGroup } from '@patternfly/react-core'; +import { func, node, string } from 'prop-types'; import AnsibleSelect from '@components/AnsibleSelect'; -function LinkModal({ - i18n, - header, - onCancel, - onConfirm, - edgeType = 'success', -}) { - const [newEdgeType, setNewEdgeType] = useState(edgeType); +function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) { + const [newLinkType, setNewLinkType] = useState(linkType); return ( onConfirm(newEdgeType)} + onClick={() => onConfirm(newLinkType)} > {i18n._(t`Save`)} , @@ -42,7 +36,7 @@ function LinkModal({ { - setNewEdgeType(value); + setNewLinkType(value); }} /> @@ -69,4 +63,15 @@ function LinkModal({ ); } +LinkModal.propTypes = { + linkType: string, + header: node.isRequired, + onCancel: func.isRequired, + onConfirm: func.isRequired, +}; + +LinkModal.defaultProps = { + linkType: 'success', +}; + export default withI18n()(LinkModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx index 5d98bc2550..31f20fcd76 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx @@ -2,6 +2,7 @@ import React, { Fragment } from 'react'; import { Button } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { func, shape } from 'prop-types'; import AlertModal from '@components/AlertModal'; function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { @@ -45,4 +46,10 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { ); } +NodeDeleteModal.propTypes = { + nodeToDelete: shape().isRequired, + onCancel: func.isRequired, + onConfirm: func.isRequired, +}; + export default withI18n()(NodeDeleteModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx index ed7bd6d546..6848fc1ec8 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx @@ -2,82 +2,84 @@ import React, { useState } from 'react'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { bool, func, node, shape } from 'prop-types'; import { Button, WizardContextConsumer, WizardFooter, } from '@patternfly/react-core'; -import NodeTypeStep from './NodeTypeStep/NodeTypeStep'; -import RunStep from './RunStep'; -import NodeNextButton from './NodeNextButton'; -import { Wizard } from '@components/Wizard'; +import Wizard from '@components/Wizard'; +import { NodeTypeStep } from './NodeTypeStep'; +import { RunStep, NodeNextButton } from '.'; function NodeModal({ + askLinkType, history, i18n, - title, + nodeToEdit, onClose, onSave, - node, - askLinkType, + title, }) { - let defaultNodeType = 'job_template'; - let defaultNodeResource = null; - let defaultApprovalName = ''; let defaultApprovalDescription = ''; + let defaultApprovalName = ''; let defaultApprovalTimeout = 0; - if (node && node.unifiedJobTemplate) { + let defaultNodeResource = null; + let defaultNodeType = 'job_template'; + if (nodeToEdit && nodeToEdit.unifiedJobTemplate) { if ( - node && - node.unifiedJobTemplate && - (node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type) + nodeToEdit && + nodeToEdit.unifiedJobTemplate && + (nodeToEdit.unifiedJobTemplate.type || + nodeToEdit.unifiedJobTemplate.unified_job_type) ) { const ujtType = - node.unifiedJobTemplate.type || - node.unifiedJobTemplate.unified_job_type; + nodeToEdit.unifiedJobTemplate.type || + nodeToEdit.unifiedJobTemplate.unified_job_type; switch (ujtType) { case 'job_template': case 'job': defaultNodeType = 'job_template'; - defaultNodeResource = node.unifiedJobTemplate; + defaultNodeResource = nodeToEdit.unifiedJobTemplate; break; case 'project': case 'project_update': defaultNodeType = 'project_sync'; - defaultNodeResource = node.unifiedJobTemplate; + defaultNodeResource = nodeToEdit.unifiedJobTemplate; break; case 'inventory_source': case 'inventory_update': defaultNodeType = 'inventory_source_sync'; - defaultNodeResource = node.unifiedJobTemplate; + defaultNodeResource = nodeToEdit.unifiedJobTemplate; break; case 'workflow_job_template': case 'workflow_job': defaultNodeType = 'workflow_job_template'; - defaultNodeResource = node.unifiedJobTemplate; + defaultNodeResource = nodeToEdit.unifiedJobTemplate; break; case 'workflow_approval_template': case 'workflow_approval': defaultNodeType = 'approval'; - defaultApprovalName = node.unifiedJobTemplate.name; - defaultApprovalDescription = node.unifiedJobTemplate.description; - defaultApprovalTimeout = node.unifiedJobTemplate.timeout; + defaultApprovalName = nodeToEdit.unifiedJobTemplate.name; + defaultApprovalDescription = + nodeToEdit.unifiedJobTemplate.description; + defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout; break; default: } } } - const [nodeType, setNodeType] = useState(defaultNodeType); - const [linkType, setLinkType] = useState('success'); - const [nodeResource, setNodeResource] = useState(defaultNodeResource); - const [triggerNext, setTriggerNext] = useState(0); - const [approvalName, setApprovalName] = useState(defaultApprovalName); 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('&'); @@ -95,19 +97,17 @@ function NodeModal({ const resource = nodeType === 'approval' ? { - name: approvalName, description: approvalDescription, + name: approvalName, timeout: approvalTimeout, type: 'workflow_approval_template', } : nodeResource; - // TODO: pick edgeType or linkType and be consistent across all files. - onSave({ - nodeType, - edgeType: linkType, + linkType, nodeResource: resource, + nodeType, }); }; @@ -145,15 +145,15 @@ function NodeModal({ (nodeType === 'approval' && approvalName !== ''), component: ( ), @@ -198,15 +198,27 @@ function NodeModal({ return ( ); } +NodeModal.propTypes = { + askLinkType: bool.isRequired, + nodeToEdit: shape(), + onClose: func.isRequired, + onSave: func.isRequired, + title: node.isRequired, +}; + +NodeModal.defaultProps = { + nodeToEdit: null, +}; + export default withI18n()(withRouter(NodeModal)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx index a941cb33da..046b2b4db8 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx @@ -1,22 +1,20 @@ import React, { useEffect } from 'react'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; +import { func, number, shape, string } from 'prop-types'; import { Button } from '@patternfly/react-core'; function NodeNextButton({ - i18n, activeStep, + buttonText, + onClick, onNext, triggerNext, - onClick, - buttonText, }) { useEffect(() => { if (!triggerNext) { return; } onNext(); - }, [triggerNext]); + }, [onNext, triggerNext]); return ( @@ -47,4 +48,8 @@ function StartScreen({ i18n, onStartClick }) { ); } -export default withI18n()(StartScreen); +VisualizerStartScreen.propTypes = { + onStartClick: func.isRequired, +}; + +export default withI18n()(VisualizerStartScreen); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx index c7ad6e08b7..68d86664f8 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx @@ -1,11 +1,11 @@ import React from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { arrayOf, bool, func, shape } from 'prop-types'; import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core'; import { BookIcon, CompassIcon, - DownloadIcon, RocketIcon, TimesIcon, TrashAltIcon, @@ -36,68 +36,50 @@ const ActionButton = styled(Button)` } `; -function Toolbar({ +function VisualizerToolbar({ i18n, - template, + keyShown, + nodes, onClose, - onSave, - nodes = [], onDeleteAllClick, onKeyToggle, - keyShown, + onSave, onToolsToggle, + template, toolsShown, }) { const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1; return (

    -
    -
    +
    +
    {i18n._(t`Workflow Visualizer`)} {template.name}
    -
    +
    {i18n._(t`Total Nodes`)}
    {totalNodes} - - - @@ -106,10 +88,10 @@ function Toolbar({ @@ -120,9 +102,9 @@ function Toolbar({ @@ -132,4 +114,20 @@ function Toolbar({ ); } -export default withI18n()(Toolbar); +VisualizerToolbar.propTypes = { + keyShown: bool.isRequired, + nodes: arrayOf(shape()), + onClose: func.isRequired, + onDeleteAllClick: func.isRequired, + onKeyToggle: func.isRequired, + onSave: func.isRequired, + onToolsToggle: func.isRequired, + template: shape().isRequired, + toolsShown: bool.isRequired, +}; + +VisualizerToolbar.defaultProps = { + nodes: [], +}; + +export default withI18n()(VisualizerToolbar); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js index 7cff003e08..9c3200ca48 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js @@ -1,9 +1,7 @@ export { default as Visualizer } from './Visualizer'; -export { default as VisualizerToolbar } from './VisualizerToolbar'; export { default as VisualizerGraph } from './VisualizerGraph'; -export { default as VisualizerStartScreen } from './VisualizerStartScreen'; -export { default as VisualizerStartNode } from './VisualizerStartNode'; export { default as VisualizerLink } from './VisualizerLink'; export { default as VisualizerNode } from './VisualizerNode'; -export { default as VisualizerKey } from './VisualizerKey'; -export { default as VisualizerTools } from './VisualizerTools'; +export { default as VisualizerStartNode } from './VisualizerStartNode'; +export { default as VisualizerStartScreen } from './VisualizerStartScreen'; +export { default as VisualizerToolbar } from './VisualizerToolbar'; diff --git a/awx/ui_next/src/util/workflow.jsx b/awx/ui_next/src/util/workflow.jsx index a914aeda80..c37dca118f 100644 --- a/awx/ui_next/src/util/workflow.jsx +++ b/awx/ui_next/src/util/workflow.jsx @@ -18,8 +18,8 @@ export function calcZoomAndFit(gRef, svgRef) { .node() .getBoundingClientRect(); - gBoundingClientRect.height = gBoundingClientRect.height / currentScale; - gBoundingClientRect.width = gBoundingClientRect.width / currentScale; + gBoundingClientRect.height /= currentScale; + gBoundingClientRect.width /= currentScale; const gBBoxDimensions = d3 .select(gRef) From 5a9248e6195e6b7a26c5c4bb182a7e38d0d31fa5 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 20 Jan 2020 16:58:24 -0500 Subject: [PATCH 05/46] Prettier --- .../src/screens/Job/WorkflowOutput/WorkflowOutput.jsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx index 9783d9ea7b..d624a34ff0 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -30,11 +30,7 @@ const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => { page: pageNo, }); if (data.next) { - return fetchWorkflowNodes( - jobId, - pageNo + 1, - nodes.concat(data.results) - ); + return fetchWorkflowNodes(jobId, pageNo + 1, nodes.concat(data.results)); } return nodes.concat(data.results); }; From 01cc0ac8f17ce15277f24f7f3f4ec29e66db8608 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 22 Jan 2020 10:52:17 -0500 Subject: [PATCH 06/46] Adds unit test coverage for shared workflow components --- .../Workflow/WorkflowActionTooltip.test.jsx | 14 ++ .../WorkflowActionTooltipItem.test.jsx | 10 ++ .../components/Workflow/WorkflowHelp.test.jsx | 10 ++ .../components/Workflow/WorkflowKey.test.jsx | 10 ++ .../components/Workflow/WorkflowLinkHelp.jsx | 2 +- .../Workflow/WorkflowLinkHelp.test.jsx | 31 +++++ .../components/Workflow/WorkflowNodeHelp.jsx | 10 +- .../Workflow/WorkflowNodeHelp.test.jsx | 33 +++++ .../Workflow/WorkflowNodeTypeLetter.test.jsx | 121 ++++++++++++++++++ .../Workflow/WorkflowTools.test.jsx | 54 ++++++++ 10 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 awx/ui_next/src/components/Workflow/WorkflowActionTooltip.test.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowActionTooltipItem.test.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowHelp.test.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowLinkHelp.test.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.test.jsx create mode 100644 awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx 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.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/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/WorkflowKey.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx new file mode 100644 index 0000000000..6fac4c4ef8 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowKey from './WorkflowKey'; + +describe('WorkflowKey', () => { + 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 index 4252351798..5180ab3bba 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx @@ -38,7 +38,7 @@ function WorkflowLinkHelp({ link, i18n }) {
    {i18n._(t`Run`)}
    -
    {linkType}
    + ); } 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 index 07d56c623b..ce25921463 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx @@ -104,11 +104,11 @@ function WorkflowNodeHelp({ node, i18n }) {
    {i18n._(t`Name`)}
    -
    {node.unifiedJobTemplate.name}
    +
    {node.unifiedJobTemplate.name}
    {i18n._(t`Type`)}
    -
    {nodeType}
    +
    {nodeType}
    )} {node.job && ( @@ -116,13 +116,15 @@ function WorkflowNodeHelp({ node, i18n }) {
    {i18n._(t`Job Status`)}
    -
    {jobStatus}
    +
    {jobStatus}
    {node.job.elapsed && (
    {i18n._(t`Elapsed`)}
    -
    {secondsToHHMMSS(node.job.elapsed)}
    +
    + {secondsToHHMMSS(node.job.elapsed)} +
    )} 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..01e771a140 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx @@ -0,0 +1,33 @@ +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: { + elapsed: 9000, + status: 'successful', + }, + 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.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/WorkflowTools.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx new file mode 100644 index 0000000000..42fb97b259 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowTools from './WorkflowTools'; + +describe('WorkflowTools', () => { + test('renders the expected content', () => { + const wrapper = mountWithContexts( + {}} + onPan={() => {}} + onPanToMiddle={() => {}} + onZoomChange={() => {}} + zoomPercentage={100} + /> + ); + expect(wrapper).toHaveLength(1); + }); + test('clicking zoom buttons passes callback correctly updated scale', () => { + const zoomChange = jest.fn(); + const wrapper = mountWithContexts( + {}} + onPan={() => {}} + 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); + }); + test('clicking pan buttons passes callback correct string direction', () => { + const pan = jest.fn(); + const wrapper = mountWithContexts( + {}} + onPan={pan} + onPanToMiddle={() => {}} + onZoomChange={() => {}} + zoomPercentage={100} + /> + ); + 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'); + }); +}); From 3d5c32c35438711eb1a4ccb0bd02582b84625328 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 22 Jan 2020 11:16:42 -0500 Subject: [PATCH 07/46] Update function/callback props passed into workflow components to start with "on" --- .../Workflow/WorkflowActionTooltip.jsx | 6 +-- .../Modals/NodeModal/NodeModal.jsx | 12 +++--- .../NodeTypeStep/InventorySourcesList.jsx | 10 ++--- .../NodeTypeStep/JobTemplatesList.jsx | 15 +++++--- .../NodeModal/NodeTypeStep/NodeTypeStep.jsx | 38 +++++++++---------- .../NodeModal/NodeTypeStep/ProjectsList.jsx | 10 ++--- .../NodeTypeStep/WorkflowJobTemplatesList.jsx | 10 ++--- .../Modals/NodeModal/RunStep.jsx | 10 ++--- .../VisualizerGraph.jsx | 8 ++-- .../VisualizerLink.jsx | 26 ++++++------- .../VisualizerNode.jsx | 38 +++++++++---------- .../VisualizerStartNode.jsx | 10 ++--- 12 files changed, 99 insertions(+), 94 deletions(-) diff --git a/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx index 8946461d63..02ec532c95 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowActionTooltip.jsx @@ -6,7 +6,7 @@ const TooltipContents = styled.div` display: flex; `; -const TooltipArrows = styled.div` +const TooltipArrow = styled.div` width: 10px; `; @@ -50,10 +50,10 @@ function WorkflowActionTooltip({ actions, pointX, pointY }) { height={tipHeight} > - + - + {actions} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx index 6848fc1ec8..71f0942e52 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx @@ -131,7 +131,7 @@ function NodeModal({ name: i18n._(t`Run Type`), key: 'run_type', component: ( - + ), enableNext: linkType !== null, }, @@ -149,12 +149,12 @@ function NodeModal({ name={approvalName} nodeResource={nodeResource} nodeType={nodeType} + onUpdateDescription={setApprovalDescription} + onUpdateName={setApprovalName} + onUpdateNodeResource={setNodeResource} + onUpdateNodeType={handleNodeTypeChange} + onUpdateTimeout={setApprovalTimeout} timeout={approvalTimeout} - updateDescription={setApprovalDescription} - updateName={setApprovalName} - updateNodeResource={setNodeResource} - updateNodeType={handleNodeTypeChange} - updateTimeout={setApprovalTimeout} /> ), }, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx index b9946be729..c73fabf6ef 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx @@ -19,7 +19,7 @@ function InventorySourcesList({ history, i18n, nodeResource, - updateNodeResource, + onUpdateNodeResource, }) { const [count, setCount] = useState(0); const [error, setError] = useState(null); @@ -50,7 +50,7 @@ function InventorySourcesList({ hasContentLoading={isLoading} itemCount={count} items={inventorySources} - onRowClick={row => updateNodeResource(row)} + onRowClick={row => onUpdateNodeResource(row)} qsConfig={QS_CONFIG} showPageSizeOptions={false} renderItem={item => ( @@ -60,8 +60,8 @@ function InventorySourcesList({ key={item.id} name={item.name} label={item.name} - onSelect={() => updateNodeResource(item)} - onDeselect={() => updateNodeResource(null)} + onSelect={() => onUpdateNodeResource(item)} + onDeselect={() => onUpdateNodeResource(null)} isRadio /> )} @@ -80,7 +80,7 @@ function InventorySourcesList({ InventorySourcesList.propTypes = { nodeResource: shape(), - updateNodeResource: func.isRequired, + onUpdateNodeResource: func.isRequired, }; InventorySourcesList.defaultProps = { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx index c9c1940a5b..a0400c07ae 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx @@ -15,7 +15,12 @@ const QS_CONFIG = getQSConfig('job_templates', { order_by: 'name', }); -function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) { +function JobTemplatesList({ + i18n, + history, + nodeResource, + onUpdateNodeResource, +}) { const [count, setCount] = useState(0); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -47,7 +52,7 @@ function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) { hasContentLoading={isLoading} itemCount={count} items={jobTemplates} - onRowClick={row => updateNodeResource(row)} + onRowClick={row => onUpdateNodeResource(row)} qsConfig={QS_CONFIG} renderItem={item => ( updateNodeResource(item)} - onDeselect={() => updateNodeResource(null)} + onSelect={() => onUpdateNodeResource(item)} + onDeselect={() => onUpdateNodeResource(null)} isRadio /> )} @@ -77,7 +82,7 @@ function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) { JobTemplatesList.propTypes = { nodeResource: shape(), - updateNodeResource: func.isRequired, + onUpdateNodeResource: func.isRequired, }; JobTemplatesList.defaultProps = { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx index 69bf57b40c..c0ba06e1d5 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx @@ -38,11 +38,11 @@ function NodeTypeStep({ nodeResource, nodeType, timeout, - updateDescription, - updateName, - updateNodeResource, - updateNodeType, - updateTimeout, + onUpdateDescription, + onUpdateName, + onUpdateNodeResource, + onUpdateNodeType, + onUpdateTimeout, }) { return ( <> @@ -87,7 +87,7 @@ function NodeTypeStep({ ]} value={nodeType} onChange={(e, val) => { - updateNodeType(val); + onUpdateNodeType(val); }} />
    @@ -96,25 +96,25 @@ function NodeTypeStep({ {nodeType === 'job_template' && ( )} {nodeType === 'project_sync' && ( )} {nodeType === 'inventory_source_sync' && ( )} {nodeType === 'workflow_job_template' && ( )} {nodeType === 'approval' && ( @@ -150,7 +150,7 @@ function NodeTypeStep({ type="text" {...field} onChange={(value, evt) => { - updateName(value); + onUpdateName(value); field.onChange(evt); }} /> @@ -172,7 +172,7 @@ function NodeTypeStep({ type="text" {...field} onChange={(value, evt) => { - updateDescription(value); + onUpdateDescription(value); field.onChange(evt); }} /> @@ -200,7 +200,7 @@ function NodeTypeStep({ if (!value || value === '') { value = 0; } - updateTimeout( + onUpdateTimeout( Number(value) * 60 + Number(form.values.timeoutSeconds) ); @@ -225,7 +225,7 @@ function NodeTypeStep({ if (!value || value === '') { value = 0; } - updateTimeout( + onUpdateTimeout( Number(value) + Number(form.values.timeoutMinutes) * 60 ); @@ -253,11 +253,11 @@ NodeTypeStep.propTypes = { nodeResource: shape(), nodeType: string, timeout: number, - updateDescription: func.isRequired, - updateName: func.isRequired, - updateNodeResource: func.isRequired, - updateNodeType: func.isRequired, - updateTimeout: func.isRequired, + onUpdateDescription: func.isRequired, + onUpdateName: func.isRequired, + onUpdateNodeResource: func.isRequired, + onUpdateNodeType: func.isRequired, + onUpdateTimeout: func.isRequired, }; NodeTypeStep.defaultProps = { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx index 60998d32e8..98e12e745b 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx @@ -15,7 +15,7 @@ const QS_CONFIG = getQSConfig('projects', { order_by: 'name', }); -function ProjectsList({ history, i18n, nodeResource, updateNodeResource }) { +function ProjectsList({ history, i18n, nodeResource, onUpdateNodeResource }) { const [count, setCount] = useState(0); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -45,7 +45,7 @@ function ProjectsList({ history, i18n, nodeResource, updateNodeResource }) { hasContentLoading={isLoading} itemCount={count} items={projects} - onRowClick={row => updateNodeResource(row)} + onRowClick={row => onUpdateNodeResource(row)} qsConfig={QS_CONFIG} renderItem={item => ( updateNodeResource(item)} - onDeselect={() => updateNodeResource(null)} + onSelect={() => onUpdateNodeResource(item)} + onDeselect={() => onUpdateNodeResource(null)} isRadio /> )} @@ -75,7 +75,7 @@ function ProjectsList({ history, i18n, nodeResource, updateNodeResource }) { ProjectsList.propTypes = { nodeResource: shape(), - updateNodeResource: func.isRequired, + onUpdateNodeResource: func.isRequired, }; ProjectsList.defaultProps = { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx index 5d999178ae..4654b34a07 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx @@ -19,7 +19,7 @@ function WorkflowJobTemplatesList({ history, i18n, nodeResource, - updateNodeResource, + onUpdateNodeResource, }) { const [count, setCount] = useState(0); const [error, setError] = useState(null); @@ -52,7 +52,7 @@ function WorkflowJobTemplatesList({ hasContentLoading={isLoading} itemCount={count} items={workflowJobTemplates} - onRowClick={row => updateNodeResource(row)} + onRowClick={row => onUpdateNodeResource(row)} qsConfig={QS_CONFIG} renderItem={item => ( updateNodeResource(item)} - onDeselect={() => updateNodeResource(null)} + onSelect={() => onUpdateNodeResource(item)} + onDeselect={() => onUpdateNodeResource(null)} isRadio /> )} @@ -82,7 +82,7 @@ function WorkflowJobTemplatesList({ WorkflowJobTemplatesList.propTypes = { nodeResource: shape(), - updateNodeResource: func.isRequired, + onUpdateNodeResource: func.isRequired, }; WorkflowJobTemplatesList.defaultProps = { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx index 9f36632f88..ce30534830 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx @@ -16,7 +16,7 @@ const Grid = styled.div` width: 100%; `; -function RunStep({ i18n, linkType, updateLinkType }) { +function RunStep({ i18n, linkType, onUpdateLinkType }) { return ( <> @@ -34,7 +34,7 @@ function RunStep({ i18n, linkType, updateLinkType }) { description={i18n._( t`Execute when the parent node results in a successful state.` )} - onClick={() => updateLinkType('success')} + onClick={() => onUpdateLinkType('success')} /> <SelectableCard isSelected={linkType === 'failure'} @@ -42,7 +42,7 @@ function RunStep({ i18n, linkType, updateLinkType }) { description={i18n._( t`Execute when the parent node results in a failure state.` )} - onClick={() => updateLinkType('failure')} + onClick={() => onUpdateLinkType('failure')} /> <SelectableCard isSelected={linkType === 'always'} @@ -50,7 +50,7 @@ function RunStep({ i18n, linkType, updateLinkType }) { description={i18n._( t`Execute regardless of the parent node's final state.` )} - onClick={() => updateLinkType('always')} + onClick={() => onUpdateLinkType('always')} /> </Grid> </> @@ -59,7 +59,7 @@ function RunStep({ i18n, linkType, updateLinkType }) { RunStep.propTypes = { linkType: string.isRequired, - updateLinkType: func.isRequired, + onUpdateLinkType: func.isRequired, }; export default withI18n()(RunStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index c41fe06d80..99108d54cd 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -262,7 +262,7 @@ function VisualizerGraph({ nodePositions={nodePositions} onAddNodeClick={onAddNodeClick} readOnly={readOnly} - updateHelpText={setHelpText} + onUpdateHelpText={setHelpText} />, links.map(link => { if ( @@ -279,8 +279,8 @@ function VisualizerGraph({ onDeleteLinkClick={onDeleteLinkClick} onLinkEditClick={onLinkEditClick} readOnly={readOnly} - updateHelpText={setHelpText} - updateLinkHelp={setLinkHelp} + onUpdateHelpText={setHelpText} + onUpdateLinkHelp={setLinkHelp} /> ); } @@ -304,7 +304,7 @@ function VisualizerGraph({ onStartAddLinkClick={onStartAddLinkClick} onViewNodeClick={onViewNodeClick} readOnly={readOnly} - updateHelpText={setHelpText} + onUpdateHelpText={setHelpText} updateNodeHelp={setNodeHelp} {...(addingLink && { onMouseOver: () => drawPotentialLinkToNode(node), diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index 90b8d242f9..c3e3ea7fa4 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -26,9 +26,9 @@ function VisualizerLink({ onAddNodeClick, onDeleteLinkClick, onLinkEditClick, + onUpdateHelpText, + onUpdateLinkHelp, readOnly, - updateHelpText, - updateLinkHelp, }) { const [hovering, setHovering] = useState(false); const [pathD, setPathD] = useState(); @@ -41,14 +41,14 @@ function VisualizerLink({ id="link-add-node" key="add" onClick={() => { - updateHelpText(null); + onUpdateHelpText(null); setHovering(false); onAddNodeClick(link.source.id, link.target.id); }} onMouseEnter={() => - updateHelpText(i18n._(t`Add a new node between these two nodes`)) + onUpdateHelpText(i18n._(t`Add a new node between these two nodes`)) } - onMouseLeave={() => updateHelpText(null)} + onMouseLeave={() => onUpdateHelpText(null)} > <PlusIcon /> </WorkflowActionTooltipItem> @@ -63,8 +63,8 @@ function VisualizerLink({ id="link-edit" key="edit" onClick={() => onLinkEditClick(link)} - onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))} - onMouseLeave={() => updateHelpText(null)} + onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this link`))} + onMouseLeave={() => onUpdateHelpText(null)} > <PencilAltIcon /> </WorkflowActionTooltipItem>, @@ -72,8 +72,8 @@ function VisualizerLink({ id="link-delete" key="delete" onClick={() => onDeleteLinkClick(link)} - onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))} - onMouseLeave={() => updateHelpText(null)} + onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this link`))} + onMouseLeave={() => onUpdateHelpText(null)} > <TrashAltIcon /> </WorkflowActionTooltipItem>, @@ -135,8 +135,8 @@ function VisualizerLink({ strokeWidth="2px" /> <polygon - onMouseEnter={() => updateLinkHelp(link)} - onMouseLeave={() => updateLinkHelp(null)} + onMouseEnter={() => onUpdateLinkHelp(link)} + onMouseLeave={() => onUpdateLinkHelp(null)} opacity="0" points={getLinkOverlayPoints(link, nodePositions)} /> @@ -159,8 +159,8 @@ VisualizerLink.propTypes = { onDeleteLinkClick: func.isRequired, onLinkEditClick: func.isRequired, readOnly: bool.isRequired, - updateHelpText: func.isRequired, - updateLinkHelp: func.isRequired, + onUpdateHelpText: func.isRequired, + onUpdateLinkHelp: func.isRequired, }; export default withI18n()(VisualizerLink); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index 681d726f5b..fd914ff241 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -51,7 +51,7 @@ function VisualizerNode({ onStartAddLinkClick, onViewNodeClick, readOnly, - updateHelpText, + onUpdateHelpText, updateNodeHelp, }) { const [hovering, setHovering] = useState(false); @@ -61,7 +61,7 @@ function VisualizerNode({ nodeEl.parentNode.appendChild(nodeEl); setHovering(true); if (addingLink) { - updateHelpText( + onUpdateHelpText( node.isInvalidLinkTarget ? i18n._( t`Invalid link target. Unable to link to children or ancestor nodes. Graph cycles are not supported.` @@ -75,7 +75,7 @@ function VisualizerNode({ const handleNodeMouseLeave = () => { setHovering(false); if (addingLink) { - updateHelpText(null); + onUpdateHelpText(null); } }; @@ -90,12 +90,12 @@ function VisualizerNode({ id="node-details" key="details" onClick={() => { - updateHelpText(null); + onUpdateHelpText(null); setHovering(false); onViewNodeClick(node); }} - onMouseEnter={() => updateHelpText(i18n._(t`View node details`))} - onMouseLeave={() => updateHelpText(null)} + onMouseEnter={() => onUpdateHelpText(i18n._(t`View node details`))} + onMouseLeave={() => onUpdateHelpText(null)} > <InfoIcon /> </WorkflowActionTooltipItem> @@ -108,12 +108,12 @@ function VisualizerNode({ id="node-add" key="add" onClick={() => { - updateHelpText(null); + onUpdateHelpText(null); setHovering(false); onAddNodeClick(node.id); }} - onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))} - onMouseLeave={() => updateHelpText(null)} + onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))} + onMouseLeave={() => onUpdateHelpText(null)} > <PlusIcon /> </WorkflowActionTooltipItem>, @@ -122,12 +122,12 @@ function VisualizerNode({ id="node-edit" key="edit" onClick={() => { - updateHelpText(null); + onUpdateHelpText(null); setHovering(false); onEditNodeClick(node); }} - onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))} - onMouseLeave={() => updateHelpText(null)} + onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this node`))} + onMouseLeave={() => onUpdateHelpText(null)} > <PencilAltIcon /> </WorkflowActionTooltipItem>, @@ -135,14 +135,14 @@ function VisualizerNode({ id="node-link" key="link" onClick={() => { - updateHelpText(null); + onUpdateHelpText(null); setHovering(false); onStartAddLinkClick(node); }} onMouseEnter={() => - updateHelpText(i18n._(t`Link to an available node`)) + onUpdateHelpText(i18n._(t`Link to an available node`)) } - onMouseLeave={() => updateHelpText(null)} + onMouseLeave={() => onUpdateHelpText(null)} > <LinkIcon /> </WorkflowActionTooltipItem>, @@ -150,12 +150,12 @@ function VisualizerNode({ id="node-delete" key="delete" onClick={() => { - updateHelpText(null); + onUpdateHelpText(null); setHovering(false); onDeleteNodeClick(node); }} - onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))} - onMouseLeave={() => updateHelpText(null)} + onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this node`))} + onMouseLeave={() => onUpdateHelpText(null)} > <TrashAltIcon /> </WorkflowActionTooltipItem>, @@ -225,7 +225,7 @@ VisualizerNode.propTypes = { onStartAddLinkClick: func.isRequired, onViewNodeClick: func.isRequired, readOnly: bool.isRequired, - updateHelpText: func.isRequired, + onUpdateHelpText: func.isRequired, updateNodeHelp: func.isRequired, }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx index f24e896b7b..c54cb51799 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx @@ -19,8 +19,8 @@ function VisualizerStartNode({ i18n, nodePositions, onAddNodeClick, + onUpdateHelpText, readOnly, - updateHelpText, }) { const [hovering, setHovering] = useState(false); @@ -56,10 +56,10 @@ function VisualizerStartNode({ <WorkflowActionTooltipItem id="node-add" key="add" - onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))} - onMouseLeave={() => updateHelpText(null)} + onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))} + onMouseLeave={() => onUpdateHelpText(null)} onClick={() => { - updateHelpText(null); + onUpdateHelpText(null); setHovering(false); onAddNodeClick(1); }} @@ -80,7 +80,7 @@ VisualizerStartNode.propTypes = { nodePositions: shape().isRequired, onAddNodeClick: func.isRequired, readOnly: bool.isRequired, - updateHelpText: func.isRequired, + onUpdateHelpText: func.isRequired, }; export default withI18n()(VisualizerStartNode); From 5a1a47b7aa25e5fc972f0f4538e3e3e39750d142 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Wed, 22 Jan 2020 11:24:50 -0500 Subject: [PATCH 08/46] Default '---' not needed due to changes to how the prop is passed to CodeMirrorInput --- awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx index 2dae4512d5..83533f1168 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx @@ -21,7 +21,7 @@ function getValueAsMode(value, mode) { return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value); } -function VariablesDetail({ value = '---', label, rows }) { +function VariablesDetail({ value, label, rows }) { const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE); const [currentValue, setCurrentValue] = useState(value || '---'); const [error, setError] = useState(null); From e394d0a6f664312d567908b9f58c0ccd5ac6280e Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Wed, 22 Jan 2020 12:05:32 -0500 Subject: [PATCH 09/46] Add close button to workflow tools/key --- .../src/components/Workflow/WorkflowKey.jsx | 23 +++++++++++++++++-- .../components/Workflow/WorkflowKey.test.jsx | 2 +- .../src/components/Workflow/WorkflowTools.jsx | 12 ++++++++++ .../Workflow/WorkflowTools.test.jsx | 19 ++++----------- .../Job/WorkflowOutput/WorkflowOutput.jsx | 2 ++ .../WorkflowOutput/WorkflowOutputGraph.jsx | 9 ++++++-- .../Visualizer.jsx | 2 ++ .../VisualizerGraph.jsx | 7 +++++- 8 files changed, 56 insertions(+), 20 deletions(-) diff --git a/awx/ui_next/src/components/Workflow/WorkflowKey.jsx b/awx/ui_next/src/components/Workflow/WorkflowKey.jsx index e0e75dd995..6c5cdd5be1 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowKey.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowKey.jsx @@ -2,18 +2,25 @@ import React from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import { ExclamationTriangleIcon, PauseIcon } from '@patternfly/react-icons'; +import { func } from 'prop-types'; +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 Key = styled.ul` @@ -63,11 +70,19 @@ const AlwaysLink = styled(Link)` background-color: #337ab7; `; -function WorkflowKey({ i18n }) { +const Close = styled(TimesIcon)` + cursor: pointer; + position: absolute; + right: 10px; + top: 15px; +`; + +function WorkflowKey({ i18n, onClose }) { return ( <Wrapper> <Header> <b>{i18n._(t`Key`)}</b> + <Close onClick={onClose} /> </Header> <Key> <li> @@ -113,4 +128,8 @@ function WorkflowKey({ i18n }) { ); } +WorkflowKey.propTypes = { + onClose: func.isRequired, +}; + export default withI18n()(WorkflowKey); diff --git a/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx index 6fac4c4ef8..05d8762acd 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx @@ -4,7 +4,7 @@ import WorkflowKey from './WorkflowKey'; describe('WorkflowKey', () => { test('renders the expected content', () => { - const wrapper = mountWithContexts(<WorkflowKey />); + const wrapper = mountWithContexts(<WorkflowKey onClose={() => {}} />); expect(wrapper).toHaveLength(1); }); }); diff --git a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx index a1aa6f4c08..31ca802ee4 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx @@ -13,12 +13,14 @@ import { HomeIcon, MinusIcon, PlusIcon, + TimesIcon, } from '@patternfly/react-icons'; const Wrapper = styled.div` background-color: white; border: 1px solid #c7c7c7; height: 135px; + position: relative; `; const Header = styled.div` @@ -42,8 +44,16 @@ const Tools = styled.div` padding: 20px; `; +const Close = styled(TimesIcon)` + cursor: pointer; + position: absolute; + right: 10px; + top: 15px; +`; + function WorkflowTools({ i18n, + onClose, onFitGraph, onPan, onPanToMiddle, @@ -70,6 +80,7 @@ function WorkflowTools({ <Wrapper> <Header> <b>{i18n._(t`Tools`)}</b> + <Close onClick={onClose} /> </Header> <Tools> <Tooltip @@ -123,6 +134,7 @@ function WorkflowTools({ } WorkflowTools.propTypes = { + onClose: func.isRequired, onFitGraph: func.isRequired, onPan: func.isRequired, onPanToMiddle: func.isRequired, diff --git a/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx index 42fb97b259..7759495d4b 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx @@ -6,6 +6,7 @@ describe('WorkflowTools', () => { test('renders the expected content', () => { const wrapper = mountWithContexts( <WorkflowTools + onClose={() => {}} onFitGraph={() => {}} onPan={() => {}} onPanToMiddle={() => {}} @@ -15,12 +16,14 @@ describe('WorkflowTools', () => { ); expect(wrapper).toHaveLength(1); }); - test('clicking zoom buttons passes callback correctly updated scale', () => { + test('clicking zoom/pan buttons passes callback correct values', () => { + const pan = jest.fn(); const zoomChange = jest.fn(); const wrapper = mountWithContexts( <WorkflowTools + onClose={() => {}} onFitGraph={() => {}} - onPan={() => {}} + onPan={pan} onPanToMiddle={() => {}} onZoomChange={zoomChange} zoomPercentage={95.7} @@ -30,18 +33,6 @@ describe('WorkflowTools', () => { expect(zoomChange).toHaveBeenCalledWith(1.1); wrapper.find('MinusIcon').simulate('click'); expect(zoomChange).toHaveBeenCalledWith(0.8); - }); - test('clicking pan buttons passes callback correct string direction', () => { - const pan = jest.fn(); - const wrapper = mountWithContexts( - <WorkflowTools - onFitGraph={() => {}} - onPan={pan} - onPanToMiddle={() => {}} - onZoomChange={() => {}} - zoomPercentage={100} - /> - ); wrapper.find('CaretLeftIcon').simulate('click'); expect(pan).toHaveBeenCalledWith('left'); wrapper.find('CaretUpIcon').simulate('click'); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx index d624a34ff0..6ff3a4350f 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -211,6 +211,8 @@ function WorkflowOutput({ job, i18n }) { links={graphLinks} nodePositions={nodePositions} nodes={graphNodes} + onUpdateShowKey={setShowKey} + onUpdateShowTools={setShowTools} showKey={showKey} showTools={showTools} /> diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 89aff5467d..04ad3219da 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -1,6 +1,6 @@ import React, { Fragment, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; -import { arrayOf, bool, shape } from 'prop-types'; +import { arrayOf, bool, shape, func } from 'prop-types'; import { calcZoomAndFit, getZoomTranslate } from '@util/workflow'; import { WorkflowOutputLink, @@ -18,6 +18,8 @@ function WorkflowOutputGraph({ links, nodePositions, nodes, + onUpdateShowKey, + onUpdateShowTools, showKey, showTools, }) { @@ -180,6 +182,7 @@ function WorkflowOutputGraph({ <div css="position: absolute; top: 75px;right: 20px;display: flex;"> {showTools && ( <WorkflowTools + onClose={() => onUpdateShowTools(false)} onFitGraph={handleFitGraph} onPan={handlePan} onPanToMiddle={handlePanToMiddle} @@ -187,7 +190,7 @@ function WorkflowOutputGraph({ zoomPercentage={zoomPercentage} /> )} - {showKey && <WorkflowKey />} + {showKey && <WorkflowKey onClose={() => onUpdateShowKey(false)} />} </div> </Fragment> ); @@ -197,6 +200,8 @@ WorkflowOutputGraph.propTypes = { links: arrayOf(shape()).isRequired, nodePositions: shape().isRequired, nodes: arrayOf(shape()).isRequired, + onUpdateShowKey: func.isRequired, + onUpdateShowTools: func.isRequired, showKey: bool.isRequired, showTools: bool.isRequired, }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 47c95424bd..8f003b29fb 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -825,6 +825,8 @@ function Visualizer({ history, template, i18n }) { onEditNodeClick={startEditNode} onLinkEditClick={setLinkToEdit} onStartAddLinkClick={selectSourceNodeForLinking} + onUpdateShowKey={setShowKey} + onUpdateShowTools={setShowTools} onViewNodeClick={setNodeToView} readOnly={!template.summary_fields.user_capabilities.edit} showKey={showKey} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index 99108d54cd..58081edad1 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -47,6 +47,8 @@ function VisualizerGraph({ onEditNodeClick, onLinkEditClick, onStartAddLinkClick, + onUpdateShowKey, + onUpdateShowTools, onViewNodeClick, readOnly, showKey, @@ -329,6 +331,7 @@ function VisualizerGraph({ <div css="position: absolute; top: 75px;right: 20px;display: flex;"> {showTools && ( <WorkflowTools + onClose={() => onUpdateShowTools(false)} onFitGraph={handleFitGraph} onPan={handlePan} onPanToMiddle={handlePanToMiddle} @@ -336,7 +339,7 @@ function VisualizerGraph({ zoomPercentage={zoomPercentage} /> )} - {showKey && <WorkflowKey />} + {showKey && <WorkflowKey onClose={() => onUpdateShowKey(false)} />} </div> </> ); @@ -356,6 +359,8 @@ VisualizerGraph.propTypes = { onEditNodeClick: func.isRequired, onLinkEditClick: func.isRequired, onStartAddLinkClick: func.isRequired, + onUpdateShowKey: func.isRequired, + onUpdateShowTools: func.isRequired, onViewNodeClick: func.isRequired, readOnly: bool.isRequired, showKey: bool.isRequired, From fd146dde30f1f05ff5fc559c3c68635ec00369a6 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Wed, 22 Jan 2020 16:46:34 -0500 Subject: [PATCH 10/46] Adds unit test coverage to some of the workflow output components. Also adds support for hovering on workflow results links to see the edge type (success/fail/always). --- .../WorkflowOutput/WorkflowOutputGraph.jsx | 63 +++-- .../Job/WorkflowOutput/WorkflowOutputLink.jsx | 45 +++- .../WorkflowOutputLink.test.jsx | 42 ++++ .../Job/WorkflowOutput/WorkflowOutputNode.jsx | 4 +- .../WorkflowOutputNode.test.jsx | 102 ++++++++ .../WorkflowOutputStartNode.test.jsx | 21 ++ .../WorkflowOutputToolbar.test.jsx | 51 ++++ .../VisualizerGraph.jsx | 54 +++-- .../VisualizerLink.jsx | 8 +- awx/ui_next/src/util/workflow.jsx | 52 ++-- awx/ui_next/src/util/workflow.test.jsx | 225 ++++++++++++++++++ 11 files changed, 582 insertions(+), 85 deletions(-) create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx create mode 100644 awx/ui_next/src/util/workflow.test.jsx diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 04ad3219da..e053d7d346 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -1,7 +1,10 @@ import React, { Fragment, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { arrayOf, bool, shape, func } from 'prop-types'; -import { calcZoomAndFit, getZoomTranslate } from '@util/workflow'; +import { + getScaleAndOffsetToFit, + getTranslatePointsForZoom, +} from '@util/workflow'; import { WorkflowOutputLink, WorkflowOutputNode, @@ -10,6 +13,7 @@ import { import { WorkflowHelp, WorkflowKey, + WorkflowLinkHelp, WorkflowNodeHelp, WorkflowTools, } from '@components/Workflow'; @@ -23,6 +27,7 @@ function WorkflowOutputGraph({ showKey, showTools, }) { + const [linkHelp, setLinkHelp] = useState(); const [nodeHelp, setNodeHelp] = useState(); const [zoomPercentage, setZoomPercentage] = useState(100); const svgRef = useRef(null); @@ -83,7 +88,17 @@ function WorkflowOutputGraph({ }; const handleZoomChange = newScale => { - const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale); + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const currentScaleAndOffset = d3.zoomTransform( + d3.select(svgRef.current).node() + ); + + const [translateX, translateY] = getTranslatePointsForZoom( + svgBoundingClientRect, + currentScaleAndOffset, + newScale + ); d3.select(svgRef.current).call( zoomRef.transform, @@ -93,9 +108,27 @@ function WorkflowOutputGraph({ }; const handleFitGraph = () => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current + 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 svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + + const [scaleToFit, yTranslate] = getScaleAndOffsetToFit( + gBoundingClientRect, + svgBoundingClientRect, + gBBoxDimensions, + currentScale ); d3.select(svgRef.current).call( @@ -118,19 +151,9 @@ function WorkflowOutputGraph({ // Attempt to zoom the graph to fit the available screen space useEffect(() => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current - ); - - d3.select(svgRef.current).call( - zoomRef.transform, - d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) - ); - - setZoomPercentage(scaleToFit * 100); + handleFitGraph(); // We only want this to run once (when the component mounts) - // Including zoomRef.transform in the deps array will cause this to + // 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 @@ -139,9 +162,10 @@ function WorkflowOutputGraph({ return ( <Fragment> - {nodeHelp && ( + {(nodeHelp || linkHelp) && ( <WorkflowHelp> - <WorkflowNodeHelp node={nodeHelp} /> + {nodeHelp && <WorkflowNodeHelp node={nodeHelp} />} + {linkHelp && <WorkflowLinkHelp link={linkHelp} />} </WorkflowHelp> )} <svg @@ -160,6 +184,7 @@ function WorkflowOutputGraph({ key={`link-${link.source.id}-${link.target.id}`} link={link} nodePositions={nodePositions} + onUpdateLinkHelp={setLinkHelp} /> )), nodes.map(node => { diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx index fdfc91d6cd..1c29899404 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx @@ -1,11 +1,32 @@ import React, { useEffect, useState } from 'react'; import { shape } from 'prop-types'; -import { generateLine, getLinePoints } from '@util/workflow'; +import { + generateLine, + getLinePoints, + getLinkOverlayPoints, +} from '@util/workflow'; -function WorkflowOutputLink({ link, nodePositions }) { +function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { + const [hovering, setHovering] = useState(false); const [pathD, setPathD] = useState(); const [pathStroke, setPathStroke] = useState('#CCCCCC'); + const handleLinkMouseEnter = () => { + const linkEl = document.getElementById( + `link-${link.source.id}-${link.target.id}` + ); + linkEl.parentNode.appendChild(linkEl); + setHovering(true); + }; + + const handleLinkMouseLeave = () => { + const linkEl = document.getElementById( + `link-${link.source.id}-${link.target.id}` + ); + linkEl.parentNode.prepend(linkEl); + setHovering(null); + }; + useEffect(() => { if (link.linkType === 'failure') { setPathStroke('#d9534f'); @@ -25,14 +46,22 @@ function WorkflowOutputLink({ link, nodePositions }) { return ( <g - className="WorkflowGraph-link" id={`link-${link.source.id}-${link.target.id}`} + onMouseEnter={handleLinkMouseEnter} + onMouseLeave={handleLinkMouseLeave} > - <path - className="WorkflowGraph-linkPath" - d={pathD} - stroke={pathStroke} - strokeWidth="2px" + <polygon + fill="#E1E1E1" + id={`link-${link.source.id}-${link.target.id}-overlay`} + opacity={hovering ? '1' : '0'} + points={getLinkOverlayPoints(link, nodePositions)} + /> + <path d={pathD} stroke={pathStroke} strokeWidth="2px" /> + <polygon + onMouseEnter={() => onUpdateLinkHelp(link)} + onMouseLeave={() => onUpdateLinkHelp(null)} + opacity="0" + points={getLinkOverlayPoints(link, nodePositions)} /> </g> ); 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..651efc1060 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +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 = mountWithContexts( + <svg> + <WorkflowOutputLink + link={link} + nodePositions={nodePositions} + onUpdateLinkHelp={() => {}} + /> + </svg> + ); + 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 index 69ebd533aa..8b111bc80c 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -114,9 +114,7 @@ function WorkflowOutputNode({ : i18n._(t`DELETED`)} </p> </JobTopLine> - <Elapsed> - <span>{secondsToHHMMSS(node.job.elapsed)}</span> - </Elapsed> + <Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed> </Fragment> ) : ( <NodeDefaultLabel> 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..046ee99c73 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +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', + 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( + <svg> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={nodeWithJT} + nodePositions={nodePositions} + /> + </svg> + ); + expect(wrapper).toHaveLength(1); + }); + test('node contents displayed correctly when Job and Job Template exist', () => { + const wrapper = mountWithContexts( + <svg> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={nodeWithJT} + nodePositions={nodePositions} + /> + </svg> + ); + expect(wrapper.contains(<p>Automation JT</p>)).toEqual(true); + expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07'); + }); + test('node contents displayed correctly when Job Template deleted', () => { + const wrapper = mountWithContexts( + <svg> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={nodeWithoutJT} + nodePositions={nodePositions} + /> + </svg> + ); + expect(wrapper.contains(<p>DELETED</p>)).toEqual(true); + expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07'); + }); + test('node contents displayed correctly when Job deleted', () => { + const wrapper = mountWithContexts( + <svg> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={{ id: 2 }} + nodePositions={nodePositions} + /> + </svg> + ); + expect(wrapper.text()).toBe('DELETED'); + }); +}); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx new file mode 100644 index 0000000000..456c8aa19c --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import WorkflowOutputStartNode from './WorkflowOutputStartNode'; + +const nodePositions = { + 1: { + x: 0, + y: 0, + }, +}; + +describe('WorkflowOutputStartNode', () => { + test('mounts successfully', () => { + const wrapper = mount( + <svg> + <WorkflowOutputStartNode nodePositions={nodePositions} /> + </svg> + ); + expect(wrapper).toHaveLength(1); + }); +}); 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..02c2fecba8 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowOutputToolbar from './WorkflowOutputToolbar'; + +const job = { + id: 1, + status: 'successful', +}; + +describe('WorkflowOutputToolbar', () => { + test('mounts successfully', () => { + const wrapper = mountWithContexts( + <WorkflowOutputToolbar + job={job} + keyShown={false} + nodes={[]} + onKeyToggle={() => {}} + onToolsToggle={() => {}} + toolsShown={false} + /> + ); + expect(wrapper).toHaveLength(1); + }); + + test('shows correct number of nodes', () => { + const nodes = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + isDeleted: true, + }, + ]; + const wrapper = mountWithContexts( + <WorkflowOutputToolbar + job={job} + keyShown={false} + nodes={nodes} + onKeyToggle={() => {}} + onToolsToggle={() => {}} + toolsShown={false} + /> + ); + // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored + expect(wrapper.find('Badge').text()).toBe('1'); + }); +}); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index 58081edad1..a37b022e14 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -5,9 +5,9 @@ import styled from 'styled-components'; import { arrayOf, bool, func, shape } from 'prop-types'; import * as d3 from 'd3'; import { - calcZoomAndFit, + getScaleAndOffsetToFit, constants as wfConstants, - getZoomTranslate, + getTranslatePointsForZoom, } from '@util/workflow'; import { WorkflowHelp, @@ -161,7 +161,17 @@ function VisualizerGraph({ }; const handleZoomChange = newScale => { - const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale); + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const currentScaleAndOffset = d3.zoomTransform( + d3.select(svgRef.current).node() + ); + + const [translateX, translateY] = getTranslatePointsForZoom( + svgBoundingClientRect, + currentScaleAndOffset, + newScale + ); d3.select(svgRef.current).call( zoomRef.transform, @@ -171,9 +181,27 @@ function VisualizerGraph({ }; const handleFitGraph = () => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current + 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 svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + + const [scaleToFit, yTranslate] = getScaleAndOffsetToFit( + gBoundingClientRect, + svgBoundingClientRect, + gBBoxDimensions, + currentScale ); d3.select(svgRef.current).call( @@ -196,19 +224,9 @@ function VisualizerGraph({ // Attempt to zoom the graph to fit the available screen space useEffect(() => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current - ); - - d3.select(svgRef.current).call( - zoomRef.transform, - d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) - ); - - setZoomPercentage(scaleToFit * 100); + handleFitGraph(); // We only want this to run once (when the component mounts) - // Including zoomRef.transform in the deps array will cause this to + // 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 diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index c3e3ea7fa4..4a9fd852f1 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -116,7 +116,6 @@ function VisualizerLink({ return ( <LinkG - className="WorkflowGraph-link" id={`link-${link.source.id}-${link.target.id}`} ignorePointerEvents={addingLink} onMouseEnter={handleLinkMouseEnter} @@ -128,12 +127,7 @@ function VisualizerLink({ opacity={hovering ? '1' : '0'} points={getLinkOverlayPoints(link, nodePositions)} /> - <path - className="WorkflowGraph-linkPath" - d={pathD} - stroke={pathStroke} - strokeWidth="2px" - /> + <path d={pathD} stroke={pathStroke} strokeWidth="2px" /> <polygon onMouseEnter={() => onUpdateLinkHelp(link)} onMouseLeave={() => onUpdateLinkHelp(null)} diff --git a/awx/ui_next/src/util/workflow.jsx b/awx/ui_next/src/util/workflow.jsx index c37dca118f..6ed9bf903f 100644 --- a/awx/ui_next/src/util/workflow.jsx +++ b/awx/ui_next/src/util/workflow.jsx @@ -11,24 +11,15 @@ export const constants = { rootH: 40, }; -export function calcZoomAndFit(gRef, svgRef) { - const { k: currentScale } = d3.zoomTransform(d3.select(svgRef).node()); - const gBoundingClientRect = d3 - .select(gRef) - .node() - .getBoundingClientRect(); - +export function getScaleAndOffsetToFit( + gBoundingClientRect, + svgBoundingClientRect, + gBBoxDimensions, + currentScale +) { gBoundingClientRect.height /= currentScale; gBoundingClientRect.width /= currentScale; - const gBBoxDimensions = d3 - .select(gRef) - .node() - .getBBox(); - - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); - // For some reason the root width needs to be added? gBoundingClientRect.width += constants.rootW; @@ -96,19 +87,19 @@ export function getLinePoints(link, nodePositions) { ]; } -export function getLinkOverlayPoints(d, nodePositions) { +export function getLinkOverlayPoints(link, nodePositions) { const sourceX = - nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1; + nodePositions[link.source.id].x + nodePositions[link.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; + 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[d.target.id].y) + - nodePositions[d.target.id].height / 2; + 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 (d.source.id === 1) { + if (link.source.id === 1) { sourceY += 10; } const slope = (targetY - sourceY) / (targetX - sourceX); @@ -177,18 +168,19 @@ export function layoutGraph(nodes, links) { return g; } -export function getZoomTranslate(svgRef, newScale) { - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); - const current = d3.zoomTransform(d3.select(svgRef).node()); - const origScale = current.k; +export function getTranslatePointsForZoom( + svgBoundingClientRect, + currentScaleAndOffset, + newScale +) { + const origScale = currentScaleAndOffset.k; const unscaledOffsetX = - (current.x + + (currentScaleAndOffset.x + (svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) / 2) / origScale; const unscaledOffsetY = - (current.y + + (currentScaleAndOffset.y + (svgBoundingClientRect.height * origScale - svgBoundingClientRect.height) / 2) / diff --git a/awx/ui_next/src/util/workflow.test.jsx b/awx/ui_next/src/util/workflow.test.jsx new file mode 100644 index 0000000000..0cc1ad05a3 --- /dev/null +++ b/awx/ui_next/src/util/workflow.test.jsx @@ -0,0 +1,225 @@ +import { + getScaleAndOffsetToFit, + generateLine, + getLinePoints, + getLinkOverlayPoints, + layoutGraph, + getTranslatePointsForZoom, +} from './workflow'; + +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]); + }); +}); From 662ff41fe9c9efda452e463b69448566f47f4bd7 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 09:27:26 -0500 Subject: [PATCH 11/46] Moves inline css to styled component in WorkflowOutputToolbar --- .../WorkflowOutput/WorkflowOutputToolbar.jsx | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx index c3f0350796..d3a93d2578 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx @@ -8,6 +8,25 @@ 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; @@ -30,6 +49,10 @@ const ActionButton = styled(Button)` } `; +const StatusIconWithMargin = styled(StatusIcon)` + margin-right: 20px; +`; + function WorkflowOutputToolbar({ i18n, job, @@ -42,12 +65,12 @@ function WorkflowOutputToolbar({ const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1; return ( - <div css="border-bottom: 1px solid grey; height: 56px; display: flex; alignItems: center"> - <div css="display: flex; align-items: center;"> - <StatusIcon status={job.status} css="margin-right: 20px" /> + <Toolbar> + <ToolbarJob> + <StatusIconWithMargin status={job.status} /> <b>{job.name}</b> - </div> - <div css="display: flex; flex: 1; justify-content: flex-end; align-items: center;"> + </ToolbarJob> + <ToolbarActions> <div>{i18n._(t`Total Nodes`)}</div> <Badge isRead>{totalNodes}</Badge> <VerticalSeparator /> @@ -69,8 +92,8 @@ function WorkflowOutputToolbar({ <WrenchIcon /> </ActionButton> </Tooltip> - </div> - </div> + </ToolbarActions> + </Toolbar> ); } From f98b2741770b35d3f2332d931221fd3079162904 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 09:56:08 -0500 Subject: [PATCH 12/46] Adds default search and sort columns to the four lists in the workflow node wizard --- .../NodeTypeStep/InventorySourcesList.jsx | 30 ++++++++++++++-- .../NodeTypeStep/JobTemplatesList.jsx | 23 +++++++++++-- .../NodeModal/NodeTypeStep/ProjectsList.jsx | 34 +++++++++++++++++-- .../NodeTypeStep/WorkflowJobTemplatesList.jsx | 27 +++++++++++++-- 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx index c73fabf6ef..a4abf420c5 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx @@ -66,12 +66,36 @@ function InventorySourcesList({ /> )} renderToolbar={props => <DataListToolbar {...props} fillWidth />} - toolbarColumns={[ + 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', - isSortable: true, - isSearchable: true, }, ]} /> diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx index a0400c07ae..bf7695f966 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx @@ -68,12 +68,29 @@ function JobTemplatesList({ )} renderToolbar={props => <DataListToolbar {...props} fillWidth />} showPageSizeOptions={false} - toolbarColumns={[ + 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', - isSortable: true, - isSearchable: true, }, ]} /> diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx index 98e12e745b..4ba28da12f 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx @@ -61,12 +61,40 @@ function ProjectsList({ history, i18n, nodeResource, onUpdateNodeResource }) { )} renderToolbar={props => <DataListToolbar {...props} fillWidth />} showPageSizeOptions={false} - toolbarColumns={[ + 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', - isSortable: true, - isSearchable: true, }, ]} /> diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx index 4654b34a07..05a0d15e9c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx @@ -68,12 +68,33 @@ function WorkflowJobTemplatesList({ )} renderToolbar={props => <DataListToolbar {...props} fillWidth />} showPageSizeOptions={false} - toolbarColumns={[ + 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', - isSortable: true, - isSearchable: true, }, ]} /> From eddb6e1faf9f02733562219aa002c24c368a0326 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 11:24:46 -0500 Subject: [PATCH 13/46] Combines the two start node components into one. Removes use of document.getElementById in workflow components in favor of refs. --- .../Workflow/WorkflowStartNode.jsx} | 31 +++++++++------- .../Workflow/WorkflowStartNode.test.jsx | 36 +++++++++++++++++++ awx/ui_next/src/components/Workflow/index.js | 1 + .../WorkflowOutput/WorkflowOutputGraph.jsx | 14 ++++---- .../Job/WorkflowOutput/WorkflowOutputLink.jsx | 14 +++----- .../WorkflowOutputStartNode.jsx | 28 --------------- .../WorkflowOutputStartNode.test.jsx | 21 ----------- .../src/screens/Job/WorkflowOutput/index.js | 1 - .../VisualizerGraph.jsx | 15 ++++---- .../VisualizerLink.jsx | 14 +++----- .../VisualizerNode.jsx | 7 ++-- .../WorkflowJobTemplateVisualizer/index.js | 1 - 12 files changed, 82 insertions(+), 101 deletions(-) rename awx/ui_next/src/{screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx => components/Workflow/WorkflowStartNode.jsx} (77%) create mode 100644 awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx delete mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx delete mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx similarity index 77% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx rename to awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx index c54cb51799..a292ef1763 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartNode.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import styled from 'styled-components'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -14,19 +14,19 @@ const StartG = styled.g` pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')}; `; -function VisualizerStartNode({ +function WorkflowStartNode({ addingLink, i18n, nodePositions, onAddNodeClick, onUpdateHelpText, - readOnly, + showActionTooltip, }) { + const ref = useRef(null); const [hovering, setHovering] = useState(false); const handleNodeMouseEnter = () => { - const nodeEl = document.getElementById('node-1'); - nodeEl.parentNode.appendChild(nodeEl); + ref.current.parentNode.appendChild(ref.current); setHovering(true); }; @@ -36,6 +36,7 @@ function VisualizerStartNode({ ignorePointerEvents={addingLink} onMouseEnter={handleNodeMouseEnter} onMouseLeave={() => setHovering(false)} + ref={ref} transform={`translate(${nodePositions[1].x},0)`} > <rect @@ -50,7 +51,7 @@ function VisualizerStartNode({ <text x="13" y="30" dy=".35em" fill="white"> START </text> - {!readOnly && hovering && ( + {showActionTooltip && hovering && ( <WorkflowActionTooltip actions={[ <WorkflowActionTooltipItem @@ -75,12 +76,18 @@ function VisualizerStartNode({ ); } -VisualizerStartNode.propTypes = { - addingLink: bool.isRequired, +WorkflowStartNode.propTypes = { + addingLink: bool, nodePositions: shape().isRequired, - onAddNodeClick: func.isRequired, - readOnly: bool.isRequired, - onUpdateHelpText: func.isRequired, + onAddNodeClick: func, + showActionTooltip: bool.isRequired, + onUpdateHelpText: func, }; -export default withI18n()(VisualizerStartNode); +WorkflowStartNode.defaultProps = { + addingLink: false, + onAddNodeClick: () => {}, + 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..8bc7d733b3 --- /dev/null +++ b/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import WorkflowStartNode from './WorkflowStartNode'; + +const nodePositions = { + 1: { + x: 0, + y: 0, + }, +}; + +describe('WorkflowStartNode', () => { + test('mounts successfully', () => { + const wrapper = mount( + <svg> + <WorkflowStartNode + nodePositions={nodePositions} + showActionTooltip={false} + /> + </svg> + ); + expect(wrapper).toHaveLength(1); + }); + test('tooltip shown on hover', () => { + const wrapper = mount( + <svg> + <WorkflowStartNode nodePositions={nodePositions} showActionTooltip /> + </svg> + ); + 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/index.js b/awx/ui_next/src/components/Workflow/index.js index c0adfbe7c1..3dd45adb26 100644 --- a/awx/ui_next/src/components/Workflow/index.js +++ b/awx/ui_next/src/components/Workflow/index.js @@ -7,4 +7,5 @@ export { default as WorkflowKey } from './WorkflowKey'; 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/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index e053d7d346..0b65674907 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -8,13 +8,13 @@ import { import { WorkflowOutputLink, WorkflowOutputNode, - WorkflowOutputStartNode, } from '@screens/Job/WorkflowOutput'; import { WorkflowHelp, WorkflowKey, WorkflowLinkHelp, WorkflowNodeHelp, + WorkflowStartNode, WorkflowTools, } from '@components/Workflow'; @@ -75,8 +75,7 @@ function WorkflowOutputGraph({ }; const handlePanToMiddle = () => { - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const svgBoundingClientRect = svgRef.current.getBoundingClientRect(); d3.select(svgRef.current).call( zoomRef.transform, d3.zoomIdentity @@ -88,8 +87,7 @@ function WorkflowOutputGraph({ }; const handleZoomChange = newScale => { - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const svgBoundingClientRect = svgRef.current.getBoundingClientRect(); const currentScaleAndOffset = d3.zoomTransform( d3.select(svgRef.current).node() ); @@ -121,8 +119,7 @@ function WorkflowOutputGraph({ .node() .getBBox(); - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const svgBoundingClientRect = svgRef.current.getBoundingClientRect(); const [scaleToFit, yTranslate] = getScaleAndOffsetToFit( gBoundingClientRect, @@ -175,9 +172,10 @@ function WorkflowOutputGraph({ > <g id="workflow-g" ref={gRef}> {nodePositions && [ - <WorkflowOutputStartNode + <WorkflowStartNode key="start" nodePositions={nodePositions} + showActionTooltip={false} />, links.map(link => ( <WorkflowOutputLink diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx index 1c29899404..40178a67a0 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { shape } from 'prop-types'; import { generateLine, @@ -7,23 +7,18 @@ import { } from '@util/workflow'; function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { + const ref = useRef(null); const [hovering, setHovering] = useState(false); const [pathD, setPathD] = useState(); const [pathStroke, setPathStroke] = useState('#CCCCCC'); const handleLinkMouseEnter = () => { - const linkEl = document.getElementById( - `link-${link.source.id}-${link.target.id}` - ); - linkEl.parentNode.appendChild(linkEl); + ref.current.parentNode.appendChild(ref.current); setHovering(true); }; const handleLinkMouseLeave = () => { - const linkEl = document.getElementById( - `link-${link.source.id}-${link.target.id}` - ); - linkEl.parentNode.prepend(linkEl); + ref.current.parentNode.prepend(ref.current); setHovering(null); }; @@ -46,6 +41,7 @@ function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { return ( <g + ref={ref} id={`link-${link.source.id}-${link.target.id}`} onMouseEnter={handleLinkMouseEnter} onMouseLeave={handleLinkMouseLeave} diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx deleted file mode 100644 index 5b6fd79ee8..0000000000 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { shape } from 'prop-types'; -import { constants as wfConstants } from '@util/workflow'; - -function WorkflowOutputStartNode({ nodePositions }) { - return ( - <g id="node-1" transform={`translate(${nodePositions[1].x},0)`}> - <rect - fill="#0279BC" - height={wfConstants.rootH} - rx="2" - ry="2" - width={wfConstants.rootW} - y="10" - /> - {/* TODO: We need to be able to handle translated text here */} - <text x="13" y="30" dy=".35em" fill="white"> - START - </text> - </g> - ); -} - -WorkflowOutputStartNode.propTypes = { - nodePositions: shape().isRequired, -}; - -export default WorkflowOutputStartNode; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx deleted file mode 100644 index 456c8aa19c..0000000000 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import WorkflowOutputStartNode from './WorkflowOutputStartNode'; - -const nodePositions = { - 1: { - x: 0, - y: 0, - }, -}; - -describe('WorkflowOutputStartNode', () => { - test('mounts successfully', () => { - const wrapper = mount( - <svg> - <WorkflowOutputStartNode nodePositions={nodePositions} /> - </svg> - ); - expect(wrapper).toHaveLength(1); - }); -}); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/index.js b/awx/ui_next/src/screens/Job/WorkflowOutput/index.js index f3020d0679..879db49502 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/index.js +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/index.js @@ -2,5 +2,4 @@ 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 WorkflowOutputStartNode } from './WorkflowOutputStartNode'; export { default as WorkflowOutputToolbar } from './WorkflowOutputToolbar'; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index a37b022e14..9e613f506c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -14,12 +14,12 @@ import { WorkflowKey, WorkflowLinkHelp, WorkflowNodeHelp, + WorkflowStartNode, WorkflowTools, } from '@components/Workflow'; import { VisualizerLink, VisualizerNode, - VisualizerStartNode, } from '@screens/Template/WorkflowJobTemplateVisualizer'; const PotentialLink = styled.polyline` @@ -148,8 +148,7 @@ function VisualizerGraph({ }; const handlePanToMiddle = () => { - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const svgBoundingClientRect = svgRef.current.getBoundingClientRect(); d3.select(svgRef.current).call( zoomRef.transform, d3.zoomIdentity @@ -161,8 +160,7 @@ function VisualizerGraph({ }; const handleZoomChange = newScale => { - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const svgBoundingClientRect = svgRef.current.getBoundingClientRect(); const currentScaleAndOffset = d3.zoomTransform( d3.select(svgRef.current).node() ); @@ -194,8 +192,7 @@ function VisualizerGraph({ .node() .getBBox(); - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const svgBoundingClientRect = svgRef.current.getBoundingClientRect(); const [scaleToFit, yTranslate] = getScaleAndOffsetToFit( gBoundingClientRect, @@ -276,12 +273,12 @@ function VisualizerGraph({ /> <g id="workflow-g" ref={gRef}> {nodePositions && [ - <VisualizerStartNode + <WorkflowStartNode addingLink={addingLink} key="start" nodePositions={nodePositions} onAddNodeClick={onAddNodeClick} - readOnly={readOnly} + showActionTooltip={!readOnly} onUpdateHelpText={setHelpText} />, links.map(link => { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index 4a9fd852f1..b719189e9e 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -30,6 +30,7 @@ function VisualizerLink({ onUpdateLinkHelp, readOnly, }) { + const ref = useRef(null); const [hovering, setHovering] = useState(false); const [pathD, setPathD] = useState(); const [pathStroke, setPathStroke] = useState('#CCCCCC'); @@ -80,18 +81,12 @@ function VisualizerLink({ ]; const handleLinkMouseEnter = () => { - const linkEl = document.getElementById( - `link-${link.source.id}-${link.target.id}` - ); - linkEl.parentNode.appendChild(linkEl); + ref.current.parentNode.appendChild(ref.current); setHovering(true); }; const handleLinkMouseLeave = () => { - const linkEl = document.getElementById( - `link-${link.source.id}-${link.target.id}` - ); - linkEl.parentNode.prepend(linkEl); + ref.current.parentNode.prepend(ref.current); setHovering(null); }; @@ -120,6 +115,7 @@ function VisualizerLink({ ignorePointerEvents={addingLink} onMouseEnter={handleLinkMouseEnter} onMouseLeave={handleLinkMouseLeave} + ref={ref} > <polygon fill="#E1E1E1" diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index fd914ff241..2519c4cf3a 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import styled from 'styled-components'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -54,11 +54,11 @@ function VisualizerNode({ onUpdateHelpText, updateNodeHelp, }) { + const ref = useRef(null); const [hovering, setHovering] = useState(false); const handleNodeMouseEnter = () => { - const nodeEl = document.getElementById(`node-${node.id}`); - nodeEl.parentNode.appendChild(nodeEl); + ref.current.parentNode.appendChild(ref.current); setHovering(true); if (addingLink) { onUpdateHelpText( @@ -168,6 +168,7 @@ function VisualizerNode({ noPointerEvents={isAddLinkSourceNode} onMouseEnter={handleNodeMouseEnter} onMouseLeave={handleNodeMouseLeave} + ref={ref} transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id] .y - nodePositions[1].y})`} > diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js index 9c3200ca48..0d7871e690 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/index.js @@ -2,6 +2,5 @@ export { default as Visualizer } from './Visualizer'; export { default as VisualizerGraph } from './VisualizerGraph'; export { default as VisualizerLink } from './VisualizerLink'; export { default as VisualizerNode } from './VisualizerNode'; -export { default as VisualizerStartNode } from './VisualizerStartNode'; export { default as VisualizerStartScreen } from './VisualizerStartScreen'; export { default as VisualizerToolbar } from './VisualizerToolbar'; From 492ea0616ed55e909b9a50a08b353c20c1934e2c Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 11:54:08 -0500 Subject: [PATCH 14/46] Moves util/workflow.jsx to components/Workflow/WorkflowUtils.jsx and updates imports --- awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx | 2 +- .../workflow.jsx => components/Workflow/WorkflowUtils.jsx} | 0 .../Workflow/WorkflowUtils.test.jsx} | 2 +- awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx | 2 +- .../src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx | 2 +- .../src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx | 2 +- .../src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx | 2 +- .../Template/WorkflowJobTemplateVisualizer/Visualizer.jsx | 2 +- .../Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx | 2 +- .../Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx | 2 +- .../Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx | 2 +- 11 files changed, 10 insertions(+), 10 deletions(-) rename awx/ui_next/src/{util/workflow.jsx => components/Workflow/WorkflowUtils.jsx} (100%) rename awx/ui_next/src/{util/workflow.test.jsx => components/Workflow/WorkflowUtils.test.jsx} (99%) diff --git a/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx index a292ef1763..3b3f89e982 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx @@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { bool, func, shape } from 'prop-types'; import { PlusIcon } from '@patternfly/react-icons'; -import { constants as wfConstants } from '@util/workflow'; +import { constants as wfConstants } from '@components/Workflow/WorkflowUtils'; import { WorkflowActionTooltip, WorkflowActionTooltipItem, diff --git a/awx/ui_next/src/util/workflow.jsx b/awx/ui_next/src/components/Workflow/WorkflowUtils.jsx similarity index 100% rename from awx/ui_next/src/util/workflow.jsx rename to awx/ui_next/src/components/Workflow/WorkflowUtils.jsx diff --git a/awx/ui_next/src/util/workflow.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx similarity index 99% rename from awx/ui_next/src/util/workflow.test.jsx rename to awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx index 0cc1ad05a3..523cf55977 100644 --- a/awx/ui_next/src/util/workflow.test.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx @@ -5,7 +5,7 @@ import { getLinkOverlayPoints, layoutGraph, getTranslatePointsForZoom, -} from './workflow'; +} from './WorkflowUtils'; describe('getScaleAndOffsetToFit', () => { const gBoundingClientRect = { diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx index 6ff3a4350f..10d438876d 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -4,7 +4,7 @@ import { t } from '@lingui/macro'; import styled from 'styled-components'; import { shape } from 'prop-types'; import { CardBody as PFCardBody } from '@patternfly/react-core'; -import { layoutGraph } from '@util/workflow'; +import { layoutGraph } from '@components/Workflow/WorkflowUtils'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import { WorkflowJobsAPI } from '@api'; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 0b65674907..571a3b7e06 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -4,7 +4,7 @@ import { arrayOf, bool, shape, func } from 'prop-types'; import { getScaleAndOffsetToFit, getTranslatePointsForZoom, -} from '@util/workflow'; +} from '@components/Workflow/WorkflowUtils'; import { WorkflowOutputLink, WorkflowOutputNode, diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx index 40178a67a0..d33c381943 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx @@ -4,7 +4,7 @@ import { generateLine, getLinePoints, getLinkOverlayPoints, -} from '@util/workflow'; +} from '@components/Workflow/WorkflowUtils'; function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { const ref = useRef(null); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx index 8b111bc80c..a54a5f6c7e 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -7,7 +7,7 @@ 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 '@util/workflow'; +import { constants as wfConstants } from '@components/Workflow/WorkflowUtils'; const NodeG = styled.g` cursor: ${props => (props.job ? 'pointer' : 'default')}; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 8f003b29fb..98a578fc5a 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -5,7 +5,7 @@ import { t } from '@lingui/macro'; import styled from 'styled-components'; import { shape } from 'prop-types'; import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core'; -import { layoutGraph } from '@util/workflow'; +import { layoutGraph } from '@components/Workflow/WorkflowUtils'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index 9e613f506c..abceda1a2d 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -8,7 +8,7 @@ import { getScaleAndOffsetToFit, constants as wfConstants, getTranslatePointsForZoom, -} from '@util/workflow'; +} from '@components/Workflow/WorkflowUtils'; import { WorkflowHelp, WorkflowKey, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index b719189e9e..d069acfa0c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -8,7 +8,7 @@ import { generateLine, getLinePoints, getLinkOverlayPoints, -} from '@util/workflow'; +} from '@components/Workflow/WorkflowUtils'; import { WorkflowActionTooltip, WorkflowActionTooltipItem, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index 2519c4cf3a..8cfd094292 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -10,7 +10,7 @@ import { PlusIcon, TrashAltIcon, } from '@patternfly/react-icons'; -import { constants as wfConstants } from '@util/workflow'; +import { constants as wfConstants } from '@components/Workflow/WorkflowUtils'; import { WorkflowActionTooltip, WorkflowActionTooltipItem, From 700296a55837fcb13121749ed3c2356188d8c238 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 14:22:55 -0500 Subject: [PATCH 15/46] Change Exit button text to Exit Without Saving in the unsaved changes modal. --- .../Modals/UnsavedChangesModal.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx index 7591bc26c2..d16b73d52d 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx @@ -15,10 +15,10 @@ function UnsavedChangesModal({ i18n, onCancel, onSaveAndExit, onExit }) { <Button key="exit" variant="danger" - aria-label={i18n._(t`Exit`)} + aria-label={i18n._(t`Exit Without Saving`)} onClick={onExit} > - {i18n._(t`Exit`)} + {i18n._(t`Exit Without Saving`)} </Button>, <Button key="save" From e34bf90ca758f402fa1d346e184de0bc5da318e0 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 14:36:19 -0500 Subject: [PATCH 16/46] Vertically center the pause icon in the node type letter component --- .../src/components/Workflow/WorkflowNodeTypeLetter.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx index eb2364503d..8abe9f4e77 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeTypeLetter.jsx @@ -12,6 +12,10 @@ const NodeTypeLetter = styled.foreignObject` text-align: center; `; +const CenteredPauseIcon = styled(PauseIcon)` + vertical-align: middle !important; +`; + function WorkflowNodeTypeLetter({ node }) { let nodeTypeLetter; if ( @@ -39,7 +43,7 @@ function WorkflowNodeTypeLetter({ node }) { break; case 'workflow_approval_template': case 'workflow_approval': - nodeTypeLetter = <PauseIcon />; + nodeTypeLetter = <CenteredPauseIcon />; break; default: nodeTypeLetter = ''; From c971e9d61c1d277819644c24c6aca606c15cdcd3 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 14:52:03 -0500 Subject: [PATCH 17/46] Turns all the workflow tool buttons in to pf button components. --- .../src/components/Workflow/WorkflowTools.jsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx index 31ca802ee4..49a07f41fe 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx @@ -3,7 +3,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; import { func, number } from 'prop-types'; -import { Tooltip } from '@patternfly/react-core'; +import { Button, Tooltip } from '@patternfly/react-core'; import { CaretDownIcon, CaretLeftIcon, @@ -19,7 +19,7 @@ import { const Wrapper = styled.div` background-color: white; border: 1px solid #c7c7c7; - height: 135px; + height: 215px; position: relative; `; @@ -87,10 +87,14 @@ function WorkflowTools({ content={i18n._(t`Fit the graph to the available screen size`)} position="bottom" > - <DesktopIcon onClick={() => onFitGraph()} css="margin-right: 30px;" /> + <Button variant="tertiary" css="margin-right: 30px;" onClick={() => onFitGraph()}> + <DesktopIcon /> + </Button> </Tooltip> <Tooltip content={i18n._(t`Zoom Out`)} position="bottom"> - <MinusIcon onClick={() => zoomOut()} css="margin-right: 10px;" /> + <Button variant="tertiary" css="margin-right: 10px;" onClick={() => zoomOut()}> + <MinusIcon /> + </Button> </Tooltip> <input id="zoom-slider" @@ -104,28 +108,40 @@ function WorkflowTools({ value={zoomPercentage} /> <Tooltip content={i18n._(t`Zoom In`)} position="bottom"> - <PlusIcon onClick={() => zoomIn()} css="margin: 0px 25px 0px 10px;" /> + <Button variant="tertiary" css="margin: 0px 25px 0px 10px;" onClick={() => zoomIn()}> + <PlusIcon /> + </Button> </Tooltip> <Pan> <Tooltip content={i18n._(t`Pan Left`)} position="left"> - <CaretLeftIcon onClick={() => onPan('left')} /> + <Button variant="tertiary" css="margin-right: 10px;" onClick={() => onPan('left')}> + <CaretLeftIcon /> + </Button> </Tooltip> <PanCenter> <Tooltip content={i18n._(t`Pan Up`)} position="top"> - <CaretUpIcon onClick={() => onPan('up')} /> + <Button variant="tertiary" css="margin-bottom: 10px;" onClick={() => onPan('up')}> + <CaretUpIcon /> + </Button> </Tooltip> <Tooltip content={i18n._(t`Set zoom to 100% and center graph`)} position="top" > - <HomeIcon onClick={() => onPanToMiddle()} /> + <Button variant="tertiary" onClick={() => onPanToMiddle()}> + <HomeIcon /> + </Button> </Tooltip> <Tooltip content={i18n._(t`Pan Down`)} position="bottom"> - <CaretDownIcon onClick={() => onPan('down')} /> + <Button variant="tertiary" css="margin-top: 10px;" onClick={() => onPan('down')}> + <CaretDownIcon /> + </Button> </Tooltip> </PanCenter> <Tooltip content={i18n._(t`Pan Right`)} position="right"> - <CaretRightIcon onClick={() => onPan('right')} /> + <Button variant="tertiary" css="margin-left: 10px;" onClick={() => onPan('right')}> + <CaretRightIcon /> + </Button> </Tooltip> </Pan> </Tools> From ce09c4b3cd28957557173ffe659d7c232ed07b0a Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 15:01:08 -0500 Subject: [PATCH 18/46] Changes "Key" references to "Legend" --- .../{WorkflowKey.jsx => WorkflowLegend.jsx} | 14 +++++++------- ...orkflowKey.test.jsx => WorkflowLegend.test.jsx} | 6 +++--- awx/ui_next/src/components/Workflow/index.js | 2 +- .../screens/Job/WorkflowOutput/WorkflowOutput.jsx | 10 +++++----- .../Job/WorkflowOutput/WorkflowOutputGraph.jsx | 12 ++++++------ .../Job/WorkflowOutput/WorkflowOutputToolbar.jsx | 14 +++++++------- .../WorkflowOutput/WorkflowOutputToolbar.test.jsx | 8 ++++---- .../WorkflowJobTemplateVisualizer/Visualizer.jsx | 10 +++++----- .../VisualizerGraph.jsx | 12 ++++++------ .../VisualizerToolbar.jsx | 14 +++++++------- 10 files changed, 51 insertions(+), 51 deletions(-) rename awx/ui_next/src/components/Workflow/{WorkflowKey.jsx => WorkflowLegend.jsx} (92%) rename awx/ui_next/src/components/Workflow/{WorkflowKey.test.jsx => WorkflowLegend.test.jsx} (53%) diff --git a/awx/ui_next/src/components/Workflow/WorkflowKey.jsx b/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx similarity index 92% rename from awx/ui_next/src/components/Workflow/WorkflowKey.jsx rename to awx/ui_next/src/components/Workflow/WorkflowLegend.jsx index 6c5cdd5be1..870d35c067 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowKey.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx @@ -23,7 +23,7 @@ const Header = styled.div` position: relative; `; -const Key = styled.ul` +const Legend = styled.ul` padding: 5px 10px; li { @@ -77,14 +77,14 @@ const Close = styled(TimesIcon)` top: 15px; `; -function WorkflowKey({ i18n, onClose }) { +function WorkflowLegend({ i18n, onClose }) { return ( <Wrapper> <Header> - <b>{i18n._(t`Key`)}</b> + <b>{i18n._(t`Legend`)}</b> <Close onClick={onClose} /> </Header> - <Key> + <Legend> <li> <NodeTypeLetter>JT</NodeTypeLetter> <span>{i18n._(t`Job Template`)}</span> @@ -123,13 +123,13 @@ function WorkflowKey({ i18n, onClose }) { <AlwaysLink /> <span>{i18n._(t`Always`)}</span> </li> - </Key> + </Legend> </Wrapper> ); } -WorkflowKey.propTypes = { +WorkflowLegend.propTypes = { onClose: func.isRequired, }; -export default withI18n()(WorkflowKey); +export default withI18n()(WorkflowLegend); diff --git a/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx similarity index 53% rename from awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx rename to awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx index 05d8762acd..19b27bd1c6 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowKey.test.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx @@ -1,10 +1,10 @@ import React from 'react'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; -import WorkflowKey from './WorkflowKey'; +import WorkflowLegend from './WorkflowLegend'; -describe('WorkflowKey', () => { +describe('WorkflowLegend', () => { test('renders the expected content', () => { - const wrapper = mountWithContexts(<WorkflowKey onClose={() => {}} />); + const wrapper = mountWithContexts(<WorkflowLegend onClose={() => {}} />); expect(wrapper).toHaveLength(1); }); }); diff --git a/awx/ui_next/src/components/Workflow/index.js b/awx/ui_next/src/components/Workflow/index.js index 3dd45adb26..d3c5d519da 100644 --- a/awx/ui_next/src/components/Workflow/index.js +++ b/awx/ui_next/src/components/Workflow/index.js @@ -3,7 +3,7 @@ export { default as WorkflowActionTooltipItem, } from './WorkflowActionTooltipItem'; export { default as WorkflowHelp } from './WorkflowHelp'; -export { default as WorkflowKey } from './WorkflowKey'; +export { default as WorkflowLegend } from './WorkflowLegend'; export { default as WorkflowLinkHelp } from './WorkflowLinkHelp'; export { default as WorkflowNodeHelp } from './WorkflowNodeHelp'; export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter'; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx index 10d438876d..0e41845ac7 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -41,7 +41,7 @@ function WorkflowOutput({ job, i18n }) { const [graphNodes, setGraphNodes] = useState([]); const [isLoading, setIsLoading] = useState(true); const [nodePositions, setNodePositions] = useState(null); - const [showKey, setShowKey] = useState(false); + const [showLegend, setShowLegend] = useState(false); const [showTools, setShowTools] = useState(false); useEffect(() => { @@ -200,9 +200,9 @@ function WorkflowOutput({ job, i18n }) { <Wrapper> <WorkflowOutputToolbar job={job} - keyShown={showKey} + legendShown={showLegend} nodes={graphNodes} - onKeyToggle={() => setShowKey(!showKey)} + onLegendToggle={() => setShowLegend(!showLegend)} onToolsToggle={() => setShowTools(!showTools)} toolsShown={showTools} /> @@ -211,9 +211,9 @@ function WorkflowOutput({ job, i18n }) { links={graphLinks} nodePositions={nodePositions} nodes={graphNodes} - onUpdateShowKey={setShowKey} + onUpdateShowLegend={setShowLegend} onUpdateShowTools={setShowTools} - showKey={showKey} + showLegend={showLegend} showTools={showTools} /> )} diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 571a3b7e06..32244b1197 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -11,7 +11,7 @@ import { } from '@screens/Job/WorkflowOutput'; import { WorkflowHelp, - WorkflowKey, + WorkflowLegend, WorkflowLinkHelp, WorkflowNodeHelp, WorkflowStartNode, @@ -22,9 +22,9 @@ function WorkflowOutputGraph({ links, nodePositions, nodes, - onUpdateShowKey, + onUpdateShowLegend, onUpdateShowTools, - showKey, + showLegend, showTools, }) { const [linkHelp, setLinkHelp] = useState(); @@ -213,7 +213,7 @@ function WorkflowOutputGraph({ zoomPercentage={zoomPercentage} /> )} - {showKey && <WorkflowKey onClose={() => onUpdateShowKey(false)} />} + {showLegend && <WorkflowLegend onClose={() => onUpdateShowLegend(false)} />} </div> </Fragment> ); @@ -223,9 +223,9 @@ WorkflowOutputGraph.propTypes = { links: arrayOf(shape()).isRequired, nodePositions: shape().isRequired, nodes: arrayOf(shape()).isRequired, - onUpdateShowKey: func.isRequired, + onUpdateShowLegend: func.isRequired, onUpdateShowTools: func.isRequired, - showKey: bool.isRequired, + showLegend: bool.isRequired, showTools: bool.isRequired, }; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx index d3a93d2578..975467adde 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx @@ -56,9 +56,9 @@ const StatusIconWithMargin = styled(StatusIcon)` function WorkflowOutputToolbar({ i18n, job, - keyShown, + legendShown, nodes, - onKeyToggle, + onLegendToggle, onToolsToggle, toolsShown, }) { @@ -74,10 +74,10 @@ function WorkflowOutputToolbar({ <div>{i18n._(t`Total Nodes`)}</div> <Badge isRead>{totalNodes}</Badge> <VerticalSeparator /> - <Tooltip content={i18n._(t`Toggle Key`)} position="bottom"> + <Tooltip content={i18n._(t`Toggle Legend`)} position="bottom"> <ActionButton - isActive={keyShown} - onClick={onKeyToggle} + isActive={legendShown} + onClick={onLegendToggle} variant="plain" > <CompassIcon /> @@ -99,9 +99,9 @@ function WorkflowOutputToolbar({ WorkflowOutputToolbar.propTypes = { job: shape().isRequired, - keyShown: bool.isRequired, + legendShown: bool.isRequired, nodes: arrayOf(shape()), - onKeyToggle: func.isRequired, + onLegendToggle: func.isRequired, onToolsToggle: func.isRequired, toolsShown: bool.isRequired, }; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx index 02c2fecba8..980f47ca23 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx @@ -12,9 +12,9 @@ describe('WorkflowOutputToolbar', () => { const wrapper = mountWithContexts( <WorkflowOutputToolbar job={job} - keyShown={false} + legendShown={false} nodes={[]} - onKeyToggle={() => {}} + onLegendToggle={() => {}} onToolsToggle={() => {}} toolsShown={false} /> @@ -38,9 +38,9 @@ describe('WorkflowOutputToolbar', () => { const wrapper = mountWithContexts( <WorkflowOutputToolbar job={job} - keyShown={false} + legendShown={false} nodes={nodes} - onKeyToggle={() => {}} + onLegendToggle={() => {}} onToolsToggle={() => {}} toolsShown={false} /> diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 98a578fc5a..2523882194 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -77,7 +77,7 @@ function Visualizer({ history, template, i18n }) { const [nodeToView, setNodeToView] = useState(null); const [nodes, setNodes] = useState([]); const [showDeleteAllNodesModal, setShowDeleteAllNodesModal] = useState(false); - const [showKey, setShowKey] = useState(false); + const [showLegend, setShowLegend] = useState(false); const [showTools, setShowTools] = useState(false); const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false); @@ -800,11 +800,11 @@ function Visualizer({ history, template, i18n }) { <Fragment> <Wrapper> <VisualizerToolbar - keyShown={showKey} + legendShown={showLegend} nodes={nodes} onClose={handleVisualizerClose} onDeleteAllClick={() => setShowDeleteAllNodesModal(true)} - onKeyToggle={() => setShowKey(!showKey)} + onLegendToggle={() => setShowLegend(!showLegend)} onSave={handleVisualizerSave} onToolsToggle={() => setShowTools(!showTools)} template={template} @@ -825,11 +825,11 @@ function Visualizer({ history, template, i18n }) { onEditNodeClick={startEditNode} onLinkEditClick={setLinkToEdit} onStartAddLinkClick={selectSourceNodeForLinking} - onUpdateShowKey={setShowKey} + onUpdateShowLegend={setShowLegend} onUpdateShowTools={setShowTools} onViewNodeClick={setNodeToView} readOnly={!template.summary_fields.user_capabilities.edit} - showKey={showKey} + showLegend={showLegend} showTools={showTools} /> ) : ( diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index abceda1a2d..7fbd0880fe 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -11,7 +11,7 @@ import { } from '@components/Workflow/WorkflowUtils'; import { WorkflowHelp, - WorkflowKey, + WorkflowLegend, WorkflowLinkHelp, WorkflowNodeHelp, WorkflowStartNode, @@ -47,11 +47,11 @@ function VisualizerGraph({ onEditNodeClick, onLinkEditClick, onStartAddLinkClick, - onUpdateShowKey, + onUpdateShowLegend, onUpdateShowTools, onViewNodeClick, readOnly, - showKey, + showLegend, showTools, }) { const [helpText, setHelpText] = useState(null); @@ -354,7 +354,7 @@ function VisualizerGraph({ zoomPercentage={zoomPercentage} /> )} - {showKey && <WorkflowKey onClose={() => onUpdateShowKey(false)} />} + {showLegend && <WorkflowLegend onClose={() => onUpdateShowLegend(false)} />} </div> </> ); @@ -374,11 +374,11 @@ VisualizerGraph.propTypes = { onEditNodeClick: func.isRequired, onLinkEditClick: func.isRequired, onStartAddLinkClick: func.isRequired, - onUpdateShowKey: func.isRequired, + onUpdateShowLegend: func.isRequired, onUpdateShowTools: func.isRequired, onViewNodeClick: func.isRequired, readOnly: bool.isRequired, - showKey: bool.isRequired, + showLegend: bool.isRequired, showTools: bool.isRequired, }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx index 68d86664f8..c60e756064 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx @@ -38,11 +38,11 @@ const ActionButton = styled(Button)` function VisualizerToolbar({ i18n, - keyShown, + legendShown, nodes, onClose, onDeleteAllClick, - onKeyToggle, + onLegendToggle, onSave, onToolsToggle, template, @@ -62,10 +62,10 @@ function VisualizerToolbar({ <div>{i18n._(t`Total Nodes`)}</div> <Badge isRead>{totalNodes}</Badge> <VerticalSeparator /> - <Tooltip content={i18n._(t`Toggle Key`)} position="bottom"> + <Tooltip content={i18n._(t`Toggle Legend`)} position="bottom"> <ActionButton - isActive={keyShown} - onClick={onKeyToggle} + isActive={legendShown} + onClick={onLegendToggle} variant="plain" > <CompassIcon /> @@ -115,11 +115,11 @@ function VisualizerToolbar({ } VisualizerToolbar.propTypes = { - keyShown: bool.isRequired, + legendShown: bool.isRequired, nodes: arrayOf(shape()), onClose: func.isRequired, onDeleteAllClick: func.isRequired, - onKeyToggle: func.isRequired, + onLegendToggle: func.isRequired, onSave: func.isRequired, onToolsToggle: func.isRequired, template: shape().isRequired, From eb6f4dca5565091f66384b90a8f8b4f6f7deb5fc Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 15:06:56 -0500 Subject: [PATCH 19/46] Run prettier --- .../src/components/Workflow/WorkflowTools.jsx | 42 +++++++++++++++---- .../WorkflowOutput/WorkflowOutputGraph.jsx | 4 +- .../VisualizerGraph.jsx | 4 +- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx index 49a07f41fe..6e1876e1c8 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowTools.jsx @@ -87,12 +87,20 @@ function WorkflowTools({ content={i18n._(t`Fit the graph to the available screen size`)} position="bottom" > - <Button variant="tertiary" css="margin-right: 30px;" onClick={() => onFitGraph()}> + <Button + variant="tertiary" + css="margin-right: 30px;" + onClick={() => onFitGraph()} + > <DesktopIcon /> </Button> </Tooltip> <Tooltip content={i18n._(t`Zoom Out`)} position="bottom"> - <Button variant="tertiary" css="margin-right: 10px;" onClick={() => zoomOut()}> + <Button + variant="tertiary" + css="margin-right: 10px;" + onClick={() => zoomOut()} + > <MinusIcon /> </Button> </Tooltip> @@ -108,19 +116,31 @@ function WorkflowTools({ value={zoomPercentage} /> <Tooltip content={i18n._(t`Zoom In`)} position="bottom"> - <Button variant="tertiary" css="margin: 0px 25px 0px 10px;" onClick={() => zoomIn()}> + <Button + variant="tertiary" + css="margin: 0px 25px 0px 10px;" + onClick={() => zoomIn()} + > <PlusIcon /> </Button> </Tooltip> <Pan> <Tooltip content={i18n._(t`Pan Left`)} position="left"> - <Button variant="tertiary" css="margin-right: 10px;" onClick={() => onPan('left')}> + <Button + variant="tertiary" + css="margin-right: 10px;" + onClick={() => onPan('left')} + > <CaretLeftIcon /> </Button> </Tooltip> <PanCenter> <Tooltip content={i18n._(t`Pan Up`)} position="top"> - <Button variant="tertiary" css="margin-bottom: 10px;" onClick={() => onPan('up')}> + <Button + variant="tertiary" + css="margin-bottom: 10px;" + onClick={() => onPan('up')} + > <CaretUpIcon /> </Button> </Tooltip> @@ -133,13 +153,21 @@ function WorkflowTools({ </Button> </Tooltip> <Tooltip content={i18n._(t`Pan Down`)} position="bottom"> - <Button variant="tertiary" css="margin-top: 10px;" onClick={() => onPan('down')}> + <Button + variant="tertiary" + css="margin-top: 10px;" + onClick={() => onPan('down')} + > <CaretDownIcon /> </Button> </Tooltip> </PanCenter> <Tooltip content={i18n._(t`Pan Right`)} position="right"> - <Button variant="tertiary" css="margin-left: 10px;" onClick={() => onPan('right')}> + <Button + variant="tertiary" + css="margin-left: 10px;" + onClick={() => onPan('right')} + > <CaretRightIcon /> </Button> </Tooltip> diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 32244b1197..2e7a3cf341 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -213,7 +213,9 @@ function WorkflowOutputGraph({ zoomPercentage={zoomPercentage} /> )} - {showLegend && <WorkflowLegend onClose={() => onUpdateShowLegend(false)} />} + {showLegend && ( + <WorkflowLegend onClose={() => onUpdateShowLegend(false)} /> + )} </div> </Fragment> ); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index 7fbd0880fe..1a14a41dec 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -354,7 +354,9 @@ function VisualizerGraph({ zoomPercentage={zoomPercentage} /> )} - {showLegend && <WorkflowLegend onClose={() => onUpdateShowLegend(false)} />} + {showLegend && ( + <WorkflowLegend onClose={() => onUpdateShowLegend(false)} /> + )} </div> </> ); From 65429e581a54f6a11ce27cf693c4bc3f755fc3fe Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 17:00:40 -0500 Subject: [PATCH 20/46] Properly bookend @constants alias so that it doesn't inadvertently match something we don't want it to match --- awx/ui_next/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/jest.config.js b/awx/ui_next/jest.config.js index 25f11b8f92..a2be1d25ab 100644 --- a/awx/ui_next/jest.config.js +++ b/awx/ui_next/jest.config.js @@ -11,7 +11,7 @@ module.exports = { '\\.(css|scss|less)$': '<rootDir>/__mocks__/styleMock.js', '^@api(.*)$': '<rootDir>/src/api$1', '^@components(.*)$': '<rootDir>/src/components$1', - '@constants$': '<rootDir>/src/constants.js', + '^@constants$': '<rootDir>/src/constants.js', '^@contexts(.*)$': '<rootDir>/src/contexts$1', '^@screens(.*)$': '<rootDir>/src/screens$1', '^@util(.*)$': '<rootDir>/src/util$1', From a7861184151741afd8f752b3913e2f38ecb5ead5 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Fri, 24 Jan 2020 17:07:15 -0500 Subject: [PATCH 21/46] Removes reference to the node unified job template name in the view modal for now. This component is really just a placeholder and this change fixes an error that is thrown when the node's unified job template is deleted. --- .../Modals/NodeViewModal.jsx | 12 +++--------- .../WorkflowJobTemplateVisualizer/Visualizer.jsx | 4 +--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx index d615aad8f4..3ef2f07d3c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx @@ -2,23 +2,17 @@ import React from 'react'; import { Modal } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { func, shape } from 'prop-types'; +import { func } from 'prop-types'; -function NodeViewModal({ i18n, onClose, node }) { +function NodeViewModal({ i18n, onClose }) { return ( - <Modal - isLarge - isOpen - title={i18n._(t`Node Details | ${node.unifiedJobTemplate.name}`)} - onClose={onClose} - > + <Modal isLarge isOpen title={i18n._(t`Node Details`)} onClose={onClose}> Coming soon :) </Modal> ); } NodeViewModal.propTypes = { - node: shape().isRequired, onClose: func.isRequired, }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index 2523882194..a7ed441967 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -907,9 +907,7 @@ function Visualizer({ history, template, i18n }) { onConfirm={() => deleteAllNodes()} /> )} - {nodeToView && ( - <NodeViewModal node={nodeToView} onClose={() => setNodeToView(null)} /> - )} + {nodeToView && <NodeViewModal onClose={() => setNodeToView(null)} />} </Fragment> ); } From 2bbcd2d663d0c9fe66e6445d3e4a2d6b558a6390 Mon Sep 17 00:00:00 2001 From: mabashian <mabashia@redhat.com> Date: Tue, 28 Jan 2020 12:14:13 -0500 Subject: [PATCH 22/46] Move visualizer/workflow output state logic out to reducer and refactor some of the larger functions. Introduces contexts for state/dispatch that can be used by descendent components of both the visualizer and the workflow output components. --- .../components/Workflow/WorkflowLegend.jsx | 14 +- .../components/Workflow/WorkflowStartNode.jsx | 26 +- .../Workflow/WorkflowStartNode.test.jsx | 15 +- .../src/components/Workflow/WorkflowTools.jsx | 8 +- .../components/Workflow/workflowReducer.js | 632 ++++++++++ awx/ui_next/src/contexts/Workflow.jsx | 5 + .../Job/WorkflowOutput/WorkflowOutput.jsx | 189 +-- .../WorkflowOutput/WorkflowOutputGraph.jsx | 45 +- .../Job/WorkflowOutput/WorkflowOutputLink.jsx | 7 +- .../WorkflowOutputLink.test.jsx | 17 +- .../Job/WorkflowOutput/WorkflowOutputNode.jsx | 18 +- .../WorkflowOutputNode.test.jsx | 53 +- .../WorkflowOutput/WorkflowOutputToolbar.jsx | 39 +- .../WorkflowOutputToolbar.test.jsx | 29 +- .../Modals/DeleteAllNodesModal.jsx | 18 +- .../Modals/LinkModals/LinkAddModal.jsx | 22 + .../{ => LinkModals}/LinkDeleteModal.jsx | 23 +- .../Modals/LinkModals/LinkEditModal.jsx | 22 + .../Modals/{ => LinkModals}/LinkModal.jsx | 33 +- .../Modals/LinkModals/index.js | 4 + .../Modals/NodeModal/index.js | 3 - .../Modals/NodeModals/NodeAddModal.jsx | 34 + .../{ => NodeModals}/NodeDeleteModal.jsx | 23 +- .../Modals/NodeModals/NodeEditModal.jsx | 30 + .../{NodeModal => NodeModals}/NodeModal.jsx | 35 +- .../NodeNextButton.jsx | 0 .../NodeTypeStep/InventorySourcesList.jsx | 0 .../NodeTypeStep/JobTemplatesList.jsx | 0 .../NodeTypeStep/NodeTypeStep.jsx | 0 .../NodeTypeStep/ProjectsList.jsx | 0 .../NodeTypeStep/WorkflowJobTemplatesList.jsx | 0 .../NodeTypeStep/index.js | 0 .../Modals/NodeModals/NodeViewModal.jsx | 21 + .../{NodeModal => NodeModals}/RunStep.jsx | 0 .../Modals/NodeModals/index.js | 7 + .../Modals/NodeViewModal.jsx | 19 - .../Modals/UnsavedChangesModal.jsx | 9 +- .../Modals/index.js | 4 - .../Visualizer.jsx | 1037 +++++------------ .../VisualizerGraph.jsx | 91 +- .../VisualizerLink.jsx | 28 +- .../VisualizerNode.jsx | 43 +- .../VisualizerStartScreen.jsx | 15 +- .../VisualizerToolbar.jsx | 50 +- 44 files changed, 1373 insertions(+), 1295 deletions(-) create mode 100644 awx/ui_next/src/components/Workflow/workflowReducer.js create mode 100644 awx/ui_next/src/contexts/Workflow.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkAddModal.jsx rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{ => LinkModals}/LinkDeleteModal.jsx (68%) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkEditModal.jsx rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{ => LinkModals}/LinkModal.jsx (68%) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/index.js delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/index.js create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{ => NodeModals}/NodeDeleteModal.jsx (69%) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeModal.jsx (92%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeNextButton.jsx (100%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeTypeStep/InventorySourcesList.jsx (100%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeTypeStep/JobTemplatesList.jsx (100%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeTypeStep/NodeTypeStep.jsx (100%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeTypeStep/ProjectsList.jsx (100%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeTypeStep/WorkflowJobTemplatesList.jsx (100%) rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/NodeTypeStep/index.js (100%) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeViewModal.jsx rename awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/{NodeModal => NodeModals}/RunStep.jsx (100%) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/index.js delete mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx diff --git a/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx b/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx index 870d35c067..79951d4bf7 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowLegend.jsx @@ -1,8 +1,8 @@ -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 styled from 'styled-components'; -import { func } from 'prop-types'; import { ExclamationTriangleIcon, PauseIcon, @@ -77,12 +77,14 @@ const Close = styled(TimesIcon)` top: 15px; `; -function WorkflowLegend({ i18n, onClose }) { +function WorkflowLegend({ i18n }) { + const dispatch = useContext(WorkflowDispatchContext); + return ( <Wrapper> <Header> <b>{i18n._(t`Legend`)}</b> - <Close onClick={onClose} /> + <Close onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })} /> </Header> <Legend> <li> @@ -128,8 +130,4 @@ function WorkflowLegend({ i18n, onClose }) { ); } -WorkflowLegend.propTypes = { - onClose: func.isRequired, -}; - export default withI18n()(WorkflowLegend); diff --git a/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx index 3b3f89e982..a13e628518 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx @@ -1,8 +1,12 @@ -import React, { useRef, useState } from 'react'; +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 { bool, func } from 'prop-types'; import { PlusIcon } from '@patternfly/react-icons'; import { constants as wfConstants } from '@components/Workflow/WorkflowUtils'; import { @@ -14,16 +18,11 @@ const StartG = styled.g` pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')}; `; -function WorkflowStartNode({ - addingLink, - i18n, - nodePositions, - onAddNodeClick, - onUpdateHelpText, - showActionTooltip, -}) { +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); @@ -62,7 +61,7 @@ function WorkflowStartNode({ onClick={() => { onUpdateHelpText(null); setHovering(false); - onAddNodeClick(1); + dispatch({ type: 'START_ADD_NODE', sourceNodeId: 1 }); }} > <PlusIcon /> @@ -77,16 +76,11 @@ function WorkflowStartNode({ } WorkflowStartNode.propTypes = { - addingLink: bool, - nodePositions: shape().isRequired, - onAddNodeClick: func, showActionTooltip: bool.isRequired, onUpdateHelpText: func, }; WorkflowStartNode.defaultProps = { - addingLink: false, - onAddNodeClick: () => {}, onUpdateHelpText: () => {}, }; diff --git a/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx index 8bc7d733b3..1079694012 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowStartNode.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; +import { WorkflowStateContext } from '@contexts/Workflow'; import WorkflowStartNode from './WorkflowStartNode'; const nodePositions = { @@ -13,10 +14,12 @@ describe('WorkflowStartNode', () => { test('mounts successfully', () => { const wrapper = mount( <svg> - <WorkflowStartNode - nodePositions={nodePositions} - showActionTooltip={false} - /> + <WorkflowStateContext.Provider value={{ nodePositions }}> + <WorkflowStartNode + nodePositions={nodePositions} + showActionTooltip={false} + /> + </WorkflowStateContext.Provider> </svg> ); expect(wrapper).toHaveLength(1); @@ -24,7 +27,9 @@ describe('WorkflowStartNode', () => { test('tooltip shown on hover', () => { const wrapper = mount( <svg> - <WorkflowStartNode nodePositions={nodePositions} showActionTooltip /> + <WorkflowStateContext.Provider value={{ nodePositions }}> + <WorkflowStartNode nodePositions={nodePositions} showActionTooltip /> + </WorkflowStateContext.Provider> </svg> ); 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 index 6e1876e1c8..1ff435f824 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowTools.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowTools.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 styled from 'styled-components'; @@ -53,13 +54,13 @@ const Close = styled(TimesIcon)` function WorkflowTools({ i18n, - onClose, onFitGraph, onPan, onPanToMiddle, onZoomChange, zoomPercentage, }) { + const dispatch = useContext(WorkflowDispatchContext); const zoomIn = () => { const newScale = Math.ceil((zoomPercentage + 10) / 10) * 10 < 200 @@ -80,7 +81,7 @@ function WorkflowTools({ <Wrapper> <Header> <b>{i18n._(t`Tools`)}</b> - <Close onClick={onClose} /> + <Close onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })} /> </Header> <Tools> <Tooltip @@ -178,7 +179,6 @@ function WorkflowTools({ } WorkflowTools.propTypes = { - onClose: func.isRequired, onFitGraph: func.isRequired, onPan: func.isRequired, onPanToMiddle: func.isRequired, 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..2afce88d0f --- /dev/null +++ b/awx/ui_next/src/components/Workflow/workflowReducer.js @@ -0,0 +1,632 @@ +import { t } from '@lingui/macro'; + +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': + return cancelLink(state); + case 'CANCEL_LINK_MODAL': + return cancelLinkModal(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 'SELECT_SOURCE_FOR_LINKING': + return selectSourceForLinking(state, action.node); + case 'SET_ADD_LINK_SOURCE_NODE': + return { ...state, addLinkSourceNode: action.value }; + case 'SET_ADD_LINK_TARGET_NODE': + return { ...state, addLinkTargetNode: action.value }; + case 'SET_ADD_NODE_SOURCE': + return { ...state, addNodeSource: action.value }; + case 'SET_ADD_NODE_TARGET': + return { ...state, addNodeTarget: action.value }; + case 'SET_ADDING_LINK': + return { ...state, addingLink: 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_LINKS': + return { ...state, links: action.value }; + case 'SET_NEXT_NODE_ID': + return { ...state, nextNodeId: 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_NODES': + return { ...state, nodes: action.value }; + case 'SET_SHOW_DELETE_ALL_NODES_MODAL': + return { ...state, showDeleteAllNodesModal: action.value }; + case 'SET_SHOW_LEGEND': + return { ...state, showLegend: action.value }; + case 'SET_SHOW_TOOLS': + return { ...state, showTools: action.value }; + case 'SET_SHOW_UNSAVED_CHANGES_MODAL': + return { ...state, showUnsavedChangesModal: action.value }; + case 'SET_UNSAVED_CHANGES': + return { ...state, unsavedChanges: 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, + type: 'link', + }); + + 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, + type: 'node', + unifiedJobTemplate: node.nodeResource, + }); + + // 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, + type: 'link', + }); + + 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, + nodes: newNodes, + }; +} + +function cancelLinkModal(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, + }; +} + +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', + type: 'link', + }); + } + + 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', + type: 'link', + }); + } + } else if (!linkParentMapping[child.id].includes(parentId)) { + newLinks.push({ + source: { id: parentId }, + target: { id: child.id }, + linkType: child.linkType, + type: 'link', + }); + } + }); + }); +} + +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`), + }, + type: 'node', + }, + ]; + workflowNodes.forEach(node => { + node.workflowMakerNodeId = nodeIdCounter; + + const nodeObj = { + id: nodeIdCounter, + type: 'node', + 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', + type: 'link', + }); + nonRootNodeIds.push(nodeId); + }); + node.failure_nodes.forEach(nodeId => { + const targetIndex = + chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + linkType: 'failure', + type: 'link', + }); + nonRootNodeIds.push(nodeId); + }); + node.always_nodes.forEach(nodeId => { + const targetIndex = + chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + linkType: 'always', + type: 'link', + }); + 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', + type: 'link', + }); + }); + + 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/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/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx index 0e41845ac7..d0f282724f 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -1,12 +1,16 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useReducer } from 'react'; import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; 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 from '@components/Workflow/workflowReducer'; import { WorkflowJobsAPI } from '@api'; import WorkflowOutputGraph from './WorkflowOutputGraph'; import WorkflowOutputToolbar from './WorkflowOutputToolbar'; @@ -36,148 +40,50 @@ const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => { }; function WorkflowOutput({ job, i18n }) { - const [contentError, setContentError] = useState(null); - const [graphLinks, setGraphLinks] = useState([]); - const [graphNodes, setGraphNodes] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [nodePositions, setNodePositions] = useState(null); - const [showLegend, setShowLegend] = useState(false); - const [showTools, setShowTools] = useState(false); + const [state, dispatch] = useReducer(workflowReducer, { + contentError: null, + isLoading: true, + links: [], + nextNodeId: 0, + nodePositions: null, + nodes: [], + showLegend: false, + showTools: false, + }); + + const { contentError, isLoading, links, nodePositions, nodes } = state; useEffect(() => { - const buildGraphArrays = nodes => { - const allNodeIds = []; - const arrayOfLinksForChart = []; - const chartNodeIdToIndexMapping = {}; - const nodeIdToChartNodeIdMapping = {}; - const nodeRef = {}; - const nonRootNodeIds = []; - 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], - linkType: 'success', - type: 'link', - }); - nonRootNodeIds.push(nodeId); - }); - node.failure_nodes.forEach(nodeId => { - const targetIndex = - chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - linkType: 'failure', - type: 'link', - }); - nonRootNodeIds.push(nodeId); - }); - node.always_nodes.forEach(nodeId => { - const targetIndex = - chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - linkType: '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], - linkType: 'always', - type: 'link', - }); - }); - - setGraphNodes(arrayOfNodesForChart); - setGraphLinks(arrayOfLinksForChart); - }; - async function fetchData() { try { - const nodes = await fetchWorkflowNodes(job.id); - buildGraphArrays(nodes); + const workflowNodes = await fetchWorkflowNodes(job.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(); - }, [job.id, job.unified_job_template, i18n]); + }, [job.id, i18n]); // Update positions of nodes/links useEffect(() => { - if (graphNodes) { + if (nodes) { const newNodePositions = {}; - const g = layoutGraph(graphNodes, graphLinks); + const g = layoutGraph(nodes, links); g.nodes().forEach(node => { newNodePositions[node] = g.node(node); }); - setNodePositions(newNodePositions); + dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions }); } - }, [graphLinks, graphNodes]); + }, [links, nodes]); if (isLoading) { return ( @@ -196,29 +102,16 @@ function WorkflowOutput({ job, i18n }) { } return ( - <CardBody> - <Wrapper> - <WorkflowOutputToolbar - job={job} - legendShown={showLegend} - nodes={graphNodes} - onLegendToggle={() => setShowLegend(!showLegend)} - onToolsToggle={() => setShowTools(!showTools)} - toolsShown={showTools} - /> - {nodePositions && ( - <WorkflowOutputGraph - links={graphLinks} - nodePositions={nodePositions} - nodes={graphNodes} - onUpdateShowLegend={setShowLegend} - onUpdateShowTools={setShowTools} - showLegend={showLegend} - showTools={showTools} - /> - )} - </Wrapper> - </CardBody> + <WorkflowStateContext.Provider value={state}> + <WorkflowDispatchContext.Provider value={dispatch}> + <CardBody> + <Wrapper> + <WorkflowOutputToolbar job={job} /> + {nodePositions && <WorkflowOutputGraph />} + </Wrapper> + </CardBody> + </WorkflowDispatchContext.Provider> + </WorkflowStateContext.Provider> ); } diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 2e7a3cf341..b3295916e3 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -1,6 +1,6 @@ -import React, { Fragment, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { WorkflowStateContext } from '@contexts/Workflow'; import * as d3 from 'd3'; -import { arrayOf, bool, shape, func } from 'prop-types'; import { getScaleAndOffsetToFit, getTranslatePointsForZoom, @@ -18,21 +18,17 @@ import { WorkflowTools, } from '@components/Workflow'; -function WorkflowOutputGraph({ - links, - nodePositions, - nodes, - onUpdateShowLegend, - onUpdateShowTools, - showLegend, - showTools, -}) { +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]; @@ -158,7 +154,7 @@ function WorkflowOutputGraph({ }, []); return ( - <Fragment> + <> {(nodeHelp || linkHelp) && ( <WorkflowHelp> {nodeHelp && <WorkflowNodeHelp node={nodeHelp} />} @@ -172,16 +168,11 @@ function WorkflowOutputGraph({ > <g id="workflow-g" ref={gRef}> {nodePositions && [ - <WorkflowStartNode - key="start" - nodePositions={nodePositions} - showActionTooltip={false} - />, + <WorkflowStartNode key="start" showActionTooltip={false} />, links.map(link => ( <WorkflowOutputLink key={`link-${link.source.id}-${link.target.id}`} link={link} - nodePositions={nodePositions} onUpdateLinkHelp={setLinkHelp} /> )), @@ -193,7 +184,6 @@ function WorkflowOutputGraph({ mouseEnter={() => setNodeHelp(node)} mouseLeave={() => setNodeHelp(null)} node={node} - nodePositions={nodePositions} /> ); } @@ -205,7 +195,6 @@ function WorkflowOutputGraph({ <div css="position: absolute; top: 75px;right: 20px;display: flex;"> {showTools && ( <WorkflowTools - onClose={() => onUpdateShowTools(false)} onFitGraph={handleFitGraph} onPan={handlePan} onPanToMiddle={handlePanToMiddle} @@ -213,22 +202,10 @@ function WorkflowOutputGraph({ zoomPercentage={zoomPercentage} /> )} - {showLegend && ( - <WorkflowLegend onClose={() => onUpdateShowLegend(false)} /> - )} + {showLegend && <WorkflowLegend />} </div> - </Fragment> + </> ); } -WorkflowOutputGraph.propTypes = { - links: arrayOf(shape()).isRequired, - nodePositions: shape().isRequired, - nodes: arrayOf(shape()).isRequired, - onUpdateShowLegend: func.isRequired, - onUpdateShowTools: func.isRequired, - showLegend: bool.isRequired, - showTools: bool.isRequired, -}; - export default WorkflowOutputGraph; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx index d33c381943..022cad9de7 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { WorkflowStateContext } from '@contexts/Workflow'; import { shape } from 'prop-types'; import { generateLine, @@ -6,11 +7,12 @@ import { getLinkOverlayPoints, } from '@components/Workflow/WorkflowUtils'; -function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { +function WorkflowOutputLink({ link, onUpdateLinkHelp }) { 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); @@ -65,7 +67,6 @@ function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { WorkflowOutputLink.propTypes = { link: shape().isRequired, - nodePositions: shape().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 index 651efc1060..09830aab45 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { mount } from 'enzyme'; +import { WorkflowStateContext } from '@contexts/Workflow'; import WorkflowOutputLink from './WorkflowOutputLink'; const link = { @@ -28,13 +29,15 @@ const nodePositions = { describe('WorkflowOutputLink', () => { test('mounts successfully', () => { - const wrapper = mountWithContexts( + const wrapper = mount( <svg> - <WorkflowOutputLink - link={link} - nodePositions={nodePositions} - onUpdateLinkHelp={() => {}} - /> + <WorkflowStateContext.Provider value={{ nodePositions }}> + <WorkflowOutputLink + link={link} + nodePositions={nodePositions} + onUpdateLinkHelp={() => {}} + /> + </WorkflowStateContext.Provider> </svg> ); 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 index a54a5f6c7e..e8fcf8f68c 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -1,4 +1,5 @@ -import React, { Fragment } from 'react'; +import React, { useContext } from 'react'; +import { WorkflowStateContext } from '@contexts/Workflow'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -55,14 +56,8 @@ const NodeDefaultLabel = styled.p` white-space: nowrap; `; -function WorkflowOutputNode({ - history, - i18n, - mouseEnter, - mouseLeave, - node, - nodePositions, -}) { +function WorkflowOutputNode({ history, i18n, mouseEnter, mouseLeave, node }) { + const { nodePositions } = useContext(WorkflowStateContext); let borderColor = '#93969A'; if (node.job) { @@ -105,7 +100,7 @@ function WorkflowOutputNode({ /> <NodeContents height="60" width="180"> {node.job ? ( - <Fragment> + <> <JobTopLine> <StatusIcon status={node.job.status} /> <p> @@ -115,7 +110,7 @@ function WorkflowOutputNode({ </p> </JobTopLine> <Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed> - </Fragment> + </> ) : ( <NodeDefaultLabel> {node.unifiedJobTemplate @@ -134,7 +129,6 @@ WorkflowOutputNode.propTypes = { mouseEnter: func.isRequired, mouseLeave: func.isRequired, node: shape().isRequired, - nodePositions: shape().isRequired, }; export default withI18n()(withRouter(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 index 046ee99c73..e819d079f3 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { WorkflowStateContext } from '@contexts/Workflow'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import WorkflowOutputNode from './WorkflowOutputNode'; @@ -48,12 +49,13 @@ describe('WorkflowOutputNode', () => { test('mounts successfully', () => { const wrapper = mountWithContexts( <svg> - <WorkflowOutputNode - mouseEnter={() => {}} - mouseLeave={() => {}} - node={nodeWithJT} - nodePositions={nodePositions} - /> + <WorkflowStateContext.Provider value={{ nodePositions }}> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={nodeWithJT} + /> + </WorkflowStateContext.Provider> </svg> ); expect(wrapper).toHaveLength(1); @@ -61,12 +63,13 @@ describe('WorkflowOutputNode', () => { test('node contents displayed correctly when Job and Job Template exist', () => { const wrapper = mountWithContexts( <svg> - <WorkflowOutputNode - mouseEnter={() => {}} - mouseLeave={() => {}} - node={nodeWithJT} - nodePositions={nodePositions} - /> + <WorkflowStateContext.Provider value={{ nodePositions }}> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={nodeWithJT} + /> + </WorkflowStateContext.Provider> </svg> ); expect(wrapper.contains(<p>Automation JT</p>)).toEqual(true); @@ -75,12 +78,13 @@ describe('WorkflowOutputNode', () => { test('node contents displayed correctly when Job Template deleted', () => { const wrapper = mountWithContexts( <svg> - <WorkflowOutputNode - mouseEnter={() => {}} - mouseLeave={() => {}} - node={nodeWithoutJT} - nodePositions={nodePositions} - /> + <WorkflowStateContext.Provider value={{ nodePositions }}> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={nodeWithoutJT} + /> + </WorkflowStateContext.Provider> </svg> ); expect(wrapper.contains(<p>DELETED</p>)).toEqual(true); @@ -89,12 +93,13 @@ describe('WorkflowOutputNode', () => { test('node contents displayed correctly when Job deleted', () => { const wrapper = mountWithContexts( <svg> - <WorkflowOutputNode - mouseEnter={() => {}} - mouseLeave={() => {}} - node={{ id: 2 }} - nodePositions={nodePositions} - /> + <WorkflowStateContext.Provider value={{ nodePositions }}> + <WorkflowOutputNode + mouseEnter={() => {}} + mouseLeave={() => {}} + node={{ id: 2 }} + /> + </WorkflowStateContext.Provider> </svg> ); 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 index 975467adde..27c5bb594a 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.jsx @@ -1,7 +1,11 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import { + WorkflowDispatchContext, + WorkflowStateContext, +} from '@contexts/Workflow'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { arrayOf, bool, func, shape } from 'prop-types'; +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'; @@ -53,15 +57,11 @@ const StatusIconWithMargin = styled(StatusIcon)` margin-right: 20px; `; -function WorkflowOutputToolbar({ - i18n, - job, - legendShown, - nodes, - onLegendToggle, - onToolsToggle, - toolsShown, -}) { +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 ( @@ -76,8 +76,8 @@ function WorkflowOutputToolbar({ <VerticalSeparator /> <Tooltip content={i18n._(t`Toggle Legend`)} position="bottom"> <ActionButton - isActive={legendShown} - onClick={onLegendToggle} + isActive={showLegend} + onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })} variant="plain" > <CompassIcon /> @@ -85,8 +85,8 @@ function WorkflowOutputToolbar({ </Tooltip> <Tooltip content={i18n._(t`Toggle Tools`)} position="bottom"> <ActionButton - isActive={toolsShown} - onClick={onToolsToggle} + isActive={showTools} + onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })} variant="plain" > <WrenchIcon /> @@ -99,15 +99,6 @@ function WorkflowOutputToolbar({ WorkflowOutputToolbar.propTypes = { job: shape().isRequired, - legendShown: bool.isRequired, - nodes: arrayOf(shape()), - onLegendToggle: func.isRequired, - onToolsToggle: func.isRequired, - toolsShown: bool.isRequired, -}; - -WorkflowOutputToolbar.defaultProps = { - nodes: [], }; 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 index 980f47ca23..4afe13e93f 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { WorkflowStateContext } from '@contexts/Workflow'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import WorkflowOutputToolbar from './WorkflowOutputToolbar'; @@ -7,17 +8,18 @@ const job = { status: 'successful', }; +const workflowContext = { + nodes: [], + showLegend: false, + showTools: false, +}; + describe('WorkflowOutputToolbar', () => { test('mounts successfully', () => { const wrapper = mountWithContexts( - <WorkflowOutputToolbar - job={job} - legendShown={false} - nodes={[]} - onLegendToggle={() => {}} - onToolsToggle={() => {}} - toolsShown={false} - /> + <WorkflowStateContext.Provider value={workflowContext}> + <WorkflowOutputToolbar job={job} /> + </WorkflowStateContext.Provider> ); expect(wrapper).toHaveLength(1); }); @@ -36,14 +38,9 @@ describe('WorkflowOutputToolbar', () => { }, ]; const wrapper = mountWithContexts( - <WorkflowOutputToolbar - job={job} - legendShown={false} - nodes={nodes} - onLegendToggle={() => {}} - onToolsToggle={() => {}} - toolsShown={false} - /> + <WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}> + <WorkflowOutputToolbar job={job} /> + </WorkflowStateContext.Provider> ); // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored expect(wrapper.find('Badge').text()).toBe('1'); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx index 727f41f9e0..324bb98b87 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.jsx @@ -1,11 +1,12 @@ -import React from 'react'; +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 { func } from 'prop-types'; import AlertModal from '@components/AlertModal'; -function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { +function DeleteAllNodesModal({ i18n }) { + const dispatch = useContext(WorkflowDispatchContext); return ( <AlertModal actions={[ @@ -13,7 +14,7 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { key="remove" variant="danger" aria-label={i18n._(t`Confirm removal of all nodes`)} - onClick={() => onConfirm()} + onClick={() => dispatch({ type: 'DELETE_ALL_NODES' })} > {i18n._(t`Remove`)} </Button>, @@ -21,13 +22,13 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { key="cancel" variant="secondary" aria-label={i18n._(t`Cancel node removal`)} - onClick={onCancel} + onClick={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })} > {i18n._(t`Cancel`)} </Button>, ]} isOpen - onClose={onCancel} + onClose={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })} title={i18n._(t`Remove All Nodes`)} variant="danger" > @@ -40,9 +41,4 @@ function DeleteAllNodesModal({ i18n, onConfirm, onCancel }) { ); } -DeleteAllNodesModal.propTypes = { - onCancel: func.isRequired, - onConfirm: func.isRequired, -}; - export default withI18n()(DeleteAllNodesModal); 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 ( + <LinkModal + header={ + <Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}> + {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/LinkDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx similarity index 68% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx index 390940937d..395ac49617 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkDeleteModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/LinkModals/LinkDeleteModal.jsx @@ -1,22 +1,27 @@ -import React, { Fragment } from 'react'; +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 { func, shape } from 'prop-types'; import AlertModal from '@components/AlertModal'; -function LinkDeleteModal({ i18n, linkToDelete, onConfirm, onCancel }) { +function LinkDeleteModal({ i18n }) { + const dispatch = useContext(WorkflowDispatchContext); + const { linkToDelete } = useContext(WorkflowStateContext); return ( dispatch({ type: 'SET_LINK_TO_DELETE', value: null })} actions={[ , @@ -27,7 +35,7 @@ function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) { key="cancel" variant="secondary" aria-label={i18n._(t`Cancel link changes`)} - onClick={onCancel} + onClick={() => dispatch({ type: 'CANCEL_LINK_MODAL' })} > {i18n._(t`Cancel`)} , @@ -36,7 +44,7 @@ function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) { { - setNewLinkType(value); + setLinkType(value); }} /> @@ -64,14 +72,7 @@ function LinkModal({ linkType, header, i18n, onCancel, onConfirm }) { } LinkModal.propTypes = { - linkType: string, - header: node.isRequired, - onCancel: func.isRequired, onConfirm: func.isRequired, }; -LinkModal.defaultProps = { - linkType: 'success', -}; - export default withI18n()(LinkModal); 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/NodeModal/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/index.js deleted file mode 100644 index c289e043df..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default as NodeModal } from './NodeModal'; -export { default as NodeNextButton } from './NodeNextButton'; -export { default as RunStep } from './RunStep'; 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..a0898e3ed7 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeAddModal.jsx @@ -0,0 +1,34 @@ +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 = (linkType, resource, nodeType) => { + dispatch({ + type: 'CREATE_NODE', + node: { + linkType, + nodeResource: resource, + nodeType, + }, + }); + }; + + return ( + + ); +} + +export default withI18n()(NodeAddModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx similarity index 69% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx index 31f20fcd76..4d245c2955 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeDeleteModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeDeleteModal.jsx @@ -1,23 +1,28 @@ -import React, { Fragment } from 'react'; +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 { func, shape } from 'prop-types'; import AlertModal from '@components/AlertModal'; -function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { +function NodeDeleteModal({ i18n }) { + const dispatch = useContext(WorkflowDispatchContext); + const { nodeToDelete } = useContext(WorkflowStateContext); return ( dispatch({ type: 'SET_NODE_TO_DELETE', value: null })} actions={[ , @@ -25,7 +30,7 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { key="cancel" variant="secondary" aria-label={i18n._(t`Cancel node removal`)} - onClick={onCancel} + onClick={() => dispatch({ type: 'SET_NODE_TO_DELETE', value: null })} > {i18n._(t`Cancel`)} , @@ -46,10 +51,4 @@ function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) { ); } -NodeDeleteModal.propTypes = { - nodeToDelete: shape().isRequired, - onCancel: func.isRequired, - onConfirm: func.isRequired, -}; - export default withI18n()(NodeDeleteModal); 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..485add87d3 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeEditModal.jsx @@ -0,0 +1,30 @@ +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 = (linkType, resource, nodeType) => { + dispatch({ + type: 'UPDATE_NODE', + node: { + linkType, + nodeResource: resource, + nodeType, + }, + }); + }; + + return ( + + ); +} + +export default withI18n()(NodeEditModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx similarity index 92% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx index 71f0942e52..27e720aba8 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx @@ -1,8 +1,12 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { withRouter } from 'react-router-dom'; +import { + WorkflowDispatchContext, + WorkflowStateContext, +} from '@contexts/Workflow'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { bool, func, node, shape } from 'prop-types'; +import { bool, node, func } from 'prop-types'; import { Button, WizardContextConsumer, @@ -12,15 +16,10 @@ import Wizard from '@components/Wizard'; import { NodeTypeStep } from './NodeTypeStep'; import { RunStep, NodeNextButton } from '.'; -function NodeModal({ - askLinkType, - history, - i18n, - nodeToEdit, - onClose, - onSave, - title, -}) { +function NodeModal({ askLinkType, history, i18n, onSave, title }) { + const dispatch = useContext(WorkflowDispatchContext); + const { nodeToEdit } = useContext(WorkflowStateContext); + let defaultApprovalDescription = ''; let defaultApprovalName = ''; let defaultApprovalTimeout = 0; @@ -104,16 +103,12 @@ function NodeModal({ } : nodeResource; - onSave({ - linkType, - nodeResource: resource, - nodeType, - }); + onSave(linkType, resource, nodeType); }; const handleCancel = () => { clearQueryParams(); - onClose(); + dispatch({ type: 'CANCEL_NODE_MODAL' }); }; const handleNodeTypeChange = newNodeType => { @@ -211,14 +206,8 @@ function NodeModal({ NodeModal.propTypes = { askLinkType: bool.isRequired, - nodeToEdit: shape(), - onClose: func.isRequired, onSave: func.isRequired, title: node.isRequired, }; -NodeModal.defaultProps = { - nodeToEdit: null, -}; - export default withI18n()(withRouter(NodeModal)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/NodeTypeStep.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/ProjectsList.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/index.js similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/index.js rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/index.js 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/NodeModal/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx rename to awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/RunStep.jsx 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/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx deleted file mode 100644 index 3ef2f07d3c..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { Modal } from '@patternfly/react-core'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { func } from 'prop-types'; - -function NodeViewModal({ i18n, onClose }) { - return ( - - Coming soon :) - - ); -} - -NodeViewModal.propTypes = { - onClose: func.isRequired, -}; - -export default withI18n()(NodeViewModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx index d16b73d52d..7a72289302 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx @@ -1,16 +1,18 @@ -import React from 'react'; +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, onCancel, onSaveAndExit, onExit }) { +function UnsavedChangesModal({ i18n, onSaveAndExit, onExit }) { + const dispatch = useContext(WorkflowDispatchContext); return ( dispatch({ type: 'TOGGLE_UNSAVED_CHANGES_MODAL' })} actions={[
    ); } VisualizerGraph.propTypes = { - addLinkSourceNode: shape(), - addingLink: bool.isRequired, - links: arrayOf(shape()).isRequired, - nodePositions: shape().isRequired, - nodes: arrayOf(shape()).isRequired, - onAddNodeClick: func.isRequired, - onCancelAddLinkClick: func.isRequired, - onConfirmAddLinkClick: func.isRequired, - onDeleteLinkClick: func.isRequired, - onDeleteNodeClick: func.isRequired, - onEditNodeClick: func.isRequired, - onLinkEditClick: func.isRequired, - onStartAddLinkClick: func.isRequired, - onUpdateShowLegend: func.isRequired, - onUpdateShowTools: func.isRequired, - onViewNodeClick: func.isRequired, readOnly: bool.isRequired, - showLegend: bool.isRequired, - showTools: bool.isRequired, -}; - -VisualizerGraph.defaultProps = { - addLinkSourceNode: {}, }; export default withI18n()(VisualizerGraph); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index d069acfa0c..b6e2142dfd 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -1,4 +1,8 @@ -import React, { useEffect, useRef, useState } from 'react'; +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'; @@ -19,13 +23,8 @@ const LinkG = styled.g` `; function VisualizerLink({ - addingLink, i18n, link, - nodePositions, - onAddNodeClick, - onDeleteLinkClick, - onLinkEditClick, onUpdateHelpText, onUpdateLinkHelp, readOnly, @@ -36,6 +35,8 @@ function VisualizerLink({ const [pathStroke, setPathStroke] = useState('#CCCCCC'); const [tooltipX, setTooltipX] = useState(); const [tooltipY, setTooltipY] = useState(); + const dispatch = useContext(WorkflowDispatchContext); + const { addingLink, nodePositions } = useContext(WorkflowStateContext); const addNodeAction = ( { onUpdateHelpText(null); setHovering(false); - onAddNodeClick(link.source.id, link.target.id); + dispatch({ + type: 'START_ADD_NODE', + sourceNodeId: link.source.id, + targetNodeId: link.target.id, + }); }} onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node between these two nodes`)) @@ -63,7 +68,7 @@ function VisualizerLink({ onLinkEditClick(link)} + onClick={() => dispatch({ type: 'SET_LINK_TO_EDIT', value: link })} onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this link`))} onMouseLeave={() => onUpdateHelpText(null)} > @@ -72,7 +77,7 @@ function VisualizerLink({ onDeleteLinkClick(link)} + onClick={() => dispatch({ type: 'START_DELETE_LINK', link })} onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this link`))} onMouseLeave={() => onUpdateHelpText(null)} > @@ -142,12 +147,7 @@ function VisualizerLink({ } VisualizerLink.propTypes = { - addingLink: bool.isRequired, link: shape().isRequired, - nodePositions: shape().isRequired, - onAddNodeClick: func.isRequired, - onDeleteLinkClick: func.isRequired, - onLinkEditClick: func.isRequired, readOnly: bool.isRequired, onUpdateHelpText: func.isRequired, onUpdateLinkHelp: func.isRequired, diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index 8cfd094292..bd66921fad 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -1,4 +1,8 @@ -import React, { useRef, useState } from 'react'; +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'; @@ -38,24 +42,21 @@ const NodeDefaultLabel = styled.p` `; function VisualizerNode({ - addingLink, i18n, - isAddLinkSourceNode, node, - nodePositions, - onAddNodeClick, - onConfirmAddLinkClick, - onDeleteNodeClick, - onEditNodeClick, onMouseOver, - onStartAddLinkClick, - onViewNodeClick, readOnly, onUpdateHelpText, 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); @@ -81,7 +82,7 @@ function VisualizerNode({ const handleNodeClick = () => { if (addingLink && !node.isInvalidLinkTarget && !isAddLinkSourceNode) { - onConfirmAddLinkClick(node); + dispatch({ type: 'SET_ADD_LINK_TARGET_NODE', value: node }); } }; @@ -92,7 +93,7 @@ function VisualizerNode({ onClick={() => { onUpdateHelpText(null); setHovering(false); - onViewNodeClick(node); + dispatch({ type: 'SET_NODE_TO_VIEW', value: node }); }} onMouseEnter={() => onUpdateHelpText(i18n._(t`View node details`))} onMouseLeave={() => onUpdateHelpText(null)} @@ -110,7 +111,7 @@ function VisualizerNode({ onClick={() => { onUpdateHelpText(null); setHovering(false); - onAddNodeClick(node.id); + dispatch({ type: 'START_ADD_NODE', sourceNodeId: node.id }); }} onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))} onMouseLeave={() => onUpdateHelpText(null)} @@ -124,7 +125,7 @@ function VisualizerNode({ onClick={() => { onUpdateHelpText(null); setHovering(false); - onEditNodeClick(node); + dispatch({ type: 'SET_NODE_TO_EDIT', value: node }); }} onMouseEnter={() => onUpdateHelpText(i18n._(t`Edit this node`))} onMouseLeave={() => onUpdateHelpText(null)} @@ -137,7 +138,7 @@ function VisualizerNode({ onClick={() => { onUpdateHelpText(null); setHovering(false); - onStartAddLinkClick(node); + dispatch({ type: 'SELECT_SOURCE_FOR_LINKING', node }); }} onMouseEnter={() => onUpdateHelpText(i18n._(t`Link to an available node`)) @@ -152,7 +153,7 @@ function VisualizerNode({ onClick={() => { onUpdateHelpText(null); setHovering(false); - onDeleteNodeClick(node); + dispatch({ type: 'SET_NODE_TO_DELETE', value: node }); }} onMouseEnter={() => onUpdateHelpText(i18n._(t`Delete this node`))} onMouseLeave={() => onUpdateHelpText(null)} @@ -214,24 +215,14 @@ function VisualizerNode({ } VisualizerNode.propTypes = { - addingLink: bool.isRequired, - isAddLinkSourceNode: bool, node: shape().isRequired, - nodePositions: shape().isRequired, - onAddNodeClick: func.isRequired, - onConfirmAddLinkClick: func.isRequired, - onDeleteNodeClick: func.isRequired, - onEditNodeClick: func.isRequired, onMouseOver: func, - onStartAddLinkClick: func.isRequired, - onViewNodeClick: func.isRequired, readOnly: bool.isRequired, onUpdateHelpText: func.isRequired, updateNodeHelp: func.isRequired, }; VisualizerNode.defaultProps = { - isAddLinkSourceNode: false, onMouseOver: () => {}, }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx index 46d429d665..83f2608934 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerStartScreen.jsx @@ -1,7 +1,7 @@ -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 { func } from 'prop-types'; import { Button as PFButton } from '@patternfly/react-core'; import styled from 'styled-components'; @@ -29,7 +29,8 @@ const StartPanelWrapper = styled.div` justify-content: center; `; -function VisualizerStartScreen({ i18n, onStartClick }) { +function VisualizerStartScreen({ i18n }) { + const dispatch = useContext(WorkflowDispatchContext); return (
    @@ -37,7 +38,9 @@ function VisualizerStartScreen({ i18n, onStartClick }) {

    {i18n._(t`Please click the Start button to begin.`)}

    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(); + }); +}); From b3929d1177a3d08b1e7872c9feac623c564b71dd Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 3 Feb 2020 17:57:34 -0500 Subject: [PATCH 33/46] Remove HorizontalSeparator component. Patternfly now has a Divider component that we can use if we need something like this. --- .../HorizontalSeparator/HorizontalSeparator.jsx | 14 -------------- .../HorizontalSeparator.test.jsx | 11 ----------- .../src/components/HorizontalSeparator/index.js | 1 - 3 files changed, 26 deletions(-) delete mode 100644 awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx delete mode 100644 awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx delete mode 100644 awx/ui_next/src/components/HorizontalSeparator/index.js diff --git a/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx deleted file mode 100644 index b1646660fe..0000000000 --- a/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; - -const Separator = styled.div` - width: 100%; - height: 1px; - margin-top: 20px; - margin-bottom: 20px; - background-color: #d7d7d7; -`; - -const HorizontalSeparator = () => ; - -export default HorizontalSeparator; diff --git a/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx b/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx deleted file mode 100644 index c02794494b..0000000000 --- a/awx/ui_next/src/components/HorizontalSeparator/HorizontalSeparator.test.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; - -import HorizontalSeparator from './HorizontalSeparator'; - -describe('HorizontalSeparator', () => { - test('renders the expected content', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - }); -}); diff --git a/awx/ui_next/src/components/HorizontalSeparator/index.js b/awx/ui_next/src/components/HorizontalSeparator/index.js deleted file mode 100644 index 7f9fe23413..0000000000 --- a/awx/ui_next/src/components/HorizontalSeparator/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './HorizontalSeparator'; From f9debb8f94d349216b0686fafe53e70e8adc5fc5 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 4 Feb 2020 10:43:39 -0500 Subject: [PATCH 34/46] Adds tests for delete all nodes and unsaved changes modals --- .../Modals/DeleteAllNodesModal.test.jsx | 44 +++++++++++++++++++ .../Modals/UnsavedChangesModal.jsx | 2 + .../Modals/UnsavedChangesModal.test.jsx | 42 ++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.test.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.test.jsx 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..5c28c06adb --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/DeleteAllNodesModal.test.jsx @@ -0,0 +1,44 @@ +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/UnsavedChangesModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx index 7a72289302..e594c7a570 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/UnsavedChangesModal.jsx @@ -15,6 +15,7 @@ function UnsavedChangesModal({ i18n, onSaveAndExit, onExit }) { onClose={() => dispatch({ type: 'TOGGLE_UNSAVED_CHANGES_MODAL' })} actions={[ , , )} - 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 index 046b2b4db8..43ae2b681e 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeNextButton.jsx @@ -18,6 +18,7 @@ function NodeNextButton({ return (