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 @@
-
+
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 tags
+ cancel: {
+ ngClick: 'formCancel()'
+ },
+ save: {
+ ngClick: 'formSave()', //$scope.function to call on click, optional
+ ngDisabled: "workflow_form.$invalid || can_edit!==true"//true //Disable when $pristine or $invalid, optional and when can_edit = false, for permission reasons
+ }
+ },
+
+ related: {
+ permissions: {
+ awToolTip: 'Please save before assigning permissions',
+ dataPlacement: 'top',
+ basePath: 'job_templates/:id/access_list/',
+ type: 'collection',
+ title: 'Permissions',
+ iterator: 'permission',
+ index: false,
+ open: false,
+ searchType: 'select',
+ actions: {
+ add: {
+ ngClick: "addPermission",
+ label: 'Add',
+ awToolTip: 'Add a permission',
+ actionClass: 'btn List-buttonSubmit',
+ buttonContent: '+ ADD',
+ ngShow: '(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ }
+ },
+
+ fields: {
+ username: {
+ key: true,
+ label: 'User',
+ linkBase: 'users',
+ class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4'
+ },
+ role: {
+ label: 'Role',
+ type: 'role',
+ noSort: true,
+ class: 'col-lg-4 col-md-4 col-sm-4 col-xs-4',
+ searchable: false
+ },
+ team_roles: {
+ label: 'Team Roles',
+ type: 'team_roles',
+ noSort: true,
+ class: 'col-lg-5 col-md-5 col-sm-5 col-xs-4',
+ searchable: false
+ }
+ }
+ },
+ "notifications": {
+ include: "NotificationsList"
+ }
+ },
+
+ relatedButtons: {
+ add_survey: {
+ ngClick: 'addSurvey()',
+ ngShow: '!survey_exists',
+ awFeature: 'surveys',
+ awToolTip: 'Please save before adding a survey',
+ dataPlacement: 'top',
+ label: 'Add Survey',
+ class: 'Form-primaryButton'
+ },
+ edit_survey: {
+ ngClick: 'editSurvey()',
+ awFeature: 'surveys',
+ ngShow: 'survey_exists',
+ label: 'Edit Survey',
+ class: 'Form-primaryButton'
+ },
+ workflow_editor: {
+ ngClick: 'openWorkflowMaker()',
+ awToolTip: 'Please save before defining the workflow graph',
+ dataPlacement: 'top',
+ label: 'Workflow Editor',
+ class: 'Form-primaryButton'
+ }
+ },
+
+ relatedSets: function(urls) {
+ return {
+ permissions: {
+ iterator: 'permission',
+ url: urls.access_list
+ },
+ notifications: {
+ iterator: 'notification',
+ url: '/api/v1/notification_templates/'
+ }
+ };
+ }
+ })
+
+ .factory('WorkflowForm', ['WorkflowFormObject', 'NotificationsList',
+ function(WorkflowFormObject, NotificationsList) {
+ return function() {
+ var itm;
+
+ for (itm in WorkflowFormObject.related) {
+ if (WorkflowFormObject.related[itm].include === "NotificationsList") {
+ WorkflowFormObject.related[itm] = NotificationsList;
+ WorkflowFormObject.related[itm].generateList = true; // tell form generator to call list generator and inject a list
+ }
+ }
+
+ return WorkflowFormObject;
+ };
+ }]);
diff --git a/awx/ui/client/src/inventories/edit/inventory-edit.controller.js b/awx/ui/client/src/inventories/edit/inventory-edit.controller.js
index 8d6b690a7d..697bfdd949 100644
--- a/awx/ui/client/src/inventories/edit/inventory-edit.controller.js
+++ b/awx/ui/client/src/inventories/edit/inventory-edit.controller.js
@@ -14,7 +14,7 @@ function InventoriesEdit($scope, $rootScope, $compile, $location,
$log, $stateParams, InventoryForm, Rest, Alert, ProcessErrors,
ClearScope, GetBasePath, ParseTypeChange, Wait, ToJSON,
ParseVariableString, Prompt, InitiatePlaybookRun,
- deleteJobTemplate, $state, $filter) {
+ JobTemplateService, $state, $filter) {
// Inject dynamic view
var defaultUrl = GetBasePath('inventory'),
@@ -141,25 +141,23 @@ function InventoriesEdit($scope, $rootScope, $compile, $location,
$location.path($location.path() + '/job_templates/' + this.scan_job_template.id);
};
- $scope.deleteScanJob = function() {
- var id = this.scan_job_template.id,
- action = function() {
- $('#prompt-modal').modal('hide');
- Wait('start');
- deleteJobTemplate(id)
- .success(function() {
- $('#prompt-modal').modal('hide');
- // @issue: OLD SEARCH
- // $scope.search(form.related.scan_job_templates.iterator);
- })
- .error(function(data) {
- Wait('stop');
- ProcessErrors($scope, data, status, null, {
- hdr: 'Error!',
- msg: 'DELETE returned status: ' + status
- });
- });
- };
+ $scope.deleteScanJob = function () {
+ var id = this.scan_job_template.id ,
+ action = function () {
+ $('#prompt-modal').modal('hide');
+ Wait('start');
+ JobTemplateService.deleteJobTemplate(id)
+ .success(function () {
+ $('#prompt-modal').modal('hide');
+ // @issue: OLD SEARCH
+ // $scope.search(form.related.scan_job_templates.iterator);
+ })
+ .error(function (data) {
+ Wait('stop');
+ ProcessErrors($scope, data, status, null, { hdr: 'Error!',
+ msg: 'DELETE returned status: ' + status });
+ });
+ };
Prompt({
hdr: 'Delete',
@@ -176,5 +174,5 @@ export default ['$scope', '$rootScope', '$compile', '$location',
'$log', '$stateParams', 'InventoryForm', 'Rest', 'Alert',
'ProcessErrors', 'ClearScope', 'GetBasePath', 'ParseTypeChange', 'Wait',
'ToJSON', 'ParseVariableString', 'Prompt', 'InitiatePlaybookRun',
- 'deleteJobTemplate', '$state', '$filter', InventoriesEdit,
+ 'JobTemplateService', '$state', '$filter', InventoriesEdit,
];
diff --git a/awx/ui/client/src/job-detail/job-detail.controller.js b/awx/ui/client/src/job-detail/job-detail.controller.js
index edeeab1034..067c26aa1f 100644
--- a/awx/ui/client/src/job-detail/job-detail.controller.js
+++ b/awx/ui/client/src/job-detail/job-detail.controller.js
@@ -525,7 +525,7 @@ export default
scope.job_template_name = data.name;
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_url = (data.credential) ? '/#/credentials/' + data.credential : '';
diff --git a/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js
index ed16e745e5..eaea7f4d8b 100644
--- a/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js
+++ b/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js
@@ -10,10 +10,10 @@ export default
var scope = params.scope.$new(),
id = params.id,
relaunch = params.relaunch || false,
- system_job = params.system_job || false;
+ job_type = params.job_type;
scope.job_template_id = id;
- var el = $compile( " " )( scope );
+ var el = $compile( " " )( scope );
$('#content-container').remove('submit-job').append( el );
};
}
diff --git a/awx/ui/client/src/job-submission/job-submission.controller.js b/awx/ui/client/src/job-submission/job-submission.controller.js
index 2dc6bc8991..97bdb7cdaf 100644
--- a/awx/ui/client/src/job-submission/job-submission.controller.js
+++ b/awx/ui/client/src/job-submission/job-submission.controller.js
@@ -136,7 +136,12 @@ export default
// jobs, and jobDetails $states.
if (!$scope.submitJobRelaunch) {
- launch_url = GetBasePath('job_templates') + $scope.submitJobId + '/launch/';
+ if($scope.submitJobType && $scope.submitJobType === 'job_template') {
+ launch_url = GetBasePath('job_templates') + $scope.submitJobId + '/launch/';
+ }
+ else if($scope.submitJobType && $scope.submitJobType === 'workflow_job_template') {
+ launch_url = GetBasePath('workflow_job_templates') + $scope.submitJobId + '/launch/';
+ }
}
else {
launch_url = GetBasePath('jobs') + $scope.submitJobId + '/relaunch/';
@@ -191,7 +196,7 @@ export default
updateRequiredPasswords();
}
- if( ($scope.submitJobRelaunch && !$scope.password_needed) || (!$scope.submitJobRelaunch && $scope.can_start_without_user_input && !$scope.ask_inventory_on_launch && !$scope.ask_credential_on_launch && !$scope.has_other_prompts && !$scope.survey_enabled)) {
+ if( ($scope.submitJobType === 'workflow_job_template' && !$scope.survey_enabled) || ($scope.submitJobRelaunch && !$scope.password_needed) || (!$scope.submitJobRelaunch && $scope.can_start_without_user_input && !$scope.ask_inventory_on_launch && !$scope.ask_credential_on_launch && !$scope.has_other_prompts && !$scope.survey_enabled)) {
// The job can be launched if
// a) It's a relaunch and no passwords are needed
// or
diff --git a/awx/ui/client/src/job-submission/job-submission.directive.js b/awx/ui/client/src/job-submission/job-submission.directive.js
index 26a3d9c826..92b9bab7ff 100644
--- a/awx/ui/client/src/job-submission/job-submission.directive.js
+++ b/awx/ui/client/src/job-submission/job-submission.directive.js
@@ -11,7 +11,7 @@ export default [ 'templateUrl', 'CreateDialog', 'Wait', 'CreateSelect2', 'ParseT
return {
scope: {
submitJobId: '=',
- submitJobSystem: '=',
+ submitJobType: '@',
submitJobRelaunch: '='
},
templateUrl: templateUrl('job-submission/job-submission'),
diff --git a/awx/ui/client/src/job-templates/add/job-templates-add.controller.js b/awx/ui/client/src/job-templates/add-job-template/job-template-add.controller.js
similarity index 99%
rename from awx/ui/client/src/job-templates/add/job-templates-add.controller.js
rename to awx/ui/client/src/job-templates/add-job-template/job-template-add.controller.js
index f509f9b2fe..b770462a3e 100644
--- a/awx/ui/client/src/job-templates/add/job-templates-add.controller.js
+++ b/awx/ui/client/src/job-templates/add-job-template/job-template-add.controller.js
@@ -280,7 +280,7 @@
function saveCompleted(id) {
- $state.go('jobTemplates.edit', {job_template_id: id}, {reload: true});
+ $state.go('templates.editJobTemplate', {id: id}, {reload: true});
}
if ($scope.removeTemplateSaveSuccess) {
@@ -501,14 +501,13 @@
} catch (err) {
Wait('stop');
- console.log(err)
Alert("Error", "Error parsing extra variables. " +
"Parser returned: " + err);
}
};
$scope.formCancel = function () {
- $state.go('jobTemplates');
+ $state.transitionTo('templates');
};
}
];
diff --git a/awx/ui/client/src/job-templates/add/main.js b/awx/ui/client/src/job-templates/add-job-template/main.js
similarity index 54%
rename from awx/ui/client/src/job-templates/add/main.js
rename to awx/ui/client/src/job-templates/add-job-template/main.js
index 1cd1f06357..fedbed4898 100644
--- a/awx/ui/client/src/job-templates/add/main.js
+++ b/awx/ui/client/src/job-templates/add-job-template/main.js
@@ -4,8 +4,8 @@
* All Rights Reserved
*************************************************/
-import controller from './job-templates-add.controller';
+import controller from './job-template-add.controller';
export default
- angular.module('jobTemplatesAdd', [])
- .controller('JobTemplatesAdd', controller);
+ angular.module('jobTemplateAdd', [])
+ .controller('JobTemplateAdd', controller);
diff --git a/awx/ui/client/src/job-templates/edit/main.js b/awx/ui/client/src/job-templates/add-workflow/main.js
similarity index 54%
rename from awx/ui/client/src/job-templates/edit/main.js
rename to awx/ui/client/src/job-templates/add-workflow/main.js
index deea134d46..26dcba9939 100644
--- a/awx/ui/client/src/job-templates/edit/main.js
+++ b/awx/ui/client/src/job-templates/add-workflow/main.js
@@ -4,8 +4,8 @@
* All Rights Reserved
*************************************************/
-import controller from './job-templates-edit.controller';
+import controller from './workflow-add.controller';
export default
- angular.module('jobTemplatesEdit', [])
- .controller('JobTemplatesEdit', controller);
+ angular.module('workflowAdd', [])
+ .controller('WorkflowAdd', controller);
diff --git a/awx/ui/client/src/job-templates/add-workflow/workflow-add.controller.js b/awx/ui/client/src/job-templates/add-workflow/workflow-add.controller.js
new file mode 100644
index 0000000000..17a26c9a4f
--- /dev/null
+++ b/awx/ui/client/src/job-templates/add-workflow/workflow-add.controller.js
@@ -0,0 +1,187 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+ export default
+ [ '$scope', 'WorkflowForm', 'GenerateForm', 'Alert', 'ProcessErrors', 'ClearScope',
+ 'Wait', '$state', 'CreateSelect2', 'JobTemplateService', 'ToJSON',
+ 'ParseTypeChange', 'OrganizationList', '$q', 'Rest', 'GetBasePath',
+ function(
+ $scope, WorkflowForm, GenerateForm, Alert, ProcessErrors, ClearScope,
+ Wait, $state, CreateSelect2, JobTemplateService, ToJSON,
+ ParseTypeChange, OrganizationList, $q, Rest, GetBasePath
+ ) {
+
+ Rest.setUrl(GetBasePath('workflow_job_templates'));
+ Rest.options()
+ .success(function(data) {
+ if (!data.actions.POST) {
+ $state.go("^");
+ Alert('Permission Error', 'You do not have permission to add a workflow job template.', 'alert-info');
+ }
+ });
+
+ ClearScope();
+ // Inject dynamic view
+ let form = WorkflowForm(),
+ generator = GenerateForm;
+
+ function init() {
+ $scope.parseType = 'yaml';
+ $scope.can_edit = true;
+ // apply form definition's default field values
+ GenerateForm.applyDefaults(form, $scope);
+
+ // Make the variables textarea look pretty
+ ParseTypeChange({
+ scope: $scope,
+ field_id: 'workflow_job_template_variables',
+ onChange: function() {
+ // Make sure the form controller knows there was a change
+ $scope[form.name + '_form'].$setDirty();
+ }
+ });
+
+ // Go out and grab the possible labels
+ JobTemplateService.getLabelOptions()
+ .then(function(data){
+ $scope.labelOptions = data;
+ // select2-ify the labels input
+ CreateSelect2({
+ element:'#workflow_job_template_labels',
+ multiple: true,
+ addNew: true
+ });
+ }, function(error){
+ ProcessErrors($scope, error.data, error.status, form, {
+ hdr: 'Error!',
+ msg: 'Failed to get labels. GET returned ' +
+ 'status: ' + error.status
+ });
+ });
+
+ }
+
+ $scope.formSave = function () {
+ let fld, data = {};
+
+ generator.clearApiErrors($scope);
+
+ Wait('start');
+
+ try {
+ for (fld in form.fields) {
+ data[fld] = $scope[fld];
+ }
+
+ data.extra_vars = ToJSON($scope.parseType,
+ $scope.variables, true);
+
+ // The idea here is that we want to find the new option elements that also have a label that exists in the dom
+ $("#workflow_job_template_labels > option").filter("[data-select2-tag=true]").each(function(optionIndex, option) {
+ $("#workflow_job_template_labels").siblings(".select2").first().find(".select2-selection__choice").each(function(labelIndex, label) {
+ if($(option).text() === $(label).attr('title')) {
+ // Mark that the option has a label present so that we can filter by that down below
+ $(option).attr('data-label-is-present', true);
+ }
+ });
+ });
+
+ $scope.newLabels = $("#workflow_job_template_labels > option")
+ .filter("[data-select2-tag=true]")
+ .filter("[data-label-is-present=true]")
+ .map((i, val) => ({name: $(val).text()}));
+
+ JobTemplateService.createWorkflowJobTemplate(data)
+ .then(function(data) {
+
+ let orgDefer = $q.defer();
+ let associationDefer = $q.defer();
+
+ Rest.setUrl(data.data.related.labels);
+
+ let currentLabels = Rest.get()
+ .then(function(data) {
+ return data.data.results
+ .map(val => val.id);
+ });
+
+ currentLabels.then(function (current) {
+ let labelsToAdd = ($scope.labels || [])
+ .map(val => val.value);
+ let labelsToDisassociate = current
+ .filter(val => labelsToAdd
+ .indexOf(val) === -1)
+ .map(val => ({id: val, disassociate: true}));
+ let labelsToAssociate = labelsToAdd
+ .filter(val => current
+ .indexOf(val) === -1)
+ .map(val => ({id: val, associate: true}));
+ let pass = labelsToDisassociate
+ .concat(labelsToAssociate);
+ associationDefer.resolve(pass);
+ });
+
+ Rest.setUrl(GetBasePath("organizations"));
+ Rest.get()
+ .success(function(data) {
+ orgDefer.resolve(data.results[0].id);
+ });
+
+ orgDefer.promise.then(function(orgId) {
+ let toPost = [];
+ $scope.newLabels = $scope.newLabels
+ .map(function(i, val) {
+ val.organization = orgId;
+ return val;
+ });
+
+ $scope.newLabels.each(function(i, val) {
+ toPost.push(val);
+ });
+
+ associationDefer.promise.then(function(arr) {
+ toPost = toPost
+ .concat(arr);
+
+ Rest.setUrl(data.data.related.labels);
+
+ let defers = [];
+ for (let i = 0; i < toPost.length; i++) {
+ defers.push(Rest.post(toPost[i]));
+ }
+ $q.all(defers)
+ .then(function() {
+ // If we follow the same pattern as job templates then the survey logic will go here
+
+ $state.go('templates.editWorkflowJobTemplate', {workflow_job_template_id: data.data.id}, {reload: true});
+ });
+ });
+ });
+
+ }, function (error) {
+ ProcessErrors($scope, error.data, error.status, form,
+ {
+ hdr: 'Error!',
+ msg: 'Failed to add new workflow. ' +
+ 'POST returned status: ' +
+ error.status
+ });
+ });
+
+ } catch (err) {
+ Wait('stop');
+ Alert("Error", "Error parsing extra variables. " +
+ "Parser returned: " + err);
+ }
+ };
+
+ $scope.formCancel = function () {
+ $state.transitionTo('templates');
+ };
+
+ init();
+ }
+ ];
diff --git a/awx/ui/client/src/job-templates/add-workflow/workflow-add.partial.html b/awx/ui/client/src/job-templates/add-workflow/workflow-add.partial.html
new file mode 100644
index 0000000000..b3a9d14086
--- /dev/null
+++ b/awx/ui/client/src/job-templates/add-workflow/workflow-add.partial.html
@@ -0,0 +1,4 @@
+
diff --git a/awx/ui/client/src/job-templates/copy/job-templates-copy.controller.js b/awx/ui/client/src/job-templates/copy/job-templates-copy.controller.js
index 7e4da2f4ec..089c7b6628 100644
--- a/awx/ui/client/src/job-templates/copy/job-templates-copy.controller.js
+++ b/awx/ui/client/src/job-templates/copy/job-templates-copy.controller.js
@@ -19,7 +19,12 @@
jobTemplateCopyService.set(res)
.success(function(res){
Wait('stop');
- $state.go('jobTemplates.edit', {id: res.id}, {reload: true});
+ if(res.type && res.type === 'job_template') {
+ $state.go('templates.editJobTemplate', {id: res.id}, {reload: true});
+ }
+ else if(res.type && res.type === 'workflow') {
+ // TODO: direct the user to the edit state for workflows
+ }
});
})
.error(function(res, status){
diff --git a/awx/ui/client/src/job-templates/copy/job-templates-copy.route.js b/awx/ui/client/src/job-templates/copy/job-templates-copy.route.js
index 5ba0807879..d5b9d5e7e3 100644
--- a/awx/ui/client/src/job-templates/copy/job-templates-copy.route.js
+++ b/awx/ui/client/src/job-templates/copy/job-templates-copy.route.js
@@ -6,7 +6,7 @@
export default {
- name: 'jobTemplates.copy',
+ name: 'templates.copy',
route: '/:id/copy',
controller: 'jobTemplateCopyController'
};
diff --git a/awx/ui/client/src/job-templates/delete-job-template.service.js b/awx/ui/client/src/job-templates/delete-job-template.service.js
deleted file mode 100644
index 6bac6c3acb..0000000000
--- a/awx/ui/client/src/job-templates/delete-job-template.service.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/*************************************************
- * Copyright (c) 2015 Ansible, Inc.
- *
- * All Rights Reserved
- *************************************************/
-
-export default ['Rest', 'GetBasePath', function(Rest, GetBasePath){
- return {
- deleteJobTemplate: function(id){
- var url = GetBasePath('job_templates');
-
- url = url + id;
-
- Rest.setUrl(url);
- return Rest.destroy();
- }
- };
-}];
diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js b/awx/ui/client/src/job-templates/edit-job-template/job-template-edit.controller.js
similarity index 100%
rename from awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js
rename to awx/ui/client/src/job-templates/edit-job-template/job-template-edit.controller.js
diff --git a/awx/ui/client/src/job-templates/edit-job-template/main.js b/awx/ui/client/src/job-templates/edit-job-template/main.js
new file mode 100644
index 0000000000..ede92fbe00
--- /dev/null
+++ b/awx/ui/client/src/job-templates/edit-job-template/main.js
@@ -0,0 +1,11 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import controller from './job-template-edit.controller';
+
+export default
+ angular.module('jobTemplateEdit', [])
+ .controller('JobTemplateEdit', controller);
diff --git a/awx/ui/client/src/job-templates/edit-workflow/main.js b/awx/ui/client/src/job-templates/edit-workflow/main.js
new file mode 100644
index 0000000000..bbab5f5402
--- /dev/null
+++ b/awx/ui/client/src/job-templates/edit-workflow/main.js
@@ -0,0 +1,11 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import controller from './workflow-edit.controller';
+
+export default
+ angular.module('workflowEdit', [])
+ .controller('WorkflowEdit', controller);
diff --git a/awx/ui/client/src/job-templates/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/job-templates/edit-workflow/workflow-edit.controller.js
new file mode 100644
index 0000000000..08eafd07b7
--- /dev/null
+++ b/awx/ui/client/src/job-templates/edit-workflow/workflow-edit.controller.js
@@ -0,0 +1,762 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+ export default
+ [ '$scope', '$stateParams', 'WorkflowForm', 'GenerateForm', 'Alert', 'ProcessErrors',
+ 'ClearScope', 'GetBasePath', '$q', 'ParseTypeChange', 'Wait', 'Empty',
+ 'ToJSON', 'initSurvey', '$state', 'CreateSelect2', 'ParseVariableString',
+ 'JobTemplateService', 'OrganizationList', 'Rest',
+ function(
+ $scope, $stateParams, WorkflowForm, GenerateForm, Alert, ProcessErrors,
+ ClearScope, GetBasePath, $q, ParseTypeChange, Wait, Empty,
+ ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString,
+ JobTemplateService, OrganizationList, Rest
+ ) {
+
+ ClearScope();
+
+ $scope.$watch('workflow_job_template_obj.summary_fields.user_capabilities.edit', function(val) {
+ if (val === false) {
+ $scope.canAdd = false;
+ }
+ });
+
+ // Inject dynamic view
+ let form = WorkflowForm(),
+ generator = GenerateForm,
+ id = $stateParams.workflow_job_template_id;
+
+ $scope.mode = 'edit';
+ $scope.parseType = 'yaml';
+ $scope.includeWorkflowMaker = false;
+
+ // What is this used for? Permissions?
+ $scope.can_edit = true;
+
+ $scope.editRequests = [];
+ $scope.associateRequests = [];
+ $scope.disassociateRequests = [];
+
+ $scope.workflowTree = {
+ data: {
+ id: 1,
+ canDelete: false,
+ canEdit: false,
+ canAddTo: true,
+ isStartNode: true,
+ unifiedJobTemplate: {
+ name: "Workflow Launch"
+ },
+ children: [],
+ deletedNodes: [],
+ totalNodes: 0
+ },
+ nextIndex: 2
+ };
+
+ function buildBranch(params) {
+ // params.nodeId
+ // params.parentId
+ // params.edgeType
+ // params.nodesObj
+ // params.isRoot
+
+ let treeNode = {
+ children: [],
+ c: "#D7D7D7",
+ id: $scope.workflowTree.nextIndex,
+ nodeId: params.nodeId,
+ canDelete: true,
+ canEdit: true,
+ canAddTo: true,
+ placeholder: false,
+ edgeType: params.edgeType,
+ unifiedJobTemplate: _.clone(params.nodesObj[params.nodeId].summary_fields.unified_job_template),
+ isNew: false,
+ edited: false,
+ originalEdge: params.edgeType,
+ originalNodeObj: _.clone(params.nodesObj[params.nodeId]),
+ promptValues: {},
+ isRoot: params.isRoot ? params.isRoot : false
+ };
+
+ $scope.workflowTree.data.totalNodes++;
+
+ $scope.workflowTree.nextIndex++;
+
+ if(params.parentId) {
+ treeNode.originalParentId = params.parentId;
+ }
+
+ // Loop across the success nodes and add them recursively
+ _.forEach(params.nodesObj[params.nodeId].success_nodes, function(successNodeId) {
+ treeNode.children.push(buildBranch({
+ nodeId: successNodeId,
+ parentId: params.nodeId,
+ edgeType: "success",
+ nodesObj: params.nodesObj
+ }));
+ });
+
+ // failure nodes
+ _.forEach(params.nodesObj[params.nodeId].failure_nodes, function(failureNodesId) {
+ treeNode.children.push(buildBranch({
+ nodeId: failureNodesId,
+ parentId: params.nodeId,
+ edgeType: "failure",
+ nodesObj: params.nodesObj
+ }));
+ });
+
+ // always nodes
+ _.forEach(params.nodesObj[params.nodeId].always_nodes, function(alwaysNodesId) {
+ treeNode.children.push(buildBranch({
+ nodeId: alwaysNodesId,
+ parentId: params.nodeId,
+ edgeType: "always",
+ nodesObj: params.nodesObj
+ }));
+ });
+
+ return treeNode;
+ }
+
+ function init() {
+ // // Inject the edit form
+ // generator.inject(form, {
+ // mode: 'edit' ,
+ // scope: $scope,
+ // related: false
+ // });
+ // generator.reset();
+
+ // Select2-ify the lables input
+ CreateSelect2({
+ element:'#workflow_job_template_labels',
+ multiple: true,
+ addNew: true
+ });
+
+ // // Make the variables textarea look nice
+ // ParseTypeChange({
+ // scope: $scope,
+ // field_id: 'workflow_job_template_variables',
+ // onChange: function() {
+ // $scope[form.name + '_form'].$setDirty();
+ // }
+ // });
+
+ // // Initialize the organization lookup
+ // LookUpInit({
+ // scope: $scope,
+ // form: form,
+ // list: OrganizationList,
+ // field: 'organization',
+ // input_type: 'radio'
+ // });
+
+ Rest.setUrl('api/v1/labels');
+ Wait("start");
+ Rest.get()
+ .success(function (data) {
+ $scope.labelOptions = data.results
+ .map((i) => ({label: i.name, value: i.id}));
+
+ var seeMoreResolve = $q.defer();
+
+ 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));
+ }
+ });
+ };
+
+ Rest.setUrl(GetBasePath('workflow_job_templates') + id +
+ "/labels");
+ Rest.get()
+ .success(function(data) {
+ if (data.next) {
+ getNext(data, data.results, seeMoreResolve);
+ } else {
+ seeMoreResolve.resolve(data.results);
+ }
+
+ seeMoreResolve.promise.then(function (labels) {
+ $scope.$emit("choicesReady");
+ var opts = labels
+ .map(i => ({id: i.id + "",
+ test: i.name}));
+ CreateSelect2({
+ element:'#workflow_job_template_labels',
+ multiple: true,
+ addNew: true,
+ opts: opts
+ });
+ Wait("stop");
+ });
+ }).error(function(){
+ // job template id is null in this case
+ $scope.$emit("choicesReady");
+ });
+
+ })
+ .error(function (data, status) {
+ ProcessErrors($scope, data, status, form, {
+ hdr: 'Error!',
+ msg: 'Failed to get labels. GET returned ' +
+ 'status: ' + status
+ });
+ });
+
+ // Get the workflow nodes
+ JobTemplateService.getWorkflowJobTemplateNodes(id)
+ .then(function(data){
+
+ let nodesArray = data.data.results;
+ let nodesObj = {};
+ let nonRootNodeIds = [];
+ let allNodeIds = [];
+
+ // Determine which nodes are root nodes
+ _.forEach(nodesArray, function(node) {
+ nodesObj[node.id] = _.clone(node);
+
+ allNodeIds.push(node.id);
+
+ _.forEach(node.success_nodes, function(nodeId){
+ nonRootNodeIds.push(nodeId);
+ });
+ _.forEach(node.failure_nodes, function(nodeId){
+ nonRootNodeIds.push(nodeId);
+ });
+ _.forEach(node.always_nodes, function(nodeId){
+ nonRootNodeIds.push(nodeId);
+ });
+ });
+
+ let rootNodes = _.difference(allNodeIds, nonRootNodeIds);
+
+ // Loop across the root nodes and re-build the tree
+ _.forEach(rootNodes, function(rootNodeId) {
+ let branch = buildBranch({
+ nodeId: rootNodeId,
+ edgeType: "always",
+ nodesObj: nodesObj,
+ isRoot: true
+ });
+
+ $scope.workflowTree.data.children.push(branch);
+ });
+
+ // TODO: I think that the workflow chart directive (and eventually d3) is meddling with
+ // this workflowTree object and removing the children object for some reason (?)
+ // This happens on occasion and I think is a race condition (?)
+ if(!$scope.workflowTree.data.children) {
+ $scope.workflowTree.data.children = [];
+ }
+
+ // In the partial, the workflow maker directive has an ng-if attribute which is pointed at this scope variable.
+ // It won't get included until this the tree has been built - I'm open to better ways of doing this.
+ $scope.includeWorkflowMaker = true;
+
+ }, function(error){
+ ProcessErrors($scope, error.data, error.status, form, {
+ hdr: 'Error!',
+ msg: 'Failed to get workflow job template nodes. GET returned ' +
+ 'status: ' + error.status
+ });
+ });
+
+ // Go out and GET the workflow job temlate data needed to populate the form
+ JobTemplateService.getWorkflowJobTemplate(id)
+ .then(function(data){
+ let workflowJobTemplateData = data.data;
+ $scope.workflow_job_template_obj = workflowJobTemplateData;
+ $scope.name = workflowJobTemplateData.name;
+ let fld, i;
+ for (fld in form.fields) {
+ if (fld !== 'variables' && fld !== 'survey' && workflowJobTemplateData[fld] !== null && workflowJobTemplateData[fld] !== undefined) {
+ if (form.fields[fld].type === 'select') {
+ if ($scope[fld + '_options'] && $scope[fld + '_options'].length > 0) {
+ for (i = 0; i < $scope[fld + '_options'].length; i++) {
+ if (workflowJobTemplateData[fld] === $scope[fld + '_options'][i].value) {
+ $scope[fld] = $scope[fld + '_options'][i];
+ }
+ }
+ } else {
+ $scope[fld] = workflowJobTemplateData[fld];
+ }
+ } else {
+ $scope[fld] = workflowJobTemplateData[fld];
+ if(!Empty(workflowJobTemplateData.summary_fields.survey)) {
+ $scope.survey_exists = true;
+ }
+ }
+ }
+ if (fld === 'variables') {
+ // Parse extra_vars, converting to YAML.
+ $scope.variables = ParseVariableString(workflowJobTemplateData.extra_vars);
+
+ ParseTypeChange({ scope: $scope, field_id: 'workflow_job_template_variables' });
+ }
+ if (form.fields[fld].type === 'lookup' && workflowJobTemplateData.summary_fields[form.fields[fld].sourceModel]) {
+ $scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] =
+ workflowJobTemplateData.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField];
+ }
+ }
+ Wait('stop');
+ $scope.url = workflowJobTemplateData.url;
+ $scope.survey_enabled = workflowJobTemplateData.survey_enabled;
+
+ }, function(error){
+ ProcessErrors($scope, error.data, error.status, form, {
+ hdr: 'Error!',
+ msg: 'Failed to get workflow job template. GET returned ' +
+ 'status: ' + error.status
+ });
+ });
+ }
+
+ function recursiveNodeUpdates(params, completionCallback) {
+ // params.parentId
+ // params.node
+
+ let generatePostUrl = function(){
+
+ let base = (params.parentId) ? GetBasePath('workflow_job_template_nodes') + params.parentId : $scope.workflow_job_template_obj.related.workflow_nodes;
+
+ if(params.parentId) {
+ if(params.node.edgeType === 'success') {
+ base += "/success_nodes";
+ }
+ else if(params.node.edgeType === 'failure') {
+ base += "/failure_nodes";
+ }
+ else if(params.node.edgeType === 'always') {
+ base += "/always_nodes";
+ }
+ }
+
+ return base;
+
+ };
+
+ let buildSendableNodeData = function() {
+ // Create the node
+ let sendableNodeData = {
+ unified_job_template: params.node.unifiedJobTemplate.id
+ };
+
+ // Check to see if the user has provided any prompt values that are different
+ // from the defaults in the job template
+
+ if(params.node.unifiedJobTemplate.type === "job_template" && params.node.promptValues) {
+ if(params.node.unifiedJobTemplate.ask_credential_on_launch) {
+ sendableNodeData.credential = !params.node.promptValues.credential || params.node.unifiedJobTemplate.summary_fields.credential.id !== params.node.promptValues.credential.id ? params.node.promptValues.credential.id : null;
+ }
+ if(params.node.unifiedJobTemplate.ask_inventory_on_launch) {
+ sendableNodeData.inventory = !params.node.promptValues.inventory || params.node.unifiedJobTemplate.summary_fields.inventory.id !== params.node.promptValues.inventory.id ? params.node.promptValues.inventory.id : null;
+ }
+ if(params.node.unifiedJobTemplate.ask_limit_on_launch) {
+ sendableNodeData.limit = !params.node.promptValues.limit || params.node.unifiedJobTemplate.limit !== params.node.promptValues.limit ? params.node.promptValues.limit : null;
+ }
+ if(params.node.unifiedJobTemplate.ask_job_type_on_launch) {
+ sendableNodeData.job_type = !params.node.promptValues.job_type || params.node.unifiedJobTemplate.job_type !== params.node.promptValues.job_type ? params.node.promptValues.job_type : null;
+ }
+ if(params.node.unifiedJobTemplate.ask_tags_on_launch) {
+ sendableNodeData.job_tags = !params.node.promptValues.job_tags || params.node.unifiedJobTemplate.job_tags !== params.node.promptValues.job_tags ? params.node.promptValues.job_tags : null;
+ }
+ if(params.node.unifiedJobTemplate.ask_skip_tags_on_launch) {
+ sendableNodeData.skip_tags = !params.node.promptValues.skip_tags || params.node.unifiedJobTemplate.skip_tags !== params.node.promptValues.skip_tags ? params.node.promptValues.skip_tags : null;
+ }
+ }
+
+ return sendableNodeData;
+ };
+
+ let continueRecursing = function(parentId) {
+ $scope.totalIteratedNodes++;
+
+ if($scope.totalIteratedNodes === $scope.workflowTree.data.totalNodes) {
+ // We're done recursing, lets move on
+ completionCallback();
+ }
+ else {
+ if(params.node.children && params.node.children.length > 0) {
+ _.forEach(params.node.children, function(child) {
+ if(child.edgeType === "success") {
+ recursiveNodeUpdates({
+ parentId: parentId,
+ node: child
+ }, completionCallback);
+ }
+ else if(child.edgeType === "failure") {
+ recursiveNodeUpdates({
+ parentId: parentId,
+ node: child
+ }, completionCallback);
+ }
+ else if(child.edgeType === "always") {
+ recursiveNodeUpdates({
+ parentId: parentId,
+ node: child
+ }, completionCallback);
+ }
+ });
+ }
+ }
+ };
+
+ if(params.node.isNew) {
+
+ JobTemplateService.addWorkflowNode({
+ url: generatePostUrl(),
+ data: buildSendableNodeData()
+ })
+ .then(function(data) {
+ continueRecursing(data.data.id);
+ }, function(error) {
+ ProcessErrors($scope, error.data, error.status, form, {
+ hdr: 'Error!',
+ msg: 'Failed to add workflow node. ' +
+ 'POST returned status: ' +
+ error.status
+ });
+ });
+ }
+ else {
+ if(params.node.edited || !params.node.originalParentId || (params.node.originalParentId && params.parentId !== params.node.originalParentId)) {
+
+ if(params.node.edited) {
+
+ $scope.editRequests.push({
+ id: params.node.nodeId,
+ data: buildSendableNodeData()
+ });
+
+ }
+
+ if((params.node.originalParentId && params.parentId !== params.node.originalParentId) || params.node.originalEdge !== params.node.edgeType) {//beep
+
+ $scope.disassociateRequests.push({
+ parentId: params.node.originalParentId,
+ nodeId: params.node.nodeId,
+ edge: params.node.originalEdge
+ });
+
+ // Can only associate if we have a parent.
+ // If we don't have a parent then this is a root node
+ // and the act of disassociating will make it a root node
+ if(params.parentId) {
+ $scope.associateRequests.push({
+ parentId: params.parentId,
+ nodeId: params.node.nodeId,
+ edge: params.node.edgeType
+ });
+ }
+
+ }
+ else if(!params.node.originalParentId && params.parentId) {
+ // This used to be a root node but is now not a root node
+ $scope.associateRequests.push({
+ parentId: params.parentId,
+ nodeId: params.node.nodeId,
+ edge: params.node.edgeType
+ });
+ }
+
+ }
+
+ continueRecursing(params.node.nodeId);
+ }
+ }
+
+ $scope.openWorkflowMaker = function() {
+ $scope.$broadcast("showWorkflowMaker");
+ };
+
+ $scope.formSave = function () {
+ let fld, data = {};
+ $scope.invalid_survey = false;
+
+ // Can't have a survey enabled without a survey
+ if($scope.survey_enabled === true && $scope.survey_exists!==true){
+ $scope.survey_enabled = false;
+ }
+
+ generator.clearApiErrors($scope);
+
+ Wait('start');
+
+ try {
+ for (fld in form.fields) {
+ data[fld] = $scope[fld];
+ }
+
+ data.extra_vars = ToJSON($scope.parseType,
+ $scope.variables, true);
+
+ // The idea here is that we want to find the new option elements that also have a label that exists in the dom
+ $("#workflow_job_template_labels > option").filter("[data-select2-tag=true]").each(function(optionIndex, option) {
+ $("#workflow_job_template_labels").siblings(".select2").first().find(".select2-selection__choice").each(function(labelIndex, label) {
+ if($(option).text() === $(label).attr('title')) {
+ // Mark that the option has a label present so that we can filter by that down below
+ $(option).attr('data-label-is-present', true);
+ }
+ });
+ });
+
+ $scope.newLabels = $("#workflow_job_template_labels > option")
+ .filter("[data-select2-tag=true]")
+ .filter("[data-label-is-present=true]")
+ .map((i, val) => ({name: $(val).text()}));
+
+ $scope.totalIteratedNodes = 0;
+
+ // TODO: this is the only way that I could figure out to get
+ // these promise arrays to play nicely. I tried to just append
+ // a single promise to deletePromises but it just wasn't working
+ let editWorkflowJobTemplate = [id].map(function(id) {
+ return JobTemplateService.updateWorkflowJobTemplate({
+ id: id,
+ data: data
+ });
+ });
+
+ if($scope.workflowTree && $scope.workflowTree.data && $scope.workflowTree.data.children && $scope.workflowTree.data.children.length > 0) {
+ let completionCallback = function() {
+
+ let disassociatePromises = $scope.disassociateRequests.map(function(request) {
+ return JobTemplateService.disassociateWorkflowNode({
+ parentId: request.parentId,
+ nodeId: request.nodeId,
+ edge: request.edge
+ });
+ });
+
+ let editNodePromises = $scope.editRequests.map(function(request) {
+ return JobTemplateService.editWorkflowNode({
+ id: request.id,
+ data: request.data
+ });
+ });
+
+ $q.all(disassociatePromises.concat(editNodePromises).concat(editWorkflowJobTemplate))
+ .then(function() {
+
+ let associatePromises = $scope.associateRequests.map(function(request) {
+ return JobTemplateService.associateWorkflowNode({
+ parentId: request.parentId,
+ nodeId: request.nodeId,
+ edge: request.edge
+ });
+ });
+
+ let deletePromises = $scope.workflowTree.data.deletedNodes.map(function(nodeId) {
+ return JobTemplateService.deleteWorkflowJobTemplateNode(nodeId);
+ });
+
+ $q.all(associatePromises.concat(deletePromises))
+ .then(function() {
+
+ var orgDefer = $q.defer();
+ var associationDefer = $q.defer();
+ var associatedLabelsDefer = $q.defer();
+
+ 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));
+ }
+ });
+ };
+
+ Rest.setUrl($scope.workflow_job_template_obj.related.labels);
+
+ Rest.get()
+ .success(function(data) {
+ if (data.next) {
+ getNext(data, data.results, associatedLabelsDefer);
+ } else {
+ associatedLabelsDefer.resolve(data.results);
+ }
+ });
+
+ associatedLabelsDefer.promise.then(function (current) {
+ current = current.map(data => data.id);
+ var labelsToAdd = $scope.labels
+ .map(val => val.value);
+ var labelsToDisassociate = current
+ .filter(val => labelsToAdd
+ .indexOf(val) === -1)
+ .map(val => ({id: val, disassociate: true}));
+ var labelsToAssociate = labelsToAdd
+ .filter(val => current
+ .indexOf(val) === -1)
+ .map(val => ({id: val, associate: true}));
+ var pass = labelsToDisassociate
+ .concat(labelsToAssociate);
+ associationDefer.resolve(pass);
+ });
+
+ Rest.setUrl(GetBasePath("organizations"));
+ Rest.get()
+ .success(function(data) {
+ orgDefer.resolve(data.results[0].id);
+ });
+
+ orgDefer.promise.then(function(orgId) {
+ var toPost = [];
+ $scope.newLabels = $scope.newLabels
+ .map(function(i, val) {
+ val.organization = orgId;
+ return val;
+ });
+
+ $scope.newLabels.each(function(i, val) {
+ toPost.push(val);
+ });
+
+ associationDefer.promise.then(function(arr) {
+ toPost = toPost
+ .concat(arr);
+
+ Rest.setUrl($scope.workflow_job_template_obj.related.labels);
+
+ var defers = [];
+ for (var i = 0; i < toPost.length; i++) {
+ defers.push(Rest.post(toPost[i]));
+ }
+ $q.all(defers)
+ .then(function() {
+ $state.go('templates.editWorkflowJobTemplate', {id: id}, {reload: true});
+ });
+ });
+ });
+
+ });
+ });
+ };
+
+ _.forEach($scope.workflowTree.data.children, function(child) {
+ recursiveNodeUpdates({
+ node: child
+ }, completionCallback);
+ });
+ }
+ else {
+
+ let deletePromises = $scope.workflowTree.data.deletedNodes.map(function(nodeId) {
+ return JobTemplateService.deleteWorkflowJobTemplateNode(nodeId);
+ });
+
+ $q.all(deletePromises.concat(editWorkflowJobTemplate))
+ .then(function() {
+ var orgDefer = $q.defer();
+ var associationDefer = $q.defer();
+ var associatedLabelsDefer = $q.defer();
+
+ 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));
+ }
+ });
+ };
+
+ Rest.setUrl($scope.workflow_job_template_obj.related.labels);
+
+ Rest.get()
+ .success(function(data) {
+ if (data.next) {
+ getNext(data, data.results, associatedLabelsDefer);
+ } else {
+ associatedLabelsDefer.resolve(data.results);
+ }
+ });
+
+ associatedLabelsDefer.promise.then(function (current) {
+ current = current.map(data => data.id);
+ var labelsToAdd = $scope.labels
+ .map(val => val.value);
+ var labelsToDisassociate = current
+ .filter(val => labelsToAdd
+ .indexOf(val) === -1)
+ .map(val => ({id: val, disassociate: true}));
+ var labelsToAssociate = labelsToAdd
+ .filter(val => current
+ .indexOf(val) === -1)
+ .map(val => ({id: val, associate: true}));
+ var pass = labelsToDisassociate
+ .concat(labelsToAssociate);
+ associationDefer.resolve(pass);
+ });
+
+ Rest.setUrl(GetBasePath("organizations"));
+ Rest.get()
+ .success(function(data) {
+ orgDefer.resolve(data.results[0].id);
+ });
+
+ orgDefer.promise.then(function(orgId) {
+ var toPost = [];
+ $scope.newLabels = $scope.newLabels
+ .map(function(i, val) {
+ val.organization = orgId;
+ return val;
+ });
+
+ $scope.newLabels.each(function(i, val) {
+ toPost.push(val);
+ });
+
+ associationDefer.promise.then(function(arr) {
+ toPost = toPost
+ .concat(arr);
+
+ Rest.setUrl($scope.workflow_job_template_obj.related.labels);
+
+ var defers = [];
+ for (var i = 0; i < toPost.length; i++) {
+ defers.push(Rest.post(toPost[i]));
+ }
+ $q.all(defers)
+ .then(function() {
+ $state.go('templates.editWorkflowJobTemplate', {id: id}, {reload: true});
+ });
+ });
+ });
+ //$state.go('templates.editWorkflowJobTemplate', {id: id}, {reload: true});
+ });
+ }
+
+ } catch (err) {
+ Wait('stop');
+ Alert("Error", "Error saving workflow job template. " +
+ "Parser returned: " + err);
+ }
+ };
+
+ $scope.formCancel = function () {
+ $state.transitionTo('templates');
+ };
+
+ init();
+ }
+];
diff --git a/awx/ui/client/src/job-templates/edit-workflow/workflow-edit.partial.html b/awx/ui/client/src/job-templates/edit-workflow/workflow-edit.partial.html
new file mode 100644
index 0000000000..2bd7f4500a
--- /dev/null
+++ b/awx/ui/client/src/job-templates/edit-workflow/workflow-edit.partial.html
@@ -0,0 +1,5 @@
+
diff --git a/awx/ui/client/src/job-templates/job-template.service.js b/awx/ui/client/src/job-templates/job-template.service.js
new file mode 100644
index 0000000000..b6205df4c3
--- /dev/null
+++ b/awx/ui/client/src/job-templates/job-template.service.js
@@ -0,0 +1,190 @@
+/*************************************************
+ * Copyright (c) 2015 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default ['Rest', 'GetBasePath', '$q', function(Rest, GetBasePath, $q){
+ return {
+ deleteJobTemplate: function(id){
+ var url = GetBasePath('job_templates');
+
+ url = url + id;
+
+ Rest.setUrl(url);
+ return Rest.destroy();
+ },
+ deleteWorkflowJobTemplate: function(id) {
+ var url = GetBasePath('workflow_job_templates');
+
+ url = url + id;
+
+ Rest.setUrl(url);
+ return Rest.destroy();
+ },
+ createJobTemplate: function(data){
+ var url = GetBasePath('job_templates');
+
+ Rest.setUrl(url);
+ return Rest.post(data);
+ },
+ createWorkflowJobTemplate: function(data) {
+ var url = GetBasePath('workflow_job_templates');
+
+ Rest.setUrl(url);
+ return Rest.post(data);
+ },
+ getLabelOptions: function(){
+ var url = GetBasePath('labels');
+
+ var deferred = $q.defer();
+
+ Rest.setUrl(url);
+ Rest.get()
+ .success(function(data) {
+ // Turn the labels into something consumable
+ var labels = data.results.map((i) => ({label: i.name, value: i.id}));
+ deferred.resolve(labels);
+ }).error(function(msg, code) {
+ deferred.reject(msg, code);
+ });
+
+ return deferred.promise;
+
+ },
+ getJobTemplate: function(id) {
+ var url = GetBasePath('job_templates');
+
+ url = url + id;
+
+ Rest.setUrl(url);
+ return Rest.get();
+ },
+ addWorkflowNode: function(params) {
+ // params.url
+ // params.data
+
+ Rest.setUrl(params.url);
+ return Rest.post(params.data);
+ },
+ editWorkflowNode: function(params) {
+ // params.id
+ // params.data
+
+ var url = GetBasePath('workflow_job_template_nodes') + params.id;
+
+ Rest.setUrl(url);
+ return Rest.put(params.data);
+ },
+ getJobTemplateLaunchInfo: function(id) {
+ var url = GetBasePath('job_templates');
+
+ url = url + id + '/launch';
+
+ Rest.setUrl(url);
+ return Rest.get();
+ },
+ getWorkflowJobTemplateNodes: function(id) {
+ var url = GetBasePath('workflow_job_templates');
+
+ url = url + id + '/workflow_nodes';
+
+ Rest.setUrl(url);
+ return Rest.get();
+ },
+ updateWorkflowJobTemplate: function(params) {
+ // params.id
+ // params.data
+
+ var url = GetBasePath('workflow_job_templates');
+
+ url = url + params.id;
+
+ Rest.setUrl(url);
+ return Rest.put(params.data);
+ },
+ getWorkflowJobTemplate: function(id) {
+ var url = GetBasePath('workflow_job_templates');
+
+ url = url + id;
+
+ Rest.setUrl(url);
+ return Rest.get();
+ },
+ deleteWorkflowJobTemplateNode: function(id) {
+ var url = GetBasePath('workflow_job_template_nodes') + id;
+
+ Rest.setUrl(url);
+ return Rest.destroy();
+ },
+ disassociateWorkflowNode: function(params) {
+ //params.parentId
+ //params.nodeId
+ //params.edge
+
+ var url = GetBasePath('workflow_job_template_nodes') + params.parentId;
+
+ if(params.edge === 'success') {
+ url = url + '/success_nodes';
+ }
+ else if(params.edge === 'failure') {
+ url = url + '/failure_nodes';
+ }
+ else if(params.edge === 'always') {
+ url = url + '/always_nodes';
+ }
+
+ Rest.setUrl(url);
+ return Rest.post({
+ "id": params.nodeId,
+ "disassociate": true
+ });
+ },
+ associateWorkflowNode: function(params) {
+ //params.parentId
+ //params.nodeId
+ //params.edge
+
+ var url = GetBasePath('workflow_job_template_nodes') + params.parentId;
+
+ if(params.edge === 'success') {
+ url = url + '/success_nodes';
+ }
+ else if(params.edge === 'failure') {
+ url = url + '/failure_nodes';
+ }
+ else if(params.edge === 'always') {
+ url = url + '/always_nodes';
+ }
+
+ Rest.setUrl(url);
+ return Rest.post({
+ id: params.nodeId
+ });
+ },
+ getUnifiedJobTemplate: function(id) {
+ var url = GetBasePath('unified_job_templates');
+
+ url = url + "?id=" + id;
+
+ Rest.setUrl(url);
+ return Rest.get();
+ },
+ getCredential: function(id) {
+ var url = GetBasePath('credentials');
+
+ url = url + id;
+
+ Rest.setUrl(url);
+ return Rest.get();
+ },
+ getInventory: function(id) {
+ var url = GetBasePath('inventory');
+
+ url = url + id;
+
+ Rest.setUrl(url);
+ return Rest.get();
+ }
+ };
+}];
diff --git a/awx/ui/client/src/job-templates/list/job-templates-list.controller.js b/awx/ui/client/src/job-templates/list/job-templates-list.controller.js
index 06dbe1ef23..d6076b6aec 100644
--- a/awx/ui/client/src/job-templates/list/job-templates-list.controller.js
+++ b/awx/ui/client/src/job-templates/list/job-templates-list.controller.js
@@ -6,16 +6,15 @@
export default ['$scope', '$rootScope', '$location', '$stateParams', 'Rest', 'Alert',
'JobTemplateList', 'Prompt', 'ClearScope', 'ProcessErrors', 'GetBasePath',
- 'InitiatePlaybookRun', 'Wait', '$state', '$filter', 'Dataset', 'rbacUiControlService',
+ 'InitiatePlaybookRun', 'Wait', '$state', '$filter', 'Dataset', 'rbacUiControlService', 'JobTemplateService',
function(
$scope, $rootScope, $location, $stateParams, Rest, Alert,
JobTemplateList, Prompt, ClearScope, ProcessErrors, GetBasePath,
- InitiatePlaybookRun, Wait, $state, $filter, Dataset, rbacUiControlService
+ InitiatePlaybookRun, Wait, $state, $filter, Dataset, rbacUiControlService, JobTemplateService
) {
ClearScope();
- var list = JobTemplateList,
- defaultUrl = GetBasePath('job_templates');
+ var list = JobTemplateList;
init();
@@ -42,43 +41,113 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', 'Rest', 'Al
$state.go('jobTemplates.add');
};
- $scope.editJobTemplate = function(id) {
- $state.go('jobTemplates.edit', { job_template_id: id });
+ $scope.editJobTemplate = function(template) {
+ if(template) {
+ if(template.type && (template.type === 'Job Template' || template.type === 'job_template')) {
+ $state.transitionTo('templates.editJobTemplate', {job_template_id: template.id});
+ }
+ else if(template.type && (template.type === 'Workflow Job Template' || template.type === 'workflow_job_template')) {
+ $state.transitionTo('templates.editWorkflowJobTemplate', {workflow_job_template_id: template.id});
+ }
+ else {
+ // Something went wrong - Let the user know that we're unable to launch because we don't know
+ // what type of job template this is
+ Alert('Error: Unable to determine template type', 'We were unable to determine this template\'s type while routing to edit.');
+ }
+ }
+ else {
+ Alert('Error: Unable to edit template', 'Template parameter is missing');
+ }
};
- $scope.deleteJobTemplate = function(id, name) {
- var action = function() {
- $('#prompt-modal').modal('hide');
- Wait('start');
- var url = defaultUrl + id + '/';
- Rest.setUrl(url);
- Rest.destroy()
- .success(function() {
- $state.go('^', null, { reload: true });
- })
- .error(function(data) {
- Wait('stop');
- ProcessErrors($scope, data, status, null, {
- hdr: 'Error!',
- msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status
- });
+ $scope.deleteJobTemplate = function(template) {
+ if(template) {
+ Prompt({
+ hdr: 'Delete',
+ body: 'Are you sure you want to delete the ' + (template.type === "Workflow Job Template" ? 'workflow ' : '') + 'job template below?
' + $filter('sanitize')(template.name) + '
',
+ action: function() {
+
+ function handleSuccessfulDelete() {
+ // TODO: look at this
+ if (parseInt($state.params.id) === template.id) {
+ $state.go("^", null, {reload: true});
+ } else {
+ $state.go(".", null, {reload: true});
+ }
+ Wait('stop');
+ }
+
+ $('#prompt-modal').modal('hide');
+ Wait('start');
+ if(template.type && (template.type === 'Workflow Job Template' || template.type === 'workflow_job_template')) {
+ JobTemplateService.deleteWorkflowJobTemplate(template.id)
+ .then(function () {
+ handleSuccessfulDelete();
+ }, function (data) {
+ Wait('stop');
+ ProcessErrors($scope, data, status, null, { hdr: 'Error!',
+ msg: 'Call to delete workflow job template failed. DELETE returned status: ' + status });
+ });
+ }
+ else if(template.type && (template.type === 'Job Template' || template.type === 'job_template')) {
+ JobTemplateService.deleteJobTemplate(template.id)
+ .then(function () {
+ handleSuccessfulDelete();
+ }, function (data) {
+ Wait('stop');
+ ProcessErrors($scope, data, status, null, { hdr: 'Error!',
+ msg: 'Call to delete job template failed. DELETE returned status: ' + status });
+ });
+ }
+ else {
+ Wait('stop');
+ Alert('Error: Unable to determine template type', 'We were unable to determine this template\'s type while deleting.');
+ }
+ },
+ actionText: 'DELETE'
});
- };
-
- Prompt({
- hdr: 'Delete',
- body: 'Are you sure you want to delete the job template below?
' + $filter('sanitize')(name) + '
',
- action: action,
- actionText: 'DELETE'
- });
+ }
+ else {
+ Alert('Error: Unable to delete template', 'Template parameter is missing');
+ }
};
- $scope.submitJob = function(id) {
- InitiatePlaybookRun({ scope: $scope, id: id });
+ $scope.submitJob = function(template) {
+ if(template) {
+ if(template.type && (template.type === 'Job Template' || template.type === 'job_template')) {
+ InitiatePlaybookRun({ scope: $scope, id: template.id, job_type: 'job_template' });
+ }
+ else if(template.type && (template.type === 'Workflow Job Template' || template.type === 'workflow_job_template')) {
+ InitiatePlaybookRun({ scope: $scope, id: template.id, job_type: 'workflow_job_template' });
+ }
+ else {
+ // Something went wrong - Let the user know that we're unable to launch because we don't know
+ // what type of job template this is
+ Alert('Error: Unable to determine template type', 'We were unable to determine this template\'s type while launching.');
+ }
+ }
+ else {
+ Alert('Error: Unable to launch template', 'Template parameter is missing');
+ }
};
- $scope.scheduleJob = function(id) {
- $state.go('jobTemplateSchedules', { id: id });
+ $scope.scheduleJob = function(template) {
+ if(template) {
+ if(template.type && (template.type === 'Job Template' || template.type === 'job_template')) {
+ $state.go('jobTemplateSchedules', {id: template.id});
+ }
+ else if(template.type && (template.type === 'Workflow Job Template' || template.type === 'workflow_job_template')) {
+ $state.go('workflowJobTemplateSchedules', {id: template.id});
+ }
+ else {
+ // Something went wrong - Let the user know that we're unable to redirect to schedule because we don't know
+ // what type of job template this is
+ Alert('Error: Unable to determine template type', 'We were unable to determine this template\'s type while routing to schedule.');
+ }
+ }
+ else {
+ Alert('Error: Unable to schedule job', 'Template parameter is missing');
+ }
};
}
];
diff --git a/awx/ui/client/src/job-templates/list/job-templates-list.route.js b/awx/ui/client/src/job-templates/list/job-templates-list.route.js
new file mode 100644
index 0000000000..b853179103
--- /dev/null
+++ b/awx/ui/client/src/job-templates/list/job-templates-list.route.js
@@ -0,0 +1,42 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default {
+ name: 'templates',
+ route: '/templates',
+ ncyBreadcrumb: {
+ label: "TEMPLATES"
+ },
+ params: {
+ unified_job_templates_search: {
+ value: {
+ type: 'workflow_job_template,job_template'
+ }
+ }
+ },
+ searchPrefix: 'unified_job_templates',
+ views: {
+ '@': {
+ controller: 'JobTemplatesListController',
+ templateProvider: function(JobTemplateList, generateList) {
+ let html = generateList.build({
+ list: JobTemplateList,
+ mode: 'edit'
+ });
+ html = generateList.wrapPanel(html);
+ return generateList.insertFormView() + html;
+ }
+ }
+ },
+ resolve: {
+ Dataset: ['JobTemplateList', 'QuerySet', '$stateParams', 'GetBasePath',
+ function(list, qs, $stateParams, GetBasePath) {
+ let path = GetBasePath(list.basePath) || GetBasePath(list.name);
+ return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ }
+ ]
+ }
+};
diff --git a/awx/ui/client/src/job-templates/list/main.js b/awx/ui/client/src/job-templates/list/main.js
index e6da0727b5..95ef62cac5 100644
--- a/awx/ui/client/src/job-templates/list/main.js
+++ b/awx/ui/client/src/job-templates/list/main.js
@@ -8,4 +8,4 @@ import controller from './job-templates-list.controller';
export default
angular.module('jobTemplatesList', [])
- .controller('JobTemplatesList', controller);
+ .controller('JobTemplatesListController', controller);
diff --git a/awx/ui/client/src/job-templates/main.js b/awx/ui/client/src/job-templates/main.js
index 840894a4bc..0e2159a1f1 100644
--- a/awx/ui/client/src/job-templates/main.js
+++ b/awx/ui/client/src/job-templates/main.js
@@ -4,47 +4,98 @@
* All Rights Reserved
*************************************************/
-import deleteJobTemplate from './delete-job-template.service';
+import jobTemplateService from './job-template.service';
import surveyMaker from './survey-maker/main';
import jobTemplatesList from './list/main';
-import jobTemplatesAdd from './add/main';
-import jobTemplatesEdit from './edit/main';
+import jobTemplatesAdd from './add-job-template/main';
+import jobTemplatesEdit from './edit-job-template/main';
import jobTemplatesCopy from './copy/main';
+import workflowAdd from './add-workflow/main';
+import workflowEdit from './edit-workflow/main';
import labels from './labels/main';
+import workflowChart from './workflow-chart/main';
+import workflowMaker from './workflow-maker/main';
+import jobTemplatesListRoute from './list/job-templates-list.route';
export default
angular.module('jobTemplates', [surveyMaker.name, jobTemplatesList.name, jobTemplatesAdd.name,
- jobTemplatesEdit.name, jobTemplatesCopy.name, labels.name
+ jobTemplatesEdit.name, jobTemplatesCopy.name, labels.name, workflowAdd.name, workflowEdit.name,
+ workflowChart.name, workflowMaker.name
])
- .service('deleteJobTemplate', deleteJobTemplate)
- .config(['$stateProvider', 'stateDefinitionsProvider',
- function($stateProvider, stateDefinitionsProvider) {
- let stateDefinitions = stateDefinitionsProvider.$get();
+ .service('JobTemplateService', jobTemplateService)
+ .config(['$stateProvider', 'stateDefinitionsProvider', '$stateExtenderProvider',
+ function($stateProvider, stateDefinitionsProvider, $stateExtenderProvider ) {
+ let stateTree, addJobTemplate, editJobTemplate, addWorkflow, editWorkflow,
+ stateDefinitions = stateDefinitionsProvider.$get(),
+ stateExtender = $stateExtenderProvider.$get();
- $stateProvider.state({
- name: 'jobTemplates',
- url: '/job_templates',
- lazyLoad: () => stateDefinitions.generateTree({
- parent: 'jobTemplates',
- modes: ['add', 'edit'],
- list: 'JobTemplateList',
+ function generateStateTree() {
+
+ addJobTemplate = stateDefinitions.generateTree({
+ name: 'templates.addJobTemplate',
+ url: '/add_job_template',
+ modes: ['add'],
form: 'JobTemplateForm',
controllers: {
- list: 'JobTemplatesList',
- add: 'JobTemplatesAdd',
- edit: 'JobTemplatesEdit'
- },
- data: {
- activityStream: true,
- activityStreamTarget: 'job_template',
- socket: {
- "groups": {
- "jobs": ["status_changed"]
- }
- }
- },
- })
- });
+ add: 'JobTemplateAdd'
+ }
+ });
+
+ editJobTemplate = stateDefinitions.generateTree({
+ name: 'templates.editJobTemplate',
+ url: '/job_template/:job_template_id',
+ modes: ['edit'],
+ form: 'JobTemplateForm',
+ controllers: {
+ edit: 'JobTemplateEdit'
+ }
+ });
+
+ addWorkflow = stateDefinitions.generateTree({
+ name: 'templates.addWorkflowJobTemplate',
+ url: '/add_workflow_job_template',
+ modes: ['add'],
+ form: 'WorkflowForm',
+ controllers: {
+ add: 'WorkflowAdd'
+ }
+ });
+
+ editWorkflow = stateDefinitions.generateTree({
+ name: 'templates.editWorkflowJobTemplate',
+ url: '/workflow_job_template/:workflow_job_template_id',
+ modes: ['edit'],
+ form: 'WorkflowForm',
+ controllers: {
+ edit: 'WorkflowEdit'
+ }
+ });
+
+ return Promise.all([
+ addJobTemplate,
+ editJobTemplate,
+ addWorkflow,
+ editWorkflow
+ ]).then((generated) => {
+ return {
+ states: _.reduce(generated, (result, definition) => {
+ return result.concat(definition.states);
+ }, [
+ stateExtender.buildDefinition(jobTemplatesListRoute)
+
+ ])
+ };
+ });
+ }
+
+ stateTree = {
+ name: 'templates',
+ url: '/templates',
+ lazyLoad: () => generateStateTree()
+ };
+
+ $stateProvider.state(stateTree);
+
}
]);
diff --git a/awx/ui/client/src/job-templates/workflow-chart/main.js b/awx/ui/client/src/job-templates/workflow-chart/main.js
new file mode 100644
index 0000000000..76f0484889
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-chart/main.js
@@ -0,0 +1,11 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import workflowChart from './workflow-chart.directive';
+
+export default
+ angular.module('jobTemplatesWorkflowChart', [])
+ .directive('workflowChart', workflowChart);
diff --git a/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.block.less
new file mode 100644
index 0000000000..bc0d4472cf
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.block.less
@@ -0,0 +1,71 @@
+.WorkflowChart-isActiveEdit {
+ stroke: red;
+}
+
+.nodeConnector circle, .nodeConnector .linkCross, .node .addCircle, .node .removeCircle, .node .WorkflowChart-hoverPath {
+ opacity: 0;
+}
+
+.node .addCircle, .nodeConnector .addCircle {
+ fill: #5CB85C;
+}
+
+.addCircle.addHovering {
+ fill: #449D44;
+}
+
+.node .removeCircle {
+ fill: #D9534F;
+}
+
+.removeCircle.removeHovering {
+ fill: #C9302C;
+}
+
+.node .WorkflowChart-defaultText {
+ font-size: 12px;
+ font-family: 'Open Sans', sans-serif;
+ fill: #707070;
+}
+
+.node .rect {
+ fill: #FCFCFC;
+}
+
+.rect.placeholder {
+ stroke-dasharray: 3;
+}
+
+.node .transparentRect {
+ fill: #FFFFFF;
+ opacity: 0;
+}
+
+.WorkflowChart-alwaysShowAdd circle,
+.WorkflowChart-alwaysShowAdd path,
+.WorkflowChart-alwaysShowAdd .linkCross,
+.hovering .addCircle,
+.hovering .removeCircle,
+.hovering .WorkflowChart-hoverPath,
+.hovering .linkCross {
+ opacity: 1;
+}
+
+.link {
+ fill: none;
+ stroke-width: 2px;
+}
+
+.link.placeholder {
+ stroke-dasharray: 3;
+}
+
+.WorkflowChart-svg {
+ background-color: #f6f6f6;
+}
+.WorkflowChart-nodeTypeCircle {
+ fill: #848992;
+}
+.WorkflowChart-nodeTypeLetter {
+ fill: #FFFFFF;
+}
diff --git a/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.directive.js
new file mode 100644
index 0000000000..29c7ebd61d
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.directive.js
@@ -0,0 +1,455 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default [
+ function() {
+
+ return {
+ scope: {
+ treeData: '=',
+ addNode: '&',
+ editNode: '&',
+ deleteNode: '&'
+ },
+ restrict: 'E',
+ link: function(scope, element) {
+
+ var margin = {top: 20, right: 20, bottom: 20, left: 20},
+ width = 950,
+ height = 590 - margin.top - margin.bottom,
+ i = 0,
+ rectW = 120,
+ rectH = 60,
+ rootW = 60,
+ rootH = 40;
+
+ var tree = d3.layout.tree()
+ .size([height, width]);
+
+ var line = d3.svg.line()
+ .x(function(d){return d.x;})
+ .y(function(d){return d.y;});
+
+ function lineData(d){
+
+ var sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + rectW;
+ var sourceY = d.source.isStartNode ? d.source.x + 10 + rootH / 2 : d.source.x + rectH / 2;
+ var targetX = d.target.y;
+ var targetY = d.target.x + rectH / 2;
+
+ var points = [
+ {
+ x: sourceX,
+ y: sourceY
+ },
+ {
+ x: targetX,
+ y: targetY
+ }
+ ];
+
+ return line(points);
+ }
+
+ // TODO: this function is hacky and we need to come up with a better solution
+ // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752
+ function wrap(text) {
+ if(text && text.length > 15) {
+ return text.substring(0,15) + '...';
+ }
+ else {
+ return text;
+ }
+ }
+
+ var svg = d3.select(element[0]).append("svg")
+ .attr("width", width)
+ .attr("height", height)
+ .attr("class", "WorkflowChart-svg")
+ .append("g")
+ .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
+
+ var node = svg.selectAll(".node"),
+ link = svg.selectAll(".link");
+
+ function update() {
+ // Declare the nodes
+ var nodes = tree.nodes(scope.treeData);
+ node = node.data(nodes, function(d) { d.y = d.depth * 180; return d.id || (d.id = ++i); });
+ link = link.data(tree.links(nodes), function(d) { return d.source.id + "-" + d.target.id; });
+
+ var nodeEnter = node.enter().append("g")
+ .attr("class", "node")
+ .attr("id", function(d){return "node-" + d.id;})
+ .attr("parent", function(d){return d.parent ? d.parent.id : null;})
+ .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
+ .attr("fill", "red");
+
+ nodeEnter.each(function(d) {
+ var thisNode = d3.select(this);
+ if(d.isStartNode) {
+ thisNode.append("rect")
+ .attr("width", 60)
+ .attr("height", 40)
+ .attr("y", 10)
+ .attr("rx", 5)
+ .attr("ry", 5)
+ .attr("fill", "#5cb85c")
+ .attr("class", "WorkflowChart-rootNode")
+ .call(add_node);
+ thisNode.append("path")
+ .style("fill", "white")
+ .attr("transform", function() { return "translate(" + 30 + "," + 30 + ")"; })
+ .attr("d", d3.svg.symbol()
+ .size(120)
+ .type("cross")
+ )
+ .call(add_node);
+ thisNode.append("text")
+ .attr("x", 14)
+ .attr("y", 0)
+ .attr("dy", ".35em")
+ .attr("class", "WorkflowChart-defaultText")
+ .text(function () { return "START"; });
+ }
+ else {
+ thisNode.append("rect")
+ .attr("width", rectW)
+ .attr("height", rectH)
+ .attr("rx", 5)
+ .attr("ry", 5)
+ .attr('stroke', function(d) { return d.isActiveEdit ? "#337ab7" : "#D7D7D7"; })
+ .attr('stroke-width', function(d){ return d.isActiveEdit ? "2px" : "1px"; })
+ .attr("class", function(d) {
+ return d.placeholder ? "rect placeholder" : "rect";
+ });
+ thisNode.append("text")
+ .attr("x", rectW / 2)
+ .attr("y", rectH / 2)
+ .attr("dy", ".35em")
+ .attr("text-anchor", "middle")
+ .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText")
+ .text(function (d) {
+ return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : "";
+ }).each(wrap);
+
+ thisNode.append("circle")
+ .attr("cy", rectH)
+ .attr("r", 10)
+ .attr("class", "WorkflowChart-nodeTypeCircle")
+ .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; });
+
+ thisNode.append("text")
+ .attr("y", rectH)
+ .attr("dy", ".35em")
+ .attr("text-anchor", "middle")
+ .attr("class", "WorkflowChart-nodeTypeLetter")
+ .text(function (d) {
+ return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : "");
+ })
+ .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; });
+
+ thisNode.append("rect")
+ .attr("width", rectW)
+ .attr("height", rectH)
+ .attr("class", "transparentRect")
+ .call(edit_node)
+ .on("mouseover", function(d) {
+ if(!d.isStartNode) {
+ d3.select("#node-" + d.id)
+ .classed("hovering", true);
+ }
+ })
+ .on("mouseout", function(d){
+ if(!d.isStartNode) {
+ d3.select("#node-" + d.id)
+ .classed("hovering", false);
+ }
+ });
+ thisNode.append("circle")
+ .attr("id", function(d){return "node-" + d.id + "-add";})
+ .attr("cx", rectW)
+ .attr("r", 10)
+ .attr("class", "addCircle nodeCircle")
+ .style("display", function(d) { return d.placeholder ? "none" : null; })
+ .call(add_node)
+ .on("mouseover", function(d) {
+ d3.select("#node-" + d.id)
+ .classed("hovering", true);
+ d3.select("#node-" + d.id + "-add")
+ .classed("addHovering", true);
+ })
+ .on("mouseout", function(d){
+ d3.select("#node-" + d.id)
+ .classed("hovering", false);
+ d3.select("#node-" + d.id + "-add")
+ .classed("addHovering", false);
+ });
+ thisNode.append("path")
+ .attr("class", "nodeAddCross WorkflowChart-hoverPath")
+ .style("fill", "white")
+ .attr("transform", function() { return "translate(" + rectW + "," + 0 + ")"; })
+ .attr("d", d3.svg.symbol()
+ .size(60)
+ .type("cross")
+ )
+ .style("display", function(d) { return d.placeholder ? "none" : null; })
+ .call(add_node)
+ .on("mouseover", function(d) {
+ d3.select("#node-" + d.id)
+ .classed("hovering", true);
+ d3.select("#node-" + d.id + "-add")
+ .classed("addHovering", true);
+ })
+ .on("mouseout", function(d){
+ d3.select("#node-" + d.id)
+ .classed("hovering", false);
+ d3.select("#node-" + d.id + "-add")
+ .classed("addHovering", false);
+ });
+ thisNode.append("circle")
+ .attr("id", function(d){return "node-" + d.id + "-remove";})
+ .attr("cx", rectW)
+ .attr("cy", rectH)
+ .attr("r", 10)
+ .attr("class", "removeCircle")
+ .style("display", function(d) { return (d.canDelete === false || d.placeholder) ? "none" : null; })
+ .call(remove_node)
+ .on("mouseover", function(d) {
+ d3.select("#node-" + d.id)
+ .classed("hovering", true);
+ d3.select("#node-" + d.id + "-remove")
+ .classed("removeHovering", true);
+ })
+ .on("mouseout", function(d){
+ d3.select("#node-" + d.id)
+ .classed("hovering", false);
+ d3.select("#node-" + d.id + "-remove")
+ .classed("removeHovering", false);
+ });
+ thisNode.append("path")
+ .attr("class", "nodeRemoveCross WorkflowChart-hoverPath")
+ .style("fill", "white")
+ .attr("transform", function() { return "translate(" + rectW + "," + rectH + ") rotate(-45)"; })
+ .attr("d", d3.svg.symbol()
+ .size(60)
+ .type("cross")
+ )
+ .style("display", function(d) { return (d.canDelete === false || d.placeholder) ? "none" : null; })
+ .call(remove_node)
+ .on("mouseover", function(d) {
+ d3.select("#node-" + d.id)
+ .classed("hovering", true);
+ d3.select("#node-" + d.id + "-remove")
+ .classed("removeHovering", true);
+ })
+ .on("mouseout", function(d){
+ d3.select("#node-" + d.id)
+ .classed("hovering", false);
+ d3.select("#node-" + d.id + "-remove")
+ .classed("removeHovering", false);
+ });
+ }
+ });
+
+ node.exit().remove();
+
+ var linkEnter = link.enter().append("g")
+ .attr("class", "nodeConnector")
+ .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;});
+
+ // Add entering links in the parent’s old position.
+ linkEnter.insert("path", ".node")
+ .attr("class", function(d) {
+ return (d.source.placeholder || d.target.placeholder) ? "link placeholder" : "link";
+ })
+ .attr("d", lineData)
+ .attr('stroke', function(d) {
+ if(d.target.edgeType) {
+ if(d.target.edgeType === "failure") {
+ return "#d9534f";
+ }
+ else if(d.target.edgeType === "success") {
+ return "#5cb85c";
+ }
+ else if(d.target.edgeType === "always"){
+ return "#337ab7";
+ }
+ }
+ else {
+ return "#D7D7D7";
+ }
+ });
+
+ linkEnter.append("circle")
+ .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";})
+ .attr("cx", function(d) {
+ return (d.target.y + d.source.y + rectW) / 2;
+ })
+ .attr("cy", function(d) {
+ return (d.target.x + d.source.x + rectH) / 2;
+ })
+ .attr("r", 10)
+ .attr("class", "addCircle linkCircle")
+ .style("display", function(d) { return (d.source.placeholder || d.target.placeholder) ? "none" : null; })
+ .call(add_node_between)
+ .on("mouseover", function(d) {
+ d3.select("#link-" + d.source.id + "-" + d.target.id)
+ .classed("hovering", true);
+ d3.select("#link-" + d.source.id + "-" + d.target.id + "-add")
+ .classed("addHovering", true);
+ })
+ .on("mouseout", function(d){
+ d3.select("#link-" + d.source.id + "-" + d.target.id)
+ .classed("hovering", false);
+ d3.select("#link-" + d.source.id + "-" + d.target.id + "-add")
+ .classed("addHovering", false);
+ });
+
+ linkEnter.append("path")
+ .attr("class", "linkCross")
+ .style("fill", "white")
+ .attr("transform", function(d) { return "translate(" + (d.target.y + d.source.y + rectW) / 2 + "," + (d.target.x + d.source.x + rectH) / 2 + ")"; })
+ .attr("d", d3.svg.symbol()
+ .size(60)
+ .type("cross")
+ )
+ .style("display", function(d) { return (d.source.placeholder || d.target.placeholder) ? "none" : null; })
+ .call(add_node_between)
+ .on("mouseover", function(d) {
+ d3.select("#link-" + d.source.id + "-" + d.target.id)
+ .classed("hovering", true);
+ d3.select("#link-" + d.source.id + "-" + d.target.id + "-add")
+ .classed("addHovering", true);
+ })
+ .on("mouseout", function(d){
+ d3.select("#link-" + d.source.id + "-" + d.target.id)
+ .classed("hovering", false);
+ d3.select("#link-" + d.source.id + "-" + d.target.id + "-add")
+ .classed("addHovering", false);
+ });
+
+ link.exit().remove();
+
+ // Transition nodes and links to their new positions.
+ var t = svg.transition();
+
+ t.selectAll(".nodeCircle")
+ .style("display", function(d) { return d.placeholder ? "none" : null; });
+
+ t.selectAll(".nodeAddCross")
+ .style("display", function(d) { return d.placeholder ? "none" : null; });
+
+ t.selectAll(".removeCircle")
+ .style("display", function(d) { return (d.canDelete === false || d.placeholder) ? "none" : null; });
+
+ t.selectAll(".nodeRemoveCross")
+ .style("display", function(d) { return (d.canDelete === false || d.placeholder) ? "none" : null; });
+
+ t.selectAll(".link")
+ .attr("class", function(d) {
+ return (d.source.placeholder || d.target.placeholder) ? "link placeholder" : "link";
+ })
+ .attr("d", lineData)
+ .attr('stroke', function(d) {
+ if(d.target.edgeType) {
+ if(d.target.edgeType === "failure") {
+ return "#d9534f";
+ }
+ else if(d.target.edgeType === "success") {
+ return "#5cb85c";
+ }
+ else if(d.target.edgeType === "always"){
+ return "#337ab7";
+ }
+ }
+ else {
+ return "#D7D7D7";
+ }
+ });
+
+ t.selectAll(".linkCircle")
+ .style("display", function(d) { return (d.source.placeholder || d.target.placeholder) ? "none" : null; })
+ .attr("cx", function(d) {
+ return (d.target.y + d.source.y + rectW) / 2;
+ })
+ .attr("cy", function(d) {
+ return (d.target.x + d.source.x + rectH) / 2;
+ });
+
+ t.selectAll(".linkCross")
+ .style("display", function(d) { return (d.source.placeholder || d.target.placeholder) ? "none" : null; })
+ .attr("transform", function(d) { return "translate(" + (d.target.y + d.source.y + rectW) / 2 + "," + (d.target.x + d.source.x + rectH) / 2 + ")"; });
+
+ t.selectAll(".rect")
+ .attr('stroke', function(d) { return d.isActiveEdit ? "#337ab7" : "#D7D7D7"; })
+ .attr('stroke-width', function(d){ return d.isActiveEdit ? "2px" : "1px"; })
+ .attr("class", function(d) {
+ return d.placeholder ? "rect placeholder" : "rect";
+ });
+
+ t.selectAll(".WorkflowChart-nameText")
+ .text(function (d) {
+ return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : "";
+ });
+
+ t.selectAll(".node")
+ .attr("transform", function(d) {d.px = d.x; d.py = d.y; return "translate(" + d.y + "," + d.x + ")"; });
+
+ t.selectAll(".WorkflowChart-nodeTypeCircle")
+ .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" ) ? null : "none"; });
+
+ t.selectAll(".WorkflowChart-nodeTypeLetter")
+ .text(function (d) {
+ return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : "");
+ })
+ .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; });
+
+ }
+
+ function add_node() {
+ this.on("click", function(d) {
+ scope.addNode({
+ parent: d,
+ betweenTwoNodes: false
+ });
+ });
+ }
+
+ function add_node_between() {
+ this.on("click", function(d) {
+ scope.addNode({
+ parent: d,
+ betweenTwoNodes: true
+ });
+ });
+ }
+
+ function remove_node() {
+ this.on("click", function(d) {
+ scope.deleteNode({
+ nodeToDelete: d
+ });
+ });
+ }
+
+ function edit_node() {
+ this.on("click", function(d) {
+ if(d.canEdit){
+ scope.editNode({
+ nodeToEdit: d
+ });
+ }
+ });
+ }
+
+ scope.$on('refreshWorkflowChart', function(){
+ update();
+ });
+
+ }
+ };
+}];
diff --git a/awx/ui/client/src/job-templates/workflow-maker/main.js b/awx/ui/client/src/job-templates/workflow-maker/main.js
new file mode 100644
index 0000000000..8266617679
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/main.js
@@ -0,0 +1,11 @@
+import helper from './workflow-help.service';
+import workflowMaker from './workflow-maker.directive';
+import WorkflowMakerController from './workflow-maker.controller';
+
+export default
+ angular.module('jobTemplates.workflowMaker', [])
+ .service('WorkflowHelpService', helper)
+ // In order to test this controller I had to expose it at the module level
+ // like so. Is this correct? Is there a better pattern for doing this?
+ .controller('WorkflowMakerController', WorkflowMakerController)
+ .directive('workflowMaker', workflowMaker);
diff --git a/awx/ui/client/src/job-templates/workflow-maker/workflow-help.service.js b/awx/ui/client/src/job-templates/workflow-maker/workflow-help.service.js
new file mode 100644
index 0000000000..f014ae938e
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-help.service.js
@@ -0,0 +1,166 @@
+export default ['CreateDialog', 'Wait', '$q', function(CreateDialog, Wait, $q){
+ return {
+ openDialog: function(params){
+ // params.scope
+
+ let deferred = $q.defer();
+
+ if (params.scope.removeWorkflowDialogReady) {
+ params.scope.removeWorkflowDialogReady();
+ }
+ params.scope.removeWorkflowDialogReady = params.scope.$on('WorkflowDialogReady', function() {
+ $('#workflow-modal-dialog').dialog('open');
+
+ deferred.resolve();
+ });
+ Wait('start');
+ CreateDialog({
+ id: 'workflow-modal-dialog',
+ scope: params.scope,
+ width: 1400,
+ height: 720,
+ draggable: false,
+ dialogClass: 'SurveyMaker-dialog',
+ position: ['center',20],
+ onClose: function() {
+ $('#workflow-modal-dialog').empty();
+ },
+ onOpen: function() {
+ Wait('stop');
+
+ // Let the modal height be variable based on the content
+ // and set a uniform padding
+ $('#workflow-modal-dialog').css({'padding': '20px'});
+
+ },
+ _allowInteraction: function(e) {
+ return !!$(e.target).is('.select2-input') || this._super(e);
+ },
+ callback: 'WorkflowDialogReady'
+ });
+
+ return deferred.promise;
+ },
+ closeDialog: function() {
+ $('#workflow-modal-dialog').dialog('destroy');
+ },
+ searchTree: function(params) {
+ // params.element
+ // params.matchingId
+
+ if(params.element.id === params.matchingId){
+ return params.element;
+ }else if (params.element.children && params.element.children.length > 0){
+ let result = null;
+ const thisService = this;
+ _.forEach(params.element.children, function(child) {
+ result = thisService.searchTree({
+ element: child,
+ matchingId: params.matchingId
+ });
+ if(result) {
+ return false;
+ }
+ });
+ return result;
+ }
+ return null;
+ },
+ removeNodeFromTree: function(params) {
+ // params.tree
+ // params.nodeToBeDeleted
+
+ let parentNode = this.searchTree({
+ element: params.tree,
+ matchingId: params.nodeToBeDeleted.parent.id
+ });
+ let nodeToBeDeleted = this.searchTree({
+ element: parentNode,
+ matchingId: params.nodeToBeDeleted.id
+ });
+
+ if(nodeToBeDeleted.children) {
+ _.forEach(nodeToBeDeleted.children, function(child) {
+ if(nodeToBeDeleted.isRoot) {
+ child.isRoot = true;
+ child.edgeType = "always";
+ }
+
+ parentNode.children.push(child);
+ });
+ }
+
+ _.forEach(parentNode.children, function(child, index) {
+ if(child.id === params.nodeToBeDeleted.id) {
+ parentNode.children.splice(index, 1);
+ return false;
+ }
+ });
+ },
+ addPlaceholderNode: function(params) {
+ // params.parent
+ // params.betweenTwoNodes
+ // params.tree
+ // params.id
+
+ let placeholder = {
+ children: [],
+ c: "#D7D7D7",
+ id: params.id,
+ canDelete: true,
+ canEdit: false,
+ canAddTo: true,
+ placeholder: true,
+ isNew: true,
+ edited: false
+ };
+
+ let parentNode = (params.betweenTwoNodes) ? this.searchTree({element: params.tree, matchingId: params.parent.source.id}) : this.searchTree({element: params.tree, matchingId: params.parent.id});
+ let placeholderRef;
+
+ if(params.betweenTwoNodes) {
+ _.forEach(parentNode.children, function(child, index) {
+ if(child.id === params.parent.target.id) {
+ placeholder.children.push(angular.copy(child));
+ parentNode.children[index] = placeholder;
+ placeholderRef = parentNode.children[index];
+ return false;
+ }
+ });
+ }
+ else {
+ if(parentNode.children) {
+ parentNode.children.push(placeholder);
+ placeholderRef = parentNode.children[parentNode.children.length - 1];
+ } else {
+ parentNode.children = [placeholder];
+ placeholderRef = parentNode.children[0];
+ }
+ }
+
+ return placeholderRef;
+ },
+ getSiblingConnectionTypes: function(params) {
+ // params.parentId
+ // params.tree
+
+ let siblingConnectionTypes = {};
+
+ let parentNode = this.searchTree({
+ element: params.tree,
+ matchingId: params.parentId
+ });
+
+ if(parentNode.children && parentNode.children.length > 0) {
+ // Loop across them and add the types as keys to siblingConnectionTypes
+ _.forEach(parentNode.children, function(child) {
+ if(!child.placeholder && child.edgeType) {
+ siblingConnectionTypes[child.edgeType] = true;
+ }
+ });
+ }
+
+ return Object.keys(siblingConnectionTypes);
+ }
+ };
+}];
diff --git a/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.block.less
new file mode 100644
index 0000000000..0dd966a5c2
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.block.less
@@ -0,0 +1,194 @@
+@import "./client/src/shared/branding/colors.default.less";
+
+.WorkflowMaker-header {
+ display: flex;
+ height: 34px;
+}
+.WorkflowMaker-title {
+ align-items: center;
+ flex: 1 0 auto;
+ display: flex;
+ height: 34px;
+}
+.WorkflowMaker-titleText {
+ color: @list-title-txt;
+ font-size: 14px;
+ font-weight: bold;
+ margin-right: 10px;
+ text-transform: uppercase;
+}
+.WorkflowMaker-exitHolder {
+ justify-content: flex-end;
+ display: flex;
+}
+.WorkflowMaker-exit{
+ cursor:pointer;
+ padding:0px;
+ border: none;
+ height:20px;
+ font-size: 20px;
+ background-color:@default-bg;
+ color:@d7grey;
+ transition: color 0.2s;
+ line-height:1;
+}
+.WorkflowMaker-exit:hover{
+ color:@default-icon;
+}
+.WorkflowMaker-contentHolder {
+ display: flex;
+ border: 1px solid #EBEBEB;
+ height: ~"calc(100% - 85px)";
+}
+.WorkflowMaker-contentLeft {
+ flex: 1 0 auto;
+ flex-direction: column;
+ height: 100%;
+}
+.WorkflowMaker-contentRight {
+ flex: 0 0 400px;
+ border-left: 1px solid #EBEBEB;
+ padding: 20px;
+ height: 100%;
+ overflow-y: scroll;
+}
+.WorkflowMaker-buttonHolder {
+ height: 30px;
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+}
+.WorkflowMaker-saveButton{
+ background-color: @submit-button-bg;
+ color: @submit-button-text;
+ text-transform: uppercase;
+ transition: background-color 0.2s;
+ padding-left:15px;
+ padding-right: 15px;
+ margin-left: 20px;
+}
+
+.WorkflowMaker-saveButton:disabled{
+ background-color: @submit-button-bg-dis;
+}
+
+.WorkflowMaker-saveButton:hover{
+ background-color: @submit-button-bg-hov;
+ color: @submit-button-text;
+}
+
+.WorkflowMaker-cancelButton{
+ background-color: @default-bg;
+ color: @btn-txt;
+ text-transform: uppercase;
+ border-radius: 5px;
+ border: 1px solid @btn-bord;
+ transition: background-color 0.2s;
+ padding-left:15px;
+ padding-right: 15px;
+}
+
+.WorkflowMaker-cancelButton:hover{
+ background-color: @btn-bg-hov;
+ color: @btn-txt;
+}
+
+.WorkflowMaker-deleteOverlay {
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: rgba(0,0,0,0.3);
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+}
+.WorkflowMaker-deleteModal {
+ height: 200px;
+ width: 600px;
+ background-color: @default-bg;
+ border-radius: 5px;
+}
+.WorkflowMaker-formTitle {
+ color: @list-title-txt;
+ font-size: 14px;
+ font-weight: bold;
+ text-transform: uppercase;
+ margin-bottom: 20px;
+}
+.WorkflowMaker-formHelp {
+ color: #707070;
+}
+.WorkflowMaker-formLists {
+ margin-bottom: 20px;
+}
+.WorkflowMaker-formTitle {
+ display: flex;
+ color: #707070;
+ margin-right: 10px;
+}
+.WorkflowMaker-formLabel {
+ font-weight: normal;
+}
+.WorkflowMaker-formElement {
+ margin-bottom: 10px;
+}
+.WorkflowMaker-legend {
+ display: flex;
+ height: 40px;
+ line-height: 40px;
+ color: #707070;
+}
+.WorkflowMaker-chart {
+ display: flex;
+}
+.WorkflowMaker-legendLeft {
+ display: flex;
+ flex: 1 0 auto;
+ padding-left: 20px;
+}
+.WorkflowMaker-legendRight {
+ flex: 0 0 170px;
+ text-align: right;
+ padding-right: 20px;
+}
+.WorkflowMaker-legendItem {
+ display: flex;
+}
+.WorkflowMaker-legendItem:not(:last-child) {
+ padding-right: 20px;
+}
+.WorkflowMaker-onSuccessLegend {
+ height: 4px;
+ width: 20px;
+ background-color: @submit-button-bg;
+ margin: 18px 5px 18px 0px;
+}
+.WorkflowMaker-onFailLegend {
+ height: 4px;
+ width: 20px;
+ background-color: #d9534f;
+ margin: 18px 5px 18px 0px;
+}
+.WorkflowMaker-alwaysLegend {
+ height: 4px;
+ width: 20px;
+ background-color: #337ab7;
+ margin: 18px 5px 18px 0px;
+}
+.WorkflowMaker-letterCircle{
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ background: #848992;
+ color: #FFF;
+ text-align: center;
+ margin: 10px 5px 10px 0px;
+ line-height: 20px;
+}
+.WorkflowMaker-totalJobs {
+ margin-right: 10px;
+}
diff --git a/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.controller.js
new file mode 100644
index 0000000000..b0c66e6ce9
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.controller.js
@@ -0,0 +1,814 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default
+ [ '$scope', 'WorkflowHelpService', 'generateList', 'JobTemplateList', 'ProjectList',
+ 'GetBasePath', 'SearchInit', 'PaginateInit', 'Wait', 'JobTemplateService',
+ 'ProcessErrors', 'InventorySourcesList', 'CreateSelect2', 'WorkflowMakerForm',
+ 'GenerateForm', 'LookUpInit', 'InventoryList', 'CredentialList', '$q', '$timeout',
+ function($scope, WorkflowHelpService, GenerateList, JobTemplateList, ProjectList,
+ GetBasePath, SearchInit, PaginateInit, Wait, JobTemplateService,
+ ProcessErrors, InventorySourcesList, CreateSelect2, WorkflowMakerForm,
+ GenerateForm, LookUpInit, InventoryList, CredentialList, $q, $timeout) {
+
+ let form = WorkflowMakerForm(),
+ generator = GenerateForm;
+
+ $scope.workflowMakerFormConfig = {
+ nodeMode: "idle",
+ activeTab: "jobs",
+ formIsValid: false
+ };
+
+ // Set the intial edge type to success
+ $scope.edgeType = "success";
+
+ $scope.job_type_options = [
+ {
+ label: "Run",
+ value: "run"
+ },
+ {
+ label: "Check",
+ value: "check"
+ }
+ ];
+
+ let job_template_url = GetBasePath('job_templates');
+ // TODO: we won't be able to rely on this in the future for security purposes. Will need to come up
+ // with another way to get the list of job templates that have credentials that don't require passwords
+ // on launch
+ job_template_url += "?not__credential__vault_password=ASK¬__credential__password=ASK";
+ //http://localhost:3000/api/v1/job_templates/?not__credential__vault_password=ASK¬__credential__password=ASK
+
+ // Set up the lists for the add/edit node form
+ let jobTemplatesList = _.cloneDeep(JobTemplateList);
+ delete jobTemplatesList.fields.type;
+ delete jobTemplatesList.fields.description;
+ delete jobTemplatesList.fields.smart_status;
+ delete jobTemplatesList.fields.labels;
+ jobTemplatesList.fields.name.columnClass = "col-md-11";
+ jobTemplatesList.name = "workflow_job_templates";
+
+ let project_url = GetBasePath('projects');
+
+ let projectList = _.cloneDeep(ProjectList);
+ delete projectList.fields.status;
+ delete projectList.fields.scm_type;
+ delete projectList.fields.last_updated;
+ projectList.fields.name.columnClass = "col-md-11";
+ projectList.name = "workflow_projects";
+
+ let inventory_sources_url = GetBasePath('inventory_sources');
+
+ let inventorySourcesList = _.cloneDeep(InventorySourcesList);
+
+ function init() {
+ $scope.$watchCollection('workflow_job_templates', function () {
+ if($scope.selectedTemplate) {
+ // Loop across the inventories and see if one of them should be "checked"
+ $scope.workflow_job_templates.forEach(function(row, i) {
+ if (row.id === $scope.selectedTemplate.id) {
+ $scope.workflow_job_templates[i].checked = 1;
+ }
+ else {
+ $scope.workflow_job_templates[i].checked = 0;
+ }
+ });
+ }
+ });
+
+ $scope.$watchCollection('workflow_projects', function () {
+ if($scope.selectedTemplate) {
+ // Loop across the inventories and see if one of them should be "checked"
+ $scope.workflow_projects.forEach(function(row, i) {
+ if (row.id === $scope.selectedTemplate.id) {
+ $scope.workflow_projects[i].checked = 1;
+ }
+ else {
+ $scope.workflow_projects[i].checked = 0;
+ }
+ });
+ }
+ });
+
+ $scope.$watchCollection('workflow_inventory_sources', function () {
+ if($scope.selectedTemplate) {
+ // Loop across the inventories and see if one of them should be "checked"
+ $scope.workflow_inventory_sources.forEach(function(row, i) {
+ if (row.id === $scope.selectedTemplate.id) {
+ $scope.workflow_inventory_sources[i].checked = 1;
+ }
+ else {
+ $scope.workflow_inventory_sources[i].checked = 0;
+ }
+ });
+ }
+ });
+
+ $scope.$watchGroup(['selectedTemplate', 'edgeType'], function() {
+ if($scope.selectedTemplate && $scope.edgeType) {
+ $scope.workflowMakerFormConfig.formIsValid = true;
+ }
+ else {
+ $scope.workflowMakerFormConfig.formIsValid = false;
+ }
+ });
+ }
+
+ function resetPromptFields() {
+ $scope.credential = null;
+ $scope.credential_name = null;
+ $scope.inventory = null;
+ $scope.inventory_name = null;
+ $scope.job_type = null;
+ $scope.limit = null;
+ $scope.job_tags = null;
+ $scope.skip_tags = null;
+ }
+
+ function resetNodeForm() {
+ $scope.workflowMakerFormConfig.nodeMode = "idle";
+ $scope.showTypeOptions = false;
+ delete $scope.selectedTemplate;
+ delete $scope.workflow_job_templates;
+ delete $scope.workflow_projects;
+ delete $scope.workflow_inventory_sources;
+ delete $scope.placeholderNode;
+ delete $scope.betweenTwoNodes;
+ $scope.nodeBeingEdited = null;
+ $scope.edgeType = "success";
+ $scope.edgeTypeRestriction = null;
+ $scope.workflowMakerFormConfig.activeTab = "jobs";
+
+ resetPromptFields();
+
+ }
+
+ function loadJobTemplates() {
+ SearchInit({
+ scope: $scope,
+ set: jobTemplatesList.name,
+ list: jobTemplatesList,
+ url: job_template_url
+ });
+
+ PaginateInit({
+ scope: $scope,
+ list: jobTemplatesList,
+ url: job_template_url,
+ mode: 'lookup',
+ pageSize: 5
+ });
+
+ $scope.search(JobTemplateList.iterator);
+ }
+
+ function loadProjects() {
+ SearchInit({
+ scope: $scope,
+ set: projectList.name,
+ list: projectList,
+ url: project_url
+ });
+
+ PaginateInit({
+ scope: $scope,
+ list: projectList,
+ url: project_url,
+ mode: 'lookup',
+ pageSize: 5
+ });
+
+ $scope.search(ProjectList.iterator);
+ }
+
+
+ function loadInventorySources() {
+ SearchInit({
+ scope: $scope,
+ set: inventorySourcesList.name,
+ list: inventorySourcesList,
+ url: inventory_sources_url
+ });
+
+ PaginateInit({
+ scope: $scope,
+ list: inventorySourcesList,
+ url: inventory_sources_url,
+ mode: 'lookup',
+ pageSize: 5
+ });
+
+ $scope.search(InventorySourcesList.iterator);
+ }
+
+ $scope.closeWorkflowMaker = function() {
+ // Revert the data to the master which was created when the dialog was opened
+ $scope.treeData.data = angular.copy($scope.treeDataMaster);
+ WorkflowHelpService.closeDialog();
+ };
+
+ $scope.saveWorkflowMaker = function() {
+ WorkflowHelpService.closeDialog();
+ };
+
+ /* ADD NODE FUNCTIONS */
+
+ $scope.startAddNode = function(parent, betweenTwoNodes) {
+
+ if($scope.placeholderNode || $scope.nodeBeingEdited) {
+ $scope.cancelNodeForm();
+ }
+
+ $scope.workflowMakerFormConfig.nodeMode = "add";
+ $scope.addParent = parent;
+ $scope.betweenTwoNodes = betweenTwoNodes;
+
+ $scope.placeholderNode = WorkflowHelpService.addPlaceholderNode({
+ parent: parent,
+ betweenTwoNodes: betweenTwoNodes,
+ tree: $scope.treeData.data,
+ id: $scope.treeData.nextIndex
+ });
+
+ $scope.treeData.nextIndex++;
+
+ loadJobTemplates();
+ loadProjects();
+ loadInventorySources();
+
+ let siblingConnectionTypes = WorkflowHelpService.getSiblingConnectionTypes({
+ tree: $scope.treeData.data,
+ parentId: betweenTwoNodes ? parent.source.id : parent.id
+ });
+
+ if(parent && ((betweenTwoNodes && parent.source.isStartNode) || (!betweenTwoNodes && parent.isStartNode))) {
+ // We don't want to give the user the option to select
+ // a type as this node will always be executed
+ $scope.edgeType = "always";
+ $scope.showTypeOptions = false;
+ }
+ else {
+ if((_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) && _.includes(siblingConnectionTypes, "always")) {
+ // This is a problem...
+ }
+ else if(_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) {
+ $scope.edgeTypeRestriction = "successFailure";
+ $scope.edgeType = "success";
+ }
+ else if(_.includes(siblingConnectionTypes, "always")) {
+ $scope.edgeTypeRestriction = "always";
+ $scope.edgeType = "always";
+ }
+
+ $scope.showTypeOptions = true;
+ }
+
+ $scope.$broadcast("refreshWorkflowChart");
+
+ };
+
+ $scope.confirmNodeForm = function() {
+ if($scope.workflowMakerFormConfig.nodeMode === "add") {
+ if($scope.selectedTemplate && $scope.edgeType) {
+
+ $scope.placeholderNode.unifiedJobTemplate = $scope.selectedTemplate;
+ $scope.placeholderNode.edgeType = $scope.edgeType;
+ if($scope.placeholderNode.unifiedJobTemplate.type === 'job_template') {
+ $scope.placeholderNode.promptValues = {
+ credential: {
+ id: $scope.credential,
+ name: $scope.credential_name
+ },
+ inventory: {
+ id: $scope.inventory,
+ name: $scope.inventory_name
+ },
+ limit: $scope.limit,
+ job_type: $scope.job_type && $scope.job_type.value ? $scope.job_type.value : null,
+ job_tags: $scope.job_tags,
+ skip_tags: $scope.skip_tags
+ };
+ }
+ $scope.placeholderNode.canEdit = true;
+
+ delete $scope.placeholderNode.placeholder;
+
+ resetNodeForm();
+
+ // Increment the total node counter
+ $scope.treeData.data.totalNodes++;
+
+ }
+ }
+ else if($scope.workflowMakerFormConfig.nodeMode === "edit") {
+ if($scope.selectedTemplate && $scope.edgeType) {
+ $scope.nodeBeingEdited.unifiedJobTemplate = $scope.selectedTemplate;
+ $scope.nodeBeingEdited.edgeType = $scope.edgeType;
+
+ if($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template') {
+ $scope.nodeBeingEdited.promptValues = {
+ credential: {
+ id: $scope.credential,
+ name: $scope.credential_name
+ },
+ inventory: {
+ id: $scope.inventory,
+ name: $scope.inventory_name
+ },
+ limit: $scope.limit,
+ job_type: $scope.job_type && $scope.job_type.value ? $scope.job_type.value : null,
+ job_tags: $scope.job_tags,
+ skip_tags: $scope.skip_tags
+ };
+ }
+
+ $scope.nodeBeingEdited.isActiveEdit = false;
+
+ $scope.nodeBeingEdited.edited = true;
+
+ resetNodeForm();
+ }
+ }
+
+ $scope.$broadcast("refreshWorkflowChart");
+ };
+
+ $scope.cancelNodeForm = function() {
+ if($scope.workflowMakerFormConfig.nodeMode === "add") {
+ // Remove the placeholder node from the tree
+ WorkflowHelpService.removeNodeFromTree({
+ tree: $scope.treeData.data,
+ nodeToBeDeleted: $scope.placeholderNode
+ });
+ }
+ else if($scope.workflowMakerFormConfig.nodeMode === "edit") {
+ $scope.nodeBeingEdited.isActiveEdit = false;
+ }
+
+ // Reset the form
+ resetNodeForm();
+
+ $scope.$broadcast("refreshWorkflowChart");
+ };
+
+ /* EDIT NODE FUNCTIONS */
+
+ $scope.startEditNode = function(nodeToEdit) {
+
+ if(!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) {
+ if($scope.placeholderNode || $scope.nodeBeingEdited) {
+ $scope.cancelNodeForm();
+ }
+
+ $scope.workflowMakerFormConfig.nodeMode = "edit";
+
+ let parent = WorkflowHelpService.searchTree({
+ element: $scope.treeData.data,
+ matchingId: nodeToEdit.parent.id
+ });
+
+ $scope.nodeBeingEdited = WorkflowHelpService.searchTree({
+ element: parent,
+ matchingId: nodeToEdit.id
+ });
+
+ $scope.nodeBeingEdited.isActiveEdit = true;
+
+ let finishConfiguringEdit = function() {
+
+ // build any prompt values
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_credential_on_launch) {
+ if($scope.nodeBeingEdited.promptValues && $scope.nodeBeingEdited.promptValues.credential) {
+ $scope.credential_name = $scope.nodeBeingEdited.promptValues.credential.name;
+ $scope.credentiial = $scope.nodeBeingEdited.promptValues.credential.id;
+ }
+ else if($scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential) {
+ $scope.credential_name = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.name ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.name : null;
+ $scope.credential = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.id ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.id : null;
+ }
+ else {
+ $scope.credential_name = null;
+ $scope.credential = null;
+ }
+ }
+
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_inventory_on_launch) {
+ if($scope.nodeBeingEdited.promptValues && $scope.nodeBeingEdited.promptValues.inventory) {
+ $scope.inventory_name = $scope.nodeBeingEdited.promptValues.inventory.name;
+ $scope.inventory = $scope.nodeBeingEdited.promptValues.inventory.id;
+ }
+ else if($scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory) {
+ $scope.inventory_name = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.name ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.name : null;
+ $scope.inventory = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.id ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.id : null;
+ }
+ else {
+ $scope.inventory_name = null;
+ $scope.inventory = null;
+ }
+ }
+
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_job_type_on_launch) {
+ if($scope.nodeBeingEdited.promptValues && $scope.nodeBeingEdited.promptValues.job_type) {
+ $scope.job_type = {
+ value: $scope.nodeBeingEdited.promptValues.job_type
+ };
+ }
+ else if($scope.nodeBeingEdited.originalNodeObj.job_type) {
+ $scope.job_type = {
+ value: $scope.nodeBeingEdited.originalNodeObj.job_type
+ };
+ }
+ else if($scope.nodeBeingEdited.unifiedJobTemplate.job_type) {
+ $scope.job_type = {
+ value: $scope.nodeBeingEdited.unifiedJobTemplate.job_type
+ };
+ }
+ else {
+ $scope.job_type = {
+ value: null
+ };
+ }
+
+ // The default needs to be in place before we can select2-ify the dropdown
+ $timeout(function() {
+ CreateSelect2({
+ element: '#workflow_maker_job_type',
+ multiple: false
+ });
+ });
+ }
+
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_limit_on_launch) {
+ if($scope.nodeBeingEdited.promptValues && typeof $scope.nodeBeingEdited.promptValues.limit === 'string') {
+ $scope.limit = $scope.nodeBeingEdited.promptValues.limit;
+ }
+ else if(typeof $scope.nodeBeingEdited.originalNodeObj.limit === 'string') {
+ $scope.limit = $scope.nodeBeingEdited.originalNodeObj.limit;
+ }
+ else if(typeof $scope.nodeBeingEdited.unifiedJobTemplate.limit === 'string') {
+ $scope.limit = $scope.nodeBeingEdited.unifiedJobTemplate.limit;
+ }
+ else {
+ $scope.limit = null;
+ }
+ }
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_skip_tags_on_launch) {
+ if($scope.nodeBeingEdited.promptValues && typeof $scope.nodeBeingEdited.promptValues.skip_tags === 'string') {
+ $scope.skip_tags = $scope.nodeBeingEdited.promptValues.skip_tags;
+ }
+ else if(typeof $scope.nodeBeingEdited.originalNodeObj.skip_tags === 'string') {
+ $scope.skip_tags = $scope.nodeBeingEdited.originalNodeObj.skip_tags;
+ }
+ else if(typeof $scope.nodeBeingEdited.unifiedJobTemplate.skip_tags === 'string') {
+ $scope.skip_tags = $scope.nodeBeingEdited.unifiedJobTemplate.skip_tags;
+ }
+ else {
+ $scope.skip_tags = null;
+ }
+ }
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_tags_on_launch) {
+ if($scope.nodeBeingEdited.promptValues && typeof $scope.nodeBeingEdited.promptValues.job_tags === 'string') {
+ $scope.job_tags = $scope.nodeBeingEdited.promptValues.job_tags;
+ }
+ else if(typeof $scope.nodeBeingEdited.originalNodeObj.job_tags === 'string') {
+ $scope.job_tags = $scope.nodeBeingEdited.originalNodeObj.job_tags;
+ }
+ else if(typeof $scope.nodeBeingEdited.unifiedJobTemplate.job_tags === 'string') {
+ $scope.job_tags = $scope.nodeBeingEdited.unifiedJobTemplate.job_tags;
+ }
+ else {
+ $scope.job_tags = null;
+ }
+ }
+
+ if($scope.nodeBeingEdited.unifiedJobTemplate.type === "job_template") {
+ $scope.workflowMakerFormConfig.activeTab = "jobs";
+ }
+
+ $scope.selectedTemplate = $scope.nodeBeingEdited.unifiedJobTemplate;
+
+ switch($scope.nodeBeingEdited.unifiedJobTemplate.type) {
+ case "job_template":
+ $scope.workflowMakerFormConfig.activeTab = "jobs";
+ break;
+ case "project":
+ $scope.workflowMakerFormConfig.activeTab = "project_sync";
+ break;
+ case "inventory_source":
+ $scope.workflowMakerFormConfig.activeTab = "inventory_sync";
+ break;
+ }
+
+ loadJobTemplates();
+ loadProjects();
+ loadInventorySources();
+
+ $scope.edgeType = $scope.nodeBeingEdited.edgeType;
+ $scope.showTypeOptions = (parent && parent.isStartNode) ? false : true;
+
+ $scope.$broadcast("refreshWorkflowChart");
+ };
+
+ // Determine whether or not we need to go out and GET this nodes unified job template
+ // in order to determine whether or not prompt fields are needed
+
+ if(!$scope.nodeBeingEdited.isNew && !$scope.nodeBeingEdited.edited && $scope.nodeBeingEdited.unifiedJobTemplate.unified_job_type && $scope.nodeBeingEdited.unifiedJobTemplate.unified_job_type === 'job') {
+ // This is a node that we got back from the api with an incomplete
+ // unified job template so we're going to pull down the whole object
+
+ JobTemplateService.getUnifiedJobTemplate($scope.nodeBeingEdited.unifiedJobTemplate.id)
+ .then(function(data){
+
+ $scope.nodeBeingEdited.unifiedJobTemplate = _.clone(data.data.results[0]);
+
+ let defers = [];
+ let retrievingCredential = false;
+ let retrievingInventory = false;
+
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_credential_on_launch && $scope.nodeBeingEdited.originalNodeObj.credential) {
+ defers.push(JobTemplateService.getCredential($scope.nodeBeingEdited.originalNodeObj.credential));
+ retrievingCredential = true;
+ }
+
+ if($scope.nodeBeingEdited.unifiedJobTemplate.ask_inventory_on_launch && $scope.nodeBeingEdited.originalNodeObj.inventory) {
+ defers.push(JobTemplateService.getInventory($scope.nodeBeingEdited.originalNodeObj.inventory));
+ retrievingInventory = true;
+ }
+
+ $q.all(defers)
+ .then(function(responses) {
+ if(retrievingCredential) {
+ $scope.credential = responses[0].data.id;
+ $scope.credential_name = responses[0].data.name;
+ $scope.nodeBeingEdited.promptValues.credential = {
+ name: responses[0].data.name,
+ id: responses[0].data.id
+ };
+
+ if(retrievingInventory) {
+ $scope.inventory = responses[1].data.id;
+ $scope.inventory_name = responses[1].data.name;
+ $scope.nodeBeingEdited.promptValues.inventory = {
+ name: responses[1].data.name,
+ id: responses[1].data.id
+ };
+ }
+ }
+ else if(retrievingInventory) {
+ $scope.inventory = responses[0].data.id;
+ $scope.inventory_name = responses[0].data.name;
+ $scope.nodeBeingEdited.promptValues.inventory = {
+ name: responses[0].data.name,
+ id: responses[0].data.id
+ };
+ }
+ finishConfiguringEdit();
+ });
+
+
+ }, function(error) {
+ ProcessErrors($scope, error.data, error.status, form, {
+ hdr: 'Error!',
+ msg: 'Failed to get unified job template. GET returned ' +
+ 'status: ' + error.status
+ });
+ });
+ }
+ else {
+ finishConfiguringEdit();
+ }
+
+ }
+
+ };
+
+ /* DELETE NODE FUNCTIONS */
+
+ function resetDeleteNode() {
+ $scope.nodeToBeDeleted = null;
+ $scope.deleteOverlayVisible = false;
+ }
+
+ $scope.startDeleteNode = function(nodeToDelete) {
+ $scope.nodeToBeDeleted = nodeToDelete;
+ $scope.deleteOverlayVisible = true;
+ };
+
+ $scope.cancelDeleteNode = function() {
+ resetDeleteNode();
+ };
+
+ $scope.confirmDeleteNode = function() {
+ if($scope.nodeToBeDeleted) {
+
+ // TODO: turn this into a promise so that we can handle errors
+
+ WorkflowHelpService.removeNodeFromTree({
+ tree: $scope.treeData.data,
+ nodeToBeDeleted: $scope.nodeToBeDeleted
+ });
+
+ if($scope.nodeToBeDeleted.isNew !== true) {
+ $scope.treeData.data.deletedNodes.push($scope.nodeToBeDeleted.nodeId);
+ }
+
+ if($scope.nodeToBeDeleted.isActiveEdit) {
+ resetNodeForm();
+ }
+
+ resetDeleteNode();
+
+ $scope.$broadcast("refreshWorkflowChart");
+
+ $scope.treeData.data.totalNodes--;
+ }
+
+ };
+
+ $scope.toggleFormTab = function(tab) {
+ if($scope.workflowMakerFormConfig.activeTab !== tab) {
+ $scope.workflowMakerFormConfig.activeTab = tab;
+ }
+ };
+
+ $scope.toggle_job_template = function(id) {
+
+ $scope.workflow_projects.forEach(function(row, i) {
+ $scope.workflow_projects[i].checked = 0;
+ });
+
+ $scope.workflow_inventory_sources.forEach(function(row, i) {
+ $scope.workflow_inventory_sources[i].checked = 0;
+ });
+
+ $scope.workflow_job_templates.forEach(function(row, i) {
+ if (row.id === id) {
+ $scope.selectedTemplate = angular.copy(row);
+ if($scope.selectedTemplate.ask_credential_on_launch) {
+ if($scope.selectedTemplate.summary_fields.credential) {
+ $scope.credential_name = $scope.selectedTemplate.summary_fields.credential.name ? $scope.selectedTemplate.summary_fields.credential.name : null;
+ $scope.credential = $scope.selectedTemplate.summary_fields.credential.id ? $scope.selectedTemplate.summary_fields.credential.id : null;
+ }
+ else {
+ $scope.credential_name = null;
+ $scope.credential = null;
+ }
+ }
+
+ if($scope.selectedTemplate.ask_inventory_on_launch) {
+ if($scope.selectedTemplate.summary_fields.inventory) {
+ $scope.inventory_name = $scope.selectedTemplate.summary_fields.inventory.name ? $scope.selectedTemplate.summary_fields.inventory.name : null;
+ $scope.inventory = $scope.selectedTemplate.summary_fields.inventory.id ? $scope.selectedTemplate.summary_fields.inventory.id : null;
+ }
+ else {
+ $scope.inventory_name = null;
+ $scope.inventory = null;
+ }
+ }
+
+ if($scope.selectedTemplate.ask_job_type_on_launch) {
+ $scope.job_type = {
+ value: $scope.selectedTemplate.job_type ? $scope.selectedTemplate.job_type : null
+ };
+
+ // The default needs to be in place before we can select2-ify the dropdown
+ CreateSelect2({
+ element: '#workflow_maker_job_type',
+ multiple: false
+ });
+ }
+
+ if($scope.selectedTemplate.ask_limit_on_launch) {
+ $scope.limit = $scope.selectedTemplate.limit ? $scope.selectedTemplate.limit : null;
+ }
+
+ if($scope.selectedTemplate.ask_skip_tags_on_launch) {
+ $scope.skip_tags = $scope.selectedTemplate.skip_tags ? $scope.selectedTemplate.skip_tags : null;
+ }
+
+ if($scope.selectedTemplate.ask_tags_on_launch) {
+ $scope.job_tags = $scope.selectedTemplate.job_tags ? $scope.selectedTemplate.job_tags : null;
+ }
+
+ $scope.workflow_job_templates[i].checked = 1;
+ } else {
+ $scope.workflow_job_templates[i].checked = 0;
+ }
+ });
+
+ };
+
+ $scope.toggle_project = function(id) {
+
+ resetPromptFields();
+
+ $scope.workflow_job_templates.forEach(function(row, i) {
+ $scope.workflow_job_templates[i].checked = 0;
+ });
+
+ $scope.workflow_inventory_sources.forEach(function(row, i) {
+ $scope.workflow_inventory_sources[i].checked = 0;
+ });
+
+ $scope.workflow_projects.forEach(function(row, i) {
+ if (row.id === id) {
+ $scope.selectedTemplate = angular.copy(row);
+ $scope.workflow_projects[i].checked = 1;
+ } else {
+ $scope.workflow_projects[i].checked = 0;
+ }
+ });
+
+ };
+
+ $scope.toggle_inventory_source = function(id) {
+
+ resetPromptFields();
+
+ $scope.workflow_job_templates.forEach(function(row, i) {
+ $scope.workflow_job_templates[i].checked = 0;
+ });
+
+ $scope.workflow_projects.forEach(function(row, i) {
+ $scope.workflow_projects[i].checked = 0;
+ });
+
+ $scope.workflow_inventory_sources.forEach(function(row, i) {
+ if (row.id === id) {
+ $scope.selectedTemplate = angular.copy(row);
+ $scope.workflow_inventory_sources[i].checked = 1;
+ } else {
+ $scope.workflow_inventory_sources[i].checked = 0;
+ }
+ });
+
+ };
+
+ $scope.$on('showWorkflowMaker', function(){
+ $scope.treeDataMaster = angular.copy($scope.treeData.data);
+ WorkflowHelpService.openDialog({
+ scope: $scope
+ })
+ .then(function(){
+
+ $scope.$broadcast("refreshWorkflowChart");
+
+ generator.inject(form, {
+ mode: 'add',
+ related: false,
+ scope: $scope,
+ id: 'workflow-maker-form'
+ });
+
+ LookUpInit({
+ scope: $scope,
+ form: form,
+ list: InventoryList,
+ field: 'inventory',
+ input_type: "radio"
+ });
+
+ LookUpInit({
+ url: GetBasePath('credentials') + '?kind=ssh',
+ scope: $scope,
+ form: form,
+ current_item: null,
+ list: CredentialList,
+ field: 'credential',
+ hdr: 'Select Machine Credential',
+ input_type: "radio"
+ });
+
+ GenerateList.inject(jobTemplatesList, {
+ mode: 'lookup',
+ id: 'workflow-jobs-list',
+ scope: $scope,
+ input_type: 'radio'
+ });
+
+ GenerateList.inject(projectList, {
+ mode: 'lookup',
+ id: 'workflow-project-sync-list',
+ scope: $scope,
+ input_type: 'radio'
+ });
+
+ GenerateList.inject(inventorySourcesList, {
+ mode: 'lookup',
+ id: 'workflow-inventory-sync-list',
+ scope: $scope,
+ input_type: 'radio'
+ });
+
+ });
+ });
+
+ init();
+
+ }
+ ];
diff --git a/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.directive.js b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.directive.js
new file mode 100644
index 0000000000..c421a6c414
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.directive.js
@@ -0,0 +1,20 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import workflowMakerController from './workflow-maker.controller';
+
+export default [ 'templateUrl',
+ function(templateUrl) {
+ return {
+ scope: {
+ treeData: '='
+ },
+ restrict: 'E',
+ templateUrl: templateUrl('job-templates/workflow-maker/workflow-maker'),
+ controller: workflowMakerController
+ };
+ }
+];
diff --git a/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.partial.html
new file mode 100644
index 0000000000..1b8d0a8e95
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.partial.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
Are you sure you want to remove the template below?
+
{{nodeToBeDeleted.unifiedJobTemplate.name}}
+
+
+
+
+
+
+
+
+
{{(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.
+
+
+
+
+ Close
+ Save
+
+
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 += "