diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index d554ce8cc0..b9b7cbc7cc 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -905,6 +905,12 @@ input[type="checkbox"].checkbox-no-label { margin-top: 10px; } +.radio-group { + .radio-inline + .radio-inline { + margin-left: 0; + } +} + .checkbox-group { .radio-inline + .radio-inline, .checkbox-inline + .checkbox-inline { diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less index b36f1bdd5b..89ff33dd03 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -184,6 +184,7 @@ .Form-formGroup--fullWidth { max-width: none !important; width: 100% !important; + padding-right: 0px !important; } .Form-formGroup--checkbox{ @@ -553,19 +554,24 @@ input[type='radio']:checked:before { color: @btn-txt; } -.Form-surveyButton { +.Form-primaryButton { background-color: @default-link; color: @default-bg; text-transform: uppercase; padding-left:15px; padding-right: 15px; + margin-right: 20px; } -.Form-surveyButton:hover{ +.Form-primaryButton:hover { background-color: @default-link-hov; color: @default-bg; } +.Form-primaryButton.Form-tab--disabled:hover { + background-color: @default-link; +} + .Form-formGroup--singleColumn { width: 100% !important; padding-right: 0px; diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index 763694f6d2..ee69342e0f 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -357,6 +357,32 @@ table, tbody { cursor: not-allowed; } +.List-dropdownButton { + border: none; +} + +.List-dropdownSuccess { + background-color: @submit-button-bg; + color: @submit-button-text; + border-color: @submit-button-bg-hov; +} + +.List-dropdownSuccess:hover, +.List-dropdownSuccess:focus { + color: @submit-button-text; + background-color: @submit-button-bg-hov; +} + +.List-dropdownCarat { + display: inline-block; + width: 0; + height: 0; + vertical-align: middle; + border-top: 4px dashed; + border-right: 4px solid transparent; + border-left: 4px solid transparent; +} + @media (max-width: 991px) { .List-searchWidget + .List-searchWidget { margin-top: 20px; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 664361123b..3174bc6b98 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -38,7 +38,6 @@ if ($basePath) { // Modules import './helpers'; -import * as forms from './forms'; import './lists'; import './widgets'; import './filters'; @@ -53,6 +52,7 @@ import inventoryScripts from './inventory-scripts/main'; import organizations from './organizations/main'; import managementJobs from './management-jobs/main'; import jobDetail from './job-detail/main'; +import workflowResults from './workflow-results/main'; import jobSubmission from './job-submission/main'; import notifications from './notifications/main'; import about from './about/main'; @@ -122,6 +122,7 @@ var tower = angular.module('Tower', [ activityStream.name, footer.name, jobDetail.name, + workflowResults.name, jobSubmission.name, notifications.name, standardOut.name, @@ -168,7 +169,6 @@ var tower = angular.module('Tower', [ 'ProjectsHelper', 'CompletedJobsDefinition', 'AllJobsDefinition', - 'JobFormDefinition', 'JobSummaryDefinition', 'ParseHelper', 'ChildrenHelper', @@ -202,6 +202,9 @@ var tower = angular.module('Tower', [ 'ActivityStreamHelper', 'gettext', 'I18N', + 'WorkflowFormDefinition', + 'InventorySourcesListDefinition', + 'WorkflowMakerFormDefinition' ]) .constant('AngularScheduler.partials', urlPrefix + 'lib/angular-scheduler/lib/') diff --git a/awx/ui/client/src/controllers/Jobs.js b/awx/ui/client/src/controllers/Jobs.js index a01b225bb1..980f440a26 100644 --- a/awx/ui/client/src/controllers/Jobs.js +++ b/awx/ui/client/src/controllers/Jobs.js @@ -84,6 +84,9 @@ export function JobsListController($state, $rootScope, $log, $scope, $compile, $ case 'inventory_update': goToJobDetails('inventorySyncStdout'); break; + case 'workflow_job': + goToJobDetails('workflowResults'); + break; } }; diff --git a/awx/ui/client/src/controllers/Users.js b/awx/ui/client/src/controllers/Users.js index 3a7f1b86a8..61ffb69d2e 100644 --- a/awx/ui/client/src/controllers/Users.js +++ b/awx/ui/client/src/controllers/Users.js @@ -114,7 +114,7 @@ UsersList.$inject = ['$scope', '$rootScope', '$stateParams', export function UsersAdd($scope, $rootScope, $stateParams, UserForm, GenerateForm, Rest, Alert, ProcessErrors, ReturnToCaller, ClearScope, - GetBasePath, ResetForm, Wait, CreateSelect2, $state, i18n) { + GetBasePath, ResetForm, Wait, CreateSelect2, $state, $location) { ClearScope(); @@ -201,7 +201,7 @@ export function UsersAdd($scope, $rootScope, $stateParams, UserForm, UsersAdd.$inject = ['$scope', '$rootScope', '$stateParams', 'UserForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'ReturnToCaller', 'ClearScope', 'GetBasePath', - 'ResetForm', 'Wait', 'CreateSelect2', '$state', 'i18n' + 'ResetForm', 'Wait', 'CreateSelect2', '$state', '$location' ]; export function UsersEdit($scope, $rootScope, $location, diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js index 5052e073a4..714b3c088a 100644 --- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js +++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js @@ -2,8 +2,8 @@ export default [ 'InitiatePlaybookRun', 'templateUrl', - '$location', - function JobTemplatesList(InitiatePlaybookRun, templateUrl, $location) { + '$state', + function JobTemplatesList(InitiatePlaybookRun, templateUrl, $state) { return { restrict: 'E', link: link, @@ -47,7 +47,7 @@ export default }; scope.editJobTemplate = function (jobTemplateId) { - $location.path( '/job_templates/' + jobTemplateId); + $state.go('templates.editJobTemplate', {id: jobTemplateId}); }; } }]; diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html index 8114f037d4..77ba2f41ac 100644 --- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html +++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html @@ -3,7 +3,7 @@

RECENTLY USED JOB TEMPLATES

- + VIEW ALL @@ -25,7 +25,7 @@ ng-class-even="'List-tableRow--evenRow'" ng-repeat = "job_template in job_templates"> - + {{ job_template.name }} @@ -53,7 +53,7 @@
-

No job templates were recently used.
- You can create a job template here.

+

No job templates were recently used.
+ You can create a job template here.

diff --git a/awx/ui/client/src/forms.js b/awx/ui/client/src/forms.js index 51cbc4a6e7..db46a6d0d8 100644 --- a/awx/ui/client/src/forms.js +++ b/awx/ui/client/src/forms.js @@ -16,7 +16,6 @@ import JobEventData from "./forms/JobEventData"; import JobSummary from "./forms/JobSummary"; import JobTemplates from "./forms/JobTemplates"; import JobVarsPrompt from "./forms/JobVarsPrompt"; -import Jobs from "./forms/Jobs"; import LogViewerOptions from "./forms/LogViewerOptions"; import LogViewerStatus from "./forms/LogViewerStatus"; import Organizations from "./forms/Organizations"; @@ -24,6 +23,8 @@ import ProjectStatus from "./forms/ProjectStatus"; import Projects from "./forms/Projects"; import Teams from "./forms/Teams"; import Users from "./forms/Users"; +import WorkflowMaker from "./forms/WorkflowMaker"; +import Workflows from "./forms/Workflows"; export @@ -39,12 +40,13 @@ export JobSummary, JobTemplates, JobVarsPrompt, - Jobs, LogViewerOptions, LogViewerStatus, Organizations, ProjectStatus, Projects, Teams, - Users + Users, + WorkflowMaker, + Workflows }; diff --git a/awx/ui/client/src/forms/Inventories.js b/awx/ui/client/src/forms/Inventories.js index 06038da005..49692f476e 100644 --- a/awx/ui/client/src/forms/Inventories.js +++ b/awx/ui/client/src/forms/Inventories.js @@ -132,7 +132,7 @@ angular.module('InventoryFormDefinition', ['ScanJobsListDefinition']) } }, - relatedSets: function(urls) { + relatedSets: function() { return { permissions: { awToolTip: i18n._('Please save before assigning permissions'), diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index 0c276777dc..00f4c5f131 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -20,10 +20,12 @@ export default addTitle: i18n._('New Job Template'), editTitle: '{{ name }}', name: 'job_template', + breadcrumbName: 'JOB TEMPLATE', basePath: 'job_templates', // the top-most node of generated state tree - stateTree: 'jobTemplates', + stateTree: 'templates', tabs: true, + activeEditState: 'templates.editJobTemplate', // (optional) array of supporting templates to ng-include inside generated html include: ['/static/partials/survey-maker-modal.html'], @@ -31,7 +33,7 @@ export default name: { label: i18n._('Name'), type: 'text', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)', + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)', required: true, column: 1 }, @@ -39,7 +41,7 @@ export default label: i18n._('Description'), type: 'text', column: 1, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, job_type: { label: i18n._('Job Type'), @@ -61,7 +63,7 @@ export default ngShow: "!job_type.value || job_type.value !== 'scan'", text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, inventory: { label: i18n._('Inventory'), @@ -85,7 +87,7 @@ export default ngShow: "!job_type.value || job_type.value !== 'scan'", text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, project: { label: i18n._('Project'), @@ -108,13 +110,13 @@ export default dataTitle: i18n._('Project'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, playbook: { label: i18n._('Playbook'), type:'select', ngOptions: 'book for book in playbook_options track by book', - ngDisabled: "(job_type.value === 'scan' && project_name === 'Default') || !(job_template_obj.summary_fields.user_capabilities.edit || canAdd)", + ngDisabled: "(job_type.value === 'scan' && project_name === 'Default') || !(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)", id: 'playbook-select', awRequiredWhen: { reqExpression: "playbookrequired", @@ -152,7 +154,7 @@ export default variable: 'ask_credential_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, cloud_credential: { label: i18n._('Cloud Credential'), @@ -170,7 +172,7 @@ export default dataTitle: i18n._('Cloud Credential'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, network_credential: { label: i18n._('Network Credential'), @@ -187,7 +189,7 @@ export default dataTitle: i18n._('Network Credential'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, forks: { label: i18n._('Forks'), @@ -205,7 +207,7 @@ export default dataTitle: i18n._('Forks'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' // TODO: get working }, limit: { label: i18n._('Limit'), @@ -221,7 +223,7 @@ export default variable: 'ask_limit_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, verbosity: { label: i18n._('Verbosity'), @@ -234,7 +236,7 @@ export default dataTitle: i18n._('Verbosity'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, job_tags: { label: i18n._('Job Tags'), @@ -252,7 +254,7 @@ export default variable: 'ask_tags_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, skip_tags: { label: i18n._('Skip Tags'), @@ -270,7 +272,7 @@ export default variable: 'ask_skip_tags_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, checkbox_group: { label: i18n._('Options'), @@ -285,7 +287,7 @@ export default dataTitle: i18n._('Become Privilege Escalation'), dataContainer: "body", labelClass: 'stack-inline', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, { name: 'allow_callbacks', label: i18n._('Allow Provisioning Callbacks'), @@ -298,7 +300,7 @@ export default dataTitle: i18n._('Allow Provisioning Callbacks'), dataContainer: "body", labelClass: 'stack-inline', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }] }, callback_url: { @@ -312,7 +314,7 @@ export default dataPlacement: 'top', dataTitle: i18n._('Provisioning Callback URL'), dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, host_config_key: { label: i18n._('Host Config Key'), @@ -326,7 +328,7 @@ export default dataPlacement: 'right', dataTitle: i18n._("Host Config Key"), dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, labels: { label: i18n._('Labels'), @@ -338,7 +340,7 @@ export default dataPlacement: 'right', awPopOver: i18n._("

Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs in the Tower display.

"), dataContainer: 'body', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, variables: { label: i18n._('Extra Variables'), @@ -360,14 +362,14 @@ export default variable: 'ask_variables_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' // TODO: get working } }, buttons: { //for now always generates + + + + + + +
+
+
EDIT WORKFLOW
+
+
+ +
+
+
+
+
+
+
KEY:
+
+
+
On Success
+
+
+
+
On Fail
+
+
+
+
Always
+
+
+
P
+
Project Sync
+
+
+
I
+
Inventory Sync
+
+
+
+ TOTAL JOBS + +
+
+ +
+
+
{{(workflowMakerFormConfig.nodeMode === 'edit' && nodeBeingEdited && nodeBeingEdited.unifiedJobTemplate && nodeBeingEdited.unifiedJobTemplate.name) ? nodeBeingEdited.unifiedJobTemplate.name : "ADD A TEMPLATE"}}
+
Please hover over a template and click the Add button.
+
+
+
JOBS
+
PROJECT SYNC
+
INVENTORY SYNC
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ diff --git a/awx/ui/client/src/license/license.controller.js b/awx/ui/client/src/license/license.controller.js index 8b0cae9269..682f1cfb01 100644 --- a/awx/ui/client/src/license/license.controller.js +++ b/awx/ui/client/src/license/license.controller.js @@ -13,6 +13,48 @@ export default function( Wait, $state, $scope, $rootScope, $location, GetBasePath, Rest, ProcessErrors, CheckLicense, moment, $window, ConfigService, FeaturesService, pendoService, i18n){ + + var calcDaysRemaining = function(seconds){ + // calculate the number of days remaining on the license + var duration = moment.duration(seconds, 'seconds').asDays(); + duration = Math.floor(duration); + if(duration < 0 ){ + duration = 0; + } + duration = (duration!==1) ? `${duration} Days` : `${duration} Day`; + return duration; + }; + + + var calcExpiresOn = function(days){ + // calculate the expiration date of the license + days = parseInt(days); + return moment().add(days, 'days').calendar(); + }; + + var reset = function(){ + document.getElementById('License-form').reset(); + }; + + var init = function(){ + // license/license.partial.html compares fileName + $scope.fileName = N_("No file selected."); + $scope.title = $rootScope.licenseMissing ? ("Tower " + i18n._("License")) : i18n._("License Management"); + Wait('start'); + ConfigService.getConfig().then(function(config){ + $scope.license = config; + $scope.license.version = config.version.split('-')[0]; + $scope.time = {}; + $scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining); + $scope.time.expiresOn = calcExpiresOn($scope.time.remaining); + $scope.valid = CheckLicense.valid($scope.license.license_info); + $scope.compliant = $scope.license.license_info.compliant; + Wait('stop'); + }); + }; + + init(); + $scope.getKey = function(event){ // Mimic HTML5 spec, show filename $scope.fileName = event.target.files[0].name; @@ -73,43 +115,5 @@ export default }); }); }; - var calcDaysRemaining = function(seconds){ - // calculate the number of days remaining on the license - var duration = moment.duration(seconds, 'seconds').asDays(); - duration = Math.floor(duration); - if(duration < 0 ){ - duration = 0; - } - duration = (duration!==1) ? `${duration} Days` : `${duration} Day`; - return duration; - }; - - - var calcExpiresOn = function(days){ - // calculate the expiration date of the license - days = parseInt(days); - return moment().add(days, 'days').calendar(); - }; - - var init = function(){ - // license/license.partial.html compares fileName - $scope.fileName = N_("No file selected."); - $scope.title = $rootScope.licenseMissing ? ("Tower " + i18n._("License")) : i18n._("License Management"); - Wait('start'); - ConfigService.getConfig().then(function(config){ - $scope.license = config; - $scope.license.version = config.version.split('-')[0]; - $scope.time = {}; - $scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining); - $scope.time.expiresOn = calcExpiresOn($scope.time.remaining); - $scope.valid = CheckLicense.valid($scope.license.license_info); - $scope.compliant = $scope.license.license_info.compliant; - Wait('stop'); - }); - }; - var reset = function(){ - document.getElementById('License-form').reset(); - }; - init(); } - ]; +]; diff --git a/awx/ui/client/src/lists.js b/awx/ui/client/src/lists.js index 545c3189fb..f7b0288822 100644 --- a/awx/ui/client/src/lists.js +++ b/awx/ui/client/src/lists.js @@ -13,6 +13,7 @@ import Hosts from "./lists/Hosts"; import Inventories from "./lists/Inventories"; import InventoryGroups from "./lists/InventoryGroups"; import InventoryHosts from "./lists/InventoryHosts"; +import InventorySources from "./lists/InventorySources"; import JobEvents from "./lists/JobEvents"; import JobHosts from "./lists/JobHosts"; import JobTemplates from "./lists/JobTemplates"; @@ -38,6 +39,7 @@ export Inventories, InventoryGroups, InventoryHosts, + InventorySources, JobEvents, JobHosts, JobTemplates, diff --git a/awx/ui/client/src/lists/InventorySources.js b/awx/ui/client/src/lists/InventorySources.js new file mode 100644 index 0000000000..127352c72b --- /dev/null +++ b/awx/ui/client/src/lists/InventorySources.js @@ -0,0 +1,30 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + +export default + angular.module('InventorySourcesListDefinition', []) + .value('InventorySourcesList', { + + name: 'workflow_inventory_sources', + iterator: 'inventory_source', + basePath: 'inventory_sources', + listTitle: 'Inventory Sources', + index: false, + hover: true, + + fields: { + name: { + key: true, + label: 'Name', + columnClass: 'col-md-11' + } + }, + + actions: {}, + + fieldActions: {} + }); diff --git a/awx/ui/client/src/lists/JobTemplates.js b/awx/ui/client/src/lists/JobTemplates.js index e809683460..0713402775 100644 --- a/awx/ui/client/src/lists/JobTemplates.js +++ b/awx/ui/client/src/lists/JobTemplates.js @@ -10,11 +10,12 @@ export default .factory('JobTemplateList', ['i18n', function(i18n) { return { - name: 'job_templates', - iterator: 'job_template', - selectTitle: i18n._('Add Job Template'), - editTitle: i18n._('Job Templates'), - listTitle: i18n._('Job Templates'), + name: 'templates', + iterator: 'template', + basePath: 'unified_job_templates', + selectTitle: i18n._('Template'), + editTitle: i18n._('Templates'), + listTitle: i18n._('Templates'), selectInstructions: "Click on a row to select it, and click Finished when done. Use the " + "button to create a new job template.", index: false, @@ -24,7 +25,14 @@ export default name: { key: true, label: i18n._('Name'), - columnClass: 'col-lg-2 col-md-2 col-sm-4 col-xs-9' + columnClass: 'col-lg-2 col-md-2 col-sm-4 col-xs-9', + ngClick: "editJobTemplate(template)" + }, + type: { + label: i18n._('Type'), + searchType: 'select', + searchOptions: [], // will be set by Options call to job templates resource + columnClass: 'col-lg-2 col-md-2 col-sm-4 hidden-xs' }, description: { label: i18n._('Description'), @@ -41,73 +49,85 @@ export default label: i18n._('Labels'), type: 'labels', nosort: true, - columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs' + columnClass: 'List-tableCell col-lg-2 col-md-4 hidden-sm hidden-xs' } }, actions: { add: { mode: 'all', // One of: edit, select, all - ngClick: 'addJobTemplate()', - basePaths: ['job_templates'], + type: 'buttonDropdown', + basePaths: ['templates'], awToolTip: i18n._('Create a new template'), - actionClass: 'btn List-buttonSubmit', - buttonContent: i18n._('+ ADD'), - ngShow: 'canAdd' + actionClass: 'btn List-dropdownSuccess', + buttonContent: i18n._('ADD'), + options: [ + { + optionContent: 'Job Template', + optionSref: 'templates.addJobTemplate', + ngShow: 'canAddJobTemplate' + }, + { + optionContent: 'Workflow Job Template', + optionSref: 'templates.addWorkflowJobTemplate', + ngShow: 'canAddWorkflowJobTemplate' + } + ], + ngShow: 'canAddJobTemplate || canAddWorkflowJobTemplate' } }, fieldActions: { - columnClass: 'col-lg-2 col-md-3 col-sm-3 col-xs-3', + columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-3', submit: { label: i18n._('Launch'), mode: 'all', - ngClick: 'submitJob(job_template.id)', + ngClick: 'submitJob(template)', awToolTip: i18n._('Start a job using this template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.start' + ngShow: 'template.summary_fields.user_capabilities.start' }, schedule: { label: i18n._('Schedule'), mode: 'all', - ngClick: 'scheduleJob(job_template.id)', + ngClick: 'scheduleJob(template)', awToolTip: i18n._('Schedule future job template runs'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.schedule' + ngShow: 'template.summary_fields.user_capabilities.schedule' }, copy: { label: i18n._('Copy'), - 'ui-sref': 'jobTemplates.copy({id: job_template.id})', + 'ui-sref': 'templates.copy({id: template.id})', "class": 'btn-danger btn-xs', awToolTip: i18n._('Copy template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.copy' + ngShow: 'template.summary_fields.user_capabilities.copy' }, edit: { label: i18n._('Edit'), - ngClick: "editJobTemplate(job_template.id)", + ngClick: "editJobTemplate(template)", awToolTip: i18n._('Edit template'), "class": 'btn-default btn-xs', dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.edit' + ngShow: 'template.summary_fields.user_capabilities.edit' }, view: { label: i18n._('View'), - ngClick: "editJobTemplate(job_template.id)", + ngClick: "editJobTemplate(template.id)", awToolTip: i18n._('View template'), "class": 'btn-default btn-xs', dataPlacement: 'top', - ngShow: '!job_template.summary_fields.user_capabilities.edit' + ngShow: '!template.summary_fields.user_capabilities.edit' }, "delete": { label: i18n._('Delete'), - ngClick: "deleteJobTemplate(job_template.id, job_template.name)", + ngClick: "deleteJobTemplate(template)", "class": 'btn-danger btn-xs', awToolTip: i18n._('Delete template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.delete' + ngShow: 'template.summary_fields.user_capabilities.delete' } } };}]); diff --git a/awx/ui/client/src/lists/PortalJobTemplates.js b/awx/ui/client/src/lists/PortalJobTemplates.js index 9a9f8c0ca7..b542ac1181 100644 --- a/awx/ui/client/src/lists/PortalJobTemplates.js +++ b/awx/ui/client/src/lists/PortalJobTemplates.js @@ -23,7 +23,7 @@ export default key: true, label: i18n._('Name'), columnClass: 'col-lg-5 col-md-5 col-sm-9 col-xs-8', - linkTo: '/#/job_templates/{{job_template.id}}', + linkTo: '/#/templates/{{job_template.id}}' }, description: { label: i18n._('Description'), diff --git a/awx/ui/client/src/lists/Projects.js b/awx/ui/client/src/lists/Projects.js index ce478129ac..31d176bd98 100644 --- a/awx/ui/client/src/lists/Projects.js +++ b/awx/ui/client/src/lists/Projects.js @@ -11,6 +11,7 @@ export default name: 'projects', iterator: 'project', + basePath: 'projects', selectTitle: i18n._('Add Project'), editTitle: i18n._('Projects'), listTitle: i18n._('Projects'), diff --git a/awx/ui/client/src/login/authenticationServices/pendo.service.js b/awx/ui/client/src/login/authenticationServices/pendo.service.js index 10cdbd33d8..ebcad0f03f 100644 --- a/awx/ui/client/src/login/authenticationServices/pendo.service.js +++ b/awx/ui/client/src/login/authenticationServices/pendo.service.js @@ -94,10 +94,10 @@ export default }, issuePendoIdentity: function () { - var config, - options, + var options, c = ConfigService.get(), - config = c.license_info; + config = c.license_info; + config.analytics_status = c.analytics_status; config.version = c.version; config.ansible_version = c.ansible_version; @@ -114,7 +114,7 @@ export default }); } else { - $log.debug('Pendo is turned off.') + $log.debug('Pendo is turned off.'); } } }; diff --git a/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js b/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js index 3d6e86ae02..d0e79872e1 100644 --- a/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js +++ b/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js @@ -1,3 +1,5 @@ +/* jshint ignore:start */ + /* * pendo.io Angular Module * @@ -25,7 +27,7 @@ setTimeout(waitFn, delay); } }; - + angular.module('pendolytics', []) .provider('$pendolytics', function() { diff --git a/awx/ui/client/src/main-menu/main-menu.partial.html b/awx/ui/client/src/main-menu/main-menu.partial.html index 558d733197..398488add7 100644 --- a/awx/ui/client/src/main-menu/main-menu.partial.html +++ b/awx/ui/client/src/main-menu/main-menu.partial.html @@ -29,10 +29,10 @@ + href="/#/templates" + ng-class="{'is-currentRoute' : isCurrentState('templates')}"> - JOB TEMPLATES + TEMPLATES + ng-class="{'is-currentRoute' : isCurrentState('templates'), 'is-loggedOut' : !current_user || !current_user.username}"> - JOB TEMPLATES + TEMPLATES { + $scope.$on('selectedOrDeselected', ()=>{ throw {name: 'NotYetImplemented'}; }); } diff --git a/awx/ui/client/src/organizations/linkout/main.js b/awx/ui/client/src/organizations/linkout/main.js index 0701b87b96..e6d31a156c 100644 --- a/awx/ui/client/src/organizations/linkout/main.js +++ b/awx/ui/client/src/organizations/linkout/main.js @@ -1,4 +1,3 @@ -import routes from './organizations-linkout.route'; import AddUsers from './addUsers/main'; export default angular.module('organizationsLinkout', [AddUsers.name]); diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js index 207ab2d8ec..c22b3c1049 100644 --- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js +++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js @@ -4,7 +4,6 @@ * All Rights Reserved *************************************************/ -import { templateUrl } from '../../shared/template-url/template-url.factory'; import OrganizationsAdmins from './controllers/organizations-admins.controller'; import OrganizationsInventories from './controllers/organizations-inventories.controller'; import OrganizationsJobTemplates from './controllers/organizations-job-templates.controller'; diff --git a/awx/ui/client/src/partials/job-template-smart-status.html b/awx/ui/client/src/partials/job-template-smart-status.html index a9c6365970..1c45c5fe36 100644 --- a/awx/ui/client/src/partials/job-template-smart-status.html +++ b/awx/ui/client/src/partials/job-template-smart-status.html @@ -1 +1 @@ - + diff --git a/awx/ui/client/src/scheduler/main.js b/awx/ui/client/src/scheduler/main.js index 9474c44dce..7cb521f3c2 100644 --- a/awx/ui/client/src/scheduler/main.js +++ b/awx/ui/client/src/scheduler/main.js @@ -23,7 +23,7 @@ export default // job templates $stateExtender.addState({ name: 'jobTemplateSchedules', - route: '/job_templates/:job_template_id/schedules', + route: '/templates/job_template/:id/schedules', templateUrl: templateUrl("scheduler/scheduler"), controller: 'schedulerListController', data: { @@ -32,7 +32,7 @@ export default activityStreamId: 'id' }, ncyBreadcrumb: { - parent: 'jobTemplates.edit', + parent: 'templates.editJobTemplate', label: 'SCHEDULES' } }); @@ -57,9 +57,45 @@ export default } }); + // workflows + $stateExtender.addState({ + name: 'workflowJobTemplateSchedules', + route: '/templates/workflow_job_template/:id/schedules', + templateUrl: templateUrl("scheduler/scheduler"), + controller: 'schedulerController', + data: { + activityStream: true, + activityStreamTarget: 'job_template', + activityStreamId: 'id' + }, + ncyBreadcrumb: { + parent: 'templates.editWorkflowJobTemplate', + label: 'SCHEDULES' + } + }); + $stateExtender.addState({ + name: 'workflowJobTemplateSchedules.add', + route: '/add', + templateUrl: templateUrl("scheduler/schedulerForm"), + controller: 'schedulerAddController', + ncyBreadcrumb: { + parent: 'workflowJobTemplateSchedules', + label: 'CREATE SCHEDULE' + } + }); + $stateExtender.addState({ + name: 'workflowJobTemplateSchedules.edit', + route: '/:schedule_id', + templateUrl: templateUrl("scheduler/schedulerForm"), + controller: 'schedulerEditController', + ncyBreadcrumb: { + parent: 'workflowJobTemplateSchedules', + label: '{{schedule_obj.name}}' + } + }); // projects $stateExtender.addState({ - searchPrefix: 'schedule', + searchPrefix: 'schedule', name: 'projectSchedules', route: '/projects/:id/schedules', data: { diff --git a/awx/ui/client/src/scheduler/schedulerList.controller.js b/awx/ui/client/src/scheduler/schedulerList.controller.js index 4400eef5c4..b532c6d6dd 100644 --- a/awx/ui/client/src/scheduler/schedulerList.controller.js +++ b/awx/ui/client/src/scheduler/schedulerList.controller.js @@ -90,9 +90,8 @@ export default [ }); }; - base = $location.path().replace(/^\//, '').split('/')[0]; - console.log(base) + if (base === 'management_jobs') { $scope.base = base = 'system_job_templates'; } diff --git a/awx/ui/client/src/shared/Modal.js b/awx/ui/client/src/shared/Modal.js index 897b4b1a73..e37defa900 100644 --- a/awx/ui/client/src/shared/Modal.js +++ b/awx/ui/client/src/shared/Modal.js @@ -65,6 +65,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper']) forms = _.chain([params.form]).flatten().compact().value(), buttons, id = params.id, + position = (params.position === undefined) ? { my: "center", at: "center", of: window } : params.position, x, y, wh, ww; function updateButtonStatus(isValid) { @@ -91,7 +92,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper']) // Set modal dimensions based on viewport width ww = $(document).width(); - wh = $('body').height(); + wh = $(document).height(); x = (width > ww) ? ww - 10 : width; y = (height > wh) ? wh - 10 : height; @@ -108,6 +109,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper']) resizable: resizable, draggable: draggable, dialogClass: dialogClass, + position: position, create: function () { // Fix the close button $('.ui-dialog[aria-describedby="' + id + '"]').find('.ui-dialog-titlebar button').empty().attr({'class': 'close'}).html(''); diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index b996f16607..dc5f01bf18 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -164,12 +164,21 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // Not a very good way to do this // Form sub-states expect to target ui-views related@stateName & modal@stateName // Also wraps mess of generated HTML in a .Panel - wrapPanel(html){ - return `
- ${html} -
-
-
`; + wrapPanel(html, ignorePanel){ + if(ignorePanel) { + return `
+ ${html} +
+
+
`; + } + else { + return `
+ ${html} +
+
+
`; + } }, inject: function (form, options) { @@ -377,7 +386,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // html = GenerateForm.buildHTML(JobVarsPromptForm, { mode: 'edit', modal: true, scope: scope }); this.mode = options.mode; - this.modal = (options.modal) ? true : false; + //this.modal = (options.modal) ? true : false; this.setForm(form); return this.build(options); }, @@ -728,7 +737,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat if ((!field.readonly) || (field.readonly && options.mode === 'edit')) { - if((field.excludeMode === undefined || field.excludeMode !== options.mode) && field.type !== 'alertblock') { + if((field.excludeMode === undefined || field.excludeMode !== options.mode) && field.type !== 'alertblock' && field.type !== 'workflow-chart') { html += "
Please select a value.
\n"; @@ -1433,8 +1449,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // Generate HTML. Do NOT call this function directly. Called by inject(). Returns an HTML // string to be injected into the current view. // - var btn, button, fld, field, html = '', i, section, group, - tab, sectionShow, offset, width,ngDisabled, itm; + var btn, button, fld, field, html = '', section, group, + sectionShow, offset, width,ngDisabled, itm; // title and exit button if(!(this.form.showHeader !== undefined && this.form.showHeader === false)) { @@ -1473,14 +1489,14 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += ""; //end of Form-header } - if (!_.isEmpty(this.form.related)) { + if (!_.isEmpty(this.form.related) || !_.isEmpty(this.form.relatedButtons)) { var collection, details = i18n._('Details'); - html += `
`; + html += "
"; if(this.mode === "edit"){ html += `
` + + `ng-class="{'is-selected': $state.is('${this.form.activeEditState}') || $state.is('${this.form.stateTree}.edit') || $state.$current.data.formChildState }">` + `${details}
`; for (itm in this.form.related) { @@ -1499,6 +1515,45 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } html += `}">${(collection.title || collection.editTitle)}
`; } + + for (itm in this.form.relatedButtons) { + button = this.form.relatedButtons[itm]; + + // Build button HTML + html += "
";//tabHolder } - if(!_.isEmpty(this.form.related) && this.mode === "edit"){ - html += `
`; + if(!_.isEmpty(this.form.related) && this.mode === "edit"){// TODO: either include $state.is('templates.editWorkflowJobTemplate') or figure out something else to do here + html += `
`; } html += "
- +
+ + +
+ + +
diff --git a/awx/ui/client/src/shared/lookup/lookup-modal.directive.js b/awx/ui/client/src/shared/lookup/lookup-modal.directive.js index 5950363d3c..588573bb27 100644 --- a/awx/ui/client/src/shared/lookup/lookup-modal.directive.js +++ b/awx/ui/client/src/shared/lookup/lookup-modal.directive.js @@ -1,4 +1,4 @@ -export default ['templateUrl', '$compile', function(templateUrl, $compile) { +export default ['templateUrl', function(templateUrl) { return { restrict: 'E', replace: true, @@ -29,6 +29,9 @@ export default ['templateUrl', '$compile', function(templateUrl, $compile) { $scope.$parent[list.iterator] = $scope.selection[list.iterator].id; $state.go('^'); }; + $scope.cancelForm = function() { + $state.go('^'); + }; }] }; }]; diff --git a/awx/ui/client/src/shared/lookup/lookup-modal.partial.html b/awx/ui/client/src/shared/lookup/lookup-modal.partial.html index 7840d25217..5371e8d454 100644 --- a/awx/ui/client/src/shared/lookup/lookup-modal.partial.html +++ b/awx/ui/client/src/shared/lookup/lookup-modal.partial.html @@ -6,7 +6,7 @@
-
@@ -16,7 +16,7 @@
diff --git a/awx/ui/client/src/shared/main.js b/awx/ui/client/src/shared/main.js index 78bc5d19f9..8db9dd8172 100644 --- a/awx/ui/client/src/shared/main.js +++ b/awx/ui/client/src/shared/main.js @@ -10,7 +10,6 @@ import lookupModal from './lookup/main'; import smartSearch from './smart-search/main'; import paginate from './paginate/main'; import columnSort from './column-sort/main'; -import title from './title.directive'; import lodashAsPromised from './lodash-as-promised'; import stringFilters from './string-filters/main'; import truncatedText from './truncated-text.directive'; diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js index b8d0a4ecf5..f0276bdebd 100644 --- a/awx/ui/client/src/shared/paginate/paginate.controller.js +++ b/awx/ui/client/src/shared/paginate/paginate.controller.js @@ -50,9 +50,9 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q } function calcDataRange() { - if ($scope.current() == 1 && $scope.dataset.count < parseInt(pageSize)) { + if ($scope.current() === 1 && $scope.dataset.count < parseInt(pageSize)) { return `1 - ${$scope.dataset.count}`; - } else if ($scope.current() == 1) { + } else if ($scope.current() === 1) { return `1 - ${pageSize}`; } else { let floor = (($scope.current() - 1) * parseInt(pageSize)) + 1; diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index 12057c1a90..1296a1394b 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -1,5 +1,5 @@ -export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', 'GetBasePath', - function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory, GetBasePath) { +export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', + function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory) { return { // kick off building a model for a specific endpoint // this is usually a list's basePath diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index 1af67cb6ef..64933f8fbd 100644 --- a/awx/ui/client/src/shared/socket/socket.service.js +++ b/awx/ui/client/src/shared/socket/socket.service.js @@ -50,13 +50,13 @@ export default $log.debug('Websocket Error Logged: ' + error); //log errors }; - self.socket.onconnecting = function (event) { + self.socket.onconnecting = function () { self.checkStatus(); $log.debug('Websocket reconnecting'); needsResubscribing = true; }; - self.socket.onclose = function (event) { + self.socket.onclose = function () { self.checkStatus(); $log.debug(`Websocket disconnected`); }; diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index 0c4112aa7b..b76c5b5a36 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -9,8 +9,6 @@ * generateLookupNodes - Attaches to a form node. Builds an abstract '*.lookup' node with field-specific 'lookup.*' children e.g. {name: 'projects.add.lookup.organizations', ...} */ -import { templateUrl } from './template-url/template-url.factory'; - export default ['$injector', '$stateExtender', '$log', function($injector, $stateExtender, $log) { return { /** @@ -140,12 +138,14 @@ export default ['$injector', '$stateExtender', '$log', function($injector, $stat let formNode, states = []; switch (mode) { case 'add': + // breadcrumbName necessary for resources that are more than one word like + // job templates. form.name can't have spaces in it or it busts form gen formNode = $stateExtender.buildDefinition({ name: params.name || `${params.parent}.add`, url: params.url || '/add', ncyBreadcrumb: { [params.parent ? 'parent' : null]: `${params.parent}`, - label: `CREATE ${form.name}` + label: `CREATE ${form.breadcrumbName || form.name}` }, views: { 'form': { @@ -370,7 +370,7 @@ export default ['$injector', '$stateExtender', '$log', function($injector, $stat // a lookup field's basePath takes precedence over generic list definition's basePath, if supplied data: { basePath: field.basePath || null, - lookup: true + formChildState: true }, params: { [field.sourceModel + '_search']: { diff --git a/awx/ui/client/src/standard-out/standard-out.controller.js b/awx/ui/client/src/standard-out/standard-out.controller.js index 663d165cf3..ffed821d7d 100644 --- a/awx/ui/client/src/standard-out/standard-out.controller.js +++ b/awx/ui/client/src/standard-out/standard-out.controller.js @@ -52,7 +52,7 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams, $scope.created_by = data.summary_fields.created_by; $scope.project_name = (data.summary_fields.project) ? data.summary_fields.project.name : ''; $scope.inventory_name = (data.summary_fields.inventory) ? data.summary_fields.inventory.name : ''; - $scope.job_template_url = '/#/job_templates/' + data.unified_job_template; + $scope.job_template_url = '/#/templates/' + data.unified_job_template; $scope.inventory_url = ($scope.inventory_name && data.inventory) ? '/#/inventories/' + data.inventory : ''; $scope.project_url = ($scope.project_name && data.project) ? '/#/projects/' + data.project : ''; $scope.credential_name = (data.summary_fields.credential) ? data.summary_fields.credential.name : ''; diff --git a/awx/ui/client/src/workflow-results/main.js b/awx/ui/client/src/workflow-results/main.js new file mode 100644 index 0000000000..7faf366fc9 --- /dev/null +++ b/awx/ui/client/src/workflow-results/main.js @@ -0,0 +1,16 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowStatusBar from './workflow-status-bar/main'; +import route from './workflow-results.route.js'; +import workflowResultsService from './workflow-results.service'; + +export default + angular.module('workflowResults', [workflowStatusBar.name]) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(route); + }]) + .service('workflowResultsService', workflowResultsService); diff --git a/awx/ui/client/src/workflow-results/workflow-results.block.less b/awx/ui/client/src/workflow-results/workflow-results.block.less new file mode 100644 index 0000000000..a053db98c1 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.block.less @@ -0,0 +1,114 @@ +@import '../shared/branding/colors.less'; +@import '../shared/branding/colors.default.less'; +@import '../shared/layouts/one-plus-two.less'; + +@breakpoint-md: 1200px; +@breakpoint-sm: 623px; + +.WorkflowResults { + .OnePlusTwo-container(100%, @breakpoint-md); + + &.fullscreen { + .WorkflowResults-rightSide { + max-width: 100%; + } + } +} + +.WorkflowResults-leftSide { + .OnePlusTwo-left--panel(100%, @breakpoint-md); + // TODO: needs to be set based on height of browser window + height: 870px !important; +} + +.WorkflowResults-rightSide { + .OnePlusTwo-right--panel(100%, @breakpoint-md); + // TODO: needs to be set based on height of browser window + height: 870px !important; + + @media (max-width: @breakpoint-md - 1px) { + padding-right: 15px; + } +} + +.WorkflowResults-stdoutActionButton--active { + display: none; + visibility: hidden; + flex:none; + width:0px; + padding-right: 0px; +} + +.WorkflowResults-panelHeader { + display: flex; + height: 30px; +} + +.WorkflowResults-panelHeaderText { + color: @default-interface-txt; + flex: 1 0 auto; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; +} + +.WorkflowResults-resultRow { + width: 100%; + display: flex; + padding-bottom: 10px; + flex-wrap: wrap; +} + +.WorkflowResults-resultRow--variables { + flex-direction: column; +} + +.WorkflowResults-resultRowLabel { + text-transform: uppercase; + color: @default-interface-txt; + font-size: 14px; + font-weight: normal!important; + width: 30%; + margin-right: 20px; + + @media screen and (max-width: @breakpoint-md) { + flex: 2.5 0 auto; + } +} + +.WorkflowResults-resultRowLabel--fullWidth { + width: 100%; + margin-right: 0px; +} + +.WorkflowResults-resultRowText { + width: ~"calc(70% - 20px)"; + flex: 1 0 auto; + text-transform: none; + word-wrap: break-word; +} + +.WorkflowResults-resultRowText--fullWidth { + width: 100%; +} + +.WorkflowResults-statusResultIcon { + padding-left: 0px; + padding-right: 10px; +} + +.WorkflowResults-badgeRow { + display: flex; + align-items: center; + margin-right: 5px; +} + +.WorkflowResults-badgeTitle{ + color: @default-interface-txt; + font-size: 14px; + margin-right: 10px; + font-weight: normal; + text-transform: uppercase; + margin-left: 20px; +} diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js new file mode 100644 index 0000000000..d3f6eb4da9 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -0,0 +1,188 @@ +export default ['workflowData', + 'workflowResultsService', + 'workflowDataOptions', + 'jobLabels', + 'workflowNodes', + '$scope', + 'ParseTypeChange', + 'ParseVariableString', + function(workflowData, + workflowResultsService, + workflowDataOptions, + jobLabels, + workflowNodes, + $scope, + ParseTypeChange, + ParseVariableString + ) { + var getTowerLinks = function() { + var getTowerLink = function(key) { + if ($scope.workflow.related[key]) { + return '/#/' + $scope.workflow.related[key] + .split('api/v1/')[1]; + } + else { + return null; + } + }; + + $scope.workflow_template_link = '/#/templates/workflow_job_template/'+$scope.workflow.workflow_job_template; + $scope.created_by_link = getTowerLink('created_by'); + $scope.cloud_credential_link = getTowerLink('cloud_credential'); + $scope.network_credential_link = getTowerLink('network_credential'); + }; + + var getTowerLabels = function() { + var getTowerLabel = function(key) { + if ($scope.workflowOptions && $scope.workflowOptions[key]) { + return $scope.workflowOptions[key].choices + .filter(val => val[0] === $scope.workflow[key]) + .map(val => val[1])[0]; + } else { + return null; + } + }; + + $scope.status_label = getTowerLabel('status'); + $scope.type_label = getTowerLabel('job_type'); + $scope.verbosity_label = getTowerLabel('verbosity'); + }; + + // var getTotalHostCount = function(count) { + // return Object + // .keys(count).reduce((acc, i) => acc += count[i], 0); + // }; + + // put initially resolved request data on scope + $scope.workflow = workflowData; + $scope.workflow_nodes = workflowNodes; + $scope.workflowOptions = workflowDataOptions.actions.GET; + $scope.labels = jobLabels; + + // turn related api browser routes into tower routes + getTowerLinks(); + + // use options labels to manipulate display of details + getTowerLabels(); + + // set up a read only code mirror for extra vars + $scope.variables = ParseVariableString($scope.workflow.extra_vars); + $scope.parseType = 'yaml'; + ParseTypeChange({ scope: $scope, + field_id: 'pre-formatted-variables', + readOnly: true }); + + // Click binding for the expand/collapse button on the standard out log + $scope.stdoutFullScreen = false; + $scope.toggleStdoutFullscreen = function() { + $scope.stdoutFullScreen = !$scope.stdoutFullScreen; + }; + + $scope.deleteJob = function() { + workflowResultsService.deleteJob($scope.workflow); + }; + + $scope.cancelJob = function() { + workflowResultsService.cancelJob($scope.workflow); + }; + + $scope.relaunchJob = function() { + workflowResultsService.relaunchJob($scope); + }; + + $scope.stdoutArr = []; + + // EVENT STUFF BELOW + + // just putting the event queue on scope so it can be inspected in the + // console + // $scope.event_queue = eventQueue.queue; + // $scope.defersArr = eventQueue.populateDefers; + + // This is where the async updates to the UI actually happen. + // Flow is event queue munging in the service -> $scope setting in here + // var processEvent = function(event) { + // // put the event in the queue + // eventQueue.populate(event).then(mungedEvent => { + // // make changes to ui based on the event returned from the queue + // if (mungedEvent.changes) { + // mungedEvent.changes.forEach(change => { + // // we've got a change we need to make to the UI! + // // update the necessary scope and make the change + // if (change === 'startTime' && !$scope.workflow.start) { + // $scope.workflow.start = mungedEvent.startTime; + // } + // + // if (change === 'count' && !$scope.countFinished) { + // // for all events that affect the host count, + // // update the status bar as well as the host + // // count badge + // $scope.count = mungedEvent.count; + // $scope.hostCount = getTotalHostCount(mungedEvent + // .count); + // } + // + // if (change === 'playCount') { + // $scope.playCount = mungedEvent.playCount; + // } + // + // if (change === 'taskCount') { + // $scope.taskCount = mungedEvent.taskCount; + // } + // + // if (change === 'finishedTime' && !$scope.workflow.finished) { + // $scope.workflow.finished = mungedEvent.finishedTime; + // } + // + // if (change === 'countFinished') { + // // the playbook_on_stats event actually lets + // // us know that we don't need to iteratively + // // look at event to update the host counts + // // any more. + // $scope.countFinished = true; + // } + // + // if(change === 'stdout'){ + // angular + // .element(".JobResultsStdOut-stdoutContainer") + // .append($compile(mungedEvent + // .stdout)($scope)); + // } + // }); + // } + // + // // the changes have been processed in the ui, mark it in the queue + // eventQueue.markProcessed(event); + // }); + // }; + + // PULL! grab completed event data and process each event + // TODO: implement retry logic in case one of these requests fails + // var getEvents = function(url) { + // workflowResultsService.getEvents(url) + // .then(events => { + // events.results.forEach(event => { + // // get the name in the same format as the data + // // coming over the websocket + // event.event_name = event.event; + // processEvent(event); + // }); + // if (events.next) { + // getEvents(events.next); + // } + // }); + // }; + // getEvents($scope.job.related.job_events); + + // // Processing of job_events messages from the websocket + // $scope.$on(`ws-job_events-${$scope.workflow.id}`, function(e, data) { + // processEvent(data); + // }); + + // Processing of job-status messages from the websocket + $scope.$on(`ws-jobs`, function(e, data) { + if (parseInt(data.unified_job_id, 10) === parseInt($scope.workflow.id,10)) { + $scope.workflow.status = data.status; + } + }); +}]; diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html new file mode 100644 index 0000000000..9612719221 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -0,0 +1,236 @@ +
+
+
+ + +
+
+ + +
+
+ RESULTS +
+ + +
+ + + + + + + + + +
+
+ + +
+ + +
+ +
+ {{ workflow.started | longDate }} +
+
+ + +
+ +
+ {{ (workflow.finished | + longDate) || "Not Finished" }} +
+
+ + +
+ + +
+ + +
+ +
+ Workflow Job +
+
+ + + + + + +
+ + +
+ + +
+ +
+
+
+
+ {{ label }} +
+
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + + {{ workflow.name }} +
+ + +
+ +
+ Total Jobs +
+ + {{ workflow_nodes.length || 0}} + + + +
+ Elapsed +
+ + {{ job.elapsed * 1000 }} + +
+ + +
+ + + + + + + + + +
+
+ + +
+ +
+
+
diff --git a/awx/ui/client/src/workflow-results/workflow-results.route.js b/awx/ui/client/src/workflow-results/workflow-results.route.js new file mode 100644 index 0000000000..4d15d50777 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.route.js @@ -0,0 +1,114 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../shared/template-url/template-url.factory'; + +import workflowResultsController from './workflow-results.controller'; + +export default { + name: 'workflowResults', + url: '/workflows/:id', + ncyBreadcrumb: { + parent: 'jobs', + label: '{{ job.id }} - {{ job.name }}' + }, + data: { + socket: { + "groups":{ + "jobs": ["status_changed", "summary"], + // not sure if you're gonna need to use job_events + // or if y'all will come up w/ a new socket group specifically + // for workflows + // "job_events": [] + } + } + }, + templateUrl: templateUrl('workflow-results/workflow-results'), + controller: workflowResultsController, + resolve: { + // the GET for the particular workflow + workflowData: ['Rest', 'GetBasePath', '$stateParams', '$q', '$state', 'Alert', function(Rest, GetBasePath, $stateParams, $q, $state, Alert) { + Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id); + var defer = $q.defer(); + Rest.get() + .then(function(data) { + defer.resolve(data.data); + }, function(data) { + defer.reject(data); + + if (data.status === 404) { + Alert('Job Not Found', 'Cannot find job.', 'alert-info'); + } else if (data.status === 403) { + Alert('Insufficient Permissions', 'You do not have permission to view this job.', 'alert-info'); + } + + $state.go('jobs'); + }); + return defer.promise; + }], + // after the GET for the job, this helps us keep the status bar from + // flashing as rest data comes in. Provides the list of workflow nodes + workflowNodes: ['workflowData', 'Rest', '$q', function(workflowData, Rest, $q) { + var defer = $q.defer(); + Rest.setUrl(workflowData.related.workflow_nodes); + Rest.get() + .success(function(data) { + defer.resolve(data.results); + }) + .error(function() { + // TODO: handle this + //defer.resolve(data); + }); + return defer.promise; + }], + // GET for the particular jobs labels to be displayed in the + // left-hand pane + jobLabels: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { + var getNext = function(data, arr, resolve) { + Rest.setUrl(data.next); + Rest.get() + .success(function (data) { + if (data.next) { + getNext(data, arr.concat(data.results), resolve); + } else { + resolve.resolve(arr.concat(data.results) + .map(val => val.name)); + } + }); + }; + + var seeMoreResolve = $q.defer(); + + Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id + '/labels/'); + Rest.get() + .success(function(data) { + if (data.next) { + getNext(data, data.results, seeMoreResolve); + } else { + seeMoreResolve.resolve(data.results + .map(val => val.name)); + } + }); + + return seeMoreResolve.promise; + }], + // OPTIONS request for the workflow. Used to make things like the + // verbosity data in the left-hand pane prettier than just an + // integer + workflowDataOptions: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { + Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id); + var defer = $q.defer(); + Rest.options() + .then(function(data) { + defer.resolve(data.data); + }, function(data) { + defer.reject(data); + }); + return defer.promise; + }] + } + +}; diff --git a/awx/ui/client/src/workflow-results/workflow-results.service.js b/awx/ui/client/src/workflow-results/workflow-results.service.js new file mode 100644 index 0000000000..cf7ba70af8 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.service.js @@ -0,0 +1,108 @@ +/************************************************* +* Copyright (c) 2016 Ansible, Inc. +* +* All Rights Reserved +*************************************************/ + + +export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'InitiatePlaybookRun', function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybookRun) { + var val = { + deleteJob: function(workflow) { + Prompt({ + hdr: 'Delete Job', + body: `
+ Are you sure you want to delete the workflow below? +
+
+ #${workflow.id} ${$filter('sanitize')(workflow.name)} +
`, + action: function() { + Wait('start'); + Rest.setUrl(workflow.url); + Rest.destroy() + .success(function() { + Wait('stop'); + $('#prompt-modal').modal('hide'); + $state.go('jobs'); + }) + .error(function(obj, status) { + Wait('stop'); + $('#prompt-modal').modal('hide'); + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not delete job. + Returned status: ${status}` + }); + }); + }, + actionText: 'DELETE' + }); + }, + cancelJob: function(workflow) { + var doCancel = function() { + Rest.setUrl(workflow.url + 'cancel'); + Rest.post({}) + .success(function() { + Wait('stop'); + $('#prompt-modal').modal('hide'); + }) + .error(function(obj, status) { + Wait('stop'); + $('#prompt-modal').modal('hide'); + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not cancel workflow. + Returned status: ${status}` + }); + }); + }; + + Prompt({ + hdr: 'Cancel Workflow', + body: `
+ Are you sure you want to cancel the workflow below? +
+
+ #${workflow.id} ${$filter('sanitize')(workflow.name)} +
`, + action: function() { + Wait('start'); + Rest.setUrl(workflow.url + 'cancel'); + Rest.get() + .success(function(data) { + if (data.can_cancel === true) { + doCancel(); + } else { + $('#prompt-modal').modal('hide'); + ProcessErrors(null, data, null, null, { + hdr: 'Error!', + msg: `Job has completed, + unabled to be canceled.` + }); + } + }); + Rest.destroy() + .success(function() { + Wait('stop'); + $('#prompt-modal').modal('hide'); + }) + .error(function(obj, status) { + Wait('stop'); + $('#prompt-modal').modal('hide'); + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not cancel workflow. + Returned status: ${status}` + }); + }); + }, + actionText: 'CANCEL' + }); + }, + relaunchJob: function(scope) { + InitiatePlaybookRun({ scope: scope, id: scope.workflow.id, + relaunch: true }); + } + }; + return val; +}]; diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/main.js b/awx/ui/client/src/workflow-results/workflow-status-bar/main.js new file mode 100644 index 0000000000..251258fc70 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/main.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowStatusBar from './workflow-status-bar.directive'; + +export default + angular.module('workflowStatusBarDirective', []) + .directive('workflowStatusBar', workflowStatusBar); diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less new file mode 100644 index 0000000000..38e57d4883 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less @@ -0,0 +1,80 @@ +@import '../../shared/branding/colors.default.less'; + +.WorkflowStatusBar { + display: flex; + flex: 0 0 auto; + width: 100%; + margin-top: 10px; +} + +.WorkflowStatusBar-ok, +.WorkflowStatusBar-changed, +.WorkflowStatusBar-unreachable, +.WorkflowStatusBar-failures, +.WorkflowStatusBar-skipped, +.WorkflowStatusBar-noData { + height: 15px; + border-top: 5px solid @default-bg; + border-bottom: 5px solid @default-bg; +} + +.WorkflowStatusBar-ok { + background-color: @default-succ; + display: flex; + flex: 0 0 auto; +} + +.WorkflowStatusBar-changed { + background-color: @default-warning; + flex: 0 0 auto; +} + +.WorkflowStatusBar-unreachable { + background-color: @default-unreachable; + flex: 0 0 auto; +} + +.WorkflowStatusBar-failures { + background-color: @default-err; + flex: 0 0 auto; +} + +.WorkflowStatusBar-skipped { + background-color: @default-link; + flex: 0 0 auto; +} + +.WorkflowStatusBar-noData { + background-color: @default-icon-hov; + flex: 1 0 auto; +} + +.WorkflowStatusBar-tooltipLabel { + text-transform: uppercase; + margin-right: 15px; +} + +.WorkflowStatusBar-tooltipBadge { + border-radius: 5px; +} + +.WorkflowStatusBar-tooltipBadge--ok { + background-color: @default-succ; +} + +.WorkflowStatusBar-tooltipBadge--unreachable { + background-color: @default-unreachable; +} + +.WorkflowStatusBar-tooltipBadge--skipped { + background-color: @default-link; +} + +.WorkflowStatusBar-tooltipBadge--changed { + background-color: @default-warning; +} + +.WorkflowStatusBar-tooltipBadge--failures { + background-color: @default-err; + +} diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js new file mode 100644 index 0000000000..a6899eb0da --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js @@ -0,0 +1,43 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +// import WorkflowStatusBarController from './host-status-bar.controller'; +export default [ 'templateUrl', + function(templateUrl) { + return { + scope: true, + templateUrl: templateUrl('workflow-results/workflow-status-bar/workflow-status-bar'), + restrict: 'E', + // controller: standardOutLogController, + link: function(scope) { + // as count is changed by event data coming in, + // update the host status bar + scope.$watch('count', function(val) { + if (val) { + Object.keys(val).forEach(key => { + // reposition the hosts status bar by setting + // the various flex values to the count of + // those hosts + $(`.WorkflowStatusBar-${key}`) + .css('flex', `${val[key]} 0 auto`); + + // set the tooltip to give how many hosts of + // each type + if (val[key] > 0) { + scope[`${key}CountTip`] = `${key}${val[key]}`; + } + }); + + // if there are any hosts that have finished, don't + // show default grey bar + scope.hostsFinished = (Object + .keys(val) + .filter(key => (val[key] > 0)).length > 0); + } + }); + } + }; +}]; diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html new file mode 100644 index 0000000000..e0efddc7b6 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html @@ -0,0 +1,26 @@ +
+
+
+
+
+
+
+
diff --git a/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js b/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js new file mode 100644 index 0000000000..5d542622cd --- /dev/null +++ b/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js @@ -0,0 +1,269 @@ +'use strict'; + +describe('Controller: JobTemplatesList', () => { + // Setup + let scope, + rootScope, + state, + JobTemplatesListController, + ClearScope, + GetChoices, + Alert, + Prompt, + InitiatePlaybookRun, + rbacUiControlService, + canAddDeferred, + q, + JobTemplateService, + deleteWorkflowJobTemplateDeferred, + deleteJobTemplateDeferred, + Dataset; + + beforeEach(angular.mock.module('Tower')); + beforeEach(angular.mock.module('jobTemplates', ($provide) => { + + state = jasmine.createSpyObj('state', [ + '$get', + 'transitionTo', + 'go' + ]); + + state.params = { + id: 1 + }; + + rbacUiControlService = { + canAdd: function(){ + return angular.noop; + } + }; + + JobTemplateService = { + deleteWorkflowJobTemplate: function(){ + return angular.noop; + }, + deleteJobTemplate: function(){ + return angular.noop; + } + }; + + Dataset = { + data: { + results: [] + } + }; + + ClearScope = jasmine.createSpy('ClearScope'); + GetChoices = jasmine.createSpy('GetChoices'); + Alert = jasmine.createSpy('Alert'); + Prompt = jasmine.createSpy('Prompt').and.callFake(function(args) { + args.action(); + }); + InitiatePlaybookRun = jasmine.createSpy('InitiatePlaybookRun'); + + $provide.value('ClearScope', ClearScope); + $provide.value('GetChoices', GetChoices); + $provide.value('Alert', Alert); + $provide.value('Prompt', Prompt); + $provide.value('state', state); + $provide.value('InitiatePlaybookRun', InitiatePlaybookRun); + })); + + beforeEach(angular.mock.inject( ($rootScope, $controller, $q, _state_, _ConfigService_, _ClearScope_, _GetChoices_, _Alert_, _Prompt_, _InitiatePlaybookRun_) => { + scope = $rootScope.$new(); + rootScope = $rootScope; + q = $q; + state = _state_; + ClearScope = _ClearScope_; + GetChoices = _GetChoices_; + Alert = _Alert_; + Prompt = _Prompt_; + InitiatePlaybookRun = _InitiatePlaybookRun_; + canAddDeferred = q.defer(); + deleteWorkflowJobTemplateDeferred = q.defer(); + deleteJobTemplateDeferred = q.defer(); + + rbacUiControlService.canAdd = jasmine.createSpy('canAdd').and.returnValue(canAddDeferred.promise); + + JobTemplateService.deleteWorkflowJobTemplate = jasmine.createSpy('deleteWorkflowJobTemplate').and.returnValue(deleteWorkflowJobTemplateDeferred.promise); + JobTemplateService.deleteJobTemplate = jasmine.createSpy('deleteJobTemplate').and.returnValue(deleteJobTemplateDeferred.promise); + + JobTemplatesListController = $controller('JobTemplatesListController', { + $scope: scope, + $rootScope: rootScope, + $state: state, + ClearScope: ClearScope, + GetChoices: GetChoices, + Alert: Alert, + Prompt: Prompt, + InitiatePlaybookRun: InitiatePlaybookRun, + rbacUiControlService: rbacUiControlService, + JobTemplateService: JobTemplateService, + Dataset: Dataset + }); + })); + + describe('scope.editJobTemplate()', () => { + + it('should call Alert when template param is not present', ()=>{ + scope.editJobTemplate(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to edit template', 'Template parameter is missing'); + }); + + it('should transition to templates.editJobTemplate when type is "Job Template"', ()=>{ + + var testTemplate = { + type: "Job Template", + id: 1 + }; + + scope.editJobTemplate(testTemplate); + expect(state.transitionTo).toHaveBeenCalledWith('templates.editJobTemplate', {job_template_id: 1}); + }); + + it('should transition to templates.templates.editWorkflowJobTemplate when type is "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Workflow Job Template", + id: 1 + }; + + scope.editJobTemplate(testTemplate); + expect(state.transitionTo).toHaveBeenCalledWith('templates.editWorkflowJobTemplate', {workflow_job_template_id: 1}); + }); + + it('should call Alert when type is not "Job Template" or "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Some Other Type", + id: 1 + }; + + scope.editJobTemplate(testTemplate); + expect(Alert).toHaveBeenCalledWith('Error: Unable to determine template type', 'We were unable to determine this template\'s type while routing to edit.'); + }); + + }); + + describe('scope.deleteJobTemplate()', () => { + + it('should call Alert when template param is not present', ()=>{ + scope.deleteJobTemplate(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to delete template', 'Template parameter is missing'); + }); + + it('should call Prompt if template param is present', ()=>{ + + var testTemplate = { + id: 1, + name: "Test Template" + }; + + scope.deleteJobTemplate(testTemplate); + expect(Prompt).toHaveBeenCalled(); + }); + + it('should call JobTemplateService.deleteWorkflowJobTemplate when the user takes affirmative action on the delete modal and type = "Workflow Job Template"', ()=>{ + // Note that Prompt has been mocked up above to immediately call the callback function that gets passed in + // which is how we access the private function in the controller + + var testTemplate = { + id: 1, + name: "Test Template", + type: "Workflow Job Template" + }; + + scope.deleteJobTemplate(testTemplate); + expect(JobTemplateService.deleteWorkflowJobTemplate).toHaveBeenCalled(); + }); + + it('should call JobTemplateService.deleteJobTemplate when the user takes affirmative action on the delete modal and type = "Workflow Job Template"', ()=>{ + // Note that Prompt has been mocked up above to immediately call the callback function that gets passed in + // which is how we access the private function in the controller + + var testTemplate = { + id: 1, + name: "Test Template", + type: "Job Template" + }; + + scope.deleteJobTemplate(testTemplate); + expect(JobTemplateService.deleteJobTemplate).toHaveBeenCalled(); + }); + + }); + + describe('scope.submitJob()', () => { + + it('should call Alert when template param is not present', ()=>{ + scope.submitJob(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to launch template', 'Template parameter is missing'); + }); + + it('should call InitiatePlaybookRun when type is "Job Template"', ()=>{ + + var testTemplate = { + type: "Job Template", + id: 1 + }; + + scope.submitJob(testTemplate); + expect(InitiatePlaybookRun).toHaveBeenCalled(); + }); + + xit('should call [something] when type is "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Workflow Job Template", + id: 1 + }; + + scope.submitJob(testTemplate); + expect([something]).toHaveBeenCalled(); + }); + + it('should call Alert when type is not "Job Template" or "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Some Other Type", + id: 1 + }; + + scope.submitJob(testTemplate); + expect(Alert).toHaveBeenCalledWith('Error: Unable to determine template type', 'We were unable to determine this template\'s type while launching.'); + }); + + }); + + describe('scope.scheduleJob()', () => { + + it('should transition to jobTemplateSchedules when type is "Job Template"', ()=>{ + + var testTemplate = { + type: "Job Template", + id: 1 + }; + + scope.scheduleJob(testTemplate); + expect(state.go).toHaveBeenCalledWith('jobTemplateSchedules', {id: 1}); + }); + + it('should transition to workflowJobTemplateSchedules when type is "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Workflow Job Template", + id: 1 + }; + + scope.scheduleJob(testTemplate); + expect(state.go).toHaveBeenCalledWith('workflowJobTemplateSchedules', {id: 1}); + }); + + it('should call Alert when template param is not present', ()=>{ + scope.scheduleJob(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to schedule job', 'Template parameter is missing'); + }); + + }); + +}); diff --git a/awx/ui/tests/spec/workflows/workflow-add.controller-test.js b/awx/ui/tests/spec/workflows/workflow-add.controller-test.js new file mode 100644 index 0000000000..a631ac66ff --- /dev/null +++ b/awx/ui/tests/spec/workflows/workflow-add.controller-test.js @@ -0,0 +1,161 @@ +'use strict'; + +describe('Controller: WorkflowAdd', () => { + // Setup + let scope, + state, + WorkflowAdd, + ClearScope, + Alert, + GenerateForm, + JobTemplateService, + q, + getLabelsDeferred, + createWorkflowJobTemplateDeferred, + httpBackend, + ProcessErrors, + CreateSelect2, + Wait, + ParseTypeChange, + ToJSON; + + beforeEach(angular.mock.module('Tower')); + beforeEach(angular.mock.module('jobTemplates', ($provide) => { + + state = jasmine.createSpyObj('state', [ + '$get', + 'transitionTo', + 'go' + ]); + + GenerateForm = jasmine.createSpyObj('GenerateForm', [ + 'inject', + 'reset', + 'clearApiErrors', + 'applyDefaults' + ]); + + JobTemplateService = { + getLabelOptions: function(){ + return angular.noop; + }, + createWorkflowJobTemplate: function(){ + return angular.noop; + } + }; + + ClearScope = jasmine.createSpy('ClearScope'); + Alert = jasmine.createSpy('Alert'); + ProcessErrors = jasmine.createSpy('ProcessErrors'); + CreateSelect2 = jasmine.createSpy('CreateSelect2'); + Wait = jasmine.createSpy('Wait'); + ParseTypeChange = jasmine.createSpy('ParseTypeChange'); + ToJSON = jasmine.createSpy('ToJSON'); + + $provide.value('ClearScope', ClearScope); + $provide.value('Alert', Alert); + $provide.value('GenerateForm', GenerateForm); + $provide.value('state', state); + $provide.value('ProcessErrors', ProcessErrors); + $provide.value('CreateSelect2', CreateSelect2); + $provide.value('Wait', Wait); + $provide.value('ParseTypeChange', ParseTypeChange); + $provide.value('ToJSON', ToJSON); + })); + + beforeEach(angular.mock.inject( ($rootScope, $controller, $q, $httpBackend, _state_, _ConfigService_, _ClearScope_, _GetChoices_, _Alert_, _GenerateForm_, _ProcessErrors_, _CreateSelect2_, _Wait_, _ParseTypeChange_, _ToJSON_) => { + scope = $rootScope.$new(); + state = _state_; + q = $q; + ClearScope = _ClearScope_; + Alert = _Alert_; + GenerateForm = _GenerateForm_; + httpBackend = $httpBackend; + ProcessErrors = _ProcessErrors_; + CreateSelect2 = _CreateSelect2_; + Wait = _Wait_; + getLabelsDeferred = q.defer(); + createWorkflowJobTemplateDeferred = q.defer(); + ParseTypeChange = _ParseTypeChange_; + ToJSON = _ToJSON_; + + JobTemplateService.getLabelOptions = jasmine.createSpy('getLabelOptions').and.returnValue(getLabelsDeferred.promise); + JobTemplateService.createWorkflowJobTemplate = jasmine.createSpy('createWorkflowJobTemplate').and.returnValue(createWorkflowJobTemplateDeferred.promise); + + WorkflowAdd = $controller('WorkflowAdd', { + $scope: scope, + $state: state, + ClearScope: ClearScope, + Alert: Alert, + GenerateForm: GenerateForm, + JobTemplateService: JobTemplateService, + ProcessErrors: ProcessErrors, + CreateSelect2: CreateSelect2, + Wait: Wait, + ParseTypeChange: ParseTypeChange, + ToJSON + }); + })); + + it('should call ClearScope', ()=>{ + expect(ClearScope).toHaveBeenCalled(); + }); + + it('should get/set the label options and select2-ify the input', ()=>{ + // Resolve JobTemplateService.getLabelsForJobTemplate + getLabelsDeferred.resolve({ + foo: "bar" + }); + // We expect the digest cycle to fire off this call to /static/config.js so we go ahead and handle it + httpBackend.expectGET('/static/config.js').respond(200); + scope.$digest(); + expect(scope.labelOptions).toEqual({ + foo: "bar" + }); + expect(CreateSelect2).toHaveBeenCalledWith({ + element:'#workflow_job_template_labels', + multiple: true, + addNew: true + }); + }); + + it('should call ProcessErrors when getLabelsForJobTemplate returns a rejected promise', ()=>{ + // Reject JobTemplateService.getLabelsForJobTemplate + getLabelsDeferred.reject({ + data: "mockedData", + status: 400 + }); + // We expect the digest cycle to fire off this call to /static/config.js so we go ahead and handle it + httpBackend.expectGET('/static/config.js').respond(200); + scope.$digest(); + expect(ProcessErrors).toHaveBeenCalled(); + }); + + describe('scope.formSave()', () => { + + it('should call JobTemplateService.createWorkflowJobTemplate', ()=>{ + scope.name = "Test Workflow"; + scope.description = "This is a test description"; + scope.formSave(); + expect(JobTemplateService.createWorkflowJobTemplate).toHaveBeenCalledWith({ + name: "Test Workflow", + description: "This is a test description", + labels: undefined, + organization: undefined, + variables: undefined, + extra_vars: undefined + }); + }); + + }); + + describe('scope.formCancel()', () => { + + it('should transition to templates', ()=>{ + scope.formCancel(); + expect(state.transitionTo).toHaveBeenCalledWith('templates'); + }); + + }); + +}); diff --git a/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js b/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js new file mode 100644 index 0000000000..0486356249 --- /dev/null +++ b/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js @@ -0,0 +1,58 @@ +'use strict'; + +describe('Controller: WorkflowMaker', () => { + // Setup + let scope, + WorkflowMakerController, + WorkflowHelpService; + + beforeEach(angular.mock.module('Tower')); + beforeEach(angular.mock.module('jobTemplates', ($provide) => { + + WorkflowHelpService = jasmine.createSpyObj('WorkflowHelpService', [ + 'closeDialog', + 'addPlaceholderNode', + 'getSiblingConnectionTypes' + ]); + + $provide.value('WorkflowHelpService', WorkflowHelpService); + + })); + + beforeEach(angular.mock.inject( ($rootScope, $controller, _WorkflowHelpService_) => { + scope = $rootScope.$new(); + scope.treeData = { + data: { + id: 1, + canDelete: false, + canEdit: false, + canAddTo: true, + isStartNode: true, + unifiedJobTemplate: { + name: "Workflow Launch" + }, + children: [], + deletedNodes: [], + totalNodes: 0 + }, + nextIndex: 2 + }; + WorkflowHelpService = _WorkflowHelpService_; + + WorkflowMakerController = $controller('WorkflowMakerController', { + $scope: scope, + WorkflowHelpService: WorkflowHelpService + }); + + })); + + describe('scope.saveWorkflowMaker()', () => { + + it('should close the dialog', ()=>{ + scope.saveWorkflowMaker(); + expect(WorkflowHelpService.closeDialog).toHaveBeenCalled(); + }); + + }); + +});