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'),