mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 18:09:57 -03:30
Merge pull request #5712 from mabashian/ui-next-workflows-4
UI Next workflow graph edit/results Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
f57fff732e
@ -11,6 +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',
|
||||
'^@contexts(.*)$': '<rootDir>/src/contexts$1',
|
||||
'^@screens(.*)$': '<rootDir>/src/screens$1',
|
||||
'^@util(.*)$': '<rootDir>/src/util$1',
|
||||
|
||||
122
awx/ui_next/package-lock.json
generated
122
awx/ui_next/package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
10
awx/ui_next/src/api/models/WorkflowApprovalTemplates.js
Normal file
10
awx/ui_next/src/api/models/WorkflowApprovalTemplates.js
Normal file
@ -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;
|
||||
60
awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
Normal file
60
awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js
Normal file
@ -0,0 +1,60 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class WorkflowJobTemplateNodes extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/workflow_job_template_nodes/';
|
||||
}
|
||||
|
||||
createApprovalTemplate(id, data) {
|
||||
return this.http.post(
|
||||
`${this.baseUrl}${id}/create_approval_template/`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
associateSuccessNode(id, idToAssociate) {
|
||||
return this.http.post(`${this.baseUrl}${id}/success_nodes/`, {
|
||||
id: idToAssociate,
|
||||
});
|
||||
}
|
||||
|
||||
associateFailureNode(id, idToAssociate) {
|
||||
return this.http.post(`${this.baseUrl}${id}/failure_nodes/`, {
|
||||
id: idToAssociate,
|
||||
});
|
||||
}
|
||||
|
||||
associateAlwaysNode(id, idToAssociate) {
|
||||
return this.http.post(`${this.baseUrl}${id}/always_nodes/`, {
|
||||
id: idToAssociate,
|
||||
});
|
||||
}
|
||||
|
||||
disassociateSuccessNode(id, idToDissociate) {
|
||||
return this.http.post(`${this.baseUrl}${id}/success_nodes/`, {
|
||||
id: idToDissociate,
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
disassociateFailuresNode(id, idToDissociate) {
|
||||
return this.http.post(`${this.baseUrl}${id}/failure_nodes/`, {
|
||||
id: idToDissociate,
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
disassociateAlwaysNode(id, idToDissociate) {
|
||||
return this.http.post(`${this.baseUrl}${id}/always_nodes/`, {
|
||||
id: idToDissociate,
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
readCredentials(id) {
|
||||
return this.http.get(`${this.baseUrl}${id}/credentials/`);
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkflowJobTemplateNodes;
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -2,10 +2,10 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Wizard } from '@patternfly/react-core';
|
||||
import SelectableCard from '@components/SelectableCard';
|
||||
import Wizard from '@components/Wizard';
|
||||
import SelectResourceStep from './SelectResourceStep';
|
||||
import SelectRoleStep from './SelectRoleStep';
|
||||
import SelectableCard from './SelectableCard';
|
||||
import { TeamsAPI, UsersAPI } from '../../api';
|
||||
|
||||
const readUsers = async queryParams =>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { string, number } from 'prop-types';
|
||||
import { string, node, number } from 'prop-types';
|
||||
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
|
||||
import { DetailName, DetailValue } from '@components/DetailList';
|
||||
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
|
||||
@ -90,7 +90,7 @@ function VariablesDetail({ value, label, rows }) {
|
||||
}
|
||||
VariablesDetail.propTypes = {
|
||||
value: string.isRequired,
|
||||
label: string.isRequired,
|
||||
label: node.isRequired,
|
||||
rows: number,
|
||||
};
|
||||
VariablesDetail.defaultProps = {
|
||||
|
||||
@ -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 }) => (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -72,6 +72,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
@ -83,6 +84,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Team Roles"
|
||||
value={
|
||||
@ -133,6 +135,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
@ -144,6 +147,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Team Roles"
|
||||
value={
|
||||
@ -217,6 +221,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
@ -228,6 +233,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
stacked={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Team Roles"
|
||||
value={
|
||||
@ -400,6 +406,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
data-pf-content={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
@ -573,6 +580,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
data-pf-content={true}
|
||||
>
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
label="Team Roles"
|
||||
value={
|
||||
|
||||
@ -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,41 +23,43 @@ 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 (
|
||||
<SelectableItem
|
||||
onClick={onClick}
|
||||
onKeyPress={onClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
data-cy={dataCy}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<Indicator isSelected={isSelected} />
|
||||
<Label>{label}</Label>
|
||||
</SelectableItem>
|
||||
);
|
||||
}
|
||||
function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
|
||||
return (
|
||||
<SelectableItem
|
||||
onClick={onClick}
|
||||
onKeyPress={onClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
data-cy={dataCy}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<Indicator isSelected={isSelected} />
|
||||
<Contents>
|
||||
<b>{label}</b>
|
||||
<Description>{description}</Description>
|
||||
</Contents>
|
||||
</SelectableItem>
|
||||
);
|
||||
}
|
||||
|
||||
SelectableCard.propTypes = {
|
||||
label: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
};
|
||||
|
||||
SelectableCard.defaultProps = {
|
||||
label: '',
|
||||
description: '',
|
||||
isSelected: false,
|
||||
};
|
||||
|
||||
1
awx/ui_next/src/components/SelectableCard/index.js
Normal file
1
awx/ui_next/src/components/SelectableCard/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './SelectableCard';
|
||||
@ -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;
|
||||
`;
|
||||
|
||||
@ -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} />)`
|
||||
|
||||
9
awx/ui_next/src/components/Wizard/Wizard.jsx
Normal file
9
awx/ui_next/src/components/Wizard/Wizard.jsx
Normal file
@ -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;
|
||||
}
|
||||
`;
|
||||
15
awx/ui_next/src/components/Wizard/Wizard.test.jsx
Normal file
15
awx/ui_next/src/components/Wizard/Wizard.test.jsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import Wizard from './Wizard';
|
||||
|
||||
describe('Wizard', () => {
|
||||
test('renders the expected content', () => {
|
||||
const wrapper = mount(
|
||||
<Wizard
|
||||
title="Simple Wizard"
|
||||
steps={[{ name: 'Step 1', component: <p>Step 1</p> }]}
|
||||
/>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/Wizard/index.js
Normal file
1
awx/ui_next/src/components/Wizard/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Wizard';
|
||||
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { node, number } from 'prop-types';
|
||||
|
||||
const TooltipContents = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const TooltipArrow = styled.div`
|
||||
width: 10px;
|
||||
`;
|
||||
|
||||
const TooltipArrowOuter = styled.div`
|
||||
border-bottom: 10px solid transparent;
|
||||
border-right: 10px solid #c4c4c4;
|
||||
border-top: 10px solid transparent;
|
||||
height: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
top: calc(50% - 10px);
|
||||
width: 0;
|
||||
`;
|
||||
|
||||
const TooltipArrowInner = styled.div`
|
||||
border-bottom: 10px solid transparent;
|
||||
border-right: 10px solid white;
|
||||
border-top: 10px solid transparent;
|
||||
height: 0;
|
||||
left: 2px;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
top: calc(50% - 10px);
|
||||
width: 0;
|
||||
`;
|
||||
|
||||
const TooltipActions = styled.div`
|
||||
background-color: white;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #c4c4c4;
|
||||
padding: 5px;
|
||||
`;
|
||||
|
||||
function WorkflowActionTooltip({ actions, pointX, pointY }) {
|
||||
const tipHeight = 25 * actions.length + 5 * actions.length - 1 + 10;
|
||||
return (
|
||||
<foreignObject
|
||||
x={pointX}
|
||||
y={Number(pointY) - tipHeight / 2}
|
||||
width="52"
|
||||
height={tipHeight}
|
||||
>
|
||||
<TooltipContents>
|
||||
<TooltipArrow>
|
||||
<TooltipArrowOuter />
|
||||
<TooltipArrowInner />
|
||||
</TooltipArrow>
|
||||
<TooltipActions>{actions}</TooltipActions>
|
||||
</TooltipContents>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowActionTooltip.propTypes = {
|
||||
actions: node.isRequired,
|
||||
pointX: number.isRequired,
|
||||
pointY: number.isRequired,
|
||||
};
|
||||
|
||||
export default WorkflowActionTooltip;
|
||||
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import WorkflowActionTooltip from './WorkflowActionTooltip';
|
||||
|
||||
describe('WorkflowActionTooltip', () => {
|
||||
test('successfully mounts', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowActionTooltip actions={[]} pointX={0} pointY={0} />
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { func } from 'prop-types';
|
||||
|
||||
const TooltipItem = styled.div`
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
height: 25px;
|
||||
justify-content: center;
|
||||
width: 25px;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background-color: #c4c4c4;
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
function WorkflowActionTooltipItem({
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) {
|
||||
return (
|
||||
<TooltipItem
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</TooltipItem>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowActionTooltipItem.propTypes = {
|
||||
onClick: func,
|
||||
onMouseEnter: func,
|
||||
onMouseLeave: func,
|
||||
};
|
||||
|
||||
WorkflowActionTooltipItem.defaultProps = {
|
||||
onClick: () => {},
|
||||
onMouseEnter: () => {},
|
||||
onMouseLeave: () => {},
|
||||
};
|
||||
|
||||
export default WorkflowActionTooltipItem;
|
||||
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import WorkflowActionTooltipItem from './WorkflowActionTooltipItem';
|
||||
|
||||
describe('WorkflowActionTooltipItem', () => {
|
||||
test('successfully mounts', () => {
|
||||
const wrapper = mount(<WorkflowActionTooltipItem />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -1,29 +1,28 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Outer = styled.div`
|
||||
position: relative;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Inner = styled.div`
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
background-color: #383f44;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 2px;
|
||||
color: white;
|
||||
left: 10px;
|
||||
max-width: 300px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
`;
|
||||
|
||||
function WorkflowHelp({ children }) {
|
||||
return (
|
||||
<Fragment>
|
||||
<Outer>
|
||||
<Inner>{children}</Inner>
|
||||
</Outer>
|
||||
</Fragment>
|
||||
<Outer>
|
||||
<Inner>{children}</Inner>
|
||||
</Outer>
|
||||
);
|
||||
}
|
||||
|
||||
10
awx/ui_next/src/components/Workflow/WorkflowHelp.test.jsx
Normal file
10
awx/ui_next/src/components/Workflow/WorkflowHelp.test.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import WorkflowHelp from './WorkflowHelp';
|
||||
|
||||
describe('WorkflowHelp', () => {
|
||||
test('successfully mounts', () => {
|
||||
const wrapper = mount(<WorkflowHelp />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
133
awx/ui_next/src/components/Workflow/WorkflowLegend.jsx
Normal file
133
awx/ui_next/src/components/Workflow/WorkflowLegend.jsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { WorkflowDispatchContext } from '@contexts/Workflow';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
PauseIcon,
|
||||
TimesIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background-color: white;
|
||||
border: 1px solid #c7c7c7;
|
||||
margin-left: 20px;
|
||||
min-width: 100px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
border-bottom: 1px solid #c7c7c7;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Legend = styled.ul`
|
||||
padding: 5px 10px;
|
||||
|
||||
li {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 5px 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
const NodeTypeLetter = styled.div`
|
||||
background-color: #393f43;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin-right: 10px;
|
||||
text-align: center;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)`
|
||||
color: #f0ad4d;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const Link = styled.div`
|
||||
height: 5px;
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const SuccessLink = styled(Link)`
|
||||
background-color: #5cb85c;
|
||||
`;
|
||||
|
||||
const FailureLink = styled(Link)`
|
||||
background-color: #d9534f;
|
||||
`;
|
||||
|
||||
const AlwaysLink = styled(Link)`
|
||||
background-color: #337ab7;
|
||||
`;
|
||||
|
||||
const Close = styled(TimesIcon)`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 15px;
|
||||
`;
|
||||
|
||||
function WorkflowLegend({ i18n }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Header>
|
||||
<b>{i18n._(t`Legend`)}</b>
|
||||
<Close onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })} />
|
||||
</Header>
|
||||
<Legend>
|
||||
<li>
|
||||
<NodeTypeLetter>JT</NodeTypeLetter>
|
||||
<span>{i18n._(t`Job Template`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<NodeTypeLetter>W</NodeTypeLetter>
|
||||
<span>{i18n._(t`Workflow`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<NodeTypeLetter>I</NodeTypeLetter>
|
||||
<span>{i18n._(t`Inventory Sync`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<NodeTypeLetter>P</NodeTypeLetter>
|
||||
<span>{i18n._(t`Project Sync`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<NodeTypeLetter>
|
||||
<PauseIcon />
|
||||
</NodeTypeLetter>
|
||||
<span>{i18n._(t`Approval`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<StyledExclamationTriangleIcon />
|
||||
<span>{i18n._(t`Warning`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<SuccessLink />
|
||||
<span>{i18n._(t`On Success`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<FailureLink />
|
||||
<span>{i18n._(t`On Failure`)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<AlwaysLink />
|
||||
<span>{i18n._(t`Always`)}</span>
|
||||
</li>
|
||||
</Legend>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(WorkflowLegend);
|
||||
10
awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx
Normal file
10
awx/ui_next/src/components/Workflow/WorkflowLegend.test.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import WorkflowLegend from './WorkflowLegend';
|
||||
|
||||
describe('WorkflowLegend', () => {
|
||||
test('renders the expected content', () => {
|
||||
const wrapper = mountWithContexts(<WorkflowLegend onClose={() => {}} />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
50
awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx
Normal file
50
awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { shape } from 'prop-types';
|
||||
|
||||
const GridDL = styled.dl`
|
||||
column-gap: 15px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content;
|
||||
row-gap: 0px;
|
||||
dt {
|
||||
grid-column-start: 1;
|
||||
}
|
||||
dd {
|
||||
grid-column-start: 2;
|
||||
}
|
||||
`;
|
||||
|
||||
function WorkflowLinkHelp({ link, i18n }) {
|
||||
let linkType;
|
||||
switch (link.linkType) {
|
||||
case 'always':
|
||||
linkType = i18n._(t`Always`);
|
||||
break;
|
||||
case 'success':
|
||||
linkType = i18n._(t`On Success`);
|
||||
break;
|
||||
case 'failure':
|
||||
linkType = i18n._(t`On Failure`);
|
||||
break;
|
||||
default:
|
||||
linkType = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<GridDL>
|
||||
<dt>
|
||||
<b>{i18n._(t`Run`)}</b>
|
||||
</dt>
|
||||
<dd id="workflow-link-help-type">{linkType}</dd>
|
||||
</GridDL>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowLinkHelp.propTypes = {
|
||||
link: shape().isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowLinkHelp);
|
||||
@ -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(<WorkflowLinkHelp link={{}} />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('renders the expected content for an on success link', () => {
|
||||
const link = {
|
||||
linkType: 'success',
|
||||
};
|
||||
const wrapper = mountWithContexts(<WorkflowLinkHelp link={link} />);
|
||||
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(<WorkflowLinkHelp link={link} />);
|
||||
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(<WorkflowLinkHelp link={link} />);
|
||||
expect(wrapper.find('#workflow-link-help-type').text()).toBe('Always');
|
||||
});
|
||||
});
|
||||
174
awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx
Normal file
174
awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx
Normal file
@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
|
||||
import { shape } from 'prop-types';
|
||||
import { secondsToHHMMSS } from '@util/dates';
|
||||
|
||||
const GridDL = styled.dl`
|
||||
column-gap: 15px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content;
|
||||
row-gap: 0px;
|
||||
dt {
|
||||
grid-column-start: 1;
|
||||
}
|
||||
dd {
|
||||
grid-column-start: 2;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResourceDeleted = styled.p`
|
||||
margin-bottom: ${props => (props.job ? '10px' : '0px')};
|
||||
`;
|
||||
|
||||
const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)`
|
||||
color: #f0ad4d;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
function WorkflowNodeHelp({ node, i18n }) {
|
||||
let nodeType;
|
||||
if (node.unifiedJobTemplate || node.job) {
|
||||
const type = node.unifiedJobTemplate
|
||||
? node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.type
|
||||
: node.job.type;
|
||||
switch (type) {
|
||||
case 'job_template':
|
||||
case 'job':
|
||||
nodeType = i18n._(t`Job Template`);
|
||||
break;
|
||||
case 'workflow_job_template':
|
||||
case 'workflow_job':
|
||||
nodeType = i18n._(t`Workflow Job Template`);
|
||||
break;
|
||||
case 'project':
|
||||
case 'project_update':
|
||||
nodeType = i18n._(t`Project Update`);
|
||||
break;
|
||||
case 'inventory_source':
|
||||
case 'inventory_update':
|
||||
nodeType = i18n._(t`Inventory Update`);
|
||||
break;
|
||||
case 'workflow_approval_template':
|
||||
case 'workflow_approval':
|
||||
nodeType = i18n._(t`Workflow Approval`);
|
||||
break;
|
||||
default:
|
||||
nodeType = '';
|
||||
}
|
||||
}
|
||||
|
||||
let jobStatus;
|
||||
if (node.job) {
|
||||
switch (node.job.status) {
|
||||
case 'new':
|
||||
jobStatus = i18n._(t`New`);
|
||||
break;
|
||||
case 'pending':
|
||||
jobStatus = i18n._(t`Pending`);
|
||||
break;
|
||||
case 'waiting':
|
||||
jobStatus = i18n._(t`Waiting`);
|
||||
break;
|
||||
case 'running':
|
||||
jobStatus = i18n._(t`Running`);
|
||||
break;
|
||||
case 'successful':
|
||||
jobStatus = i18n._(t`Successful`);
|
||||
break;
|
||||
case 'failed':
|
||||
jobStatus = i18n._(t`Failed`);
|
||||
break;
|
||||
case 'error':
|
||||
jobStatus = i18n._(t`Error`);
|
||||
break;
|
||||
case 'canceled':
|
||||
jobStatus = i18n._(t`Canceled`);
|
||||
break;
|
||||
case 'never updated':
|
||||
jobStatus = i18n._(t`Never Updated`);
|
||||
break;
|
||||
case 'ok':
|
||||
jobStatus = i18n._(t`OK`);
|
||||
break;
|
||||
case 'missing':
|
||||
jobStatus = i18n._(t`Missing`);
|
||||
break;
|
||||
case 'none':
|
||||
jobStatus = i18n._(t`None`);
|
||||
break;
|
||||
case 'updating':
|
||||
jobStatus = i18n._(t`Updating`);
|
||||
break;
|
||||
default:
|
||||
jobStatus = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!node.unifiedJobTemplate &&
|
||||
(!node.job || node.job.type !== 'workflow_approval') && (
|
||||
<>
|
||||
<ResourceDeleted job={node.job}>
|
||||
<StyledExclamationTriangleIcon />
|
||||
<Trans>
|
||||
The resource associated with this node has been deleted.
|
||||
</Trans>
|
||||
</ResourceDeleted>
|
||||
</>
|
||||
)}
|
||||
{node.job && (
|
||||
<GridDL>
|
||||
<dt>
|
||||
<b>{i18n._(t`Name`)}</b>
|
||||
</dt>
|
||||
<dd id="workflow-node-help-name">{node.job.name}</dd>
|
||||
<dt>
|
||||
<b>{i18n._(t`Type`)}</b>
|
||||
</dt>
|
||||
<dd id="workflow-node-help-type">{nodeType}</dd>
|
||||
<dt>
|
||||
<b>{i18n._(t`Job Status`)}</b>
|
||||
</dt>
|
||||
<dd id="workflow-node-help-status">{jobStatus}</dd>
|
||||
{typeof node.job.elapsed === 'number' && (
|
||||
<>
|
||||
<dt>
|
||||
<b>{i18n._(t`Elapsed`)}</b>
|
||||
</dt>
|
||||
<dd id="workflow-node-help-elapsed">
|
||||
{secondsToHHMMSS(node.job.elapsed)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</GridDL>
|
||||
)}
|
||||
{node.unifiedJobTemplate && !node.job && (
|
||||
<GridDL>
|
||||
<dt>
|
||||
<b>{i18n._(t`Name`)}</b>
|
||||
</dt>
|
||||
<dd id="workflow-node-help-name">{node.unifiedJobTemplate.name}</dd>
|
||||
<dt>
|
||||
<b>{i18n._(t`Type`)}</b>
|
||||
</dt>
|
||||
<dd id="workflow-node-help-type">{nodeType}</dd>
|
||||
</GridDL>
|
||||
)}
|
||||
{node.job && node.job.type !== 'workflow_approval' && (
|
||||
<p css="margin-top: 10px">{i18n._(t`Click to view job details`)}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowNodeHelp.propTypes = {
|
||||
node: shape().isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowNodeHelp);
|
||||
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import WorkflowNodeHelp from './WorkflowNodeHelp';
|
||||
|
||||
describe('WorkflowNodeHelp', () => {
|
||||
test('successfully mounts', () => {
|
||||
const wrapper = mountWithContexts(<WorkflowNodeHelp node={{}} />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('renders the expected content for a completed job template job', () => {
|
||||
const node = {
|
||||
job: {
|
||||
name: 'Foo Job Template',
|
||||
elapsed: 9000,
|
||||
status: 'successful',
|
||||
type: 'job',
|
||||
},
|
||||
unifiedJobTemplate: {
|
||||
name: 'Foo Job Template',
|
||||
unified_job_type: 'job',
|
||||
},
|
||||
};
|
||||
const wrapper = mountWithContexts(<WorkflowNodeHelp node={node} />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { shape } from 'prop-types';
|
||||
import { PauseIcon } from '@patternfly/react-icons';
|
||||
|
||||
const NodeTypeLetter = styled.div`
|
||||
background-color: #393f43;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const CenteredPauseIcon = styled(PauseIcon)`
|
||||
vertical-align: middle !important;
|
||||
`;
|
||||
|
||||
function WorkflowNodeTypeLetter({ node }) {
|
||||
let nodeTypeLetter;
|
||||
if (
|
||||
(node.unifiedJobTemplate &&
|
||||
(node.unifiedJobTemplate.type ||
|
||||
node.unifiedJobTemplate.unified_job_type)) ||
|
||||
(node.job && node.job.type)
|
||||
) {
|
||||
const ujtType = node.unifiedJobTemplate
|
||||
? node.unifiedJobTemplate.type || node.unifiedJobTemplate.unified_job_type
|
||||
: node.job.type;
|
||||
switch (ujtType) {
|
||||
case 'job_template':
|
||||
case 'job':
|
||||
nodeTypeLetter = 'JT';
|
||||
break;
|
||||
case 'project':
|
||||
case 'project_update':
|
||||
nodeTypeLetter = 'P';
|
||||
break;
|
||||
case 'inventory_source':
|
||||
case 'inventory_update':
|
||||
nodeTypeLetter = 'I';
|
||||
break;
|
||||
case 'workflow_job_template':
|
||||
case 'workflow_job':
|
||||
nodeTypeLetter = 'W';
|
||||
break;
|
||||
case 'workflow_approval_template':
|
||||
case 'workflow_approval':
|
||||
nodeTypeLetter = <CenteredPauseIcon />;
|
||||
break;
|
||||
default:
|
||||
nodeTypeLetter = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<foreignObject y="50" x="-10" height="20" width="20">
|
||||
<NodeTypeLetter id={`node-${node.id}-type-letter`}>
|
||||
{nodeTypeLetter}
|
||||
</NodeTypeLetter>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowNodeTypeLetter.propTypes = {
|
||||
node: shape().isRequired,
|
||||
};
|
||||
|
||||
export default WorkflowNodeTypeLetter;
|
||||
@ -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(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { type: 'job_template' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('JT');
|
||||
});
|
||||
test('renders JT when unified_job_type=job', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { unified_job_type: 'job' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('JT');
|
||||
});
|
||||
test('renders P when type=project', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { type: 'project' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('P');
|
||||
});
|
||||
test('renders P when unified_job_type=project_update', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { unified_job_type: 'project_update' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('P');
|
||||
});
|
||||
test('renders I when type=inventory_source', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { type: 'inventory_source' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('I');
|
||||
});
|
||||
test('renders I when unified_job_type=inventory_update', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{
|
||||
unifiedJobTemplate: { unified_job_type: 'inventory_update' },
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('I');
|
||||
});
|
||||
test('renders W when type=workflow_job_template', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { type: 'workflow_job_template' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('W');
|
||||
});
|
||||
test('renders W when unified_job_type=workflow_job', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { unified_job_type: 'workflow_job' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.text()).toBe('W');
|
||||
});
|
||||
test('renders puse icon when type=workflow_approval_template', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{ unifiedJobTemplate: { type: 'workflow_approval_template' } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.containsMatchingElement(<PauseIcon />));
|
||||
});
|
||||
test('renders W when unified_job_type=workflow_approval', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowNodeTypeLetter
|
||||
node={{
|
||||
unifiedJobTemplate: { unified_job_type: 'workflow_approval' },
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
expect(wrapper.containsMatchingElement(<PauseIcon />));
|
||||
});
|
||||
});
|
||||
87
awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx
Normal file
87
awx/ui_next/src/components/Workflow/WorkflowStartNode.jsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useContext, useRef, useState } from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import styled from 'styled-components';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { bool, func } from 'prop-types';
|
||||
import { PlusIcon } from '@patternfly/react-icons';
|
||||
import { constants as wfConstants } from '@components/Workflow/WorkflowUtils';
|
||||
import {
|
||||
WorkflowActionTooltip,
|
||||
WorkflowActionTooltipItem,
|
||||
} from '@components/Workflow';
|
||||
|
||||
const StartG = styled.g`
|
||||
pointer-events: ${props => (props.ignorePointerEvents ? 'none' : 'auto')};
|
||||
`;
|
||||
|
||||
function WorkflowStartNode({ i18n, onUpdateHelpText, showActionTooltip }) {
|
||||
const ref = useRef(null);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
const { addingLink, nodePositions } = useContext(WorkflowStateContext);
|
||||
|
||||
const handleNodeMouseEnter = () => {
|
||||
ref.current.parentNode.appendChild(ref.current);
|
||||
setHovering(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<StartG
|
||||
id="node-1"
|
||||
ignorePointerEvents={addingLink}
|
||||
onMouseEnter={handleNodeMouseEnter}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
ref={ref}
|
||||
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>
|
||||
{showActionTooltip && hovering && (
|
||||
<WorkflowActionTooltip
|
||||
actions={[
|
||||
<WorkflowActionTooltipItem
|
||||
id="node-add"
|
||||
key="add"
|
||||
onMouseEnter={() => onUpdateHelpText(i18n._(t`Add a new node`))}
|
||||
onMouseLeave={() => onUpdateHelpText(null)}
|
||||
onClick={() => {
|
||||
onUpdateHelpText(null);
|
||||
setHovering(false);
|
||||
dispatch({ type: 'START_ADD_NODE', sourceNodeId: 1 });
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</WorkflowActionTooltipItem>,
|
||||
]}
|
||||
pointX={wfConstants.rootW}
|
||||
pointY={wfConstants.rootH / 2 + 10}
|
||||
/>
|
||||
)}
|
||||
</StartG>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowStartNode.propTypes = {
|
||||
showActionTooltip: bool.isRequired,
|
||||
onUpdateHelpText: func,
|
||||
};
|
||||
|
||||
WorkflowStartNode.defaultProps = {
|
||||
onUpdateHelpText: () => {},
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowStartNode);
|
||||
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { WorkflowStateContext } from '@contexts/Workflow';
|
||||
import WorkflowStartNode from './WorkflowStartNode';
|
||||
|
||||
const nodePositions = {
|
||||
1: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe('WorkflowStartNode', () => {
|
||||
test('mounts successfully', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowStartNode
|
||||
nodePositions={nodePositions}
|
||||
showActionTooltip={false}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('tooltip shown on hover', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowStartNode nodePositions={nodePositions} showActionTooltip />
|
||||
</WorkflowStateContext.Provider>
|
||||
</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);
|
||||
});
|
||||
});
|
||||
189
awx/ui_next/src/components/Workflow/WorkflowTools.jsx
Normal file
189
awx/ui_next/src/components/Workflow/WorkflowTools.jsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { WorkflowDispatchContext } from '@contexts/Workflow';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { func, number } from 'prop-types';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
CaretUpIcon,
|
||||
DesktopIcon,
|
||||
HomeIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
TimesIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background-color: white;
|
||||
border: 1px solid #c7c7c7;
|
||||
height: 215px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
border-bottom: 1px solid #c7c7c7;
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
const Pan = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const PanCenter = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Tools = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Close = styled(TimesIcon)`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 15px;
|
||||
`;
|
||||
|
||||
function WorkflowTools({
|
||||
i18n,
|
||||
onFitGraph,
|
||||
onPan,
|
||||
onPanToMiddle,
|
||||
onZoomChange,
|
||||
zoomPercentage,
|
||||
}) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
const zoomIn = () => {
|
||||
const newScale =
|
||||
Math.ceil((zoomPercentage + 10) / 10) * 10 < 200
|
||||
? Math.ceil((zoomPercentage + 10) / 10) * 10
|
||||
: 200;
|
||||
onZoomChange(newScale / 100);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
const newScale =
|
||||
Math.floor((zoomPercentage - 10) / 10) * 10 > 10
|
||||
? Math.floor((zoomPercentage - 10) / 10) * 10
|
||||
: 10;
|
||||
onZoomChange(newScale / 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Header>
|
||||
<b>{i18n._(t`Tools`)}</b>
|
||||
<Close onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })} />
|
||||
</Header>
|
||||
<Tools>
|
||||
<Tooltip
|
||||
content={i18n._(t`Fit the graph to the available screen size`)}
|
||||
position="bottom"
|
||||
>
|
||||
<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()}
|
||||
>
|
||||
<MinusIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<input
|
||||
id="zoom-slider"
|
||||
max="200"
|
||||
min="10"
|
||||
onChange={event =>
|
||||
onZoomChange(parseInt(event.target.value, 10) / 100)
|
||||
}
|
||||
step="10"
|
||||
type="range"
|
||||
value={zoomPercentage}
|
||||
/>
|
||||
<Tooltip content={i18n._(t`Zoom In`)} position="bottom">
|
||||
<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')}
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<PanCenter>
|
||||
<Tooltip content={i18n._(t`Pan Up`)} position="top">
|
||||
<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"
|
||||
>
|
||||
<Button variant="tertiary" onClick={() => onPanToMiddle()}>
|
||||
<HomeIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={i18n._(t`Pan Down`)} position="bottom">
|
||||
<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')}
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Pan>
|
||||
</Tools>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowTools.propTypes = {
|
||||
onFitGraph: func.isRequired,
|
||||
onPan: func.isRequired,
|
||||
onPanToMiddle: func.isRequired,
|
||||
onZoomChange: func.isRequired,
|
||||
zoomPercentage: number.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowTools);
|
||||
45
awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx
Normal file
45
awx/ui_next/src/components/Workflow/WorkflowTools.test.jsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import WorkflowTools from './WorkflowTools';
|
||||
|
||||
describe('WorkflowTools', () => {
|
||||
test('renders the expected content', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowTools
|
||||
onClose={() => {}}
|
||||
onFitGraph={() => {}}
|
||||
onPan={() => {}}
|
||||
onPanToMiddle={() => {}}
|
||||
onZoomChange={() => {}}
|
||||
zoomPercentage={100}
|
||||
/>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('clicking zoom/pan buttons passes callback correct values', () => {
|
||||
const pan = jest.fn();
|
||||
const zoomChange = jest.fn();
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowTools
|
||||
onClose={() => {}}
|
||||
onFitGraph={() => {}}
|
||||
onPan={pan}
|
||||
onPanToMiddle={() => {}}
|
||||
onZoomChange={zoomChange}
|
||||
zoomPercentage={95.7}
|
||||
/>
|
||||
);
|
||||
wrapper.find('PlusIcon').simulate('click');
|
||||
expect(zoomChange).toHaveBeenCalledWith(1.1);
|
||||
wrapper.find('MinusIcon').simulate('click');
|
||||
expect(zoomChange).toHaveBeenCalledWith(0.8);
|
||||
wrapper.find('CaretLeftIcon').simulate('click');
|
||||
expect(pan).toHaveBeenCalledWith('left');
|
||||
wrapper.find('CaretUpIcon').simulate('click');
|
||||
expect(pan).toHaveBeenCalledWith('up');
|
||||
wrapper.find('CaretRightIcon').simulate('click');
|
||||
expect(pan).toHaveBeenCalledWith('right');
|
||||
wrapper.find('CaretDownIcon').simulate('click');
|
||||
expect(pan).toHaveBeenCalledWith('down');
|
||||
});
|
||||
});
|
||||
196
awx/ui_next/src/components/Workflow/WorkflowUtils.jsx
Normal file
196
awx/ui_next/src/components/Workflow/WorkflowUtils.jsx
Normal file
@ -0,0 +1,196 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import * as d3 from 'd3';
|
||||
import * as dagre from 'dagre';
|
||||
|
||||
const normalizeY = (nodePositions, y) => y - nodePositions[1].y;
|
||||
|
||||
export const constants = {
|
||||
nodeW: 180,
|
||||
nodeH: 60,
|
||||
rootW: 72,
|
||||
rootH: 40,
|
||||
};
|
||||
|
||||
export function getScaleAndOffsetToFit(
|
||||
gBoundingClientRect,
|
||||
svgBoundingClientRect,
|
||||
gBBoxDimensions,
|
||||
currentScale
|
||||
) {
|
||||
gBoundingClientRect.height /= currentScale;
|
||||
gBoundingClientRect.width /= currentScale;
|
||||
|
||||
// For some reason the root width needs to be added?
|
||||
gBoundingClientRect.width += constants.rootW;
|
||||
|
||||
const scaleNeededForMaxHeight =
|
||||
svgBoundingClientRect.height / gBoundingClientRect.height;
|
||||
const scaleNeededForMaxWidth =
|
||||
svgBoundingClientRect.width / gBoundingClientRect.width;
|
||||
const lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth);
|
||||
|
||||
let scaleToFit;
|
||||
let yTranslate;
|
||||
if (lowerScale < 0.1 || lowerScale > 2) {
|
||||
scaleToFit = lowerScale < 0.1 ? 0.1 : 2;
|
||||
yTranslate =
|
||||
svgBoundingClientRect.height / 2 - (constants.nodeH * scaleToFit) / 2;
|
||||
} else {
|
||||
scaleToFit = Math.floor(lowerScale * 1000) / 1000;
|
||||
yTranslate =
|
||||
(svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) /
|
||||
2 -
|
||||
(gBBoxDimensions.y / currentScale) * scaleToFit;
|
||||
}
|
||||
|
||||
return [scaleToFit, yTranslate];
|
||||
}
|
||||
|
||||
export function generateLine(points) {
|
||||
const line = d3
|
||||
.line()
|
||||
.x(d => {
|
||||
return d.x;
|
||||
})
|
||||
.y(d => {
|
||||
return d.y;
|
||||
});
|
||||
|
||||
return line(points);
|
||||
}
|
||||
|
||||
export function getLinePoints(link, nodePositions) {
|
||||
const sourceX =
|
||||
nodePositions[link.source.id].x + nodePositions[link.source.id].width + 1;
|
||||
let sourceY =
|
||||
normalizeY(nodePositions, nodePositions[link.source.id].y) +
|
||||
nodePositions[link.source.id].height / 2;
|
||||
const targetX = nodePositions[link.target.id].x - 1;
|
||||
const targetY =
|
||||
normalizeY(nodePositions, nodePositions[link.target.id].y) +
|
||||
nodePositions[link.target.id].height / 2;
|
||||
|
||||
// There's something off with the math on the root node...
|
||||
if (link.source.id === 1) {
|
||||
sourceY += 10;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
x: sourceX,
|
||||
y: sourceY,
|
||||
},
|
||||
{
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getLinkOverlayPoints(link, nodePositions) {
|
||||
const sourceX =
|
||||
nodePositions[link.source.id].x + nodePositions[link.source.id].width + 1;
|
||||
let sourceY =
|
||||
normalizeY(nodePositions, nodePositions[link.source.id].y) +
|
||||
nodePositions[link.source.id].height / 2;
|
||||
const targetX = nodePositions[link.target.id].x - 1;
|
||||
const targetY =
|
||||
normalizeY(nodePositions, nodePositions[link.target.id].y) +
|
||||
nodePositions[link.target.id].height / 2;
|
||||
|
||||
// There's something off with the math on the root node...
|
||||
if (link.source.id === 1) {
|
||||
sourceY += 10;
|
||||
}
|
||||
const slope = (targetY - sourceY) / (targetX - sourceX);
|
||||
const yIntercept = targetY - slope * targetX;
|
||||
const orthogonalDistance = 8;
|
||||
|
||||
const pt1 = [
|
||||
targetX,
|
||||
slope * targetX +
|
||||
yIntercept +
|
||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||
].join(',');
|
||||
const pt2 = [
|
||||
sourceX,
|
||||
slope * sourceX +
|
||||
yIntercept +
|
||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||
].join(',');
|
||||
const pt3 = [
|
||||
sourceX,
|
||||
slope * sourceX +
|
||||
yIntercept -
|
||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||
].join(',');
|
||||
const pt4 = [
|
||||
targetX,
|
||||
slope * targetX +
|
||||
yIntercept -
|
||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||
].join(',');
|
||||
|
||||
return [pt1, pt2, pt3, pt4].join(' ');
|
||||
}
|
||||
|
||||
export function layoutGraph(nodes, links) {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 120 });
|
||||
|
||||
// This is needed for Dagre
|
||||
g.setDefaultEdgeLabel(() => {
|
||||
return {};
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (node.id === 1) {
|
||||
g.setNode(node.id, {
|
||||
label: '',
|
||||
width: constants.rootW,
|
||||
height: constants.rootH,
|
||||
});
|
||||
} else {
|
||||
g.setNode(node.id, {
|
||||
label: '',
|
||||
width: constants.nodeW,
|
||||
height: constants.nodeH,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
links.forEach(link => {
|
||||
g.setEdge(link.source.id, link.target.id);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
export function getTranslatePointsForZoom(
|
||||
svgBoundingClientRect,
|
||||
currentScaleAndOffset,
|
||||
newScale
|
||||
) {
|
||||
const origScale = currentScaleAndOffset.k;
|
||||
const unscaledOffsetX =
|
||||
(currentScaleAndOffset.x +
|
||||
(svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) /
|
||||
2) /
|
||||
origScale;
|
||||
const unscaledOffsetY =
|
||||
(currentScaleAndOffset.y +
|
||||
(svgBoundingClientRect.height * origScale -
|
||||
svgBoundingClientRect.height) /
|
||||
2) /
|
||||
origScale;
|
||||
const translateX =
|
||||
unscaledOffsetX * newScale -
|
||||
(newScale * svgBoundingClientRect.width - svgBoundingClientRect.width) / 2;
|
||||
const translateY =
|
||||
unscaledOffsetY * newScale -
|
||||
(newScale * svgBoundingClientRect.height - svgBoundingClientRect.height) /
|
||||
2;
|
||||
return [translateX, translateY];
|
||||
}
|
||||
225
awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx
Normal file
225
awx/ui_next/src/components/Workflow/WorkflowUtils.test.jsx
Normal file
@ -0,0 +1,225 @@
|
||||
import {
|
||||
getScaleAndOffsetToFit,
|
||||
generateLine,
|
||||
getLinePoints,
|
||||
getLinkOverlayPoints,
|
||||
layoutGraph,
|
||||
getTranslatePointsForZoom,
|
||||
} from './WorkflowUtils';
|
||||
|
||||
describe('getScaleAndOffsetToFit', () => {
|
||||
const gBoundingClientRect = {
|
||||
x: 36,
|
||||
y: 11,
|
||||
width: 798,
|
||||
height: 160,
|
||||
top: 11,
|
||||
right: 834,
|
||||
bottom: 171,
|
||||
left: 36,
|
||||
};
|
||||
const svgBoundingClientRect = {
|
||||
x: 0,
|
||||
y: 56,
|
||||
width: 1680,
|
||||
height: 455,
|
||||
top: 56,
|
||||
right: 1680,
|
||||
bottom: 511,
|
||||
left: 0,
|
||||
};
|
||||
const gBBoxDimensions = {
|
||||
x: 36,
|
||||
y: -45,
|
||||
width: 726,
|
||||
height: 160,
|
||||
};
|
||||
const currentScale = 1;
|
||||
test('returns correct scale and y-offset for zooming the graph to best fit the available space', () => {
|
||||
expect(
|
||||
getScaleAndOffsetToFit(
|
||||
gBoundingClientRect,
|
||||
svgBoundingClientRect,
|
||||
gBBoxDimensions,
|
||||
currentScale
|
||||
)
|
||||
).toEqual([1.931, 159.91499999999996]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLine', () => {
|
||||
test('returns correct svg path string', () => {
|
||||
expect(
|
||||
generateLine([
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
y: 10,
|
||||
},
|
||||
])
|
||||
).toEqual('M0,0L10,10');
|
||||
expect(
|
||||
generateLine([
|
||||
{
|
||||
x: 900,
|
||||
y: 44,
|
||||
},
|
||||
{
|
||||
x: 5000,
|
||||
y: 359,
|
||||
},
|
||||
])
|
||||
).toEqual('M900,44L5000,359');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinePoints', () => {
|
||||
const link = {
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: 2,
|
||||
},
|
||||
};
|
||||
const nodePositions = {
|
||||
1: {
|
||||
width: 72,
|
||||
height: 40,
|
||||
x: 36,
|
||||
y: 130,
|
||||
},
|
||||
2: {
|
||||
width: 180,
|
||||
height: 60,
|
||||
x: 282,
|
||||
y: 40,
|
||||
},
|
||||
};
|
||||
test('returns the correct endpoints of the line', () => {
|
||||
expect(getLinePoints(link, nodePositions)).toEqual([
|
||||
{ x: 109, y: 30 },
|
||||
{ x: 281, y: -60 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinkOverlayPoints', () => {
|
||||
const link = {
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: 2,
|
||||
},
|
||||
};
|
||||
const nodePositions = {
|
||||
1: {
|
||||
width: 72,
|
||||
height: 40,
|
||||
x: 36,
|
||||
y: 130,
|
||||
},
|
||||
2: {
|
||||
width: 180,
|
||||
height: 60,
|
||||
x: 282,
|
||||
y: 40,
|
||||
},
|
||||
};
|
||||
test('returns the four points of the polygon that will act as the overlay for the link', () => {
|
||||
expect(getLinkOverlayPoints(link, nodePositions)).toEqual(
|
||||
'281,-50.970992003685446 109,39.02900799631457 109,20.97099200368546 281,-69.02900799631456'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutGraph', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
},
|
||||
];
|
||||
const links = [
|
||||
{
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: 2,
|
||||
},
|
||||
target: {
|
||||
id: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: 4,
|
||||
},
|
||||
target: {
|
||||
id: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
test('returns the correct dimensions and positions for the nodes', () => {
|
||||
expect(layoutGraph(nodes, links)._nodes).toEqual({
|
||||
1: { height: 40, label: '', width: 72, x: 36, y: 75 },
|
||||
2: { height: 60, label: '', width: 180, x: 282, y: 30 },
|
||||
3: { height: 60, label: '', width: 180, x: 582, y: 75 },
|
||||
4: { height: 60, label: '', width: 180, x: 282, y: 120 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTranslatePointsForZoom', () => {
|
||||
const svgBoundingClientRect = {
|
||||
x: 0,
|
||||
y: 56,
|
||||
width: 1680,
|
||||
height: 455,
|
||||
top: 56,
|
||||
right: 1680,
|
||||
bottom: 511,
|
||||
left: 0,
|
||||
};
|
||||
const currentScaleAndOffset = {
|
||||
k: 2,
|
||||
x: 0,
|
||||
y: 167.5,
|
||||
};
|
||||
const newScale = 1.9;
|
||||
test('returns the correct translation point', () => {
|
||||
expect(
|
||||
getTranslatePointsForZoom(
|
||||
svgBoundingClientRect,
|
||||
currentScaleAndOffset,
|
||||
newScale
|
||||
)
|
||||
).toEqual([42, 170.5]);
|
||||
});
|
||||
});
|
||||
11
awx/ui_next/src/components/Workflow/index.js
Normal file
11
awx/ui_next/src/components/Workflow/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
export { default as WorkflowActionTooltip } from './WorkflowActionTooltip';
|
||||
export {
|
||||
default as WorkflowActionTooltipItem,
|
||||
} from './WorkflowActionTooltipItem';
|
||||
export { default as WorkflowHelp } from './WorkflowHelp';
|
||||
export { default as WorkflowLegend } from './WorkflowLegend';
|
||||
export { default as WorkflowLinkHelp } from './WorkflowLinkHelp';
|
||||
export { default as WorkflowNodeHelp } from './WorkflowNodeHelp';
|
||||
export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter';
|
||||
export { default as WorkflowStartNode } from './WorkflowStartNode';
|
||||
export { default as WorkflowTools } from './WorkflowTools';
|
||||
609
awx/ui_next/src/components/Workflow/workflowReducer.js
Normal file
609
awx/ui_next/src/components/Workflow/workflowReducer.js
Normal file
@ -0,0 +1,609 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
export function initReducer() {
|
||||
return {
|
||||
addLinkSourceNode: null,
|
||||
addLinkTargetNode: null,
|
||||
addNodeSource: null,
|
||||
addNodeTarget: null,
|
||||
addingLink: false,
|
||||
contentError: null,
|
||||
isLoading: true,
|
||||
linkToDelete: null,
|
||||
linkToEdit: null,
|
||||
links: [],
|
||||
nextNodeId: 0,
|
||||
nodePositions: null,
|
||||
nodes: [],
|
||||
nodeToDelete: null,
|
||||
nodeToEdit: null,
|
||||
showDeleteAllNodesModal: false,
|
||||
showLegend: false,
|
||||
showTools: false,
|
||||
showUnsavedChangesModal: false,
|
||||
unsavedChanges: false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function visualizerReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'CREATE_LINK':
|
||||
return createLink(state, action.linkType);
|
||||
case 'CREATE_NODE':
|
||||
return createNode(state, action.node);
|
||||
case 'CANCEL_LINK':
|
||||
case 'CANCEL_LINK_MODAL':
|
||||
return cancelLink(state);
|
||||
case 'CANCEL_NODE_MODAL':
|
||||
return {
|
||||
...state,
|
||||
addNodeSource: null,
|
||||
addNodeTarget: null,
|
||||
nodeToEdit: null,
|
||||
};
|
||||
case 'DELETE_ALL_NODES':
|
||||
return deleteAllNodes(state);
|
||||
case 'DELETE_LINK':
|
||||
return deleteLink(state);
|
||||
case 'DELETE_NODE':
|
||||
return deleteNode(state);
|
||||
case 'GENERATE_NODES_AND_LINKS':
|
||||
return generateNodesAndLinks(state, action.nodes, action.i18n);
|
||||
case 'RESET':
|
||||
return initReducer();
|
||||
case 'SELECT_SOURCE_FOR_LINKING':
|
||||
return selectSourceForLinking(state, action.node);
|
||||
case 'SET_ADD_LINK_TARGET_NODE':
|
||||
return { ...state, addLinkTargetNode: action.value };
|
||||
case 'SET_CONTENT_ERROR':
|
||||
return { ...state, contentError: action.value };
|
||||
case 'SET_IS_LOADING':
|
||||
return { ...state, isLoading: action.value };
|
||||
case 'SET_LINK_TO_DELETE':
|
||||
return { ...state, linkToDelete: action.value };
|
||||
case 'SET_LINK_TO_EDIT':
|
||||
return { ...state, linkToEdit: action.value };
|
||||
case 'SET_NODE_POSITIONS':
|
||||
return { ...state, nodePositions: action.value };
|
||||
case 'SET_NODE_TO_DELETE':
|
||||
return { ...state, nodeToDelete: action.value };
|
||||
case 'SET_NODE_TO_EDIT':
|
||||
return { ...state, nodeToEdit: action.value };
|
||||
case 'SET_NODE_TO_VIEW':
|
||||
return { ...state, nodeToView: action.value };
|
||||
case 'SET_SHOW_DELETE_ALL_NODES_MODAL':
|
||||
return { ...state, showDeleteAllNodesModal: action.value };
|
||||
case 'START_ADD_NODE':
|
||||
return {
|
||||
...state,
|
||||
addNodeSource: action.sourceNodeId,
|
||||
addNodeTarget: action.targetNodeId || null,
|
||||
};
|
||||
case 'START_DELETE_LINK':
|
||||
return startDeleteLink(state, action.link);
|
||||
case 'TOGGLE_DELETE_ALL_NODES_MODAL':
|
||||
return toggleDeleteAllNodesModal(state);
|
||||
case 'TOGGLE_LEGEND':
|
||||
return toggleLegend(state);
|
||||
case 'TOGGLE_TOOLS':
|
||||
return toggleTools(state);
|
||||
case 'TOGGLE_UNSAVED_CHANGES_MODAL':
|
||||
return toggleUnsavedChangesModal(state);
|
||||
case 'UPDATE_LINK':
|
||||
return updateLink(state, action.linkType);
|
||||
case 'UPDATE_NODE':
|
||||
return updateNode(state, action.node);
|
||||
default:
|
||||
throw new Error(`Unrecognized action type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createLink(state, linkType) {
|
||||
const { addLinkSourceNode, addLinkTargetNode, links, nodes } = state;
|
||||
const newLinks = [...links];
|
||||
const newNodes = [...nodes];
|
||||
|
||||
newNodes.forEach(node => {
|
||||
node.isInvalidLinkTarget = false;
|
||||
});
|
||||
|
||||
newLinks.push({
|
||||
source: { id: addLinkSourceNode.id },
|
||||
target: { id: addLinkTargetNode.id },
|
||||
linkType,
|
||||
});
|
||||
|
||||
newLinks.forEach((link, index) => {
|
||||
if (link.source.id === 1 && link.target.id === addLinkTargetNode.id) {
|
||||
newLinks.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
addLinkSourceNode: null,
|
||||
addLinkTargetNode: null,
|
||||
addingLink: false,
|
||||
linkToEdit: null,
|
||||
links: newLinks,
|
||||
nodes: newNodes,
|
||||
unsavedChanges: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createNode(state, node) {
|
||||
const { addNodeSource, addNodeTarget, links, nodes, nextNodeId } = state;
|
||||
const newNodes = [...nodes];
|
||||
const newLinks = [...links];
|
||||
|
||||
newNodes.push({
|
||||
id: nextNodeId,
|
||||
unifiedJobTemplate: node.nodeResource,
|
||||
isInvalidLinkTarget: false,
|
||||
});
|
||||
|
||||
// Ensures that root nodes appear to always run
|
||||
// after "START"
|
||||
if (addNodeSource === 1) {
|
||||
node.linkType = 'always';
|
||||
}
|
||||
|
||||
newLinks.push({
|
||||
source: { id: addNodeSource },
|
||||
target: { id: nextNodeId },
|
||||
linkType: node.linkType,
|
||||
});
|
||||
|
||||
if (addNodeTarget) {
|
||||
newLinks.forEach(linkToCompare => {
|
||||
if (
|
||||
linkToCompare.source.id === addNodeSource &&
|
||||
linkToCompare.target.id === addNodeTarget
|
||||
) {
|
||||
linkToCompare.source = { id: nextNodeId };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
addNodeSource: null,
|
||||
addNodeTarget: null,
|
||||
links: newLinks,
|
||||
nextNodeId: nextNodeId + 1,
|
||||
nodes: newNodes,
|
||||
unsavedChanges: true,
|
||||
};
|
||||
}
|
||||
|
||||
function cancelLink(state) {
|
||||
const { nodes } = state;
|
||||
const newNodes = [...nodes];
|
||||
|
||||
newNodes.forEach(node => {
|
||||
node.isInvalidLinkTarget = false;
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
addLinkSourceNode: null,
|
||||
addLinkTargetNode: null,
|
||||
addingLink: false,
|
||||
linkToEdit: null,
|
||||
nodes: newNodes,
|
||||
};
|
||||
}
|
||||
|
||||
function deleteAllNodes(state) {
|
||||
const { nodes } = state;
|
||||
return {
|
||||
...state,
|
||||
addLinkSourceNode: null,
|
||||
addLinkTargetNode: null,
|
||||
addingLink: false,
|
||||
links: [],
|
||||
nodes: nodes.map(node => {
|
||||
if (node.id !== 1) {
|
||||
node.isDeleted = true;
|
||||
}
|
||||
|
||||
return node;
|
||||
}),
|
||||
showDeleteAllNodesModal: false,
|
||||
unsavedChanges: true,
|
||||
};
|
||||
}
|
||||
|
||||
function deleteLink(state) {
|
||||
const { links, linkToDelete } = state;
|
||||
const newLinks = [...links];
|
||||
|
||||
for (let i = newLinks.length; i--; ) {
|
||||
const link = newLinks[i];
|
||||
|
||||
if (
|
||||
link.source.id === linkToDelete.source.id &&
|
||||
link.target.id === linkToDelete.target.id
|
||||
) {
|
||||
newLinks.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkToDelete.isConvergenceLink) {
|
||||
// Add a new link from the start node to the orphaned node
|
||||
newLinks.push({
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: linkToDelete.target.id,
|
||||
},
|
||||
linkType: 'always',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
links: newLinks,
|
||||
linkToDelete: null,
|
||||
unsavedChanges: true,
|
||||
};
|
||||
}
|
||||
|
||||
function addLinksFromParentsToChildren(
|
||||
parents,
|
||||
children,
|
||||
newLinks,
|
||||
linkParentMapping
|
||||
) {
|
||||
parents.forEach(parentId => {
|
||||
children.forEach(child => {
|
||||
if (parentId === 1) {
|
||||
// We only want to create a link from the start node to this node if it
|
||||
// doesn't have any other parents
|
||||
if (linkParentMapping[child.id].length === 1) {
|
||||
newLinks.push({
|
||||
source: { id: parentId },
|
||||
target: { id: child.id },
|
||||
linkType: 'always',
|
||||
});
|
||||
}
|
||||
} else if (!linkParentMapping[child.id].includes(parentId)) {
|
||||
newLinks.push({
|
||||
source: { id: parentId },
|
||||
target: { id: child.id },
|
||||
linkType: child.linkType,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeLinksFromDeletedNode(
|
||||
nodeId,
|
||||
newLinks,
|
||||
linkParentMapping,
|
||||
children,
|
||||
parents
|
||||
) {
|
||||
for (let i = newLinks.length; i--; ) {
|
||||
const link = newLinks[i];
|
||||
|
||||
if (!linkParentMapping[link.target.id]) {
|
||||
linkParentMapping[link.target.id] = [];
|
||||
}
|
||||
|
||||
linkParentMapping[link.target.id].push(link.source.id);
|
||||
|
||||
if (link.source.id === nodeId || link.target.id === nodeId) {
|
||||
if (link.source.id === nodeId) {
|
||||
children.push({ id: link.target.id, linkType: link.linkType });
|
||||
} else if (link.target.id === nodeId) {
|
||||
parents.push(link.source.id);
|
||||
}
|
||||
newLinks.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteNode(state) {
|
||||
const { links, nodes, nodeToDelete } = state;
|
||||
|
||||
const nodeId = nodeToDelete.id;
|
||||
const newNodes = [...nodes];
|
||||
const newLinks = [...links];
|
||||
|
||||
newNodes.find(node => node.id === nodeToDelete.id).isDeleted = true;
|
||||
|
||||
// Update the links
|
||||
const parents = [];
|
||||
const children = [];
|
||||
const linkParentMapping = {};
|
||||
|
||||
removeLinksFromDeletedNode(
|
||||
nodeId,
|
||||
newLinks,
|
||||
linkParentMapping,
|
||||
children,
|
||||
parents
|
||||
);
|
||||
|
||||
addLinksFromParentsToChildren(parents, children, newLinks, linkParentMapping);
|
||||
|
||||
return {
|
||||
...state,
|
||||
links: newLinks,
|
||||
nodeToDelete: null,
|
||||
nodes: newNodes,
|
||||
unsavedChanges: true,
|
||||
};
|
||||
}
|
||||
|
||||
function generateNodes(workflowNodes, i18n) {
|
||||
const allNodeIds = [];
|
||||
const chartNodeIdToIndexMapping = {};
|
||||
const nodeIdToChartNodeIdMapping = {};
|
||||
let nodeIdCounter = 2;
|
||||
const arrayOfNodesForChart = [
|
||||
{
|
||||
id: 1,
|
||||
unifiedJobTemplate: {
|
||||
name: i18n._(t`START`),
|
||||
},
|
||||
},
|
||||
];
|
||||
workflowNodes.forEach(node => {
|
||||
node.workflowMakerNodeId = nodeIdCounter;
|
||||
|
||||
const nodeObj = {
|
||||
id: nodeIdCounter,
|
||||
originalNodeObject: node,
|
||||
};
|
||||
|
||||
if (node.summary_fields.job) {
|
||||
nodeObj.job = node.summary_fields.job;
|
||||
}
|
||||
if (node.summary_fields.unified_job_template) {
|
||||
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
|
||||
}
|
||||
|
||||
arrayOfNodesForChart.push(nodeObj);
|
||||
allNodeIds.push(node.id);
|
||||
nodeIdToChartNodeIdMapping[node.id] = node.workflowMakerNodeId;
|
||||
chartNodeIdToIndexMapping[nodeIdCounter] = nodeIdCounter - 1;
|
||||
nodeIdCounter++;
|
||||
});
|
||||
|
||||
return [
|
||||
arrayOfNodesForChart,
|
||||
allNodeIds,
|
||||
nodeIdToChartNodeIdMapping,
|
||||
chartNodeIdToIndexMapping,
|
||||
nodeIdCounter,
|
||||
];
|
||||
}
|
||||
|
||||
function generateLinks(
|
||||
workflowNodes,
|
||||
chartNodeIdToIndexMapping,
|
||||
nodeIdToChartNodeIdMapping,
|
||||
arrayOfNodesForChart
|
||||
) {
|
||||
const arrayOfLinksForChart = [];
|
||||
const nonRootNodeIds = [];
|
||||
workflowNodes.forEach(node => {
|
||||
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
|
||||
node.success_nodes.forEach(nodeId => {
|
||||
const targetIndex =
|
||||
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
|
||||
arrayOfLinksForChart.push({
|
||||
source: arrayOfNodesForChart[sourceIndex],
|
||||
target: arrayOfNodesForChart[targetIndex],
|
||||
linkType: 'success',
|
||||
});
|
||||
nonRootNodeIds.push(nodeId);
|
||||
});
|
||||
node.failure_nodes.forEach(nodeId => {
|
||||
const targetIndex =
|
||||
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
|
||||
arrayOfLinksForChart.push({
|
||||
source: arrayOfNodesForChart[sourceIndex],
|
||||
target: arrayOfNodesForChart[targetIndex],
|
||||
linkType: 'failure',
|
||||
});
|
||||
nonRootNodeIds.push(nodeId);
|
||||
});
|
||||
node.always_nodes.forEach(nodeId => {
|
||||
const targetIndex =
|
||||
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
|
||||
arrayOfLinksForChart.push({
|
||||
source: arrayOfNodesForChart[sourceIndex],
|
||||
target: arrayOfNodesForChart[targetIndex],
|
||||
linkType: 'always',
|
||||
});
|
||||
nonRootNodeIds.push(nodeId);
|
||||
});
|
||||
});
|
||||
|
||||
return [arrayOfLinksForChart, nonRootNodeIds];
|
||||
}
|
||||
|
||||
// TODO: check to make sure passing i18n into this reducer
|
||||
// actually works the way we want it to. If not we may
|
||||
// have to explore other options
|
||||
function generateNodesAndLinks(state, workflowNodes, i18n) {
|
||||
const [
|
||||
arrayOfNodesForChart,
|
||||
allNodeIds,
|
||||
nodeIdToChartNodeIdMapping,
|
||||
chartNodeIdToIndexMapping,
|
||||
nodeIdCounter,
|
||||
] = generateNodes(workflowNodes, i18n);
|
||||
const [arrayOfLinksForChart, nonRootNodeIds] = generateLinks(
|
||||
workflowNodes,
|
||||
chartNodeIdToIndexMapping,
|
||||
nodeIdToChartNodeIdMapping,
|
||||
arrayOfNodesForChart
|
||||
);
|
||||
|
||||
const uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds));
|
||||
|
||||
const rootNodes = allNodeIds.filter(
|
||||
nodeId => !uniqueNonRootNodeIds.includes(nodeId)
|
||||
);
|
||||
|
||||
rootNodes.forEach(rootNodeId => {
|
||||
const targetIndex =
|
||||
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[rootNodeId]];
|
||||
arrayOfLinksForChart.push({
|
||||
source: arrayOfNodesForChart[0],
|
||||
target: arrayOfNodesForChart[targetIndex],
|
||||
linkType: 'always',
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
links: arrayOfLinksForChart,
|
||||
nodes: arrayOfNodesForChart,
|
||||
nextNodeId: nodeIdCounter,
|
||||
};
|
||||
}
|
||||
|
||||
function selectSourceForLinking(state, sourceNode) {
|
||||
const { links, nodes } = state;
|
||||
const newNodes = [...nodes];
|
||||
const parentMap = {};
|
||||
const invalidLinkTargetIds = [];
|
||||
// Find and mark any ancestors as disabled to prevent cycles
|
||||
links.forEach(link => {
|
||||
// id=1 is our artificial root node so we don't care about that
|
||||
if (link.source.id === 1) {
|
||||
return;
|
||||
}
|
||||
if (link.source.id === sourceNode.id) {
|
||||
// Disables direct children from the add link process
|
||||
invalidLinkTargetIds.push(link.target.id);
|
||||
}
|
||||
if (!parentMap[link.target.id]) {
|
||||
parentMap[link.target.id] = [];
|
||||
}
|
||||
parentMap[link.target.id].push(link.source.id);
|
||||
});
|
||||
|
||||
const getAncestors = id => {
|
||||
if (parentMap[id]) {
|
||||
parentMap[id].forEach(parentId => {
|
||||
invalidLinkTargetIds.push(parentId);
|
||||
getAncestors(parentId);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getAncestors(sourceNode.id);
|
||||
|
||||
// Filter out the duplicates
|
||||
invalidLinkTargetIds
|
||||
.filter((element, index, array) => index === array.indexOf(element))
|
||||
.forEach(ancestorId => {
|
||||
newNodes.forEach(node => {
|
||||
if (node.id === ancestorId) {
|
||||
node.isInvalidLinkTarget = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
addLinkSourceNode: sourceNode,
|
||||
addingLink: true,
|
||||
nodes: newNodes,
|
||||
};
|
||||
}
|
||||
|
||||
function startDeleteLink(state, link) {
|
||||
const { links } = state;
|
||||
const parentMap = {};
|
||||
links.forEach(existingLink => {
|
||||
if (!parentMap[existingLink.target.id]) {
|
||||
parentMap[existingLink.target.id] = [];
|
||||
}
|
||||
parentMap[existingLink.target.id].push(existingLink.source.id);
|
||||
});
|
||||
|
||||
link.isConvergenceLink = parentMap[link.target.id].length > 1;
|
||||
|
||||
return {
|
||||
...state,
|
||||
linkToDelete: link,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleDeleteAllNodesModal(state) {
|
||||
const { showDeleteAllNodesModal } = state;
|
||||
return {
|
||||
...state,
|
||||
showDeleteAllNodesModal: !showDeleteAllNodesModal,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleLegend(state) {
|
||||
const { showLegend } = state;
|
||||
return {
|
||||
...state,
|
||||
showLegend: !showLegend,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleTools(state) {
|
||||
const { showTools } = state;
|
||||
return {
|
||||
...state,
|
||||
showTools: !showTools,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleUnsavedChangesModal(state) {
|
||||
const { showUnsavedChangesModal } = state;
|
||||
return {
|
||||
...state,
|
||||
showUnsavedChangesModal: !showUnsavedChangesModal,
|
||||
};
|
||||
}
|
||||
|
||||
function updateLink(state, linkType) {
|
||||
const { linkToEdit, links } = state;
|
||||
const newLinks = [...links];
|
||||
|
||||
newLinks.forEach(link => {
|
||||
if (
|
||||
link.source.id === linkToEdit.source.id &&
|
||||
link.target.id === linkToEdit.target.id
|
||||
) {
|
||||
link.linkType = linkType;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
linkToEdit: null,
|
||||
links: newLinks,
|
||||
unsavedChanges: true,
|
||||
};
|
||||
}
|
||||
|
||||
function updateNode(state, editedNode) {
|
||||
const { nodeToEdit, nodes } = state;
|
||||
const newNodes = [...nodes];
|
||||
|
||||
const matchingNode = newNodes.find(node => node.id === nodeToEdit.id);
|
||||
matchingNode.unifiedJobTemplate = editedNode.nodeResource;
|
||||
matchingNode.isEdited = true;
|
||||
|
||||
return {
|
||||
...state,
|
||||
nodeToEdit: null,
|
||||
nodes: newNodes,
|
||||
unsavedChanges: true,
|
||||
};
|
||||
}
|
||||
1777
awx/ui_next/src/components/Workflow/workflowReducer.test.js
Normal file
1777
awx/ui_next/src/components/Workflow/workflowReducer.test.js
Normal file
File diff suppressed because it is too large
Load Diff
5
awx/ui_next/src/contexts/Workflow.jsx
Normal file
5
awx/ui_next/src/contexts/Workflow.jsx
Normal file
@ -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);
|
||||
@ -11,7 +11,9 @@ import RoutedTabs from '@components/RoutedTabs';
|
||||
|
||||
import JobDetail from './JobDetail';
|
||||
import JobOutput from './JobOutput';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||
import WorkflowDetail from './WorkflowDetail';
|
||||
import { WorkflowOutput } from './WorkflowOutput';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||
|
||||
class Job extends Component {
|
||||
constructor(props) {
|
||||
@ -120,33 +122,54 @@ class Job extends Component {
|
||||
to="/jobs/:type/:id/output"
|
||||
exact
|
||||
/>
|
||||
{job && [
|
||||
<Route
|
||||
key="details"
|
||||
path="/jobs/:type/:id/details"
|
||||
render={() => <JobDetail type={match.params.type} job={job} />}
|
||||
/>,
|
||||
<Route
|
||||
key="output"
|
||||
path="/jobs/:type/:id/output"
|
||||
render={() => <JobOutput type={match.params.type} job={job} />}
|
||||
/>,
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
render={() =>
|
||||
!hasContentLoading && (
|
||||
<ContentError isNotFound>
|
||||
<Link
|
||||
to={`/jobs/${match.params.type}/${match.params.id}/details`}
|
||||
>
|
||||
{i18n._(`View Job Details`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
)
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
<Route
|
||||
key="workflow-details"
|
||||
path="/jobs/workflow/:id/details"
|
||||
render={() =>
|
||||
job &&
|
||||
job.type === 'workflow_job' && <WorkflowDetail job={job} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
key="workflow-output"
|
||||
path="/jobs/workflow/:id/output"
|
||||
render={() =>
|
||||
job &&
|
||||
job.type === 'workflow_job' && <WorkflowOutput job={job} />
|
||||
}
|
||||
/>
|
||||
{job &&
|
||||
job.type !== 'workflow_job' && [
|
||||
<Route
|
||||
key="details"
|
||||
path="/jobs/:type/:id/details"
|
||||
render={() => (
|
||||
<JobDetail type={match.params.type} job={job} />
|
||||
)}
|
||||
/>,
|
||||
<Route
|
||||
key="output"
|
||||
path="/jobs/:type/:id/output"
|
||||
render={() => (
|
||||
<JobOutput type={match.params.type} job={job} />
|
||||
)}
|
||||
/>,
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
render={() =>
|
||||
!hasContentLoading && (
|
||||
<ContentError isNotFound>
|
||||
<Link
|
||||
to={`/jobs/${match.params.type}/${match.params.id}/details`}
|
||||
>
|
||||
{i18n._(`View Job Details`)}
|
||||
</Link>
|
||||
</ContentError>
|
||||
)
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
function WorkflowDetail() {
|
||||
return <div>Workflow Detail!</div>;
|
||||
}
|
||||
|
||||
export default WorkflowDetail;
|
||||
1
awx/ui_next/src/screens/Job/WorkflowDetail/index.js
Normal file
1
awx/ui_next/src/screens/Job/WorkflowDetail/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './WorkflowDetail';
|
||||
116
awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
Normal file
116
awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
Normal file
@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import styled from 'styled-components';
|
||||
import { shape } from 'prop-types';
|
||||
import { CardBody as PFCardBody } from '@patternfly/react-core';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { layoutGraph } from '@components/Workflow/WorkflowUtils';
|
||||
import ContentError from '@components/ContentError';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
import workflowReducer, {
|
||||
initReducer,
|
||||
} from '@components/Workflow/workflowReducer';
|
||||
import { WorkflowJobsAPI } from '@api';
|
||||
import WorkflowOutputGraph from './WorkflowOutputGraph';
|
||||
import WorkflowOutputToolbar from './WorkflowOutputToolbar';
|
||||
|
||||
const CardBody = styled(PFCardBody)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 240px);
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
|
||||
const { data } = await WorkflowJobsAPI.readNodes(jobId, {
|
||||
page_size: 200,
|
||||
page: pageNo,
|
||||
});
|
||||
|
||||
if (data.next) {
|
||||
return fetchWorkflowNodes(jobId, pageNo + 1, nodes.concat(data.results));
|
||||
}
|
||||
return nodes.concat(data.results);
|
||||
};
|
||||
|
||||
function WorkflowOutput({ job, i18n }) {
|
||||
const [state, dispatch] = useReducer(workflowReducer, {}, initReducer);
|
||||
const { contentError, isLoading, links, nodePositions, nodes } = state;
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const workflowNodes = await fetchWorkflowNodes(job.id);
|
||||
dispatch({
|
||||
type: 'GENERATE_NODES_AND_LINKS',
|
||||
nodes: workflowNodes,
|
||||
i18n,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({ type: 'SET_CONTENT_ERROR', value: error });
|
||||
} finally {
|
||||
dispatch({ type: 'SET_IS_LOADING', value: false });
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'RESET' });
|
||||
fetchData();
|
||||
}, [job.id, i18n]);
|
||||
|
||||
// Update positions of nodes/links
|
||||
useEffect(() => {
|
||||
if (nodes) {
|
||||
const newNodePositions = {};
|
||||
const g = layoutGraph(nodes, links);
|
||||
|
||||
g.nodes().forEach(node => {
|
||||
newNodePositions[node] = g.node(node);
|
||||
});
|
||||
|
||||
dispatch({ type: 'SET_NODE_POSITIONS', value: newNodePositions });
|
||||
}
|
||||
}, [job.id, links, nodes]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CardBody>
|
||||
<ContentLoading />
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return (
|
||||
<CardBody>
|
||||
<ContentError error={contentError} />
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowStateContext.Provider value={state}>
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<CardBody>
|
||||
<Wrapper>
|
||||
<WorkflowOutputToolbar job={job} />
|
||||
{nodePositions && <WorkflowOutputGraph />}
|
||||
</Wrapper>
|
||||
</CardBody>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
</WorkflowStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowOutput.propTypes = {
|
||||
job: shape().isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowOutput);
|
||||
@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { WorkflowJobsAPI } from '@api';
|
||||
import WorkflowOutput from './WorkflowOutput';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
const job = {
|
||||
id: 1,
|
||||
name: 'Foo JT',
|
||||
status: 'successful',
|
||||
};
|
||||
|
||||
const mockWorkflowJobNodes = [
|
||||
{
|
||||
id: 8,
|
||||
success_nodes: [10],
|
||||
failure_nodes: [],
|
||||
always_nodes: [9],
|
||||
summary_fields: {
|
||||
job: {
|
||||
elapsed: 10,
|
||||
id: 14,
|
||||
name: 'A Playbook',
|
||||
status: 'successful',
|
||||
type: 'job',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
success_nodes: [],
|
||||
failure_nodes: [],
|
||||
always_nodes: [],
|
||||
summary_fields: {
|
||||
job: {
|
||||
elapsed: 10,
|
||||
id: 14,
|
||||
name: 'A Project Update',
|
||||
status: 'successful',
|
||||
type: 'project_update',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
success_nodes: [],
|
||||
failure_nodes: [],
|
||||
always_nodes: [],
|
||||
summary_fields: {
|
||||
job: {
|
||||
elapsed: 10,
|
||||
id: 14,
|
||||
name: 'An Inventory Source Sync',
|
||||
status: 'successful',
|
||||
type: 'inventory_update',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
success_nodes: [9],
|
||||
failure_nodes: [],
|
||||
always_nodes: [],
|
||||
summary_fields: {
|
||||
job: {
|
||||
elapsed: 10,
|
||||
id: 14,
|
||||
name: 'Pause',
|
||||
status: 'successful',
|
||||
type: 'workflow_approval',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('WorkflowOutput', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
WorkflowJobsAPI.readNodes.mockResolvedValue({
|
||||
data: {
|
||||
count: mockWorkflowJobNodes.length,
|
||||
results: mockWorkflowJobNodes,
|
||||
},
|
||||
});
|
||||
window.SVGElement.prototype.height = {
|
||||
baseVal: {
|
||||
value: 100,
|
||||
},
|
||||
};
|
||||
window.SVGElement.prototype.width = {
|
||||
baseVal: {
|
||||
value: 100,
|
||||
},
|
||||
};
|
||||
window.SVGElement.prototype.getBBox = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 500,
|
||||
height: 250,
|
||||
});
|
||||
|
||||
window.SVGElement.prototype.getBoundingClientRect = () => ({
|
||||
x: 303,
|
||||
y: 252.359375,
|
||||
width: 1329,
|
||||
height: 259.640625,
|
||||
top: 252.359375,
|
||||
right: 1632,
|
||||
bottom: 512,
|
||||
left: 303,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
delete window.SVGElement.prototype.getBBox;
|
||||
delete window.SVGElement.prototype.getBoundingClientRect;
|
||||
delete window.SVGElement.prototype.height;
|
||||
delete window.SVGElement.prototype.width;
|
||||
});
|
||||
|
||||
test('renders successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowOutput job={job} />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ContentError')).toHaveLength(0);
|
||||
expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
|
||||
expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
|
||||
expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('error shown to user when error thrown fetching workflow job nodes', async () => {
|
||||
WorkflowJobsAPI.readNodes.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowOutput job={job} />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ContentError')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,212 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WorkflowStateContext } from '@contexts/Workflow';
|
||||
import * as d3 from 'd3';
|
||||
import {
|
||||
getScaleAndOffsetToFit,
|
||||
getTranslatePointsForZoom,
|
||||
} from '@components/Workflow/WorkflowUtils';
|
||||
import {
|
||||
WorkflowOutputLink,
|
||||
WorkflowOutputNode,
|
||||
} from '@screens/Job/WorkflowOutput';
|
||||
import {
|
||||
WorkflowHelp,
|
||||
WorkflowLegend,
|
||||
WorkflowLinkHelp,
|
||||
WorkflowNodeHelp,
|
||||
WorkflowStartNode,
|
||||
WorkflowTools,
|
||||
} from '@components/Workflow';
|
||||
|
||||
function WorkflowOutputGraph() {
|
||||
const [linkHelp, setLinkHelp] = useState();
|
||||
const [nodeHelp, setNodeHelp] = useState();
|
||||
const [zoomPercentage, setZoomPercentage] = useState(100);
|
||||
const svgRef = useRef(null);
|
||||
const gRef = useRef(null);
|
||||
|
||||
const { links, nodePositions, nodes, showLegend, showTools } = useContext(
|
||||
WorkflowStateContext
|
||||
);
|
||||
|
||||
// This is the zoom function called by using the mousewheel/click and drag
|
||||
const zoom = () => {
|
||||
const translation = [d3.event.transform.x, d3.event.transform.y];
|
||||
d3.select(gRef.current).attr(
|
||||
'transform',
|
||||
`translate(${translation}) scale(${d3.event.transform.k})`
|
||||
);
|
||||
|
||||
setZoomPercentage(d3.event.transform.k * 100);
|
||||
};
|
||||
|
||||
const handlePan = direction => {
|
||||
const transform = d3.zoomTransform(d3.select(svgRef.current).node());
|
||||
|
||||
let { x: xPos, y: yPos } = transform;
|
||||
const { k: currentScale } = transform;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
yPos -= 50;
|
||||
break;
|
||||
case 'down':
|
||||
yPos += 50;
|
||||
break;
|
||||
case 'left':
|
||||
xPos -= 50;
|
||||
break;
|
||||
case 'right':
|
||||
xPos += 50;
|
||||
break;
|
||||
default:
|
||||
// Throw an error?
|
||||
break;
|
||||
}
|
||||
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity.translate(xPos, yPos).scale(currentScale)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePanToMiddle = () => {
|
||||
const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity
|
||||
.translate(0, svgBoundingClientRect.height / 2 - 30)
|
||||
.scale(1)
|
||||
);
|
||||
|
||||
setZoomPercentage(100);
|
||||
};
|
||||
|
||||
const handleZoomChange = newScale => {
|
||||
const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
|
||||
const currentScaleAndOffset = d3.zoomTransform(
|
||||
d3.select(svgRef.current).node()
|
||||
);
|
||||
|
||||
const [translateX, translateY] = getTranslatePointsForZoom(
|
||||
svgBoundingClientRect,
|
||||
currentScaleAndOffset,
|
||||
newScale
|
||||
);
|
||||
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity.translate(translateX, translateY).scale(newScale)
|
||||
);
|
||||
setZoomPercentage(newScale * 100);
|
||||
};
|
||||
|
||||
const handleFitGraph = () => {
|
||||
const { k: currentScale } = d3.zoomTransform(
|
||||
d3.select(svgRef.current).node()
|
||||
);
|
||||
const gBoundingClientRect = d3
|
||||
.select(gRef.current)
|
||||
.node()
|
||||
.getBoundingClientRect();
|
||||
|
||||
const gBBoxDimensions = d3
|
||||
.select(gRef.current)
|
||||
.node()
|
||||
.getBBox();
|
||||
|
||||
const svgBoundingClientRect = svgRef.current.getBoundingClientRect();
|
||||
|
||||
const [scaleToFit, yTranslate] = getScaleAndOffsetToFit(
|
||||
gBoundingClientRect,
|
||||
svgBoundingClientRect,
|
||||
gBBoxDimensions,
|
||||
currentScale
|
||||
);
|
||||
|
||||
d3.select(svgRef.current).call(
|
||||
zoomRef.transform,
|
||||
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
|
||||
);
|
||||
|
||||
setZoomPercentage(scaleToFit * 100);
|
||||
};
|
||||
|
||||
const zoomRef = d3
|
||||
.zoom()
|
||||
.scaleExtent([0.1, 2])
|
||||
.on('zoom', zoom);
|
||||
|
||||
// Initialize the zoom
|
||||
useEffect(() => {
|
||||
d3.select(svgRef.current).call(zoomRef);
|
||||
}, [zoomRef]);
|
||||
|
||||
// Attempt to zoom the graph to fit the available screen space
|
||||
useEffect(() => {
|
||||
handleFitGraph();
|
||||
// We only want this to run once (when the component mounts)
|
||||
// Including handleFitGraph in the deps array will cause this to
|
||||
// run very frequently.
|
||||
// Discussion: https://github.com/facebook/create-react-app/issues/6880
|
||||
// and https://github.com/facebook/react/issues/15865 amongst others
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(nodeHelp || linkHelp) && (
|
||||
<WorkflowHelp>
|
||||
{nodeHelp && <WorkflowNodeHelp node={nodeHelp} />}
|
||||
{linkHelp && <WorkflowLinkHelp link={linkHelp} />}
|
||||
</WorkflowHelp>
|
||||
)}
|
||||
<svg
|
||||
id="workflow-svg"
|
||||
ref={svgRef}
|
||||
css="display: flex; height: 100%; background-color: #f6f6f6;"
|
||||
>
|
||||
<g id="workflow-g" ref={gRef}>
|
||||
{nodePositions && [
|
||||
<WorkflowStartNode key="start" showActionTooltip={false} />,
|
||||
links.map(link => (
|
||||
<WorkflowOutputLink
|
||||
key={`link-${link.source.id}-${link.target.id}`}
|
||||
link={link}
|
||||
mouseEnter={() => setLinkHelp(link)}
|
||||
mouseLeave={() => setLinkHelp(null)}
|
||||
/>
|
||||
)),
|
||||
nodes.map(node => {
|
||||
if (node.id > 1) {
|
||||
return (
|
||||
<WorkflowOutputNode
|
||||
key={`node-${node.id}`}
|
||||
mouseEnter={() => setNodeHelp(node)}
|
||||
mouseLeave={() => setNodeHelp(null)}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
]}
|
||||
</g>
|
||||
</svg>
|
||||
<div css="position: absolute; top: 75px;right: 20px;display: flex;">
|
||||
{showTools && (
|
||||
<WorkflowTools
|
||||
onFitGraph={handleFitGraph}
|
||||
onPan={handlePan}
|
||||
onPanToMiddle={handlePanToMiddle}
|
||||
onZoomChange={handleZoomChange}
|
||||
zoomPercentage={zoomPercentage}
|
||||
/>
|
||||
)}
|
||||
{showLegend && <WorkflowLegend />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowOutputGraph;
|
||||
@ -0,0 +1,225 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { WorkflowStateContext } from '@contexts/Workflow';
|
||||
import WorkflowOutputGraph from './WorkflowOutputGraph';
|
||||
|
||||
const workflowContext = {
|
||||
links: [
|
||||
{
|
||||
source: {
|
||||
id: 2,
|
||||
},
|
||||
target: {
|
||||
id: 4,
|
||||
},
|
||||
linkType: 'success',
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: 2,
|
||||
},
|
||||
target: {
|
||||
id: 3,
|
||||
},
|
||||
linkType: 'always',
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: 5,
|
||||
},
|
||||
target: {
|
||||
id: 3,
|
||||
},
|
||||
linkType: 'success',
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: 2,
|
||||
},
|
||||
linkType: 'always',
|
||||
},
|
||||
{
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: 5,
|
||||
},
|
||||
linkType: 'success',
|
||||
},
|
||||
],
|
||||
nodePositions: {
|
||||
1: { label: '', width: 72, height: 40, x: 36, y: 85 },
|
||||
2: { label: '', width: 180, height: 60, x: 282, y: 40 },
|
||||
3: { label: '', width: 180, height: 60, x: 582, y: 130 },
|
||||
4: { label: '', width: 180, height: 60, x: 582, y: 30 },
|
||||
5: { label: '', width: 180, height: 60, x: 282, y: 140 },
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
job: {
|
||||
name: 'Foo JT',
|
||||
type: 'job',
|
||||
status: 'successful',
|
||||
elapsed: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
},
|
||||
],
|
||||
showLegend: false,
|
||||
showTools: false,
|
||||
};
|
||||
|
||||
describe('WorkflowOutputGraph', () => {
|
||||
beforeEach(() => {
|
||||
window.SVGElement.prototype.height = {
|
||||
baseVal: {
|
||||
value: 100,
|
||||
},
|
||||
};
|
||||
window.SVGElement.prototype.width = {
|
||||
baseVal: {
|
||||
value: 100,
|
||||
},
|
||||
};
|
||||
window.SVGElement.prototype.getBBox = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 500,
|
||||
height: 250,
|
||||
});
|
||||
|
||||
window.SVGElement.prototype.getBoundingClientRect = () => ({
|
||||
x: 303,
|
||||
y: 252.359375,
|
||||
width: 1329,
|
||||
height: 259.640625,
|
||||
top: 252.359375,
|
||||
right: 1632,
|
||||
bottom: 512,
|
||||
left: 303,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.SVGElement.prototype.getBBox;
|
||||
delete window.SVGElement.prototype.getBoundingClientRect;
|
||||
delete window.SVGElement.prototype.height;
|
||||
delete window.SVGElement.prototype.width;
|
||||
});
|
||||
|
||||
test('mounts successfully', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<WorkflowOutputGraph />
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('tools and legend are shown when flags are true', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{ ...workflowContext, showLegend: true, showTools: true }}
|
||||
>
|
||||
<WorkflowOutputGraph />
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
|
||||
expect(wrapper.find('WorkflowLegend')).toHaveLength(1);
|
||||
expect(wrapper.find('WorkflowTools')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('nodes and links are properly rendered', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<WorkflowOutputGraph />
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
|
||||
expect(wrapper.find('WorkflowStartNode')).toHaveLength(1);
|
||||
expect(wrapper.find('WorkflowOutputNode')).toHaveLength(4);
|
||||
expect(wrapper.find('WorkflowOutputLink')).toHaveLength(5);
|
||||
expect(wrapper.find('#link-2-4')).toHaveLength(1);
|
||||
expect(wrapper.find('#link-2-3')).toHaveLength(1);
|
||||
expect(wrapper.find('#link-5-3')).toHaveLength(1);
|
||||
expect(wrapper.find('#link-1-2')).toHaveLength(1);
|
||||
expect(wrapper.find('#link-1-5')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('proper help text is shown when hovering over links and nodes', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<WorkflowOutputGraph />
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
|
||||
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
|
||||
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
|
||||
wrapper.find('g#node-2').simulate('mouseenter');
|
||||
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(1);
|
||||
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Name</b>)).toEqual(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
wrapper.find('WorkflowNodeHelp').containsMatchingElement(<dd>Foo JT</dd>)
|
||||
).toEqual(true);
|
||||
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Type</b>)).toEqual(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WorkflowNodeHelp')
|
||||
.containsMatchingElement(<dd>Job Template</dd>)
|
||||
).toEqual(true);
|
||||
expect(
|
||||
wrapper.find('WorkflowNodeHelp').contains(<b>Job Status</b>)
|
||||
).toEqual(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WorkflowNodeHelp')
|
||||
.containsMatchingElement(<dd>Successful</dd>)
|
||||
).toEqual(true);
|
||||
expect(wrapper.find('WorkflowNodeHelp').contains(<b>Elapsed</b>)).toEqual(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('WorkflowNodeHelp')
|
||||
.containsMatchingElement(<dd>00:01:00</dd>)
|
||||
).toEqual(true);
|
||||
wrapper.find('g#node-2').simulate('mouseleave');
|
||||
expect(wrapper.find('WorkflowNodeHelp')).toHaveLength(0);
|
||||
wrapper.find('g#link-2-3').simulate('mouseenter');
|
||||
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(1);
|
||||
expect(wrapper.find('WorkflowLinkHelp').contains(<b>Run</b>)).toEqual(true);
|
||||
expect(
|
||||
wrapper.find('WorkflowLinkHelp').containsMatchingElement(<dd>Always</dd>)
|
||||
).toEqual(true);
|
||||
wrapper.find('g#link-2-3').simulate('mouseleave');
|
||||
expect(wrapper.find('WorkflowLinkHelp')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,76 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { WorkflowStateContext } from '@contexts/Workflow';
|
||||
import { func, shape } from 'prop-types';
|
||||
import {
|
||||
generateLine,
|
||||
getLinePoints,
|
||||
getLinkOverlayPoints,
|
||||
} from '@components/Workflow/WorkflowUtils';
|
||||
|
||||
function WorkflowOutputLink({ link, mouseEnter, mouseLeave }) {
|
||||
const ref = useRef(null);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [pathD, setPathD] = useState();
|
||||
const [pathStroke, setPathStroke] = useState('#CCCCCC');
|
||||
const { nodePositions } = useContext(WorkflowStateContext);
|
||||
|
||||
const handleLinkMouseEnter = () => {
|
||||
ref.current.parentNode.appendChild(ref.current);
|
||||
setHovering(true);
|
||||
mouseEnter();
|
||||
};
|
||||
|
||||
const handleLinkMouseLeave = () => {
|
||||
ref.current.parentNode.prepend(ref.current);
|
||||
setHovering(null);
|
||||
mouseLeave();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (link.linkType === 'failure') {
|
||||
setPathStroke('#d9534f');
|
||||
}
|
||||
if (link.linkType === 'success') {
|
||||
setPathStroke('#5cb85c');
|
||||
}
|
||||
if (link.linkType === 'always') {
|
||||
setPathStroke('#337ab7');
|
||||
}
|
||||
}, [link.linkType]);
|
||||
|
||||
useEffect(() => {
|
||||
const linePoints = getLinePoints(link, nodePositions);
|
||||
setPathD(generateLine(linePoints));
|
||||
}, [link, nodePositions]);
|
||||
|
||||
return (
|
||||
<g
|
||||
ref={ref}
|
||||
id={`link-${link.source.id}-${link.target.id}`}
|
||||
onMouseEnter={handleLinkMouseEnter}
|
||||
onMouseLeave={handleLinkMouseLeave}
|
||||
>
|
||||
<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={() => mouseEnter()}
|
||||
onMouseLeave={() => mouseLeave()}
|
||||
opacity="0"
|
||||
points={getLinkOverlayPoints(link, nodePositions)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowOutputLink.propTypes = {
|
||||
link: shape().isRequired,
|
||||
mouseEnter: func.isRequired,
|
||||
mouseLeave: func.isRequired,
|
||||
};
|
||||
|
||||
export default WorkflowOutputLink;
|
||||
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { WorkflowStateContext } from '@contexts/Workflow';
|
||||
import WorkflowOutputLink from './WorkflowOutputLink';
|
||||
|
||||
const link = {
|
||||
source: {
|
||||
id: 1,
|
||||
},
|
||||
target: {
|
||||
id: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const nodePositions = {
|
||||
1: {
|
||||
width: 72,
|
||||
height: 40,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
2: {
|
||||
width: 180,
|
||||
height: 60,
|
||||
x: 282,
|
||||
y: 40,
|
||||
},
|
||||
};
|
||||
|
||||
describe('WorkflowOutputLink', () => {
|
||||
test('mounts successfully', () => {
|
||||
const wrapper = mount(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowOutputLink
|
||||
link={link}
|
||||
nodePositions={nodePositions}
|
||||
mouseEnter={() => {}}
|
||||
mouseLeave={() => {}}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,137 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { WorkflowStateContext } from '@contexts/Workflow';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { StatusIcon } from '@components/Sparkline';
|
||||
import { WorkflowNodeTypeLetter } from '@components/Workflow';
|
||||
import { secondsToHHMMSS } from '@util/dates';
|
||||
import { constants as wfConstants } from '@components/Workflow/WorkflowUtils';
|
||||
|
||||
const NodeG = styled.g`
|
||||
cursor: ${props =>
|
||||
props.job && props.job.type !== 'workflow_approval'
|
||||
? 'pointer'
|
||||
: 'default'};
|
||||
`;
|
||||
|
||||
const JobTopLine = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
p {
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
const Elapsed = styled.div`
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
background-color: #ededed;
|
||||
padding: 3px 12px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const NodeContents = styled.div`
|
||||
font-size: 13px;
|
||||
padding: 0px 10px;
|
||||
`;
|
||||
|
||||
const NodeDefaultLabel = styled.p`
|
||||
margin-top: 20px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) {
|
||||
const history = useHistory();
|
||||
const { nodePositions } = useContext(WorkflowStateContext);
|
||||
let borderColor = '#93969A';
|
||||
|
||||
if (node.job) {
|
||||
if (
|
||||
node.job.status === 'failed' ||
|
||||
node.job.status === 'error' ||
|
||||
node.job.status === 'canceled'
|
||||
) {
|
||||
borderColor = '#d9534f';
|
||||
}
|
||||
if (node.job.status === 'successful' || node.job.status === 'ok') {
|
||||
borderColor = '#5cb85c';
|
||||
}
|
||||
}
|
||||
|
||||
const handleNodeClick = () => {
|
||||
if (node.job && node.job.type !== 'workflow_aproval') {
|
||||
history.push(`/jobs/${node.job.id}/details`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeG
|
||||
id={`node-${node.id}`}
|
||||
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
|
||||
.y - nodePositions[1].y})`}
|
||||
job={node.job}
|
||||
onClick={handleNodeClick}
|
||||
onMouseEnter={mouseEnter}
|
||||
onMouseLeave={mouseLeave}
|
||||
>
|
||||
<rect
|
||||
fill="#FFFFFF"
|
||||
height={wfConstants.nodeH}
|
||||
rx="2"
|
||||
ry="2"
|
||||
stroke={borderColor}
|
||||
strokeWidth="2px"
|
||||
width={wfConstants.nodeW}
|
||||
/>
|
||||
<foreignObject height="58" width="178" x="1" y="1">
|
||||
<NodeContents>
|
||||
{node.job ? (
|
||||
<>
|
||||
<JobTopLine>
|
||||
{node.job.status && <StatusIcon status={node.job.status} />}
|
||||
<p>{node.job.name}</p>
|
||||
</JobTopLine>
|
||||
<Elapsed>{secondsToHHMMSS(node.job.elapsed)}</Elapsed>
|
||||
</>
|
||||
) : (
|
||||
<NodeDefaultLabel>
|
||||
{node.unifiedJobTemplate
|
||||
? node.unifiedJobTemplate.name
|
||||
: i18n._(t`DELETED`)}
|
||||
</NodeDefaultLabel>
|
||||
)}
|
||||
</NodeContents>
|
||||
</foreignObject>
|
||||
{(node.unifiedJobTemplate || node.job) && (
|
||||
<WorkflowNodeTypeLetter node={node} />
|
||||
)}
|
||||
</NodeG>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowOutputNode.propTypes = {
|
||||
mouseEnter: func.isRequired,
|
||||
mouseLeave: func.isRequired,
|
||||
node: shape().isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowOutputNode);
|
||||
@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { WorkflowStateContext } from '@contexts/Workflow';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import WorkflowOutputNode from './WorkflowOutputNode';
|
||||
|
||||
const nodeWithJT = {
|
||||
id: 2,
|
||||
job: {
|
||||
elapsed: 7,
|
||||
id: 9000,
|
||||
name: 'Automation JT',
|
||||
status: 'successful',
|
||||
type: 'job',
|
||||
},
|
||||
unifiedJobTemplate: {
|
||||
id: 77,
|
||||
name: 'Automation JT',
|
||||
unified_job_type: 'job',
|
||||
},
|
||||
};
|
||||
|
||||
const nodeWithoutJT = {
|
||||
id: 2,
|
||||
job: {
|
||||
elapsed: 7,
|
||||
id: 9000,
|
||||
name: 'Automation JT 2',
|
||||
status: 'successful',
|
||||
type: 'job',
|
||||
},
|
||||
};
|
||||
|
||||
const nodePositions = {
|
||||
1: {
|
||||
width: 72,
|
||||
height: 40,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
2: {
|
||||
width: 180,
|
||||
height: 60,
|
||||
x: 282,
|
||||
y: 40,
|
||||
},
|
||||
};
|
||||
|
||||
describe('WorkflowOutputNode', () => {
|
||||
test('mounts successfully', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowOutputNode
|
||||
mouseEnter={() => {}}
|
||||
mouseLeave={() => {}}
|
||||
node={nodeWithJT}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
test('node contents displayed correctly when Job and Job Template exist', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<svg>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowOutputNode
|
||||
mouseEnter={() => {}}
|
||||
mouseLeave={() => {}}
|
||||
node={nodeWithJT}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</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>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowOutputNode
|
||||
mouseEnter={() => {}}
|
||||
mouseLeave={() => {}}
|
||||
node={nodeWithoutJT}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper.contains(<p>Automation JT 2</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>
|
||||
<WorkflowStateContext.Provider value={{ nodePositions }}>
|
||||
<WorkflowOutputNode
|
||||
mouseEnter={() => {}}
|
||||
mouseLeave={() => {}}
|
||||
node={{ id: 2 }}
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</svg>
|
||||
);
|
||||
expect(wrapper.text()).toBe('DELETED');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { shape } from 'prop-types';
|
||||
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
|
||||
import { CompassIcon, WrenchIcon } from '@patternfly/react-icons';
|
||||
import { StatusIcon } from '@components/Sparkline';
|
||||
import VerticalSeparator from '@components/VerticalSeparator';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Toolbar = styled.div`
|
||||
align-items: center
|
||||
border-bottom: 1px solid grey;
|
||||
display: flex;
|
||||
height: 56px;
|
||||
`;
|
||||
|
||||
const ToolbarJob = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const ToolbarActions = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const Badge = styled(PFBadge)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const ActionButton = styled(Button)`
|
||||
border: none;
|
||||
margin: 0px 6px;
|
||||
padding: 6px 10px;
|
||||
&:hover {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.pf-m-active {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
const StatusIconWithMargin = styled(StatusIcon)`
|
||||
margin-right: 20px;
|
||||
`;
|
||||
|
||||
function WorkflowOutputToolbar({ i18n, job }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
|
||||
const { nodes, showLegend, showTools } = useContext(WorkflowStateContext);
|
||||
|
||||
const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1;
|
||||
|
||||
return (
|
||||
<Toolbar id="workflow-output-toolbar">
|
||||
<ToolbarJob>
|
||||
<StatusIconWithMargin status={job.status} />
|
||||
<b>{job.name}</b>
|
||||
</ToolbarJob>
|
||||
<ToolbarActions>
|
||||
<div>{i18n._(t`Total Nodes`)}</div>
|
||||
<Badge isRead>{totalNodes}</Badge>
|
||||
<VerticalSeparator />
|
||||
<Tooltip content={i18n._(t`Toggle Legend`)} position="bottom">
|
||||
<ActionButton
|
||||
id="workflow-output-toggle-legend"
|
||||
isActive={showLegend}
|
||||
onClick={() => dispatch({ type: 'TOGGLE_LEGEND' })}
|
||||
variant="plain"
|
||||
>
|
||||
<CompassIcon />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={i18n._(t`Toggle Tools`)} position="bottom">
|
||||
<ActionButton
|
||||
id="workflow-output-toggle-tools"
|
||||
isActive={showTools}
|
||||
onClick={() => dispatch({ type: 'TOGGLE_TOOLS' })}
|
||||
variant="plain"
|
||||
>
|
||||
<WrenchIcon />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowOutputToolbar.propTypes = {
|
||||
job: shape().isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(WorkflowOutputToolbar);
|
||||
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import WorkflowOutputToolbar from './WorkflowOutputToolbar';
|
||||
|
||||
let wrapper;
|
||||
const dispatch = jest.fn();
|
||||
const job = {
|
||||
id: 1,
|
||||
status: 'successful',
|
||||
};
|
||||
const workflowContext = {
|
||||
nodes: [],
|
||||
showLegend: false,
|
||||
showTools: false,
|
||||
};
|
||||
|
||||
describe('WorkflowOutputToolbar', () => {
|
||||
beforeAll(() => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
isDeleted: true,
|
||||
},
|
||||
];
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider value={{ ...workflowContext, nodes }}>
|
||||
<WorkflowOutputToolbar job={job} />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
5
awx/ui_next/src/screens/Job/WorkflowOutput/index.js
Normal file
5
awx/ui_next/src/screens/Job/WorkflowOutput/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
export { default as WorkflowOutput } from './WorkflowOutput';
|
||||
export { default as WorkflowOutputGraph } from './WorkflowOutputGraph';
|
||||
export { default as WorkflowOutputLink } from './WorkflowOutputLink';
|
||||
export { default as WorkflowOutputNode } from './WorkflowOutputNode';
|
||||
export { default as WorkflowOutputToolbar } from './WorkflowOutputToolbar';
|
||||
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
|
||||
import Templates from './Templates';
|
||||
|
||||
@ -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(`
|
||||
<div class="WorkflowChart-tooltipContents">
|
||||
<div class="WorkflowChart-tooltipArrows">
|
||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
||||
</div>
|
||||
<div class="WorkflowChart-tooltipActions">
|
||||
<div id="node-add-between" class="WorkflowChart-action WorkflowChart-action--add">
|
||||
<i class="pf-icon pf-icon-add-circle-o"></i>
|
||||
</div>
|
||||
<div id="link-edit" class="WorkflowChart-action WorkflowChart-action--edit">
|
||||
<i class="pf-icon pf-icon-edit"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<div class="WorkflowChart-tooltipContents">
|
||||
<div class="WorkflowChart-tooltipArrows">
|
||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
||||
</div>
|
||||
<div class="WorkflowChart-tooltipActions">
|
||||
<div id="node-add" class="WorkflowChart-action WorkflowChart-action--add">
|
||||
<i class="pf-icon pf-icon-add-circle-o"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
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 =>
|
||||
`<p>${
|
||||
d.unifiedJobTemplate
|
||||
? d.unifiedJobTemplate.name
|
||||
: i18n._(t`DELETED`)
|
||||
}</p>`
|
||||
);
|
||||
|
||||
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(`
|
||||
<div class="WorkflowChart-tooltipContents">
|
||||
<div class="WorkflowChart-tooltipArrows">
|
||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
||||
</div>
|
||||
<div class="WorkflowChart-tooltipActions">
|
||||
<div id="node-details" class="WorkflowChart-action WorkflowChart-action--details">
|
||||
<i class="pf-icon pf-icon-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
nodeRef
|
||||
.append('foreignObject')
|
||||
.attr('x', nodeW)
|
||||
.attr('y', -49)
|
||||
.attr('width', 52)
|
||||
.attr('height', 157)
|
||||
.attr('class', 'WorkflowChart-tooltip').html(`
|
||||
<div class="WorkflowChart-tooltipContents">
|
||||
<div class="WorkflowChart-tooltipArrows">
|
||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
||||
</div>
|
||||
<div class="WorkflowChart-tooltipActions">
|
||||
<div id="node-add" class="WorkflowChart-action WorkflowChart-action--add">
|
||||
<i class="pf-icon pf-icon-add-circle-o"></i>
|
||||
</div>
|
||||
<div id="node-details" class="WorkflowChart-action WorkflowChart-action--details">
|
||||
<i class="pf-icon pf-icon-info"></i>
|
||||
</div>
|
||||
<div id="node-edit" class="WorkflowChart-action WorkflowChart-action--edit">
|
||||
<i class="pf-icon pf-icon-edit"></i>
|
||||
</div>
|
||||
<div id="node-link" class="WorkflowChart-action WorkflowChart-action--link">
|
||||
<i class="pf-icon pf-icon-automation"></i>
|
||||
</div>
|
||||
<div id="node-delete" class="WorkflowChart-action WorkflowChart-action--delete">
|
||||
<i class="pf-icon pf-icon-remove2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
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 (
|
||||
<Fragment>
|
||||
{helpText && helpText !== '' && (
|
||||
<WorkflowHelp>
|
||||
{typeof helpText === 'string' && <Fragment>{helpText}</Fragment>}
|
||||
{typeof helpText === 'object' && <WorkflowHelpDetails d={helpText} />}
|
||||
</WorkflowHelp>
|
||||
)}
|
||||
<SVG id="workflow-svg" ref={svgRef}>
|
||||
<g id="workflow-g" ref={gRef} />
|
||||
</SVG>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(Graph);
|
||||
@ -0,0 +1,46 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { WorkflowDispatchContext } from '@contexts/Workflow';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
|
||||
function DeleteAllNodesModal({ i18n }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
return (
|
||||
<AlertModal
|
||||
actions={[
|
||||
<Button
|
||||
id="confirm-delete-all-nodes"
|
||||
key="remove"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Confirm removal of all nodes`)}
|
||||
onClick={() => dispatch({ type: 'DELETE_ALL_NODES' })}
|
||||
>
|
||||
{i18n._(t`Remove`)}
|
||||
</Button>,
|
||||
<Button
|
||||
id="cancel-delete-all-nodes"
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Cancel node removal`)}
|
||||
onClick={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
isOpen
|
||||
onClose={() => dispatch({ type: 'TOGGLE_DELETE_ALL_NODES_MODAL' })}
|
||||
title={i18n._(t`Remove All Nodes`)}
|
||||
variant="danger"
|
||||
>
|
||||
<p>
|
||||
{i18n._(
|
||||
t`Are you sure you want to remove all the nodes in this workflow?`
|
||||
)}
|
||||
</p>
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(DeleteAllNodesModal);
|
||||
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { WorkflowDispatchContext } from '@contexts/Workflow';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import DeleteAllNodesModal from './DeleteAllNodesModal';
|
||||
|
||||
let wrapper;
|
||||
const dispatch = jest.fn();
|
||||
|
||||
describe('DeleteAllNodesModal', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<DeleteAllNodesModal />
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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`)}
|
||||
</Title>
|
||||
}
|
||||
onConfirm={linkType => dispatch({ type: 'CREATE_LINK', linkType })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(LinkAddModal);
|
||||
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import LinkAddModal from './LinkAddModal';
|
||||
|
||||
const dispatch = jest.fn();
|
||||
|
||||
const workflowContext = {
|
||||
linkToEdit: null,
|
||||
};
|
||||
|
||||
describe('LinkAddModal', () => {
|
||||
test('Confirm button dispatches as expected', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<LinkAddModal />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
wrapper.find('button#link-confirm').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'CREATE_LINK',
|
||||
linkType: 'success',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
import React, { Fragment, useContext } from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
|
||||
function LinkDeleteModal({ i18n }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
const { linkToDelete } = useContext(WorkflowStateContext);
|
||||
return (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title="Remove Link"
|
||||
isOpen={linkToDelete}
|
||||
onClose={() => dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
|
||||
actions={[
|
||||
<Button
|
||||
id="confirm-link-removal"
|
||||
aria-label={i18n._(t`Confirm link removal`)}
|
||||
key="remove"
|
||||
onClick={() => dispatch({ type: 'DELETE_LINK' })}
|
||||
variant="danger"
|
||||
>
|
||||
{i18n._(t`Remove`)}
|
||||
</Button>,
|
||||
<Button
|
||||
id="cancel-link-removal"
|
||||
aria-label={i18n._(t`Cancel link removal`)}
|
||||
key="cancel"
|
||||
onClick={() => dispatch({ type: 'SET_LINK_TO_DELETE', value: null })}
|
||||
variant="secondary"
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<p>{i18n._(t`Are you sure you want to remove this link?`)}</p>
|
||||
{!linkToDelete.isConvergenceLink && (
|
||||
<Fragment>
|
||||
<br />
|
||||
<p>
|
||||
{i18n._(
|
||||
t`Removing this link will orphan the rest of the branch and cause it to be executed immediately on launch.`
|
||||
)}
|
||||
</p>
|
||||
</Fragment>
|
||||
)}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(LinkDeleteModal);
|
||||
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import LinkDeleteModal from './LinkDeleteModal';
|
||||
|
||||
let wrapper;
|
||||
const dispatch = jest.fn();
|
||||
|
||||
const workflowContext = {
|
||||
linkToDelete: {
|
||||
source: {
|
||||
id: 2,
|
||||
},
|
||||
target: {
|
||||
id: 3,
|
||||
},
|
||||
linkType: 'always',
|
||||
},
|
||||
};
|
||||
|
||||
describe('LinkDeleteModal', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<LinkDeleteModal />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Confirm button dispatches as expected', () => {
|
||||
wrapper.find('button#confirm-link-removal').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'DELETE_LINK',
|
||||
});
|
||||
});
|
||||
|
||||
test('Cancel button dispatches as expected', () => {
|
||||
wrapper.find('button#cancel-link-removal').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_LINK_TO_DELETE',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('Close button dispatches as expected', () => {
|
||||
wrapper.find('TimesIcon').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_LINK_TO_DELETE',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { WorkflowDispatchContext } from '@contexts/Workflow';
|
||||
import { BaseSizes, Title, TitleLevel } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import LinkModal from './LinkModal';
|
||||
|
||||
function LinkEditModal({ i18n }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
return (
|
||||
<LinkModal
|
||||
header={
|
||||
<Title headingLevel={TitleLevel.h1} size={BaseSizes['2xl']}>
|
||||
{i18n._(t`Edit Link`)}
|
||||
</Title>
|
||||
}
|
||||
onConfirm={linkType => dispatch({ type: 'UPDATE_LINK', linkType })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(LinkEditModal);
|
||||
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import LinkEditModal from './LinkEditModal';
|
||||
|
||||
const dispatch = jest.fn();
|
||||
|
||||
const workflowContext = {
|
||||
linkToEdit: {
|
||||
source: {
|
||||
id: 2,
|
||||
},
|
||||
target: {
|
||||
id: 3,
|
||||
},
|
||||
linkType: 'always',
|
||||
},
|
||||
};
|
||||
|
||||
describe('LinkEditModal', () => {
|
||||
test('Confirm button dispatches as expected', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<LinkEditModal />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
wrapper.find('button#link-confirm').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'UPDATE_LINK',
|
||||
linkType: 'always',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { Button, FormGroup, Modal } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func } from 'prop-types';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
|
||||
function LinkModal({ header, i18n, onConfirm }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
const { linkToEdit } = useContext(WorkflowStateContext);
|
||||
const [linkType, setLinkType] = useState(
|
||||
linkToEdit ? linkToEdit.linkType : 'success'
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
width={600}
|
||||
header={header}
|
||||
isOpen
|
||||
title={i18n._(t`Workflow Link`)}
|
||||
onClose={() => dispatch({ type: 'CANCEL_LINK_MODAL' })}
|
||||
actions={[
|
||||
<Button
|
||||
id="link-confirm"
|
||||
key="save"
|
||||
variant="primary"
|
||||
aria-label={i18n._(t`Save link changes`)}
|
||||
onClick={() => onConfirm(linkType)}
|
||||
>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>,
|
||||
<Button
|
||||
id="link-cancel"
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Cancel link changes`)}
|
||||
onClick={() => dispatch({ type: 'CANCEL_LINK_MODAL' })}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<FormGroup fieldId="link-select" label={i18n._(t`Run`)}>
|
||||
<AnsibleSelect
|
||||
id="link-select"
|
||||
name="linkType"
|
||||
value={linkType}
|
||||
data={[
|
||||
{
|
||||
value: 'always',
|
||||
key: 'always',
|
||||
label: i18n._(t`Always`),
|
||||
},
|
||||
{
|
||||
value: 'success',
|
||||
key: 'success',
|
||||
label: i18n._(t`On Success`),
|
||||
},
|
||||
{
|
||||
value: 'failure',
|
||||
key: 'failure',
|
||||
label: i18n._(t`On Failure`),
|
||||
},
|
||||
]}
|
||||
onChange={(event, value) => {
|
||||
setLinkType(value);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
LinkModal.propTypes = {
|
||||
onConfirm: func.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(LinkModal);
|
||||
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import LinkModal from './LinkModal';
|
||||
|
||||
const dispatch = jest.fn();
|
||||
const onConfirm = jest.fn();
|
||||
let wrapper;
|
||||
|
||||
describe('LinkModal', () => {
|
||||
describe('Adding new link', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{
|
||||
linkToEdit: null,
|
||||
}}
|
||||
>
|
||||
<LinkModal header="TEST" onConfirm={onConfirm} />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Dropdown defaults to success when adding new link', () => {
|
||||
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('success');
|
||||
});
|
||||
|
||||
test('Cancel button dispatches as expected', () => {
|
||||
wrapper.find('button#link-cancel').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'CANCEL_LINK_MODAL',
|
||||
});
|
||||
});
|
||||
|
||||
test('Close button dispatches as expected', () => {
|
||||
wrapper.find('TimesIcon').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'CANCEL_LINK_MODAL',
|
||||
});
|
||||
});
|
||||
|
||||
test('Confirm button passes callback correct link type after changing dropdown', () => {
|
||||
act(() => {
|
||||
wrapper.find('AnsibleSelect').prop('onChange')(null, 'always');
|
||||
});
|
||||
wrapper.find('button#link-confirm').simulate('click');
|
||||
expect(onConfirm).toHaveBeenCalledWith('always');
|
||||
});
|
||||
});
|
||||
describe('Editing existing link', () => {
|
||||
test('Dropdown defaults to existing link type when editing link', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{
|
||||
linkToEdit: {
|
||||
source: {
|
||||
id: 2,
|
||||
},
|
||||
target: {
|
||||
id: 3,
|
||||
},
|
||||
linkType: 'failure',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LinkModal header="TEST" onConfirm={onConfirm} />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('failure');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
@ -0,0 +1,33 @@
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import NodeModal from './NodeModal';
|
||||
|
||||
function NodeAddModal({ i18n }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
const { addNodeSource } = useContext(WorkflowStateContext);
|
||||
|
||||
const addNode = (resource, linkType) => {
|
||||
dispatch({
|
||||
type: 'CREATE_NODE',
|
||||
node: {
|
||||
linkType,
|
||||
nodeResource: resource,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeModal
|
||||
askLinkType={addNodeSource !== 1}
|
||||
onSave={addNode}
|
||||
title={i18n._(t`Add Node`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(NodeAddModal);
|
||||
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import NodeAddModal from './NodeAddModal';
|
||||
|
||||
const dispatch = jest.fn();
|
||||
|
||||
const nodeResource = {
|
||||
id: 448,
|
||||
type: 'job_template',
|
||||
name: 'Test JT',
|
||||
};
|
||||
|
||||
const workflowContext = {
|
||||
addNodeSource: 2,
|
||||
};
|
||||
|
||||
describe('NodeAddModal', () => {
|
||||
test('Node modal confirmation dispatches as expected', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<NodeAddModal />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
act(() => {
|
||||
wrapper.find('NodeModal').prop('onSave')(nodeResource, 'success');
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'CREATE_NODE',
|
||||
node: {
|
||||
linkType: 'success',
|
||||
nodeResource,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
import React, { Fragment, useContext } from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
|
||||
function NodeDeleteModal({ i18n }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
const { nodeToDelete } = useContext(WorkflowStateContext);
|
||||
return (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={i18n._(t`Remove Node`)}
|
||||
isOpen={nodeToDelete}
|
||||
onClose={() => dispatch({ type: 'SET_NODE_TO_DELETE', value: null })}
|
||||
actions={[
|
||||
<Button
|
||||
id="confirm-node-removal"
|
||||
key="remove"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Confirm node removal`)}
|
||||
onClick={() => dispatch({ type: 'DELETE_NODE' })}
|
||||
>
|
||||
{i18n._(t`Remove`)}
|
||||
</Button>,
|
||||
<Button
|
||||
id="cancel-node-removal"
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Cancel node removal`)}
|
||||
onClick={() => dispatch({ type: 'SET_NODE_TO_DELETE', value: null })}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{nodeToDelete && nodeToDelete.unifiedJobTemplate ? (
|
||||
<Fragment>
|
||||
<p>{i18n._(t`Are you sure you want to remove the node below:`)}</p>
|
||||
<br />
|
||||
<strong css="color: var(--pf-global--danger-color--100)">
|
||||
{nodeToDelete.unifiedJobTemplate.name}
|
||||
</strong>
|
||||
</Fragment>
|
||||
) : (
|
||||
<p>{i18n._(t`Are you sure you want to remove this node?`)}</p>
|
||||
)}
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(NodeDeleteModal);
|
||||
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import NodeDeleteModal from './NodeDeleteModal';
|
||||
|
||||
let wrapper;
|
||||
const dispatch = jest.fn();
|
||||
|
||||
describe('NodeDeleteModal', () => {
|
||||
describe('Node with unified job template', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{
|
||||
nodeToDelete: {
|
||||
id: 2,
|
||||
unifiedJobTemplate: {
|
||||
id: 4000,
|
||||
name: 'Test JT',
|
||||
type: 'job_template',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NodeDeleteModal />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Mounts successfully', () => {
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
|
||||
test('Confirm button dispatches as expected', () => {
|
||||
wrapper.find('button#confirm-node-removal').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'DELETE_NODE',
|
||||
});
|
||||
});
|
||||
|
||||
test('Cancel button dispatches as expected', () => {
|
||||
wrapper.find('button#cancel-node-removal').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_NODE_TO_DELETE',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('Close button dispatches as expected', () => {
|
||||
wrapper.find('TimesIcon').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_NODE_TO_DELETE',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Node without unified job template', () => {
|
||||
test('Mounts successfully', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{
|
||||
nodeToDelete: {
|
||||
id: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NodeDeleteModal />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
expect(wrapper.length).toBe(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { WorkflowDispatchContext } from '@contexts/Workflow';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import NodeModal from './NodeModal';
|
||||
|
||||
function NodeEditModal({ i18n }) {
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
|
||||
const updateNode = resource => {
|
||||
dispatch({
|
||||
type: 'UPDATE_NODE',
|
||||
node: {
|
||||
nodeResource: resource,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeModal
|
||||
askLinkType={false}
|
||||
onSave={updateNode}
|
||||
title={i18n._(t`Edit Node`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(NodeEditModal);
|
||||
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import NodeEditModal from './NodeEditModal';
|
||||
|
||||
const dispatch = jest.fn();
|
||||
|
||||
const nodeResource = {
|
||||
id: 448,
|
||||
type: 'job_template',
|
||||
name: 'Test JT',
|
||||
};
|
||||
|
||||
const workflowContext = {
|
||||
nodeToEdit: {
|
||||
id: 4,
|
||||
unifiedJobTemplate: {
|
||||
id: 30,
|
||||
name: 'Foo JT',
|
||||
type: 'job_template',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('NodeEditModal', () => {
|
||||
test('Node modal confirmation dispatches as expected', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider value={workflowContext}>
|
||||
<NodeEditModal />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
act(() => {
|
||||
wrapper.find('NodeModal').prop('onSave')(nodeResource);
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'UPDATE_NODE',
|
||||
node: {
|
||||
nodeResource,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,218 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
WorkflowDispatchContext,
|
||||
WorkflowStateContext,
|
||||
} from '@contexts/Workflow';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { bool, node, func } from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
WizardContextConsumer,
|
||||
WizardFooter,
|
||||
} from '@patternfly/react-core';
|
||||
import Wizard from '@components/Wizard';
|
||||
import { NodeTypeStep } from './NodeTypeStep';
|
||||
import { RunStep, NodeNextButton } from '.';
|
||||
|
||||
function NodeModal({ askLinkType, i18n, onSave, title }) {
|
||||
const history = useHistory();
|
||||
const dispatch = useContext(WorkflowDispatchContext);
|
||||
const { nodeToEdit } = useContext(WorkflowStateContext);
|
||||
|
||||
let defaultApprovalDescription = '';
|
||||
let defaultApprovalName = '';
|
||||
let defaultApprovalTimeout = 0;
|
||||
let defaultNodeResource = null;
|
||||
let defaultNodeType = 'job_template';
|
||||
if (nodeToEdit && nodeToEdit.unifiedJobTemplate) {
|
||||
if (
|
||||
nodeToEdit &&
|
||||
nodeToEdit.unifiedJobTemplate &&
|
||||
(nodeToEdit.unifiedJobTemplate.type ||
|
||||
nodeToEdit.unifiedJobTemplate.unified_job_type)
|
||||
) {
|
||||
const ujtType =
|
||||
nodeToEdit.unifiedJobTemplate.type ||
|
||||
nodeToEdit.unifiedJobTemplate.unified_job_type;
|
||||
switch (ujtType) {
|
||||
case 'job_template':
|
||||
case 'job':
|
||||
defaultNodeType = 'job_template';
|
||||
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
|
||||
break;
|
||||
case 'project':
|
||||
case 'project_update':
|
||||
defaultNodeType = 'project_sync';
|
||||
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
|
||||
break;
|
||||
case 'inventory_source':
|
||||
case 'inventory_update':
|
||||
defaultNodeType = 'inventory_source_sync';
|
||||
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
|
||||
break;
|
||||
case 'workflow_job_template':
|
||||
case 'workflow_job':
|
||||
defaultNodeType = 'workflow_job_template';
|
||||
defaultNodeResource = nodeToEdit.unifiedJobTemplate;
|
||||
break;
|
||||
case 'workflow_approval_template':
|
||||
case 'workflow_approval':
|
||||
defaultNodeType = 'approval';
|
||||
defaultApprovalName = nodeToEdit.unifiedJobTemplate.name;
|
||||
defaultApprovalDescription =
|
||||
nodeToEdit.unifiedJobTemplate.description;
|
||||
defaultApprovalTimeout = nodeToEdit.unifiedJobTemplate.timeout;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
const [approvalDescription, setApprovalDescription] = useState(
|
||||
defaultApprovalDescription
|
||||
);
|
||||
const [approvalName, setApprovalName] = useState(defaultApprovalName);
|
||||
const [approvalTimeout, setApprovalTimeout] = useState(
|
||||
defaultApprovalTimeout
|
||||
);
|
||||
const [linkType, setLinkType] = useState('success');
|
||||
const [nodeResource, setNodeResource] = useState(defaultNodeResource);
|
||||
const [nodeType, setNodeType] = useState(defaultNodeType);
|
||||
const [triggerNext, setTriggerNext] = useState(0);
|
||||
|
||||
const clearQueryParams = () => {
|
||||
const parts = history.location.search.replace(/^\?/, '').split('&');
|
||||
const otherParts = parts.filter(param =>
|
||||
/^!(job_templates\.|projects\.|inventory_sources\.|workflow_job_templates\.)/.test(
|
||||
param
|
||||
)
|
||||
);
|
||||
history.replace(`${history.location.pathname}?${otherParts.join('&')}`);
|
||||
};
|
||||
|
||||
const handleSaveNode = () => {
|
||||
clearQueryParams();
|
||||
|
||||
const resource =
|
||||
nodeType === 'approval'
|
||||
? {
|
||||
description: approvalDescription,
|
||||
name: approvalName,
|
||||
timeout: approvalTimeout,
|
||||
type: 'workflow_approval_template',
|
||||
}
|
||||
: nodeResource;
|
||||
|
||||
onSave(resource, askLinkType ? linkType : null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
clearQueryParams();
|
||||
dispatch({ type: 'CANCEL_NODE_MODAL' });
|
||||
};
|
||||
|
||||
const handleNodeTypeChange = newNodeType => {
|
||||
setNodeType(newNodeType);
|
||||
setNodeResource(null);
|
||||
setApprovalName('');
|
||||
setApprovalDescription('');
|
||||
setApprovalTimeout(0);
|
||||
};
|
||||
|
||||
const steps = [
|
||||
...(askLinkType
|
||||
? [
|
||||
{
|
||||
name: i18n._(t`Run Type`),
|
||||
key: 'run_type',
|
||||
component: (
|
||||
<RunStep linkType={linkType} onUpdateLinkType={setLinkType} />
|
||||
),
|
||||
enableNext: linkType !== null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: i18n._(t`Node Type`),
|
||||
key: 'node_resource',
|
||||
enableNext:
|
||||
(nodeType !== 'approval' && nodeResource !== null) ||
|
||||
(nodeType === 'approval' && approvalName !== ''),
|
||||
component: (
|
||||
<NodeTypeStep
|
||||
description={approvalDescription}
|
||||
name={approvalName}
|
||||
nodeResource={nodeResource}
|
||||
nodeType={nodeType}
|
||||
onUpdateDescription={setApprovalDescription}
|
||||
onUpdateName={setApprovalName}
|
||||
onUpdateNodeResource={setNodeResource}
|
||||
onUpdateNodeType={handleNodeTypeChange}
|
||||
onUpdateTimeout={setApprovalTimeout}
|
||||
timeout={approvalTimeout}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
steps.forEach((step, n) => {
|
||||
step.id = n + 1;
|
||||
});
|
||||
|
||||
const CustomFooter = (
|
||||
<WizardFooter>
|
||||
<WizardContextConsumer>
|
||||
{({ activeStep, onNext, onBack }) => (
|
||||
<>
|
||||
<NodeNextButton
|
||||
triggerNext={triggerNext}
|
||||
activeStep={activeStep}
|
||||
onNext={onNext}
|
||||
onClick={() => setTriggerNext(triggerNext + 1)}
|
||||
buttonText={
|
||||
activeStep.key === 'node_resource'
|
||||
? i18n._(t`Save`)
|
||||
: i18n._(t`Next`)
|
||||
}
|
||||
/>
|
||||
{activeStep && activeStep.id !== 1 && (
|
||||
<Button id="back-node-modal" variant="secondary" onClick={onBack}>
|
||||
{i18n._(t`Back`)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
id="cancel-node-modal"
|
||||
variant="link"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</WizardContextConsumer>
|
||||
</WizardFooter>
|
||||
);
|
||||
|
||||
const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title;
|
||||
|
||||
return (
|
||||
<Wizard
|
||||
footer={CustomFooter}
|
||||
isOpen
|
||||
onClose={handleCancel}
|
||||
onSave={handleSaveNode}
|
||||
steps={steps}
|
||||
css="overflow: scroll"
|
||||
title={wizardTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
NodeModal.propTypes = {
|
||||
askLinkType: bool.isRequired,
|
||||
onSave: func.isRequired,
|
||||
title: node.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(NodeModal);
|
||||
@ -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(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{
|
||||
nodeToEdit: null,
|
||||
}}
|
||||
>
|
||||
<NodeModal askLinkType onSave={onSave} title="Add Node" />
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{
|
||||
nodeToEdit: {
|
||||
id: 2,
|
||||
unifiedJobTemplate: {
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
unified_job_type: 'project_update',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NodeModal
|
||||
askLinkType={false}
|
||||
onSave={onSave}
|
||||
title="Edit Node"
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<WorkflowStateContext.Provider
|
||||
value={{
|
||||
nodeToEdit: {
|
||||
id: 2,
|
||||
unifiedJobTemplate: {
|
||||
id: 1,
|
||||
name: 'Test Approval',
|
||||
description: 'Test Approval Description',
|
||||
unified_job_type: 'workflow_approval',
|
||||
timeout: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NodeModal
|
||||
askLinkType={false}
|
||||
onSave={onSave}
|
||||
title="Edit Node"
|
||||
/>
|
||||
</WorkflowStateContext.Provider>
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
});
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,40 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { func, number, shape, string } from 'prop-types';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
|
||||
function NodeNextButton({
|
||||
activeStep,
|
||||
buttonText,
|
||||
onClick,
|
||||
onNext,
|
||||
triggerNext,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!triggerNext) {
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
}, [onNext, triggerNext]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
id="next-node-modal"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
onClick={() => onClick(activeStep)}
|
||||
isDisabled={!activeStep.enableNext}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
NodeNextButton.propTypes = {
|
||||
activeStep: shape().isRequired,
|
||||
buttonText: string.isRequired,
|
||||
onClick: func.isRequired,
|
||||
onNext: func.isRequired,
|
||||
triggerNext: number.isRequired,
|
||||
};
|
||||
|
||||
export default NodeNextButton;
|
||||
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import NodeNextButton from './NodeNextButton';
|
||||
|
||||
const activeStep = {
|
||||
name: 'Node Type',
|
||||
key: 'node_resource',
|
||||
enableNext: true,
|
||||
component: {},
|
||||
id: 1,
|
||||
};
|
||||
const buttonText = 'Next';
|
||||
const onClick = jest.fn();
|
||||
const onNext = jest.fn();
|
||||
const triggerNext = 0;
|
||||
let wrapper;
|
||||
|
||||
describe('NodeNextButton', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mount(
|
||||
<NodeNextButton
|
||||
activeStep={activeStep}
|
||||
buttonText={buttonText}
|
||||
onClick={onClick}
|
||||
onNext={onNext}
|
||||
triggerNext={triggerNext}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Button text matches', () => {
|
||||
expect(wrapper.find('button').text()).toBe(buttonText);
|
||||
});
|
||||
|
||||
test('Clicking button makes expected callback', () => {
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(onClick).toBeCalledWith(activeStep);
|
||||
});
|
||||
|
||||
test('onNext triggered when triggerNext counter incrimented', () => {
|
||||
wrapper.setProps({ triggerNext: 1 });
|
||||
expect(onNext).toBeCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,114 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { InventorySourcesAPI } from '@api';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import PaginatedDataList from '@components/PaginatedDataList';
|
||||
import DataListToolbar from '@components/DataListToolbar';
|
||||
import CheckboxListItem from '@components/CheckboxListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('inventory_sources', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function InventorySourcesList({
|
||||
history,
|
||||
i18n,
|
||||
nodeResource,
|
||||
onUpdateNodeResource,
|
||||
}) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const [inventorySources, setInventorySources] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
setInventorySources([]);
|
||||
setCount(0);
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await InventorySourcesAPI.read(params);
|
||||
setInventorySources(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
|
||||
return (
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
itemCount={count}
|
||||
items={inventorySources}
|
||||
onRowClick={row => onUpdateNodeResource(row)}
|
||||
qsConfig={QS_CONFIG}
|
||||
showPageSizeOptions={false}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={!!(nodeResource && nodeResource.id === item.id)}
|
||||
itemId={item.id}
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
label={item.name}
|
||||
onSelect={() => onUpdateNodeResource(item)}
|
||||
onDeselect={() => onUpdateNodeResource(null)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Source`),
|
||||
key: 'source',
|
||||
options: [
|
||||
[``, i18n._(t`Manual`)],
|
||||
[`file`, i18n._(t`File, Directory or Script`)],
|
||||
[`scm`, i18n._(t`Sourced from a Project`)],
|
||||
[`ec2`, i18n._(t`Amazon EC2`)],
|
||||
[`gce`, i18n._(t`Google Compute Engine`)],
|
||||
[`azure_rm`, i18n._(t`Microsoft Azure Resource Manager`)],
|
||||
[`vmware`, i18n._(t`VMware vCenter`)],
|
||||
[`satellite6`, i18n._(t`Red Hat Satellite 6`)],
|
||||
[`cloudforms`, i18n._(t`Red Hat CloudForms`)],
|
||||
[`openstack`, i18n._(t`OpenStack`)],
|
||||
[`rhv`, i18n._(t`Red Hat Virtualization`)],
|
||||
[`tower`, i18n._(t`Ansible Tower`)],
|
||||
[`custom`, i18n._(t`Custom Script`)],
|
||||
],
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
InventorySourcesList.propTypes = {
|
||||
nodeResource: shape(),
|
||||
onUpdateNodeResource: func.isRequired,
|
||||
};
|
||||
|
||||
InventorySourcesList.defaultProps = {
|
||||
nodeResource: null,
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(InventorySourcesList));
|
||||
@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { InventorySourcesAPI } from '@api';
|
||||
import InventorySourcesList from './InventorySourcesList';
|
||||
|
||||
jest.mock('@api/models/InventorySources');
|
||||
|
||||
const nodeResource = {
|
||||
id: 1,
|
||||
name: 'Test Inventory Source',
|
||||
unified_job_type: 'workflow_approval',
|
||||
};
|
||||
const onUpdateNodeResource = jest.fn();
|
||||
|
||||
describe('InventorySourcesList', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
|
||||
InventorySourcesAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Inventory Source',
|
||||
type: 'inventory_source',
|
||||
url: '/api/v2/inventory_sources/1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Inventory Source 2',
|
||||
type: 'inventory_source',
|
||||
url: '/api/v2/inventory_sources/2',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventorySourcesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('CheckboxListItem[name="Test Inventory Source"]').props()
|
||||
.isSelected
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.find('CheckboxListItem[name="Test Inventory Source 2"]').props()
|
||||
.isSelected
|
||||
).toBe(false);
|
||||
wrapper
|
||||
.find('CheckboxListItem[name="Test Inventory Source 2"]')
|
||||
.simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 2,
|
||||
name: 'Test Inventory Source 2',
|
||||
type: 'inventory_source',
|
||||
url: '/api/v2/inventory_sources/2',
|
||||
});
|
||||
});
|
||||
test('Error shown when read() request errors', async () => {
|
||||
InventorySourcesAPI.read.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventorySourcesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,109 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { JobTemplatesAPI } from '@api';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import PaginatedDataList from '@components/PaginatedDataList';
|
||||
import DataListToolbar from '@components/DataListToolbar';
|
||||
import CheckboxListItem from '@components/CheckboxListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('job_templates', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function JobTemplatesList({
|
||||
i18n,
|
||||
history,
|
||||
nodeResource,
|
||||
onUpdateNodeResource,
|
||||
}) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [jobTemplates, setJobTemplates] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
setJobTemplates([]);
|
||||
setCount(0);
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await JobTemplatesAPI.read(params, {
|
||||
role_level: 'execute_role',
|
||||
});
|
||||
setJobTemplates(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
|
||||
return (
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
itemCount={count}
|
||||
items={jobTemplates}
|
||||
onRowClick={row => onUpdateNodeResource(row)}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={!!(nodeResource && nodeResource.id === item.id)}
|
||||
itemId={item.id}
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
label={item.name}
|
||||
onSelect={() => onUpdateNodeResource(item)}
|
||||
onDeselect={() => onUpdateNodeResource(null)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
showPageSizeOptions={false}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Playbook name`),
|
||||
key: 'playbook',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
JobTemplatesList.propTypes = {
|
||||
nodeResource: shape(),
|
||||
onUpdateNodeResource: func.isRequired,
|
||||
};
|
||||
|
||||
JobTemplatesList.defaultProps = {
|
||||
nodeResource: null,
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(JobTemplatesList));
|
||||
@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { JobTemplatesAPI } from '@api';
|
||||
import JobTemplatesList from './JobTemplatesList';
|
||||
|
||||
jest.mock('@api/models/JobTemplates');
|
||||
|
||||
const nodeResource = {
|
||||
id: 1,
|
||||
name: 'Test Job Template',
|
||||
unified_job_type: 'job',
|
||||
};
|
||||
const onUpdateNodeResource = jest.fn();
|
||||
|
||||
describe('JobTemplatesList', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
|
||||
JobTemplatesAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Job Template',
|
||||
type: 'job_template',
|
||||
url: '/api/v2/job_templates/1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Job Template 2',
|
||||
type: 'job_template',
|
||||
url: '/api/v2/job_templates/2',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobTemplatesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
||||
.isSelected
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
|
||||
.isSelected
|
||||
).toBe(false);
|
||||
wrapper
|
||||
.find('CheckboxListItem[name="Test Job Template 2"]')
|
||||
.simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 2,
|
||||
name: 'Test Job Template 2',
|
||||
type: 'job_template',
|
||||
url: '/api/v2/job_templates/2',
|
||||
});
|
||||
});
|
||||
test('Error shown when read() request errors', async () => {
|
||||
JobTemplatesAPI.read.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobTemplatesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,279 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { func, number, shape, string } from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import { Formik, Field } from 'formik';
|
||||
import { Form, FormGroup, TextInput } from '@patternfly/react-core';
|
||||
import FormRow from '@components/FormRow';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import VerticalSeperator from '@components/VerticalSeparator';
|
||||
import InventorySourcesList from './InventorySourcesList';
|
||||
import JobTemplatesList from './JobTemplatesList';
|
||||
import ProjectsList from './ProjectsList';
|
||||
import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
|
||||
|
||||
const Divider = styled.div`
|
||||
height: 1px;
|
||||
background-color: var(--pf-global--Color--light-300);
|
||||
border: 0;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const TimeoutInput = styled(TextInput)`
|
||||
width: 200px;
|
||||
:not(:first-of-type) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
`;
|
||||
TimeoutInput.displayName = 'TimeoutInput';
|
||||
|
||||
const TimeoutLabel = styled.p`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
function NodeTypeStep({
|
||||
description,
|
||||
i18n,
|
||||
name,
|
||||
nodeResource,
|
||||
nodeType,
|
||||
timeout,
|
||||
onUpdateDescription,
|
||||
onUpdateName,
|
||||
onUpdateNodeResource,
|
||||
onUpdateNodeType,
|
||||
onUpdateTimeout,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div css=" display: flex; align-items: center; margin-bottom: 20px;">
|
||||
<b>{i18n._(t`Node Type`)}</b>
|
||||
<VerticalSeperator />
|
||||
<div>
|
||||
<AnsibleSelect
|
||||
id="nodeResource-select"
|
||||
label={i18n._(t`Select a Node Type`)}
|
||||
data={[
|
||||
{
|
||||
key: 'approval',
|
||||
value: 'approval',
|
||||
label: i18n._(t`Approval`),
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
key: 'inventory_source_sync',
|
||||
value: 'inventory_source_sync',
|
||||
label: i18n._(t`Inventory Source Sync`),
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
key: 'job_template',
|
||||
value: 'job_template',
|
||||
label: i18n._(t`Job Template`),
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
key: 'project_sync',
|
||||
value: 'project_sync',
|
||||
label: i18n._(t`Project Sync`),
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
key: 'workflow_job_template',
|
||||
value: 'workflow_job_template',
|
||||
label: i18n._(t`Workflow Job Template`),
|
||||
isDisabled: false,
|
||||
},
|
||||
]}
|
||||
value={nodeType}
|
||||
onChange={(e, val) => {
|
||||
onUpdateNodeType(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider component="div" />
|
||||
{nodeType === 'job_template' && (
|
||||
<JobTemplatesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'project_sync' && (
|
||||
<ProjectsList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'inventory_source_sync' && (
|
||||
<InventorySourcesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'workflow_job_template' && (
|
||||
<WorkflowJobTemplatesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'approval' && (
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: name || '',
|
||||
description: description || '',
|
||||
timeoutMinutes: Math.floor(timeout / 60),
|
||||
timeoutSeconds: timeout - Math.floor(timeout / 60) * 60,
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<Form css="margin-top: 20px;">
|
||||
<FormRow>
|
||||
<Field name="name">
|
||||
{({ field, form }) => {
|
||||
const isValid =
|
||||
form &&
|
||||
(!form.touched[field.name] || !form.errors[field.name]);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="approval-name"
|
||||
isRequired
|
||||
isValid={isValid}
|
||||
label={i18n._(t`Name`)}
|
||||
>
|
||||
<TextInput
|
||||
autoFocus
|
||||
id="approval-name"
|
||||
isRequired
|
||||
isValid={isValid}
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(value, evt) => {
|
||||
onUpdateName(evt.target.value);
|
||||
field.onChange(evt);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<Field name="description">
|
||||
{({ field }) => (
|
||||
<FormGroup
|
||||
fieldId="approval-description"
|
||||
label={i18n._(t`Description`)}
|
||||
>
|
||||
<TextInput
|
||||
id="approval-description"
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(value, evt) => {
|
||||
onUpdateDescription(evt.target.value);
|
||||
field.onChange(evt);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormGroup
|
||||
label={i18n._(t`Timeout`)}
|
||||
fieldId="approval-timeout"
|
||||
>
|
||||
<div css="display: flex;align-items: center;">
|
||||
<Field name="timeoutMinutes">
|
||||
{({ field, form }) => (
|
||||
<>
|
||||
<TimeoutInput
|
||||
id="approval-timeout-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
{...field}
|
||||
onChange={(value, evt) => {
|
||||
if (
|
||||
!evt.target.value ||
|
||||
evt.target.value === ''
|
||||
) {
|
||||
evt.target.value = 0;
|
||||
}
|
||||
onUpdateTimeout(
|
||||
Number(evt.target.value) * 60 +
|
||||
Number(form.values.timeoutSeconds)
|
||||
);
|
||||
field.onChange(evt);
|
||||
}}
|
||||
/>
|
||||
<TimeoutLabel>
|
||||
<Trans>min</Trans>
|
||||
</TimeoutLabel>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="timeoutSeconds">
|
||||
{({ field, form }) => (
|
||||
<>
|
||||
<TimeoutInput
|
||||
id="approval-timeout-seconds"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
{...field}
|
||||
onChange={(value, evt) => {
|
||||
if (
|
||||
!evt.target.value ||
|
||||
evt.target.value === ''
|
||||
) {
|
||||
evt.target.value = 0;
|
||||
}
|
||||
onUpdateTimeout(
|
||||
Number(evt.target.value) +
|
||||
Number(form.values.timeoutMinutes) * 60
|
||||
);
|
||||
field.onChange(evt);
|
||||
}}
|
||||
/>
|
||||
<TimeoutLabel>
|
||||
<Trans>sec</Trans>
|
||||
</TimeoutLabel>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
NodeTypeStep.propTypes = {
|
||||
description: string,
|
||||
name: string,
|
||||
nodeResource: shape(),
|
||||
nodeType: string,
|
||||
timeout: number,
|
||||
onUpdateDescription: func.isRequired,
|
||||
onUpdateName: func.isRequired,
|
||||
onUpdateNodeResource: func.isRequired,
|
||||
onUpdateNodeType: func.isRequired,
|
||||
onUpdateTimeout: func.isRequired,
|
||||
};
|
||||
|
||||
NodeTypeStep.defaultProps = {
|
||||
description: '',
|
||||
name: '',
|
||||
nodeResource: null,
|
||||
nodeType: 'job_template',
|
||||
timeout: 0,
|
||||
};
|
||||
|
||||
export default withI18n()(NodeTypeStep);
|
||||
@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import {
|
||||
InventorySourcesAPI,
|
||||
JobTemplatesAPI,
|
||||
ProjectsAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '@api';
|
||||
import NodeTypeStep from './NodeTypeStep';
|
||||
|
||||
jest.mock('@api/models/InventorySources');
|
||||
jest.mock('@api/models/JobTemplates');
|
||||
jest.mock('@api/models/Projects');
|
||||
jest.mock('@api/models/WorkflowJobTemplates');
|
||||
|
||||
const onUpdateDescription = jest.fn();
|
||||
const onUpdateName = jest.fn();
|
||||
const onUpdateNodeResource = jest.fn();
|
||||
const onUpdateNodeType = jest.fn();
|
||||
const onUpdateTimeout = jest.fn();
|
||||
|
||||
describe('NodeTypeStep', () => {
|
||||
beforeAll(() => {
|
||||
JobTemplatesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Job Template',
|
||||
type: 'job_template',
|
||||
url: '/api/v2/job_templates/1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
ProjectsAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
type: 'project',
|
||||
url: '/api/v2/projects/1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
InventorySourcesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Inventory Source',
|
||||
type: 'inventory_source',
|
||||
url: '/api/v2/inventory_sources/1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
WorkflowJobTemplatesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Workflow Job Template',
|
||||
type: 'workflow_job_template',
|
||||
url: '/api/v2/workflow_job_templates/1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('It shows the job template list by default', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NodeTypeStep
|
||||
onUpdateDescription={onUpdateDescription}
|
||||
onUpdateName={onUpdateName}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
onUpdateNodeType={onUpdateNodeType}
|
||||
onUpdateTimeout={onUpdateTimeout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('job_template');
|
||||
expect(wrapper.find('JobTemplatesList').length).toBe(1);
|
||||
wrapper.find('DataListRadio').simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Test Job Template',
|
||||
type: 'job_template',
|
||||
url: '/api/v2/job_templates/1',
|
||||
});
|
||||
});
|
||||
test('It shows the project list when node type is project sync', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NodeTypeStep
|
||||
nodeType="project_sync"
|
||||
onUpdateDescription={onUpdateDescription}
|
||||
onUpdateName={onUpdateName}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
onUpdateNodeType={onUpdateNodeType}
|
||||
onUpdateTimeout={onUpdateTimeout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('project_sync');
|
||||
expect(wrapper.find('ProjectsList').length).toBe(1);
|
||||
wrapper.find('DataListRadio').simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
type: 'project',
|
||||
url: '/api/v2/projects/1',
|
||||
});
|
||||
});
|
||||
test('It shows the inventory source list when node type is inventory source sync', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NodeTypeStep
|
||||
nodeType="inventory_source_sync"
|
||||
onUpdateDescription={onUpdateDescription}
|
||||
onUpdateName={onUpdateName}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
onUpdateNodeType={onUpdateNodeType}
|
||||
onUpdateTimeout={onUpdateTimeout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
|
||||
'inventory_source_sync'
|
||||
);
|
||||
expect(wrapper.find('InventorySourcesList').length).toBe(1);
|
||||
wrapper.find('DataListRadio').simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Test Inventory Source',
|
||||
type: 'inventory_source',
|
||||
url: '/api/v2/inventory_sources/1',
|
||||
});
|
||||
});
|
||||
test('It shows the workflow job template list when node type is workflow job template', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NodeTypeStep
|
||||
nodeType="workflow_job_template"
|
||||
onUpdateDescription={onUpdateDescription}
|
||||
onUpdateName={onUpdateName}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
onUpdateNodeType={onUpdateNodeType}
|
||||
onUpdateTimeout={onUpdateTimeout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AnsibleSelect').prop('value')).toBe(
|
||||
'workflow_job_template'
|
||||
);
|
||||
expect(wrapper.find('WorkflowJobTemplatesList').length).toBe(1);
|
||||
wrapper.find('DataListRadio').simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Test Workflow Job Template',
|
||||
type: 'workflow_job_template',
|
||||
url: '/api/v2/workflow_job_templates/1',
|
||||
});
|
||||
});
|
||||
test('It shows the approval form fields when node type is approval', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<NodeTypeStep
|
||||
nodeType="approval"
|
||||
onUpdateDescription={onUpdateDescription}
|
||||
onUpdateName={onUpdateName}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
onUpdateNodeType={onUpdateNodeType}
|
||||
onUpdateTimeout={onUpdateTimeout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('approval');
|
||||
expect(wrapper.find('input#approval-name').length).toBe(1);
|
||||
expect(wrapper.find('input#approval-description').length).toBe(1);
|
||||
expect(wrapper.find('input#approval-timeout-minutes').length).toBe(1);
|
||||
expect(wrapper.find('input#approval-timeout-seconds').length).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('input#approval-name').simulate('change', {
|
||||
target: { value: 'Test Approval', name: 'name' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onUpdateName).toHaveBeenCalledWith('Test Approval');
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('input#approval-description').simulate('change', {
|
||||
target: { value: 'Test Approval Description', name: 'description' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onUpdateDescription).toHaveBeenCalledWith(
|
||||
'Test Approval Description'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('input#approval-timeout-minutes').simulate('change', {
|
||||
target: { value: 5, name: 'timeoutMinutes' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onUpdateTimeout).toHaveBeenCalledWith(300);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('input#approval-timeout-seconds').simulate('change', {
|
||||
target: { value: 30, name: 'timeoutSeconds' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onUpdateTimeout).toHaveBeenCalledWith(330);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,113 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { ProjectsAPI } from '@api';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import PaginatedDataList from '@components/PaginatedDataList';
|
||||
import DataListToolbar from '@components/DataListToolbar';
|
||||
import CheckboxListItem from '@components/CheckboxListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('projects', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function ProjectsList({ history, i18n, nodeResource, onUpdateNodeResource }) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [projects, setProjects] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
setProjects([]);
|
||||
setCount(0);
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await ProjectsAPI.read(params);
|
||||
setProjects(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
|
||||
return (
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
itemCount={count}
|
||||
items={projects}
|
||||
onRowClick={row => onUpdateNodeResource(row)}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={!!(nodeResource && nodeResource.id === item.id)}
|
||||
itemId={item.id}
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
label={item.name}
|
||||
onSelect={() => onUpdateNodeResource(item)}
|
||||
onDeselect={() => onUpdateNodeResource(null)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
showPageSizeOptions={false}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'type',
|
||||
options: [
|
||||
[``, i18n._(t`Manual`)],
|
||||
[`git`, i18n._(t`Git`)],
|
||||
[`hg`, i18n._(t`Mercurial`)],
|
||||
[`svn`, i18n._(t`Subversion`)],
|
||||
[`insights`, i18n._(t`Red Hat Insights`)],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: i18n._(t`SCM URL`),
|
||||
key: 'scm_url',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectsList.propTypes = {
|
||||
nodeResource: shape(),
|
||||
onUpdateNodeResource: func.isRequired,
|
||||
};
|
||||
|
||||
ProjectsList.defaultProps = {
|
||||
nodeResource: null,
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(ProjectsList));
|
||||
@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { ProjectsAPI } from '@api';
|
||||
import ProjectsList from './ProjectsList';
|
||||
|
||||
jest.mock('@api/models/Projects');
|
||||
|
||||
const nodeResource = {
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
unified_job_type: 'project_update',
|
||||
};
|
||||
const onUpdateNodeResource = jest.fn();
|
||||
|
||||
describe('ProjectsList', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
|
||||
ProjectsAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
type: 'project',
|
||||
url: '/api/v2/projects/1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Project 2',
|
||||
type: 'project',
|
||||
url: '/api/v2/projects/2',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectsList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('CheckboxListItem[name="Test Project"]').props().isSelected
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.find('CheckboxListItem[name="Test Project 2"]').props().isSelected
|
||||
).toBe(false);
|
||||
wrapper.find('CheckboxListItem[name="Test Project 2"]').simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 2,
|
||||
name: 'Test Project 2',
|
||||
type: 'project',
|
||||
url: '/api/v2/projects/2',
|
||||
});
|
||||
});
|
||||
test('Error shown when read() request errors', async () => {
|
||||
ProjectsAPI.read.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectsList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,113 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { WorkflowJobTemplatesAPI } from '@api';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import PaginatedDataList from '@components/PaginatedDataList';
|
||||
import DataListToolbar from '@components/DataListToolbar';
|
||||
import CheckboxListItem from '@components/CheckboxListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('workflow_job_templates', {
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
function WorkflowJobTemplatesList({
|
||||
history,
|
||||
i18n,
|
||||
nodeResource,
|
||||
onUpdateNodeResource,
|
||||
}) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
setWorkflowJobTemplates([]);
|
||||
setCount(0);
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
try {
|
||||
const { data } = await WorkflowJobTemplatesAPI.read(params, {
|
||||
role_level: 'execute_role',
|
||||
});
|
||||
setWorkflowJobTemplates(data.results);
|
||||
setCount(data.count);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [history.location]);
|
||||
|
||||
return (
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
itemCount={count}
|
||||
items={workflowJobTemplates}
|
||||
onRowClick={row => onUpdateNodeResource(row)}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={item => (
|
||||
<CheckboxListItem
|
||||
isSelected={!!(nodeResource && nodeResource.id === item.id)}
|
||||
itemId={item.id}
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
label={item.name}
|
||||
onSelect={() => onUpdateNodeResource(item)}
|
||||
onDeselect={() => onUpdateNodeResource(null)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
showPageSizeOptions={false}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Organization (Name)`),
|
||||
key: 'organization__name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Inventory (Name)`),
|
||||
key: 'inventory__name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowJobTemplatesList.propTypes = {
|
||||
nodeResource: shape(),
|
||||
onUpdateNodeResource: func.isRequired,
|
||||
};
|
||||
|
||||
WorkflowJobTemplatesList.defaultProps = {
|
||||
nodeResource: null,
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(WorkflowJobTemplatesList));
|
||||
@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { WorkflowJobTemplatesAPI } from '@api';
|
||||
import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
|
||||
|
||||
jest.mock('@api/models/WorkflowJobTemplates');
|
||||
|
||||
const nodeResource = {
|
||||
id: 1,
|
||||
name: 'Test Workflow Job Template',
|
||||
unified_job_type: 'workflow_job',
|
||||
};
|
||||
const onUpdateNodeResource = jest.fn();
|
||||
|
||||
describe('WorkflowJobTemplatesList', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
|
||||
WorkflowJobTemplatesAPI.read.mockResolvedValueOnce({
|
||||
data: {
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Workflow Job Template',
|
||||
type: 'workflow_job_template',
|
||||
url: '/api/v2/workflow_job_templates/1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Workflow Job Template 2',
|
||||
type: 'workflow_job_template',
|
||||
url: '/api/v2/workflow_job_templates/2',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowJobTemplatesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper
|
||||
.find('CheckboxListItem[name="Test Workflow Job Template"]')
|
||||
.props().isSelected
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('CheckboxListItem[name="Test Workflow Job Template 2"]')
|
||||
.props().isSelected
|
||||
).toBe(false);
|
||||
wrapper
|
||||
.find('CheckboxListItem[name="Test Workflow Job Template 2"]')
|
||||
.simulate('click');
|
||||
expect(onUpdateNodeResource).toHaveBeenCalledWith({
|
||||
id: 2,
|
||||
name: 'Test Workflow Job Template 2',
|
||||
type: 'workflow_job_template',
|
||||
url: '/api/v2/workflow_job_templates/2',
|
||||
});
|
||||
});
|
||||
test('Error shown when read() request errors', async () => {
|
||||
WorkflowJobTemplatesAPI.read.mockRejectedValue(new Error());
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowJobTemplatesList
|
||||
nodeResource={nodeResource}
|
||||
onUpdateNodeResource={onUpdateNodeResource}
|
||||
/>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
export { default as InventorySourcesList } from './InventorySourcesList';
|
||||
export { default as JobTemplatesList } from './JobTemplatesList';
|
||||
export { default as NodeTypeStep } from './NodeTypeStep';
|
||||
export { default as ProjectsList } from './ProjectsList';
|
||||
export {
|
||||
default as WorkflowJobTemplatesList,
|
||||
} from './WorkflowJobTemplatesList';
|
||||
@ -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 (
|
||||
<Modal
|
||||
isLarge
|
||||
isOpen
|
||||
title={i18n._(t`Node Details`)}
|
||||
onClose={() => dispatch({ type: 'SET_NODE_TO_VIEW', value: null })}
|
||||
>
|
||||
Coming soon :)
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(NodeViewModal);
|
||||
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { WorkflowDispatchContext } from '@contexts/Workflow';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import NodeViewModal from './NodeViewModal';
|
||||
|
||||
let wrapper;
|
||||
const dispatch = jest.fn();
|
||||
|
||||
describe('NodeViewModal', () => {
|
||||
test('Close button dispatches as expected', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<WorkflowDispatchContext.Provider value={dispatch}>
|
||||
<NodeViewModal />
|
||||
</WorkflowDispatchContext.Provider>
|
||||
);
|
||||
wrapper.find('TimesIcon').simulate('click');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_NODE_TO_VIEW',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import { func, string } from 'prop-types';
|
||||
import { Title } from '@patternfly/react-core';
|
||||
import SelectableCard from '@components/SelectableCard';
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-auto-rows: 100px;
|
||||
grid-gap: 20px;
|
||||
grid-template-columns: 33% 33% 33%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
margin: 20px 0px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
function RunStep({ i18n, linkType, onUpdateLinkType }) {
|
||||
return (
|
||||
<>
|
||||
<Title headingLevel="h1" size="xl">
|
||||
{i18n._(t`Run`)}
|
||||
</Title>
|
||||
<p>
|
||||
{i18n._(
|
||||
t`Specify the conditions under which this node should be executed`
|
||||
)}
|
||||
</p>
|
||||
<Grid>
|
||||
<SelectableCard
|
||||
id="link-type-success"
|
||||
isSelected={linkType === 'success'}
|
||||
label={i18n._(t`On Success`)}
|
||||
description={i18n._(
|
||||
t`Execute when the parent node results in a successful state.`
|
||||
)}
|
||||
onClick={() => onUpdateLinkType('success')}
|
||||
/>
|
||||
<SelectableCard
|
||||
id="link-type-failure"
|
||||
isSelected={linkType === 'failure'}
|
||||
label={i18n._(t`On Failure`)}
|
||||
description={i18n._(
|
||||
t`Execute when the parent node results in a failure state.`
|
||||
)}
|
||||
onClick={() => onUpdateLinkType('failure')}
|
||||
/>
|
||||
<SelectableCard
|
||||
id="link-type-always"
|
||||
isSelected={linkType === 'always'}
|
||||
label={i18n._(t`Always`)}
|
||||
description={i18n._(
|
||||
t`Execute regardless of the parent node's final state.`
|
||||
)}
|
||||
onClick={() => onUpdateLinkType('always')}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
RunStep.propTypes = {
|
||||
linkType: string.isRequired,
|
||||
onUpdateLinkType: func.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(RunStep);
|
||||
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import RunStep from './RunStep';
|
||||
|
||||
let wrapper;
|
||||
const linkType = 'always';
|
||||
const onUpdateLinkType = jest.fn();
|
||||
|
||||
describe('RunStep', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<RunStep linkType={linkType} onUpdateLinkType={onUpdateLinkType} />
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Default selected card matches default link type when present', () => {
|
||||
expect(wrapper.find('#link-type-success').props().isSelected).toBe(false);
|
||||
expect(wrapper.find('#link-type-failure').props().isSelected).toBe(false);
|
||||
expect(wrapper.find('#link-type-always').props().isSelected).toBe(true);
|
||||
});
|
||||
|
||||
test('Clicking success card makes expected callback', () => {
|
||||
wrapper.find('#link-type-success').simulate('click');
|
||||
expect(onUpdateLinkType).toHaveBeenCalledWith('success');
|
||||
});
|
||||
|
||||
test('Clicking failure card makes expected callback', () => {
|
||||
wrapper.find('#link-type-failure').simulate('click');
|
||||
expect(onUpdateLinkType).toHaveBeenCalledWith('failure');
|
||||
});
|
||||
|
||||
test('Clicking always card makes expected callback', () => {
|
||||
wrapper.find('#link-type-always').simulate('click');
|
||||
expect(onUpdateLinkType).toHaveBeenCalledWith('always');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user