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..3081725e69 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -357,6 +357,33 @@ 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-top: 4px solid\9; + 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 cdc39200f3..7d3f3d302e 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -195,6 +195,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/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..dca21972f2 100644 --- a/awx/ui/client/src/forms.js +++ b/awx/ui/client/src/forms.js @@ -24,6 +24,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 @@ -46,5 +48,7 @@ export ProjectStatus, Projects, Teams, - Users + Users, + WorkflowMaker, + Workflows }; diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index 0c276777dc..d7e0b502ee 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'], diff --git a/awx/ui/client/src/forms/WorkflowMaker.js b/awx/ui/client/src/forms/WorkflowMaker.js new file mode 100644 index 0000000000..6d3d602ed8 --- /dev/null +++ b/awx/ui/client/src/forms/WorkflowMaker.js @@ -0,0 +1,154 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/** + * @ngdoc function + * @name forms.function:JobTemplate + * @description This form is for adding/editing a Job Template +*/ + +export default + angular.module('WorkflowMakerFormDefinition', []) + + .value ('WorkflowMakerFormObject', { + + addTitle: '', + editTitle: '', + name: 'workflow_maker', + base: 'job_templates', + tabs: false, + cancelButton: false, + showHeader: false, + + fields: { + credential: { + label: 'Credential', + type: 'lookup', + sourceModel: 'credential', + sourceField: 'name', + ngClick: 'lookUpCredential()', + requiredErrorMsg: "Please select a Credential.", + column: 1, + class: 'Form-formGroup--fullWidth', + awPopOver: "

Select the credential you want the job to use when accessing the remote hosts. Choose the credential containing " + + " the username and SSH key or password that Ansible will need to log into the remote hosts.

", + dataTitle: 'Credential', + dataPlacement: 'right', + dataContainer: "body", + ngShow: "selectedTemplate.ask_credential_on_launch", + awRequiredWhen: { + reqExpression: 'selectedTemplate.ask_credential_on_launch' + } + }, + inventory: { + label: 'Inventory', + type: 'lookup', + sourceModel: 'inventory', + sourceField: 'name', + ngClick: 'lookUpInventory()', + requiredErrorMsg: "Please select an Inventory.", + column: 1, + class: 'Form-formGroup--fullWidth', + awPopOver: "

Select the inventory containing the hosts you want this job to manage.

", + dataTitle: 'Inventory', + dataPlacement: 'right', + dataContainer: "body", + ngShow: "selectedTemplate.ask_inventory_on_launch", + awRequiredWhen: { + reqExpression: 'selectedTemplate.ask_inventory_on_launch' + } + }, + job_type: { + label: 'Job Type', + type: 'select', + ngOptions: 'type.label for type in job_type_options track by type.value', + "default": 0, + column: 1, + class: 'Form-formGroup--fullWidth', + awPopOver: "

When this template is submitted as a job, setting the type to run will execute the playbook, running tasks " + + " on the selected hosts.

Setting the type to check will not execute the playbook. Instead, ansible will check playbook " + + " syntax, test environment setup and report problems.

Setting the type to scan will execute the playbook and store any " + + " scanned facts for use with Tower's System Tracking feature.

", + dataTitle: 'Job Type', + dataPlacement: 'right', + dataContainer: "body", + ngShow: "selectedTemplate.ask_job_type_on_launch", + awRequiredWhen: { + reqExpression: 'selectedTemplate.ask_job_type_on_launch' + } + }, + limit: { + label: 'Limit', + type: 'text', + addRequired: false, + editRequired: false, + column: 1, + class: 'Form-formGroup--fullWidth', + awPopOver: "

Provide a host pattern to further constrain the list of hosts that will be managed or affected by the playbook. " + + "Multiple patterns can be separated by ; : or ,

For more information and examples see " + + "the Patterns topic at docs.ansible.com.

", + dataTitle: 'Limit', + dataPlacement: 'right', + dataContainer: "body", + ngShow: "selectedTemplate.ask_limit_on_launch" + }, + job_tags: { + label: 'Job Tags', + type: 'textarea', + rows: 5, + addRequired: false, + editRequired: false, + 'elementClass': 'Form-textInput', + column: 2, + class: 'Form-formGroup--fullWidth', + awPopOver: "

Provide a comma separated list of tags.

\n" + + "

Tags are useful when you have a large playbook, and you want to run a specific part of a play or task.

" + + "

Consult the Ansible documentation for further details on the usage of tags.

", + dataTitle: "Job Tags", + dataPlacement: "right", + dataContainer: "body", + ngShow: "selectedTemplate.ask_tags_on_launch" + }, + skip_tags: { + label: 'Skip Tags', + type: 'textarea', + rows: 5, + addRequired: false, + editRequired: false, + 'elementClass': 'Form-textInput', + column: 2, + class: 'Form-formGroup--fullWidth', + awPopOver: "

Provide a comma separated list of tags.

\n" + + "

Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task.

" + + "

Consult the Ansible documentation for further details on the usage of tags.

", + dataTitle: "Skip Tags", + dataPlacement: "right", + dataContainer: "body", + ngShow: "selectedTemplate.ask_skip_tags_on_launch" + } + }, + buttons: { + cancel: { + ngClick: 'cancelNodeForm()' + }, + save: { + ngClick: 'confirmNodeForm()', + ngDisabled: "workflow_maker_form.$invalid || !selectedTemplate" + } + } + }) + .factory('WorkflowMakerForm', ['WorkflowMakerFormObject', 'NotificationsList', function(WorkflowMakerFormObject, NotificationsList) { + return function() { + var itm; + for (itm in WorkflowMakerFormObject.related) { + if (WorkflowMakerFormObject.related[itm].include === "NotificationsList") { + WorkflowMakerFormObject.related[itm] = NotificationsList; + WorkflowMakerFormObject.related[itm].generateList = true; // tell form generator to call list generator and inject a list + } + } + return WorkflowMakerFormObject; + }; + }]); diff --git a/awx/ui/client/src/forms/Workflows.js b/awx/ui/client/src/forms/Workflows.js new file mode 100644 index 0000000000..442c651d7a --- /dev/null +++ b/awx/ui/client/src/forms/Workflows.js @@ -0,0 +1,203 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/** + * @ngdoc function + * @name forms.function:Workflow + * @description This form is for adding/editing a Workflow +*/ + +export default + angular.module('WorkflowFormDefinition', []) + + .value ('WorkflowFormObject', { + + addTitle: 'New Workflow', + editTitle: '{{ name }}', + name: 'workflow_job_template', + base: 'workflow', + basePath: 'workflow_job_templates', + // the top-most node of generated state tree + stateTree: 'templates', + activeEditState: 'templates.editWorkflowJobTemplate', + tabs: true, + + fields: { + name: { + label: 'Name', + type: 'text', + addRequired: true, + editRequired: true, + column: 1 + }, + description: { + label: 'Description', + type: 'text', + addRequired: false, + editRequired: false, + column: 1 + }, + organization: { + label: 'Organization', + type: 'lookup', + sourceModel: 'organization', + basePath: 'organizations', + list: 'OrganizationList', + sourceField: 'name', + dataTitle: 'Organization', + dataContainer: 'body', + dataPlacement: 'right', + column: 1 + }, + labels: { + label: 'Labels', + type: 'select', + class: 'Form-formGroup--fullWidth', + ngOptions: 'label.label for label in labelOptions track by label.value', + multiSelect: true, + addRequired: false, + editRequired: false, + dataTitle: 'Labels', + dataPlacement: 'right', + awPopOver: "

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' + }, + variables: { + label: 'Extra Variables', + type: 'textarea', + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + rows: 6, + addRequired: false, + editRequired: false, + "default": "---", + column: 2, + awPopOver: "

Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter " + + "for ansible-playbook. Provide key/value pairs using either YAML or JSON.

" + + "JSON:
\n" + + "
{
 \"somevar\": \"somevalue\",
 \"password\": \"magic\"
}
\n" + + "YAML:
\n" + + "
---
somevar: somevalue
password: magic
\n", + dataTitle: 'Extra Variables', + dataPlacement: 'right', + dataContainer: "body" + } + }, + + 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/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..e946e9d4d0 --- /dev/null +++ b/awx/ui/client/src/lists/InventorySources.js @@ -0,0 +1,29 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + +export default + angular.module('InventorySourcesListDefinition', []) + .value('InventorySourcesList', { + + name: 'workflow_inventory_sources', + iterator: 'inventory_source', + 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..fd5cbac79a 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: 'unified_job_templates', + 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', + ngHref: "{{job_template.editLink}}" + }, + 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'), @@ -34,80 +42,90 @@ export default label: i18n._('Activity'), columnClass: 'List-tableCell col-lg-2 col-md-2 hidden-sm hidden-xs', nosort: true, - ngInclude: "'/static/partials/job-template-smart-status.html'", + //ngInclude: "'/static/partials/job-template-smart-status.html'", type: 'template' }, labels: { 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'), + actionClass: 'btn List-dropdownSuccess', + buttonContent: i18n._('ADD'), + options: [ + { + optionContent: 'Job Template', + optionSref: 'templates.addJobTemplate' + }, + { + optionContent: 'Workflow Job Template', + optionSref: 'templates.addWorkflowJobTemplate' + } + ], ngShow: 'canAdd' } }, 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(unified_job_templates)', awToolTip: i18n._('Start a job using this template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.start' + ngShow: 'unified_job_templates.summary_fields.user_capabilities.start' }, schedule: { label: i18n._('Schedule'), mode: 'all', - ngClick: 'scheduleJob(job_template.id)', + ngClick: 'scheduleJob(unified_job_templates)', awToolTip: i18n._('Schedule future job template runs'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.schedule' + ngShow: 'unified_job_templates.summary_fields.user_capabilities.schedule' }, copy: { label: i18n._('Copy'), - 'ui-sref': 'jobTemplates.copy({id: job_template.id})', + 'ui-sref': 'templates.copy({id: unified_job_templates.id})', "class": 'btn-danger btn-xs', awToolTip: i18n._('Copy template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.copy' + ngShow: 'unified_job_templates.summary_fields.user_capabilities.copy' }, edit: { label: i18n._('Edit'), - ngClick: "editJobTemplate(job_template.id)", + ngClick: "editJobTemplate(unified_job_templates)", awToolTip: i18n._('Edit template'), "class": 'btn-default btn-xs', dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.edit' + ngShow: 'unified_job_templates.summary_fields.user_capabilities.edit' }, view: { label: i18n._('View'), - ngClick: "editJobTemplate(job_template.id)", + ngClick: "editJobTemplate(unified_job_templates.id)", awToolTip: i18n._('View template'), "class": 'btn-default btn-xs', dataPlacement: 'top', - ngShow: '!job_template.summary_fields.user_capabilities.edit' + ngShow: '!unified_job_templates.summary_fields.user_capabilities.edit' }, "delete": { label: i18n._('Delete'), - ngClick: "deleteJobTemplate(job_template.id, job_template.name)", + ngClick: "deleteJobTemplate(unified_job_templates)", "class": 'btn-danger btn-xs', awToolTip: i18n._('Delete template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.delete' + ngShow: 'unified_job_templates.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/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 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 f3ea41ea07..548a36caee 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -142,10 +142,10 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat .factory('GenerateForm', ['$rootScope', '$location', '$compile', 'generateList', 'Attr', 'Icon', 'Column', 'NavigationLink', 'HelpCollapse', 'DropDown', 'Empty', 'SelectIcon', - 'Store', 'ActionButton', '$log', 'i18n', + 'Store', 'ActionButton', '$log', 'i18n', '$timeout', function ($rootScope, $location, $compile, GenerateList, Attr, Icon, Column, NavigationLink, HelpCollapse, - DropDown, Empty, SelectIcon, Store, ActionButton, $log, i18n) { + DropDown, Empty, SelectIcon, Store, ActionButton, $log, i18n, $timeout) { return { setForm: function (form) { this.form = form; }, @@ -523,7 +523,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 += "
`; + html += "
"; if(this.mode === "edit"){ html += `
${(collection.title || collection.editTitle)}
`; } + + for (itm in this.form.relatedButtons) { + button = this.form.relatedButtons[itm]; + + // Build button HTML + html += "
";//tabHolder } @@ -1430,6 +1492,10 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat button.label = i18n._('View Survey'); button['class'] = 'Form-surveyButton'; } + if (btn === 'workflow_editor') { + button.label = i18n._('Workflow Editor'); + button['class'] = 'Form-primaryButton'; + } // Build button HTML html += " +
+ + + +
+ + +
- +
+ + +
+ + +
diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index 0c4112aa7b..79170ac8bb 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -140,12 +140,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': { 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/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..1088d5fc48 --- /dev/null +++ b/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js @@ -0,0 +1,265 @@ +'use strict'; + +describe('Controller: JobTemplatesList', () => { + // Setup + let scope, + rootScope, + state, + JobTemplatesList, + ClearScope, + GetChoices, + Alert, + Prompt, + InitiatePlaybookRun, + rbacUiControlService, + canAddDeferred, + q, + JobTemplateService, + deleteWorkflowJobTemplateDeferred, + deleteJobTemplateDeferred; + + 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; + } + }; + + 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); + + JobTemplatesList = $controller('JobTemplatesList', { + $scope: scope, + $rootScope: rootScope, + $state: state, + ClearScope: ClearScope, + GetChoices: GetChoices, + Alert: Alert, + Prompt: Prompt, + InitiatePlaybookRun: InitiatePlaybookRun, + rbacUiControlService: rbacUiControlService, + JobTemplateService: JobTemplateService + }); + })); + + it('should call GetChoices', ()=> { + expect(GetChoices).toHaveBeenCalled(); + }); + + 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', {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', {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..c97a0580b4 --- /dev/null +++ b/awx/ui/tests/spec/workflows/workflow-add.controller-test.js @@ -0,0 +1,169 @@ +'use strict'; + +describe('Controller: WorkflowAdd', () => { + // Setup + let scope, + state, + WorkflowAdd, + ClearScope, + Alert, + GenerateForm, + initSurvey, + 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' + ]); + + 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 call GenerateForm.inject', ()=>{ + expect(GenerateForm.inject).toHaveBeenCalled(); + }); + + it('should call GenerateForm.reset', ()=>{ + expect(GenerateForm.reset).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_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..bc2547ef42 --- /dev/null +++ b/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js @@ -0,0 +1,85 @@ +'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(); + WorkflowHelpService = _WorkflowHelpService_; + + WorkflowMakerController = $controller('WorkflowMakerController', { + $scope: scope, + WorkflowHelpService: WorkflowHelpService + }); + + })); + + describe('scope.saveWorkflowMaker()', () => { + + it('should close the dialog', ()=>{ + scope.saveWorkflowMaker(); + expect(WorkflowHelpService.closeDialog).toHaveBeenCalled(); + }); + + }); + + describe('scope.startAddNode()', () => { + + }); + + describe('scope.confirmNodeForm()', () => { + + }); + + describe('scope.cancelNodeForm()', () => { + + }); + + describe('scope.startEditNode()', () => { + + }); + + describe('scope.startDeleteNode()', () => { + + }); + + describe('scope.cancelDeleteNode()', () => { + + }); + + describe('scope.confirmDeleteNode()', () => { + + }); + + describe('scope.toggleFormTab()', () => { + + }); + + describe('scope.toggle_job_template()', () => { + + }); + + describe('scope.toggle_project()', () => { + + }); + + describe('scope.toggle_inventory_source()', () => { + + }); +});