diff --git a/.bowerrc b/.bowerrc
deleted file mode 100644
index 49a14a942b..0000000000
--- a/.bowerrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "directory": "awx/ui/client/lib"
-}
diff --git a/.dockerignore b/.dockerignore
index 46c83b0467..f5faf1f0e3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1 +1,2 @@
+.git
awx/ui/node_modules
diff --git a/Makefile b/Makefile
index 95d5cffc59..2835118cd0 100644
--- a/Makefile
+++ b/Makefile
@@ -231,10 +231,14 @@ clean: clean-rpm clean-deb clean-ui clean-tar clean-packer clean-bundle
rm -rf awx/public
rm -rf awx/lib/site-packages
rm -rf dist/*
+ rm -rf awx/job_status
+ rm -rf reports
+ rm -f awx/awx_test.sqlite3
rm -rf tmp
mkdir tmp
rm -rf build $(NAME)-$(VERSION) *.egg-info
find . -type f -regex ".*\.py[co]$$" -delete
+ find . -type d -name "__pycache__" -delete
# convenience target to assert environment variables are defined
guard-%:
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index 3012c722c6..839ebfe7c6 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -2682,9 +2682,10 @@ class NotificationTemplateSerializer(BaseSerializer):
def to_representation(self, obj):
ret = super(NotificationTemplateSerializer, self).to_representation(obj)
for field in obj.notification_class.init_parameters:
- if field in ret['notification_configuration'] and \
- force_text(ret['notification_configuration'][field]).startswith('$encrypted$'):
- ret['notification_configuration'][field] = '$encrypted$'
+ config = obj.notification_configuration
+ if field in config and force_text(config[field]).startswith('$encrypted$'):
+ config[field] = '$encrypted$'
+ ret['notification_configuration'] = config
return ret
def get_related(self, obj):
diff --git a/awx/api/views.py b/awx/api/views.py
index 390fc0194a..06a249d60e 100644
--- a/awx/api/views.py
+++ b/awx/api/views.py
@@ -2332,23 +2332,20 @@ class JobTemplateSurveySpec(GenericAPIView):
if not request.user.can_access(self.model, 'change', obj, None):
raise PermissionDenied()
- try:
- obj.survey_spec = json.dumps(request.data)
- except ValueError:
- return Response(dict(error=_("Invalid JSON when parsing survey spec.")), status=status.HTTP_400_BAD_REQUEST)
- if "name" not in obj.survey_spec:
+ new_spec = request.data
+ if "name" not in new_spec:
return Response(dict(error=_("'name' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST)
- if "description" not in obj.survey_spec:
+ if "description" not in new_spec:
return Response(dict(error=_("'description' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST)
- if "spec" not in obj.survey_spec:
+ if "spec" not in new_spec:
return Response(dict(error=_("'spec' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST)
- if not isinstance(obj.survey_spec["spec"], list):
+ if not isinstance(new_spec["spec"], list):
return Response(dict(error=_("'spec' must be a list of items.")), status=status.HTTP_400_BAD_REQUEST)
- if len(obj.survey_spec["spec"]) < 1:
+ if len(new_spec["spec"]) < 1:
return Response(dict(error=_("'spec' doesn't contain any items.")), status=status.HTTP_400_BAD_REQUEST)
idx = 0
variable_set = set()
- for survey_item in obj.survey_spec["spec"]:
+ for survey_item in new_spec["spec"]:
if not isinstance(survey_item, dict):
return Response(dict(error=_("Survey question %s is not a json object.") % str(idx)), status=status.HTTP_400_BAD_REQUEST)
if "type" not in survey_item:
@@ -2365,7 +2362,8 @@ class JobTemplateSurveySpec(GenericAPIView):
if "required" not in survey_item:
return Response(dict(error=_("'required' missing from survey question %s.") % str(idx)), status=status.HTTP_400_BAD_REQUEST)
idx += 1
- obj.save()
+ obj.survey_spec = new_spec
+ obj.save(update_fields=['survey_spec'])
return Response()
def delete(self, request, *args, **kwargs):
diff --git a/awx/main/access.py b/awx/main/access.py
index 2df99e7d26..e8dbd0fc59 100644
--- a/awx/main/access.py
+++ b/awx/main/access.py
@@ -1956,7 +1956,7 @@ class LabelAccess(BaseAccess):
def get_queryset(self):
if self.user.is_superuser or self.user.is_system_auditor:
return self.model.objects.all()
- return self.model.objects.filter(
+ return self.model.objects.all().filter(
organization__in=Organization.accessible_objects(self.user, 'read_role')
)
diff --git a/awx/main/tests/functional/__init__.py b/awx/main/tests/functional/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py
index 450fbd8dc9..1e64222522 100644
--- a/awx/main/tests/functional/api/test_settings.py
+++ b/awx/main/tests/functional/api/test_settings.py
@@ -3,6 +3,7 @@
# Python
import pytest
+import os
# Django
from django.core.urlresolvers import reverse
@@ -10,9 +11,17 @@ from django.core.urlresolvers import reverse
# AWX
from awx.conf.models import Setting
+'''
+Ensures that tests don't pick up dev container license file
+'''
+@pytest.fixture
+def mock_no_license_file(mocker):
+ os.environ['AWX_LICENSE_FILE'] = '/does_not_exist'
+ return None
@pytest.mark.django_db
-def test_license_cannot_be_removed_via_system_settings(get, put, patch, delete, admin, enterprise_license):
+def test_license_cannot_be_removed_via_system_settings(mock_no_license_file, get, put, patch, delete, admin, enterprise_license):
+
url = reverse('api:setting_singleton_detail', args=('system',))
response = get(url, user=admin, expect=200)
assert not response.data['LICENSE']
diff --git a/awx/main/tests/functional/test_partial.py b/awx/main/tests/functional/test_partial.py
index 0ab84dc901..d0b3ec6fa2 100644
--- a/awx/main/tests/functional/test_partial.py
+++ b/awx/main/tests/functional/test_partial.py
@@ -101,8 +101,11 @@ class TestInventoryUpdateLatestDict():
inv_src2 = InventorySource.objects.create(group=g2, update_on_launch=False, inventory=inventory)
inv_src3 = InventorySource.objects.create(group=g3, update_on_launch=True, inventory=inventory)
+ import time
iu1 = InventoryUpdate.objects.create(inventory_source=inv_src1, status='successful')
+ time.sleep(0.1)
iu2 = InventoryUpdate.objects.create(inventory_source=inv_src2, status='waiting')
+ time.sleep(0.1)
iu3 = InventoryUpdate.objects.create(inventory_source=inv_src3, status='waiting')
return [iu1, iu2, iu3]
@@ -114,7 +117,7 @@ class TestInventoryUpdateLatestDict():
inventory_updates_expected = [inventory_updates[0], inventory_updates[2]]
assert 2 == len(tasks)
- for i, inventory_update in enumerate(inventory_updates_expected):
- assert inventory_update.id == tasks[i]['id']
-
+ task_ids = [task['id'] for task in tasks]
+ for inventory_update in inventory_updates_expected:
+ inventory_update.id in task_ids
diff --git a/awx/main/tests/functional/test_rbac_label.py b/awx/main/tests/functional/test_rbac_label.py
index 57700d17e2..d10e48ed0a 100644
--- a/awx/main/tests/functional/test_rbac_label.py
+++ b/awx/main/tests/functional/test_rbac_label.py
@@ -6,8 +6,9 @@ from awx.main.access import (
@pytest.mark.django_db
def test_label_get_queryset_user(label, user):
- access = LabelAccess(user('user', False))
- label.organization.member_role.members.add(user('user', False))
+ u = user('user', False)
+ access = LabelAccess(u)
+ label.organization.member_role.members.add(u)
assert access.get_queryset().count() == 1
@pytest.mark.django_db
diff --git a/awx/main/tests/unit/__init__.py b/awx/main/tests/unit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/awx/main/tests/unit/test_python_requirements.py b/awx/main/tests/unit/test_python_requirements.py
new file mode 100644
index 0000000000..0db4f6a29d
--- /dev/null
+++ b/awx/main/tests/unit/test_python_requirements.py
@@ -0,0 +1,48 @@
+from pip.operations import freeze
+from django.conf import settings
+
+def test_req():
+ def check_is_in(src, dests):
+ if src not in dests:
+ src2 = [src[0].replace('_', '-'), src[1]]
+ if src2 not in dests:
+ print("%s not in" % src2)
+ return False
+ else:
+ print("%s not in" % src)
+ return False
+ return True
+
+ base_dir = settings.BASE_DIR
+
+ reqs_actual = []
+ xs = freeze.freeze(local_only=True, requirement=base_dir + "/../requirements/requirements.txt")
+ for x in xs:
+ if '## The following requirements were added by pip freeze' in x:
+ break
+ reqs_actual.append(x.split('=='))
+
+ reqs_expected = []
+ with open(base_dir + "/../requirements/requirements.txt") as f:
+ for line in f:
+ line.rstrip()
+ # TODO: process git requiremenst and use egg
+ if line.strip().startswith('#') or line.strip().startswith('git'):
+ continue
+ if line.startswith('-e'):
+ continue
+ line.rstrip()
+ reqs_expected.append(line.rstrip().split('=='))
+
+ for r in reqs_actual:
+ print(r)
+
+ not_found = []
+ for r in reqs_expected:
+ res = check_is_in(r, reqs_actual)
+ if res is False:
+ not_found.append(r)
+
+ raise RuntimeError("%s not found in \n\n%s" % (not_found, reqs_expected))
+
+
diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less
index d554ce8cc0..b9b7cbc7cc 100644
--- a/awx/ui/client/legacy-styles/ansible-ui.less
+++ b/awx/ui/client/legacy-styles/ansible-ui.less
@@ -905,6 +905,12 @@ input[type="checkbox"].checkbox-no-label {
margin-top: 10px;
}
+.radio-group {
+ .radio-inline + .radio-inline {
+ margin-left: 0;
+ }
+}
+
.checkbox-group {
.radio-inline + .radio-inline,
.checkbox-inline + .checkbox-inline {
diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less
index b36f1bdd5b..89ff33dd03 100644
--- a/awx/ui/client/legacy-styles/forms.less
+++ b/awx/ui/client/legacy-styles/forms.less
@@ -184,6 +184,7 @@
.Form-formGroup--fullWidth {
max-width: none !important;
width: 100% !important;
+ padding-right: 0px !important;
}
.Form-formGroup--checkbox{
@@ -553,19 +554,24 @@ input[type='radio']:checked:before {
color: @btn-txt;
}
-.Form-surveyButton {
+.Form-primaryButton {
background-color: @default-link;
color: @default-bg;
text-transform: uppercase;
padding-left:15px;
padding-right: 15px;
+ margin-right: 20px;
}
-.Form-surveyButton:hover{
+.Form-primaryButton:hover {
background-color: @default-link-hov;
color: @default-bg;
}
+.Form-primaryButton.Form-tab--disabled:hover {
+ background-color: @default-link;
+}
+
.Form-formGroup--singleColumn {
width: 100% !important;
padding-right: 0px;
diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less
index 763694f6d2..ee69342e0f 100644
--- a/awx/ui/client/legacy-styles/lists.less
+++ b/awx/ui/client/legacy-styles/lists.less
@@ -357,6 +357,32 @@ table, tbody {
cursor: not-allowed;
}
+.List-dropdownButton {
+ border: none;
+}
+
+.List-dropdownSuccess {
+ background-color: @submit-button-bg;
+ color: @submit-button-text;
+ border-color: @submit-button-bg-hov;
+}
+
+.List-dropdownSuccess:hover,
+.List-dropdownSuccess:focus {
+ color: @submit-button-text;
+ background-color: @submit-button-bg-hov;
+}
+
+.List-dropdownCarat {
+ display: inline-block;
+ width: 0;
+ height: 0;
+ vertical-align: middle;
+ border-top: 4px dashed;
+ border-right: 4px solid transparent;
+ border-left: 4px solid transparent;
+}
+
@media (max-width: 991px) {
.List-searchWidget + .List-searchWidget {
margin-top: 20px;
diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js
index 664361123b..3174bc6b98 100644
--- a/awx/ui/client/src/app.js
+++ b/awx/ui/client/src/app.js
@@ -38,7 +38,6 @@ if ($basePath) {
// Modules
import './helpers';
-import * as forms from './forms';
import './lists';
import './widgets';
import './filters';
@@ -53,6 +52,7 @@ import inventoryScripts from './inventory-scripts/main';
import organizations from './organizations/main';
import managementJobs from './management-jobs/main';
import jobDetail from './job-detail/main';
+import workflowResults from './workflow-results/main';
import jobSubmission from './job-submission/main';
import notifications from './notifications/main';
import about from './about/main';
@@ -122,6 +122,7 @@ var tower = angular.module('Tower', [
activityStream.name,
footer.name,
jobDetail.name,
+ workflowResults.name,
jobSubmission.name,
notifications.name,
standardOut.name,
@@ -168,7 +169,6 @@ var tower = angular.module('Tower', [
'ProjectsHelper',
'CompletedJobsDefinition',
'AllJobsDefinition',
- 'JobFormDefinition',
'JobSummaryDefinition',
'ParseHelper',
'ChildrenHelper',
@@ -202,6 +202,9 @@ var tower = angular.module('Tower', [
'ActivityStreamHelper',
'gettext',
'I18N',
+ 'WorkflowFormDefinition',
+ 'InventorySourcesListDefinition',
+ 'WorkflowMakerFormDefinition'
])
.constant('AngularScheduler.partials', urlPrefix + 'lib/angular-scheduler/lib/')
diff --git a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js
index 3ba3d2fa72..bc9688f4e2 100644
--- a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js
+++ b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js
@@ -22,7 +22,6 @@ export default [
'CreateSelect2',
'GenerateForm',
'ParseTypeChange',
- 'Wait',
function(
$scope,
$state,
@@ -40,8 +39,7 @@ export default [
ConfigurationUtils,
CreateSelect2,
GenerateForm,
- ParseTypeChange,
- Wait
+ ParseTypeChange
) {
var authVm = this;
diff --git a/awx/ui/client/src/configuration/configuration.controller.js b/awx/ui/client/src/configuration/configuration.controller.js
index 1214c994d3..38b180c25d 100644
--- a/awx/ui/client/src/configuration/configuration.controller.js
+++ b/awx/ui/client/src/configuration/configuration.controller.js
@@ -122,6 +122,30 @@ export default [
$scope.configDataResolve = configDataResolve;
+ var triggerModal = function(msg, title, buttons) {
+ if ($scope.removeModalReady) {
+ $scope.removeModalReady();
+ }
+ $scope.removeModalReady = $scope.$on('ModalReady', function() {
+ // $('#lookup-save-button').attr('disabled', 'disabled');
+ $('#FormModal-dialog').dialog('open');
+ });
+
+ $('#FormModal-dialog').html(msg);
+
+ CreateDialog({
+ scope: $scope,
+ buttons: buttons,
+ width: 600,
+ height: 200,
+ minWidth: 500,
+ title: title,
+ id: 'FormModal-dialog',
+ resizable: false,
+ callback: 'ModalReady'
+ });
+ };
+
function activeTabCheck(setForm) {
if(!$scope[formTracker.currentFormName()].$dirty) {
active(setForm);
@@ -212,7 +236,7 @@ export default [
payload[key] = $scope.configDataResolve[key].default;
ConfigurationService.patchConfiguration(payload)
- .then(function(data) {
+ .then(function() {
$scope[key] = $scope.configDataResolve[key].default;
})
.catch(function(error) {
@@ -228,30 +252,6 @@ export default [
});
};
- var triggerModal = function(msg, title, buttons) {
- if ($scope.removeModalReady) {
- $scope.removeModalReady();
- }
- $scope.removeModalReady = $scope.$on('ModalReady', function() {
- // $('#lookup-save-button').attr('disabled', 'disabled');
- $('#FormModal-dialog').dialog('open');
- });
-
- $('#FormModal-dialog').html(msg);
-
- CreateDialog({
- scope: $scope,
- buttons: buttons,
- width: 600,
- height: 200,
- minWidth: 500,
- title: title,
- id: 'FormModal-dialog',
- resizable: false,
- callback: 'ModalReady'
- });
- };
-
function clearApiErrors() {
var currentForm = formDefs[formTracker.getCurrent()];
for (var fld in currentForm.fields) {
@@ -332,7 +332,7 @@ export default [
var payload = {};
payload[key] = $scope[key];
ConfigurationService.patchConfiguration(payload)
- .then(function(results) {
+ .then(function() {
//TODO consider updating form values with returned data here
})
.catch(function(error, status) {
@@ -352,7 +352,7 @@ export default [
var resetAll = function() {
Wait('start');
ConfigurationService.resetAll()
- .then(function(results) {
+ .then(function() {
populateFromApi();
$scope[formTracker.currentFormName].$setPristine();
})
diff --git a/awx/ui/client/src/configuration/configuration.service.js b/awx/ui/client/src/configuration/configuration.service.js
index 0438b4501c..5c86f3beae 100644
--- a/awx/ui/client/src/configuration/configuration.service.js
+++ b/awx/ui/client/src/configuration/configuration.service.js
@@ -4,8 +4,8 @@
* All Rights Reserved
*************************************************/
-export default ['GetBasePath', 'ProcessErrors', '$q', '$http', 'Rest', '$rootScope', '$timeout', 'Wait',
- function(GetBasePath, ProcessErrors, $q, $http, Rest, $rootScope, $timeout, Wait) {
+export default ['GetBasePath', 'ProcessErrors', '$q', '$http', 'Rest',
+ function(GetBasePath, ProcessErrors, $q, $http, Rest) {
var url = GetBasePath('settings');
return {
diff --git a/awx/ui/client/src/configuration/configurationUtils.service.js b/awx/ui/client/src/configuration/configurationUtils.service.js
index ba6069e3f3..fe8d4f27c0 100644
--- a/awx/ui/client/src/configuration/configurationUtils.service.js
+++ b/awx/ui/client/src/configuration/configurationUtils.service.js
@@ -8,7 +8,7 @@ export default [
function() {
return {
- listToArray: function(input, key) {
+ listToArray: function(input) {
if (input.indexOf('\n') !== -1) {
//Parse multiline input
return input.replace(/^\s+|\s+$/g, "").split('\n');
@@ -17,7 +17,7 @@ export default [
}
},
- arrayToList: function(input, key) {
+ arrayToList: function(input) {
var multiLineInput = false;
_.each(input, function(statement) {
if (statement.indexOf(',') !== -1) {
@@ -40,7 +40,7 @@ export default [
return true;
},
- formatPlaceholder: function(input, key) {
+ formatPlaceholder: function(input) {
if(input !== null && typeof input === 'object') {
if(Array.isArray(input)) {
var multiLineInput = false;
diff --git a/awx/ui/client/src/controllers/Jobs.js b/awx/ui/client/src/controllers/Jobs.js
index a01b225bb1..5129efb7d6 100644
--- a/awx/ui/client/src/controllers/Jobs.js
+++ b/awx/ui/client/src/controllers/Jobs.js
@@ -41,7 +41,7 @@ export function JobsListController($state, $rootScope, $log, $scope, $compile, $
};
$scope.relaunchJob = function(event, id) {
- var list, job, typeId;
+ var job, typeId;
try {
$(event.target).tooltip('hide');
} catch (e) {
@@ -84,6 +84,9 @@ export function JobsListController($state, $rootScope, $log, $scope, $compile, $
case 'inventory_update':
goToJobDetails('inventorySyncStdout');
break;
+ case 'workflow_job':
+ goToJobDetails('workflowResults');
+ break;
}
};
diff --git a/awx/ui/client/src/controllers/Users.js b/awx/ui/client/src/controllers/Users.js
index 3a7f1b86a8..61ffb69d2e 100644
--- a/awx/ui/client/src/controllers/Users.js
+++ b/awx/ui/client/src/controllers/Users.js
@@ -114,7 +114,7 @@ UsersList.$inject = ['$scope', '$rootScope', '$stateParams',
export function UsersAdd($scope, $rootScope, $stateParams, UserForm,
GenerateForm, Rest, Alert, ProcessErrors, ReturnToCaller, ClearScope,
- GetBasePath, ResetForm, Wait, CreateSelect2, $state, i18n) {
+ GetBasePath, ResetForm, Wait, CreateSelect2, $state, $location) {
ClearScope();
@@ -201,7 +201,7 @@ export function UsersAdd($scope, $rootScope, $stateParams, UserForm,
UsersAdd.$inject = ['$scope', '$rootScope', '$stateParams', 'UserForm', 'GenerateForm',
'Rest', 'Alert', 'ProcessErrors', 'ReturnToCaller', 'ClearScope', 'GetBasePath',
- 'ResetForm', 'Wait', 'CreateSelect2', '$state', 'i18n'
+ 'ResetForm', 'Wait', 'CreateSelect2', '$state', '$location'
];
export function UsersEdit($scope, $rootScope, $location,
diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js
index 5052e073a4..714b3c088a 100644
--- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js
+++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js
@@ -2,8 +2,8 @@
export default
[ 'InitiatePlaybookRun',
'templateUrl',
- '$location',
- function JobTemplatesList(InitiatePlaybookRun, templateUrl, $location) {
+ '$state',
+ function JobTemplatesList(InitiatePlaybookRun, templateUrl, $state) {
return {
restrict: 'E',
link: link,
@@ -47,7 +47,7 @@ export default
};
scope.editJobTemplate = function (jobTemplateId) {
- $location.path( '/job_templates/' + jobTemplateId);
+ $state.go('templates.editJobTemplate', {id: jobTemplateId});
};
}
}];
diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html
index 8114f037d4..77ba2f41ac 100644
--- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html
+++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html
@@ -3,7 +3,7 @@
-
+
VIEW ALL
@@ -25,7 +25,7 @@
ng-class-even="'List-tableRow--evenRow'"
ng-repeat = "job_template in job_templates">
-
+
{{ job_template.name }}
@@ -53,7 +53,7 @@
-
No job templates were recently used.
- You can create a job template here .
+
No job templates were recently used.
+ You can create a job template here .
diff --git a/awx/ui/client/src/forms.js b/awx/ui/client/src/forms.js
index 51cbc4a6e7..db46a6d0d8 100644
--- a/awx/ui/client/src/forms.js
+++ b/awx/ui/client/src/forms.js
@@ -16,7 +16,6 @@ import JobEventData from "./forms/JobEventData";
import JobSummary from "./forms/JobSummary";
import JobTemplates from "./forms/JobTemplates";
import JobVarsPrompt from "./forms/JobVarsPrompt";
-import Jobs from "./forms/Jobs";
import LogViewerOptions from "./forms/LogViewerOptions";
import LogViewerStatus from "./forms/LogViewerStatus";
import Organizations from "./forms/Organizations";
@@ -24,6 +23,8 @@ import ProjectStatus from "./forms/ProjectStatus";
import Projects from "./forms/Projects";
import Teams from "./forms/Teams";
import Users from "./forms/Users";
+import WorkflowMaker from "./forms/WorkflowMaker";
+import Workflows from "./forms/Workflows";
export
@@ -39,12 +40,13 @@ export
JobSummary,
JobTemplates,
JobVarsPrompt,
- Jobs,
LogViewerOptions,
LogViewerStatus,
Organizations,
ProjectStatus,
Projects,
Teams,
- Users
+ Users,
+ WorkflowMaker,
+ Workflows
};
diff --git a/awx/ui/client/src/forms/Inventories.js b/awx/ui/client/src/forms/Inventories.js
index 06038da005..49692f476e 100644
--- a/awx/ui/client/src/forms/Inventories.js
+++ b/awx/ui/client/src/forms/Inventories.js
@@ -132,7 +132,7 @@ angular.module('InventoryFormDefinition', ['ScanJobsListDefinition'])
}
},
- relatedSets: function(urls) {
+ relatedSets: function() {
return {
permissions: {
awToolTip: i18n._('Please save before assigning permissions'),
diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js
index 0c276777dc..00f4c5f131 100644
--- a/awx/ui/client/src/forms/JobTemplates.js
+++ b/awx/ui/client/src/forms/JobTemplates.js
@@ -20,10 +20,12 @@ export default
addTitle: i18n._('New Job Template'),
editTitle: '{{ name }}',
name: 'job_template',
+ breadcrumbName: 'JOB TEMPLATE',
basePath: 'job_templates',
// the top-most node of generated state tree
- stateTree: 'jobTemplates',
+ stateTree: 'templates',
tabs: true,
+ activeEditState: 'templates.editJobTemplate',
// (optional) array of supporting templates to ng-include inside generated html
include: ['/static/partials/survey-maker-modal.html'],
@@ -31,7 +33,7 @@ export default
name: {
label: i18n._('Name'),
type: 'text',
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)',
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)',
required: true,
column: 1
},
@@ -39,7 +41,7 @@ export default
label: i18n._('Description'),
type: 'text',
column: 1,
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
job_type: {
label: i18n._('Job Type'),
@@ -61,7 +63,7 @@ export default
ngShow: "!job_type.value || job_type.value !== 'scan'",
text: i18n._('Prompt on launch')
},
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
inventory: {
label: i18n._('Inventory'),
@@ -85,7 +87,7 @@ export default
ngShow: "!job_type.value || job_type.value !== 'scan'",
text: i18n._('Prompt on launch')
},
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
project: {
label: i18n._('Project'),
@@ -108,13 +110,13 @@ export default
dataTitle: i18n._('Project'),
dataPlacement: 'right',
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
playbook: {
label: i18n._('Playbook'),
type:'select',
ngOptions: 'book for book in playbook_options track by book',
- ngDisabled: "(job_type.value === 'scan' && project_name === 'Default') || !(job_template_obj.summary_fields.user_capabilities.edit || canAdd)",
+ ngDisabled: "(job_type.value === 'scan' && project_name === 'Default') || !(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)",
id: 'playbook-select',
awRequiredWhen: {
reqExpression: "playbookrequired",
@@ -152,7 +154,7 @@ export default
variable: 'ask_credential_on_launch',
text: i18n._('Prompt on launch')
},
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
cloud_credential: {
label: i18n._('Cloud Credential'),
@@ -170,7 +172,7 @@ export default
dataTitle: i18n._('Cloud Credential'),
dataPlacement: 'right',
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
network_credential: {
label: i18n._('Network Credential'),
@@ -187,7 +189,7 @@ export default
dataTitle: i18n._('Network Credential'),
dataPlacement: 'right',
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
forks: {
label: i18n._('Forks'),
@@ -205,7 +207,7 @@ export default
dataTitle: i18n._('Forks'),
dataPlacement: 'right',
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' // TODO: get working
},
limit: {
label: i18n._('Limit'),
@@ -221,7 +223,7 @@ export default
variable: 'ask_limit_on_launch',
text: i18n._('Prompt on launch')
},
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
verbosity: {
label: i18n._('Verbosity'),
@@ -234,7 +236,7 @@ export default
dataTitle: i18n._('Verbosity'),
dataPlacement: 'right',
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
job_tags: {
label: i18n._('Job Tags'),
@@ -252,7 +254,7 @@ export default
variable: 'ask_tags_on_launch',
text: i18n._('Prompt on launch')
},
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
skip_tags: {
label: i18n._('Skip Tags'),
@@ -270,7 +272,7 @@ export default
variable: 'ask_skip_tags_on_launch',
text: i18n._('Prompt on launch')
},
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
checkbox_group: {
label: i18n._('Options'),
@@ -285,7 +287,7 @@ export default
dataTitle: i18n._('Become Privilege Escalation'),
dataContainer: "body",
labelClass: 'stack-inline',
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
}, {
name: 'allow_callbacks',
label: i18n._('Allow Provisioning Callbacks'),
@@ -298,7 +300,7 @@ export default
dataTitle: i18n._('Allow Provisioning Callbacks'),
dataContainer: "body",
labelClass: 'stack-inline',
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
}]
},
callback_url: {
@@ -312,7 +314,7 @@ export default
dataPlacement: 'top',
dataTitle: i18n._('Provisioning Callback URL'),
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
host_config_key: {
label: i18n._('Host Config Key'),
@@ -326,7 +328,7 @@ export default
dataPlacement: 'right',
dataTitle: i18n._("Host Config Key"),
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
labels: {
label: i18n._('Labels'),
@@ -338,7 +340,7 @@ export default
dataPlacement: 'right',
awPopOver: i18n._("Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs in the Tower display.
"),
dataContainer: 'body',
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
variables: {
label: i18n._('Extra Variables'),
@@ -360,14 +362,14 @@ export default
variable: 'ask_variables_on_launch',
text: i18n._('Prompt on launch')
},
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' // TODO: get working
}
},
buttons: { //for now always generates tags
add_survey: {
ngClick: 'addSurvey()',
- ngShow: 'job_type.value !== "scan" && !survey_exists && (job_template_obj.summary_fields.user_capabilities.edit || canAdd)',
+ ngShow: 'job_type.value !== "scan" && !survey_exists && (job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)',
awFeature: 'surveys',
awToolTip: 'Surveys allow users to be prompted at job launch with a series of questions related to the job. This allows for variables to be defined that affect the playbook run at time of launch.',
dataPlacement: 'top'
@@ -375,25 +377,25 @@ export default
edit_survey: {
ngClick: 'editSurvey()',
awFeature: 'surveys',
- ngShow: 'job_type.value !== "scan" && survey_exists && (job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngShow: 'job_type.value !== "scan" && survey_exists && (job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
view_survey: {
ngClick: 'editSurvey()',
awFeature: 'surveys',
- ngShow: 'job_type.value !== "scan" && survey_exists && !(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngShow: 'job_type.value !== "scan" && survey_exists && !(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
cancel: {
ngClick: 'formCancel()',
- ngShow: '(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngShow: '(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
close: {
ngClick: 'formCancel()',
- ngShow: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngShow: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
save: {
ngClick: 'formSave()', //$scope.function to call on click, optional
ngDisabled: "job_templates_form.$invalid",//true //Disable when $pristine or $invalid, optional and when can_edit = false, for permission reasons
- ngShow: '(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngShow: '(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
}
},
@@ -420,7 +422,7 @@ export default
awToolTip: 'Add a permission',
actionClass: 'btn List-buttonSubmit',
buttonContent: '+ ADD',
- ngShow: '(job_template_obj.summary_fields.user_capabilities.edit || canAdd)'
+ ngShow: '(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
}
},
diff --git a/awx/ui/client/src/forms/Jobs.js b/awx/ui/client/src/forms/Jobs.js
deleted file mode 100644
index 0584c10159..0000000000
--- a/awx/ui/client/src/forms/Jobs.js
+++ /dev/null
@@ -1,116 +0,0 @@
-/*************************************************
- * Copyright (c) 2015 Ansible, Inc.
- *
- * All Rights Reserved
- *************************************************/
-
- /**
- * @ngdoc function
- * @name forms.function:Jobs
- * @description This form is for adding/editing a Job
-*/
-
-export default
- angular.module('JobFormDefinition', [])
- .value('JobForm', {
-
- addTitle: 'Create Job',
- editTitle: '{{ id }} - {{ name }}',
- name: 'jobs',
- stateTree: 'jobs',
- well: true,
- base: 'jobs',
- tabs: true,
-
- navigationLinks: {
- details: {
- href: "/#/jobs/{{ job_id }}",
- label: 'Status',
- icon: 'icon-zoom-in',
- active: true,
- ngShow: "job_id !== null"
- },
- events: {
- href: "/#/jobs/{{ job_id }}/job_events",
- label: 'Events',
- icon: 'icon-list-ul'
- },
- hosts: {
- href: "/#/jobs/{{ job_id }}/job_host_summaries",
- label: 'Host Summary',
- icon: 'icon-laptop'
- }
- },
-
- fields: {
- status: {
- type: 'custom',
- control: " {{ job_explanation }}",
- readonly: true
- },
- result_stdout: {
- label: 'Standard Out',
- type: 'textarea',
- readonly: true,
- xtraWide: true,
- rows: "{{ stdout_rows }}",
- "class": 'nowrap mono-space allowresize',
- ngShow: "result_stdout != ''"
- },
- result_traceback: {
- label: 'Traceback',
- type: 'textarea',
- xtraWide: true,
- readonly: true,
- rows: "{{ traceback_rows }}",
- "class": 'nowrap mono-space allowresize',
- ngShow: "result_traceback != ''"
- },
- type: {
- label: 'Job Type',
- type: 'text',
- readonly: true
- },
- launch_type: {
- label: 'Launch Type',
- type: 'text',
- readonly: true
- },
- created: {
- label: 'Created On',
- type: 'text',
- readonly: true
- },
- modified: {
- label: 'Last Updated',
- type: 'text',
- readonly: true
- }
- },
-
- actions: {
- refresh: {
- dataPlacement: 'top',
- icon: "icon-refresh",
- iconSize: 'large',
- mode: 'all',
- //ngShow: "job_status == 'pending' || job_status == 'waiting' || job_status == 'running'",
- 'class': 'btn-xs btn-primary',
- awToolTip: "Refresh the page",
- ngClick: "refresh()"
- }
- },
-
- related: {
- job_template: {
- type: 'collection',
- title: 'Job Tempate',
- iterator: 'job',
- index: false,
- open: false,
-
- fields: { }
- }
- }
-
- });
diff --git a/awx/ui/client/src/forms/WorkflowMaker.js b/awx/ui/client/src/forms/WorkflowMaker.js
new file mode 100644
index 0000000000..cc80fd2179
--- /dev/null
+++ b/awx/ui/client/src/forms/WorkflowMaker.js
@@ -0,0 +1,188 @@
+/*************************************************
+ * 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', {
+
+export default
+ angular.module('WorkflowMakerFormDefinition', [])
+
+ .factory('WorkflowMakerFormObject', ['i18n', function(i18n) {
+ return {
+
+ addTitle: '',
+ editTitle: '',
+ name: 'workflow_maker',
+ basePath: 'job_templates',
+ tabs: false,
+ cancelButton: false,
+ showHeader: false,
+
+ fields: {
+ edgeType: {
+ label: i18n._('Type'),
+ type: 'radio_group',
+ ngShow: 'selectedTemplate && showTypeOptions',
+ ngDisabled: '!canAddWorkflowJobTemplate',
+ options: [
+ {
+ label: i18n._('On Success'),
+ value: 'success',
+ ngShow: '!edgeTypeRestriction || edgeTypeRestriction === "successFailure"'
+ },
+ {
+ label: i18n._('On Failure'),
+ value: 'failure',
+ ngShow: '!edgeTypeRestriction || edgeTypeRestriction === "successFailure"'
+ },
+ {
+ label: i18n._('Always'),
+ value: 'always',
+ ngShow: '!edgeTypeRestriction || edgeTypeRestriction === "always"'
+ }
+ ],
+ awRequiredWhen: {
+ reqExpression: 'showTypeOptions'
+ }
+ },
+ credential: {
+ label: i18n._('Credential'),
+ type: 'lookup',
+ sourceModel: 'credential',
+ sourceField: 'name',
+ ngClick: 'lookUpCredential()',
+ requiredErrorMsg: i18n._("Please select a Credential."),
+ class: 'Form-formGroup--fullWidth',
+ awPopOver: i18n._("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: i18n._('Credential'),
+ dataPlacement: 'right',
+ dataContainer: "body",
+ ngShow: "selectedTemplate.ask_credential_on_launch",
+ ngDisabled: '!canAddWorkflowJobTemplate',
+ awRequiredWhen: {
+ reqExpression: 'selectedTemplate && selectedTemplate.ask_credential_on_launch'
+ }
+ },
+ inventory: {
+ label: i18n._('Inventory'),
+ type: 'lookup',
+ sourceModel: 'inventory',
+ sourceField: 'name',
+ list: 'OrganizationList',
+ basePath: 'organization',
+ ngClick: 'lookUpInventory()',
+ requiredErrorMsg: i18n._("Please select an Inventory."),
+ class: 'Form-formGroup--fullWidth',
+ awPopOver: i18n._("Select the inventory containing the hosts you want this job to manage.
"),
+ dataTitle: i18n._('Inventory'),
+ dataPlacement: 'right',
+ dataContainer: "body",
+ ngShow: "selectedTemplate.ask_inventory_on_launch",
+ ngDisabled: '!canAddWorkflowJobTemplate',
+ awRequiredWhen: {
+ reqExpression: 'selectedTemplate && selectedTemplate.ask_inventory_on_launch'
+ }
+ },
+ job_type: {
+ label: i18n._('Job Type'),
+ type: 'select',
+ ngOptions: 'type.label for type in job_type_options track by type.value',
+ "default": 0,
+ class: 'Form-formGroup--fullWidth',
+ awPopOver: i18n._("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: i18n._('Job Type'),
+ dataPlacement: 'right',
+ dataContainer: "body",
+ ngShow: "selectedTemplate.ask_job_type_on_launch",
+ ngDisabled: '!canAddWorkflowJobTemplate',
+ awRequiredWhen: {
+ reqExpression: 'selectedTemplate && selectedTemplate.ask_job_type_on_launch'
+ }
+ },
+ limit: {
+ label: i18n._('Limit'),
+ type: 'text',
+ class: 'Form-formGroup--fullWidth',
+ awPopOver: i18n._("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: i18n._('Limit'),
+ dataPlacement: 'right',
+ dataContainer: "body",
+ ngShow: "selectedTemplate.ask_limit_on_launch",
+ ngDisabled: '!canAddWorkflowJobTemplate'
+ },
+ job_tags: {
+ label: i18n._('Job Tags'),
+ type: 'textarea',
+ rows: 5,
+ 'elementClass': 'Form-textInput',
+ class: 'Form-formGroup--fullWidth',
+ awPopOver: i18n._("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: i18n._("Job Tags"),
+ dataPlacement: "right",
+ dataContainer: "body",
+ ngShow: "selectedTemplate.ask_tags_on_launch",
+ ngDisabled: '!canAddWorkflowJobTemplate'
+ },
+ skip_tags: {
+ label: i18n._('Skip Tags'),
+ type: 'textarea',
+ rows: 5,
+ 'elementClass': 'Form-textInput',
+ class: 'Form-formGroup--fullWidth',
+ awPopOver: i18n._("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: i18n._("Skip Tags"),
+ dataPlacement: "right",
+ dataContainer: "body",
+ ngShow: "selectedTemplate.ask_skip_tags_on_launch",
+ ngDisabled: '!canAddWorkflowJobTemplate'
+ }
+ },
+ buttons: {
+ cancel: {
+ ngClick: 'cancelNodeForm()',
+ ngShow: 'canAddWorkflowJobTemplate'
+ },
+ close: {
+ ngClick: 'cancelNodeForm()',
+ ngShow: '!canAddWorkflowJobTemplate'
+ },
+ save: {
+ ngClick: 'saveNodeForm()',
+ ngDisabled: "workflow_maker_form.$invalid || !selectedTemplate",
+ ngShow: 'canAddWorkflowJobTemplate'
+ }
+ }
+ };}])
+ .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..113251b8c4
--- /dev/null
+++ b/awx/ui/client/src/forms/Workflows.js
@@ -0,0 +1,208 @@
+/*************************************************
+ * 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', [])
+
+ .factory('WorkflowFormObject', ['i18n', function(i18n) {
+ return {
+
+ addTitle: i18n._('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: i18n._('Name'),
+ type: 'text',
+ required: true,
+ ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)',
+ column: 1
+ },
+ description: {
+ label: i18n._('Description'),
+ type: 'text',
+ column: 1,
+ ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
+ },
+ organization: {
+ label: i18n._('Organization'),
+ type: 'lookup',
+ sourceModel: 'organization',
+ basePath: 'organizations',
+ list: 'OrganizationList',
+ sourceField: 'name',
+ dataTitle: i18n._('Organization'),
+ dataContainer: 'body',
+ dataPlacement: 'right',
+ column: 1,
+ ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
+ },
+ labels: {
+ label: i18n._('Labels'),
+ type: 'select',
+ class: 'Form-formGroup--fullWidth',
+ ngOptions: 'label.label for label in labelOptions track by label.value',
+ multiSelect: true,
+ dataTitle: i18n._('Labels'),
+ dataPlacement: 'right',
+ awPopOver: i18n._("Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs in the Tower display.
"),
+ dataContainer: 'body',
+ ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
+ },
+ variables: {
+ label: i18n._('Extra Variables'),
+ type: 'textarea',
+ class: 'Form-textAreaLabel Form-formGroup--fullWidth',
+ rows: 6,
+ "default": "---",
+ column: 2,
+ awPopOver: i18n._("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: i18n._('Extra Variables'),
+ dataPlacement: 'right',
+ dataContainer: "body",
+ ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' // TODO: get working
+ }
+ },
+
+ buttons: { //for now always generates tags
+ cancel: {
+ ngClick: 'formCancel()',
+ ngShow: '(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
+ },
+ close: {
+ ngClick: 'formCancel()',
+ ngShow: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
+ },
+ save: {
+ ngClick: 'formSave()', //$scope.function to call on click, optional
+ ngDisabled: "workflow_form.$invalid || can_edit!==true", //Disable when $pristine or $invalid, optional and when can_edit = false, for permission reasons
+ ngShow: '(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
+ }
+ },
+
+ related: {
+ permissions: {
+ awToolTip: i18n._('Please save before assigning permissions'),
+ dataPlacement: 'top',
+ basePath: 'api/v1/workflow_job_templates/{{$stateParams.workflow_job_template_id}}/access_list/',
+ search: {
+ order_by: 'username'
+ },
+ type: 'collection',
+ title: i18n._('Permissions'),
+ iterator: 'permission',
+ index: false,
+ open: false,
+ actions: {
+ add: {
+ ngClick: "$state.go('.add')",
+ label: 'Add',
+ awToolTip: 'Add a permission',
+ actionClass: 'btn List-buttonSubmit',
+ buttonContent: '+ ADD',
+ ngShow: '(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
+ }
+ },
+
+ 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',
+ },
+ team_roles: {
+ label: 'Team Roles',
+ type: 'team_roles',
+ noSort: true,
+ class: 'col-lg-5 col-md-5 col-sm-5 col-xs-4',
+ }
+ }
+ },
+ "notifications": {
+ include: "NotificationsList"
+ }
+ },
+
+ relatedButtons: {
+ add_survey: {
+ ngClick: 'addSurvey()',
+ ngShow: '!survey_exists',
+ awFeature: 'surveys',
+ awToolTip: i18n._('Please save before adding a survey'),
+ dataPlacement: 'top',
+ label: i18n._('Add Survey'),
+ class: 'Form-primaryButton'
+ },
+ edit_survey: {
+ ngClick: 'editSurvey()',
+ awFeature: 'surveys',
+ ngShow: 'survey_exists',
+ label: i18n._('Edit Survey'),
+ class: 'Form-primaryButton'
+ },
+ workflow_editor: {
+ ngClick: 'openWorkflowMaker()',
+ awToolTip: i18n._('Please save before defining the workflow graph'),
+ dataPlacement: 'top',
+ label: i18n._('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/helpers/JobDetail.js b/awx/ui/client/src/helpers/JobDetail.js
index b96c8c3d37..0cda454f2e 100644
--- a/awx/ui/client/src/helpers/JobDetail.js
+++ b/awx/ui/client/src/helpers/JobDetail.js
@@ -1039,7 +1039,6 @@ export default
//plays = JSON.parse(JSON.stringify(scope.jobData.plays)),
plays = scope.jobData.plays,
filteredListX = [],
- filteredListA = [],
filteredListB = [],
key,
keys;
@@ -1113,7 +1112,6 @@ export default
var scope = params.scope,
result = [],
filteredListX = [],
- filteredListA = [],
filteredListB = [],
idx, key, keys, newKeys, tasks, t;
@@ -1196,11 +1194,9 @@ export default
return function(params) {
var scope = params.scope,
result = [],
- filteredListA = [],
filteredListB = [],
idx = 0,
hostResults,
- key,
keys;
if (scope.activePlay && scope.activeTask && scope.jobData.plays[scope.activePlay] &&
@@ -1232,7 +1228,7 @@ export default
// else {
// filteredListB = filteredListA;
// }
-
+
keys = Object.keys(filteredListB);
keys.sort(function compare(a, b) {
if (filteredListB[a].name === filteredListB[b].name) {
diff --git a/awx/ui/client/src/helpers/Schedules.js b/awx/ui/client/src/helpers/Schedules.js
index 198d204975..b56d966e4f 100644
--- a/awx/ui/client/src/helpers/Schedules.js
+++ b/awx/ui/client/src/helpers/Schedules.js
@@ -381,7 +381,6 @@ export default
return function(params) {
var scope = params.scope,
id = params.id,
- callback = params.callback,
url = GetBasePath('schedules') + id +'/';
// Perform the update
diff --git a/awx/ui/client/src/helpers/teams.js b/awx/ui/client/src/helpers/teams.js
index 2ff67079ca..b89d2a1283 100644
--- a/awx/ui/client/src/helpers/teams.js
+++ b/awx/ui/client/src/helpers/teams.js
@@ -22,8 +22,7 @@ export default
return function (params) {
var scope = params.scope,
- set = params.set,
- iterator = params.iterator;
+ set = params.set;
// Listeners to perform lookups after main inventory list loads
@@ -74,66 +73,4 @@ export default
});
};
}
- ])
-
- .factory('TeamLookUpOrganizationInit', ['Alert', 'Rest', 'OrganizationList', 'generateList',
- function (Alert, Rest, OrganizationList, GenerateList) {
- return function (params) {
-
- var scope = params.scope;
-
- // Show pop-up to select organization
- scope.lookUpOrganization = function () {
- var list = OrganizationList,
- listGenerator = GenerateList,
- listScope = listGenerator.inject(list, { mode: 'lookup', hdr: 'Select Organization' }),
- defaultUrl = '/api/v1/organizations/';
-
- listScope.selectAction = function () {
- var i, found = false;
- for (i = 0; i < listScope[list.name].length; i++) {
- if (listScope[list.iterator + "_" + listScope[list.name][i].id + "_class"] === "success") {
- found = true;
- scope.organization = listScope[list.name][i].id;
- scope.organization_name = listScope[list.name][i].name;
- scope.team_form.$setDirty();
- listGenerator.hide();
- }
- }
- if (found === false) {
- Alert('No Selection', 'Click on a row to select an Organization before clicking the Select button.');
- }
- };
-
- listScope.toggle_organization = function (id) {
- // when user clicks a row, remove 'success' class from all rows except clicked-on row
- if (listScope[list.name]) {
- for (var i = 0; i < listScope[list.name].length; i++) {
- listScope[list.iterator + "_" + listScope[list.name][i].id + "_class"] = "";
- }
- }
- if (id !== null && id !== undefined) {
- listScope[list.iterator + "_" + id + "_class"] = "success";
- }
- };
-
- // @issue: OLD SEARCH
- // SearchInit({
- // scope: listScope,
- // set: list.name,
- // list: list,
- // url: defaultUrl
- // });
- // PaginateInit({
- // scope: listScope,
- // list: list,
- // url: defaultUrl,
- // mode: 'lookup'
- // });
- // scope.search(list.iterator);
-
- listScope.toggle_organization(scope.organization);
- };
- };
- }
]);
diff --git a/awx/ui/client/src/i18n.js b/awx/ui/client/src/i18n.js
index c5e5b782de..0b64a40222 100644
--- a/awx/ui/client/src/i18n.js
+++ b/awx/ui/client/src/i18n.js
@@ -1,3 +1,5 @@
+/* jshint ignore:start */
+
function isString(arg) {
return typeof arg === 'string';
}
@@ -83,7 +85,7 @@ export default
gettextCatalog.setCurrentLanguage(langInfo);
// TODO: the line below is commented out temporarily until
// the .po files are received from the i18n team, in order to avoid
- // 404 file not found console errors in dev
+ // 404 file not found console errors in dev
// gettextCatalog.loadRemote('/static/languages/' + langUrl + '.json');
};
}])
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/inventories/manage/groups/groups-add.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js
index 97da922561..031cf03f8a 100644
--- a/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js
+++ b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js
@@ -8,8 +8,8 @@ export default ['$state', '$stateParams', '$scope', 'GroupForm', 'CredentialList
'GroupManageService', 'GetChoices', 'GetBasePath', 'CreateSelect2', 'GetSourceTypeOptions', 'rbacUiControlService', 'ToJSON',
function($state, $stateParams, $scope, GroupForm, CredentialList, ParseTypeChange, GenerateForm, inventoryData,
GroupManageService, GetChoices, GetBasePath, CreateSelect2, GetSourceTypeOptions, rbacUiControlService, ToJSON) {
- var generator = GenerateForm,
- form = GroupForm();
+
+ let form = GroupForm();
init();
function init() {
diff --git a/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js
index 1c9559e76e..7385e2f09b 100644
--- a/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js
+++ b/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js
@@ -6,10 +6,10 @@
export default
['$scope', '$rootScope', '$state', '$stateParams', 'InventoryGroups', 'generateList', 'InventoryUpdate',
'GroupManageService', 'GroupsCancelUpdate', 'ViewUpdateStatus', 'rbacUiControlService', 'GetBasePath',
- 'InventoryManageService', 'groupsUrl', 'GetSyncStatusMsg', 'GetHostsStatusMsg', 'groupsDataset',
+ 'InventoryManageService', 'groupsUrl', 'GetSyncStatusMsg', 'GetHostsStatusMsg', 'groupsDataset', 'Find',
function($scope, $rootScope, $state, $stateParams, InventoryGroups, generateList, InventoryUpdate,
GroupManageService, GroupsCancelUpdate, ViewUpdateStatus, rbacUiControlService, GetBasePath,
- InventoryManageService, groupsUrl, GetSyncStatusMsg, GetHostsStatusMsg, groupsDataset){
+ InventoryManageService, groupsUrl, GetSyncStatusMsg, GetHostsStatusMsg, groupsDataset, Find){
let list = InventoryGroups;
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-factories/launchjob.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js
index 6c3d861afa..4005e8164e 100644
--- a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js
+++ b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js
@@ -112,7 +112,7 @@ export default
Rest.post(job_launch_data)
.success(function(data) {
Wait('stop');
- var job = data.job || data.system_job || data.project_update || data.inventory_update || data.ad_hoc_command;
+ var job = data.job || data.system_job || data.project_update || data.inventory_update || data.ad_hoc_command || data.workflow_job;
if((scope.portalMode===false || scope.$parent.portalMode===false ) && Empty(data.system_job) || (base === 'home')){
// use $state.go with reload: true option to re-instantiate sockets in
@@ -123,6 +123,9 @@ export default
if(_.has(data, 'job')) {
goToJobDetails('jobDetail');
}
+ else if(_.has(data, 'workflow_job')) {
+ goToJobDetails('workflowResults');
+ }
else if(_.has(data, 'ad_hoc_command')) {
goToJobDetails('adHocJobStdout');
}
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..1313fc0051 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', {job_template_id: id}, {reload: true});
}
if ($scope.removeTemplateSaveSuccess) {
@@ -426,7 +426,7 @@
try {
for (fld in form.fields) {
if (form.fields[fld].type === 'select' &&
- fld !== 'playbook') {
+ fld !== 'playbook' && $scope[fld]) {
data[fld] = $scope[fld].value;
}
else if(form.fields[fld].type === 'checkbox_group') {
@@ -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..fd51a4adc1
--- /dev/null
+++ b/awx/ui/client/src/job-templates/add-workflow/workflow-add.controller.js
@@ -0,0 +1,191 @@
+/*************************************************
+ * 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..c82f412bc5 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,11 @@
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});
+ }
+ // Workflow edit to be implemented post 3.1 but we'll need to handle the
+ // state transition for that here
});
})
.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 99%
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
index 95d7a8272a..0ce5645a3d 100644
--- 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
@@ -31,7 +31,7 @@ export default
$scope.$watch('job_template_obj.summary_fields.user_capabilities.edit', function(val) {
if (val === false) {
- $scope.canAdd = false;
+ $scope.canAddJobTemplate = false;
}
});
@@ -187,7 +187,6 @@ export default
Rest.setUrl(GetBasePath('projects') + $scope.project + '/');
Rest.get()
.success(function (data) {
- console.log(data)
var msg;
switch (data.status) {
case 'failed':
@@ -252,7 +251,7 @@ export default
if ($scope.cloudCredentialReadyRemove) {
$scope.cloudCredentialReadyRemove();
}
- $scope.cloudCredentialReadyRemove = $scope.$on('cloudCredentialReady', function (e, name) {
+ $scope.cloudCredentialReadyRemove = $scope.$on('cloudCredentialReady', function () {
$scope.$emit('jobTemplateLoadFinished');
});
@@ -261,7 +260,7 @@ export default
if ($scope.jobTemplateLoadedRemove) {
$scope.jobTemplateLoadedRemove();
}
- $scope.jobTemplateLoadedRemove = $scope.$on('jobTemplateLoaded', function (e, related_cloud_credential, masterObject, relatedSets) {
+ $scope.jobTemplateLoadedRemove = $scope.$on('jobTemplateLoaded', function (e, related_cloud_credential, masterObject) {
var dft;
master = masterObject;
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..c666f44815
--- /dev/null
+++ b/awx/ui/client/src/job-templates/edit-workflow/workflow-edit.controller.js
@@ -0,0 +1,737 @@
+/*************************************************
+ * 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
+ ) {window.state = $state;
+
+ ClearScope();
+
+ $scope.$watch('workflow_job_template_obj.summary_fields.user_capabilities.edit', function(val) {
+ if (val === false) {
+ $scope.canAddWorkflowJobTemplate = 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() {
+
+ // Select2-ify the lables input
+ CreateSelect2({
+ element:'#workflow_job_template_labels',
+ multiple: true,
+ addNew: true
+ });
+
+ 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() {
+ $state.go('.workflowMaker');
+ };
+
+ $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..ab807971de 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,17 @@
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',
+ 'QuerySet',
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,
+ qs
) {
ClearScope();
- var list = JobTemplateList,
- defaultUrl = GetBasePath('job_templates');
+ var list = JobTemplateList;
init();
@@ -23,8 +24,13 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', 'Rest', 'Al
$scope.canAdd = false;
rbacUiControlService.canAdd("job_templates")
- .then(function(canAdd) {
- $scope.canAdd = canAdd;
+ .then(function(canAddJobTemplate) {
+ $scope.canAddJobTemplate = canAddJobTemplate;
+ });
+
+ rbacUiControlService.canAdd("workflow_job_templates")
+ .then(function(canAddWorkflowJobTemplate) {
+ $scope.canAddWorkflowJobTemplate = canAddWorkflowJobTemplate;
});
// search init
$scope.list = list;
@@ -35,50 +41,128 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', 'Rest', 'Al
}
$scope.$on(`ws-jobs`, function () {
- // @issue - this is ham-fisted, expose a simple QuerySet.reload() fn that'll re-fetch dataset
- $state.reload();
+ // @issue - this is no longer quite as ham-fisted but I'd like for someone else to take a peek
+ // calling $state.reload(); here was problematic when launching a job because job launch also
+ // attempts to transition the state and they were squashing each other.
+
+ let path = GetBasePath(list.basePath) || GetBasePath(list.name);
+ qs.search(path, $stateParams[`${list.iterator}_search`])
+ .then(function(searchResponse) {
+ $scope[`${list.iterator}_dataset`] = searchResponse.data;
+ $scope[list.name] = $scope[`${list.iterator}_dataset`].results;
+ });
});
$scope.addJobTemplate = function() {
$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..3e64e8cd1a
--- /dev/null
+++ b/awx/ui/client/src/job-templates/list/job-templates-list.route.js
@@ -0,0 +1,49 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default {
+ name: 'templates',
+ route: '/templates',
+ ncyBreadcrumb: {
+ label: "TEMPLATES"
+ },
+ data: {
+ socket: {
+ "groups": {
+ "jobs": ["status_changed"]
+ }
+ }
+ },
+ params: {
+ template_search: {
+ value: {
+ type: 'workflow_job_template,job_template'
+ }
+ }
+ },
+ searchPrefix: 'template',
+ 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..ad80b12967 100644
--- a/awx/ui/client/src/job-templates/main.js
+++ b/awx/ui/client/src/job-templates/main.js
@@ -4,47 +4,490 @@
* 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,
+ workflowMaker, inventoryLookup, credentialLookup,
+ 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'
- },
+ 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'
+ }
+ });
+
+ workflowMaker = {
+ name: 'templates.editWorkflowJobTemplate.workflowMaker',
+ url: '/workflow-maker',
+ // ncyBreadcrumb: {
+ // label: 'WORKFLOW MAKER'
+ // },
data: {
- activityStream: true,
- activityStreamTarget: 'job_template',
- socket: {
- "groups": {
- "jobs": ["status_changed"]
+ formChildState: true
+ },
+ params: {
+ job_template_search: {
+ value: {
+ page_size: '5',
+ type: 'job_template'
+ },
+ squash: true,
+ dynamic: true
+ },
+ project_search: {
+ value: {
+ page_size: '5'
+ },
+ squash: true,
+ dynamic: true
+ },
+ inventory_source_search: {
+ value: {
+ page_size: '5'
+ },
+ squash: true,
+ dynamic: true
+ }
+ },
+ views: {
+ 'modal': {
+ template: ` `
+ },
+ 'jobTemplateList@templates.editWorkflowJobTemplate.workflowMaker': {
+ templateProvider: function(WorkflowMakerJobTemplateList, generateList) {
+ //debugger;
+ let html = generateList.build({
+ list: WorkflowMakerJobTemplateList,
+ input_type: 'radio',
+ mode: 'lookup'
+ });
+ return html;
+ },
+ // $scope encapsulated in this controller will be a initialized as child of 'modal' $scope, because of element hierarchy
+ controller: ['$scope', 'WorkflowMakerJobTemplateList', 'JobTemplateDataset',
+ function($scope, list, Dataset) {
+
+ init();
+
+ function init() {
+ $scope.list = list;
+ $scope[`${list.iterator}_dataset`] = Dataset.data;
+ $scope[list.name] = $scope[`${list.iterator}_dataset`].results;
+ }
+
+ $scope.toggle_job_template = function(id) {
+
+ $scope.job_templates.forEach(function(row, i) {
+ if (row.id === id) {
+ $scope.job_templates[i].checked = 1;
+ $scope.selection[list.iterator] = {
+ id: row.id,
+ name: row.name
+ };
+
+ $scope.templateSelected(row);
+ }
+ });
+
+ };
+
+ $scope.$on('templateSelected', function(e, options) {
+ if(options.activeTab !== 'jobs') {
+ // Clear out any selected job
+ }
+ });
+ }
+ ]
+ },
+ 'inventorySyncList@templates.editWorkflowJobTemplate.workflowMaker': {
+ templateProvider: function(InventorySourcesList, generateList) {
+ let list = _.cloneDeep(InventorySourcesList);
+ // mutate list definition here!
+ let html = generateList.build({
+ list: list,
+ input_type: 'radio',
+ mode: 'lookup'
+ });
+ return html;
+ },
+ // encapsulated $scope in this controller will be a initialized as child of 'modal' $scope, because of element hierarchy
+ controller: ['$scope', 'InventorySourcesList', 'InventorySourcesDataset',
+ function($scope, list, Dataset) {
+
+ init();
+
+ function init() {
+ $scope.list = list;
+ $scope[`${list.iterator}_dataset`] = Dataset.data;
+ $scope[list.name] = $scope[`${list.iterator}_dataset`].results;
+
+ }
+
+ $scope.toggle_inventory_source = function(id) {
+
+ $scope.workflow_inventory_sources.forEach(function(row, i) {
+ if (row.id === id) {
+ $scope.workflow_inventory_sources[i].checked = 1;
+ $scope.selection[list.iterator] = {
+ id: row.id,
+ name: row.name
+ };
+
+ $scope.templateSelected(row);
+ }
+ });
+
+ };
+
+ $scope.$on('templateSelected', function(e, options) {
+ if(options.activeTab !== 'project_sync') {
+
+ }
+ });
+ }
+ ]
+ },
+ 'projectSyncList@templates.editWorkflowJobTemplate.workflowMaker': {
+ templateProvider: function(WorkflowProjectList, generateList) {
+ let html = generateList.build({
+ list: WorkflowProjectList,
+ input_type: 'radio',
+ mode: 'lookup'
+ });
+ return html;
+ },
+ // encapsulated $scope in this controller will be a initialized as child of 'modal' $scope, because of element hierarchy
+ controller: ['$scope', 'WorkflowProjectList', 'ProjectDataset',
+ function($scope, list, Dataset) {
+
+ init();
+
+ function init() {
+ $scope.list = list;
+ $scope[`${list.iterator}_dataset`] = Dataset.data;
+ $scope[list.name] = $scope[`${list.iterator}_dataset`].results;
+
+ }
+
+ $scope.toggle_project = function(id) {
+
+ $scope.projects.forEach(function(row, i) {
+ if (row.id === id) {
+ $scope.projects[i].checked = 1;
+ $scope.selection[list.iterator] = {
+ id: row.id,
+ name: row.name
+ };
+
+ $scope.templateSelected(row);
+ }
+ });
+
+ };
+
+ $scope.$on('templateSelected', function(e, options) {
+ if(options.activeTab !== 'inventory_sync') {
+
+ }
+ });
+ }
+ ]
+ },
+ 'workflowForm@templates.editWorkflowJobTemplate.workflowMaker': {
+ templateProvider: function(WorkflowMakerForm, GenerateForm) {
+ let form = WorkflowMakerForm();
+ let html = GenerateForm.buildHTML(form, {
+ mode: 'add',
+ related: false,
+ noPanel: true
+ });
+ return html;
+ },
+ controller: ['$scope', '$timeout', 'CreateSelect2',
+ function($scope, $timeout, CreateSelect2) {
+ 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;
+ }
+
+ $scope.saveNodeForm = function(){
+ // Gather up all of our form data - then let the main scope know what
+ // the new data is
+
+ $scope.confirmNodeForm({
+ skip_tags: $scope.skip_tags,
+ job_tags: $scope.job_tags,
+ limit: $scope.limit,
+ credential: $scope.credential,
+ credential_name: $scope.credential_name,
+ inventory: $scope.inventory,
+ inventory_name: $scope.inventory_name,
+ edgeType: $scope.edgeType,
+ job_type: $scope.job_type
+ });
+ };
+
+ $scope.$on('templateSelected', function(e, options) {
+ resetPromptFields();
+ // Loop across the preset values and attach them to scope
+ _.forOwn(options.presetValues, function(value, key) {
+ $scope[key] = value;
+ });
+
+ // The default needs to be in place before we can select2-ify the dropdown
+ $timeout(function() {
+ CreateSelect2({
+ element: '#workflow_maker_job_type',
+ multiple: false
+ });
+ });
+ });
+
+ $scope.$on('setEdgeType', function(e, edgeType) {
+ $scope.edgeType = edgeType;
+ });
+ }
+ ]
+ }
+ },
+ resolve: {
+ JobTemplateDataset: ['WorkflowMakerJobTemplateList', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => {
+ let path = GetBasePath(list.basePath);
+ return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ }
+ ],
+ ProjectDataset: ['ProjectList', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => {
+ let path = GetBasePath(list.basePath);
+ return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ }
+ ],
+ InventorySourcesDataset: ['InventorySourcesList', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => {
+ let path = GetBasePath(list.basePath);
+ return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ }
+ ],
+ WorkflowMakerJobTemplateList: ['JobTemplateList',
+ (JobTemplateList) => {
+ let list = _.cloneDeep(JobTemplateList);
+ delete list.fields.type;
+ delete list.fields.description;
+ delete list.fields.smart_status;
+ delete list.fields.labels;
+ delete list.fieldActions;
+ list.fields.name.columnClass = "col-md-11";
+ list.iterator = 'job_template';
+ list.name = 'job_templates';
+
+ return list;
+ }
+ ],
+ WorkflowProjectList: ['ProjectList',
+ (ProjectList) => {
+ let list = _.cloneDeep(ProjectList);
+ delete list.fields.status;
+ delete list.fields.scm_type;
+ delete list.fields.last_updated;
+ list.fields.name.columnClass = "col-md-11";
+
+ return list;
+ }
+ ]
+ }
+ };
+
+ inventoryLookup = {
+ searchPrefix: 'inventory',
+ name: 'templates.editWorkflowJobTemplate.workflowMaker.inventory',
+ url: '/inventory',
+ data: {
+ formChildState: true
+ },
+ params: {
+ inventory_search: {
+ value: {
+ page_size: '5'
+ },
+ squash: true,
+ dynamic: true
+ }
+ },
+ views: {
+ 'related': {
+ templateProvider: function(ListDefinition, generateList) {
+ let list_html = generateList.build({
+ mode: 'lookup',
+ list: ListDefinition,
+ input_type: 'radio'
+ });
+ return `${list_html} `;
+
}
}
},
- })
- });
+ resolve: {
+ ListDefinition: ['InventoryList', function(list) {
+ // mutate the provided list definition here
+ return list;
+ }],
+ Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => {
+ let path = GetBasePath(list.name) || GetBasePath(list.basePath);
+ return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ }
+ ]
+ },
+ onExit: function($state) {
+ if ($state.transition) {
+ $('#form-modal').modal('hide');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open');
+ }
+ },
+ };
+
+ credentialLookup = {
+ searchPrefix: 'credential',
+ name: 'templates.editWorkflowJobTemplate.workflowMaker.credential',
+ url: '/credential',
+ data: {
+ formChildState: true
+ },
+ params: {
+ credential_search: {
+ value: {
+ page_size: '5'
+ },
+ squash: true,
+ dynamic: true
+ }
+ },
+ views: {
+ 'related': {
+ templateProvider: function(ListDefinition, generateList) {
+ let list_html = generateList.build({
+ mode: 'lookup',
+ list: ListDefinition,
+ input_type: 'radio'
+ });
+ return `${list_html} `;
+
+ }
+ }
+ },
+ resolve: {
+ ListDefinition: ['CredentialList', function(list) {
+ // mutate the provided list definition here
+ return list;
+ }],
+ Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => {
+ let path = GetBasePath(list.name) || GetBasePath(list.basePath);
+ return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ }
+ ]
+ },
+ onExit: function($state) {
+ if ($state.transition) {
+ $('#form-modal').modal('hide');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open');
+ }
+ },
+ };
+
+
+ return Promise.all([
+ addJobTemplate,
+ editJobTemplate,
+ addWorkflow,
+ editWorkflow
+ ]).then((generated) => {
+ return {
+ states: _.reduce(generated, (result, definition) => {
+ return result.concat(definition.states);
+ }, [
+ stateExtender.buildDefinition(jobTemplatesListRoute),
+ stateExtender.buildDefinition(workflowMaker),
+ stateExtender.buildDefinition(inventoryLookup),
+ stateExtender.buildDefinition(credentialLookup)
+ ])
+ };
+ });
+ }
+
+ 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..9afc170a01
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.block.less
@@ -0,0 +1,69 @@
+@import "./client/src/shared/branding/colors.default.less";
+
+.nodeConnector circle, .nodeConnector .linkCross, .node .addCircle, .node .removeCircle, .node .WorkflowChart-hoverPath {
+ opacity: 0;
+}
+
+.node .addCircle, .nodeConnector .addCircle {
+ fill: @default-succ;
+}
+
+.addCircle.addHovering {
+ fill: @default-succ-hov;
+}
+
+.node .removeCircle {
+ fill: @default-err;
+}
+
+.removeCircle.removeHovering {
+ fill: @default-err-hov;
+}
+
+.node .WorkflowChart-defaultText {
+ font-size: 12px;
+ font-family: 'Open Sans', sans-serif;
+ fill: @default-interface-txt;
+}
+
+.node .rect {
+ fill: @default-secondary-bg;
+}
+
+.rect.placeholder {
+ stroke-dasharray: 3;
+}
+
+.node .transparentRect {
+ fill: @default-bg;
+ 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: @default-no-items-bord;
+}
+.WorkflowChart-nodeTypeCircle {
+ fill: @default-icon;
+}
+.WorkflowChart-nodeTypeLetter {
+ fill: @default-bg;
+}
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..f989bbc2a0
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-chart/workflow-chart.directive.js
@@ -0,0 +1,467 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default [
+ function() {
+
+ return {
+ scope: {
+ treeData: '=',
+ canAddWorkflowJobTemplate: '=',
+ addNode: '&',
+ editNode: '&',
+ deleteNode: '&'
+ },
+ restrict: 'E',
+ link: function(scope, element) {
+
+ scope.$watch('canAddWorkflowJobTemplate', function() {
+ // Redraw the graph if permissions change
+ update();
+ });
+
+ let 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;
+
+ let tree = d3.layout.tree()
+ .size([height, width]);
+
+ let line = d3.svg.line()
+ .x(function(d){return d.x;})
+ .y(function(d){return d.y;});
+
+ function lineData(d){
+
+ let sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + rectW;
+ let sourceY = d.source.isStartNode ? d.source.x + 10 + rootH / 2 : d.source.x + rectH / 2;
+ let targetX = d.target.y;
+ let targetY = d.target.x + rectH / 2;
+
+ let 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;
+ }
+ }
+
+ let 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 + ")");
+
+ let node = svg.selectAll(".node"),
+ link = svg.selectAll(".link");
+
+ function update() {
+ // Declare the nodes
+ let 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; });
+
+ let 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) {
+ let 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 || scope.canAddWorkflowJobTemplate === false ? "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 || scope.canAddWorkflowJobTemplate === false ? "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 || scope.canAddWorkflowJobTemplate === false) ? "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 || scope.canAddWorkflowJobTemplate === false) ? "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();
+
+ let 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 || scope.canAddWorkflowJobTemplate === false) ? "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 || scope.canAddWorkflowJobTemplate === false) ? "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.
+ let t = svg.transition();
+
+ t.selectAll(".nodeCircle")
+ .style("display", function(d) { return d.placeholder || scope.canAddWorkflowJobTemplate === false ? "none" : null; });
+
+ t.selectAll(".nodeAddCross")
+ .style("display", function(d) { return d.placeholder || scope.canAddWorkflowJobTemplate === false ? "none" : null; });
+
+ t.selectAll(".removeCircle")
+ .style("display", function(d) { return (d.canDelete === false || d.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; });
+
+ t.selectAll(".nodeRemoveCross")
+ .style("display", function(d) { return (d.canDelete === false || d.placeholder || scope.canAddWorkflowJobTemplate === false) ? "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) {
+ if(scope.canAddWorkflowJobTemplate !== false) {
+ scope.addNode({
+ parent: d,
+ betweenTwoNodes: false
+ });
+ }
+ });
+ }
+
+ function add_node_between() {
+ this.on("click", function(d) {
+ if(scope.canAddWorkflowJobTemplate !== false) {
+ scope.addNode({
+ parent: d,
+ betweenTwoNodes: true
+ });
+ }
+ });
+ }
+
+ function remove_node() {
+ this.on("click", function(d) {
+ if(scope.canAddWorkflowJobTemplate !== false) {
+ 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..25b889e4fd
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-help.service.js
@@ -0,0 +1,127 @@
+export default ['CreateDialog', 'Wait', '$q', '$state', function(CreateDialog, Wait, $q, $state){
+ return {
+ closeDialog: function() {
+ $('#workflow-modal-dialog').dialog('destroy');
+
+ $state.go('^');
+ },
+ 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..01cc4441c2
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.controller.js
@@ -0,0 +1,520 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+export default ['$scope', 'WorkflowHelpService', 'generateList', 'JobTemplateList', 'ProjectList',
+ 'GetBasePath', 'Wait', 'JobTemplateService', '$state',
+ 'ProcessErrors', 'InventorySourcesList', 'CreateSelect2', 'WorkflowMakerForm',
+ 'GenerateForm', 'InventoryList', 'CredentialList', '$q',
+ function($scope, WorkflowHelpService, GenerateList, JobTemplateList, ProjectList,
+ GetBasePath, Wait, JobTemplateService, $state,
+ ProcessErrors, InventorySourcesList, CreateSelect2, WorkflowMakerForm,
+ GenerateForm, InventoryList, CredentialList, $q) {
+
+ let form = WorkflowMakerForm();
+
+ $scope.workflowMakerFormConfig = {
+ nodeMode: "idle",
+ activeTab: "jobs",
+ formIsValid: false
+ };
+
+ $scope.job_type_options = [{
+ label: "Run",
+ value: "run"
+ }, {
+ label: "Check",
+ value: "check"
+ }];
+
+ function init() {
+ $scope.treeDataMaster = angular.copy($scope.treeData.data);
+ $scope.$broadcast("refreshWorkflowChart");
+ }
+
+ 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.edgeTypeRestriction = null;
+ $scope.workflowMakerFormConfig.activeTab = "jobs";
+ }
+
+ $scope.lookUpInventory = function(){
+ $state.go('.inventory');
+ };
+
+ $scope.lookUpCredential = function(){
+ $state.go('.credential');
+ };
+
+ $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++;
+
+ let siblingConnectionTypes = WorkflowHelpService.getSiblingConnectionTypes({
+ tree: $scope.treeData.data,
+ parentId: betweenTwoNodes ? parent.source.id : parent.id
+ });
+
+ // Set the default to success
+ let edgeType = "success";
+
+ 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
+ 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";
+ edgeType = "success";
+ } else if (_.includes(siblingConnectionTypes, "always")) {
+ $scope.edgeTypeRestriction = "always";
+ edgeType = "always";
+ }
+
+ $scope.showTypeOptions = true;
+ }
+
+ $scope.$broadcast("setEdgeType", edgeType);
+ $scope.$broadcast("refreshWorkflowChart");
+
+ };
+
+ $scope.confirmNodeForm = function(formValues) {
+ if ($scope.workflowMakerFormConfig.nodeMode === "add") {
+ if ($scope.selectedTemplate && formValues.edgeType) {
+
+ $scope.placeholderNode.unifiedJobTemplate = $scope.selectedTemplate;
+ $scope.placeholderNode.edgeType = formValues.edgeType;
+ if ($scope.placeholderNode.unifiedJobTemplate.type === 'job_template') {
+ $scope.placeholderNode.promptValues = {
+ credential: {
+ id: formValues.credential,
+ name: formValues.credential_name
+ },
+ inventory: {
+ id: formValues.inventory,
+ name: formValues.inventory_name
+ },
+ limit: formValues.limit,
+ job_type: formValues.job_type && formValues.job_type.value ? formValues.job_type.value : null,
+ job_tags: formValues.job_tags,
+ skip_tags: formValues.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 && formValues.edgeType) {
+ $scope.nodeBeingEdited.unifiedJobTemplate = $scope.selectedTemplate;
+ $scope.nodeBeingEdited.edgeType = formValues.edgeType;
+
+ if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template') {
+ $scope.nodeBeingEdited.promptValues = {
+ credential: {
+ id: formValues.credential,
+ name: formValues.credential_name
+ },
+ inventory: {
+ id: formValues.inventory,
+ name: formValues.inventory_name
+ },
+ limit: formValues.limit,
+ job_type: formValues.job_type && formValues.job_type.value ? formValues.job_type.value : null,
+ job_tags: formValues.job_tags,
+ skip_tags: formValues.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() {
+
+ let formValues = {};
+
+ // build any prompt values
+ if ($scope.nodeBeingEdited.unifiedJobTemplate.ask_credential_on_launch) {
+ if ($scope.nodeBeingEdited.promptValues && $scope.nodeBeingEdited.promptValues.credential) {
+ formValues.credential_name = $scope.nodeBeingEdited.promptValues.credential.name;
+ formValues.credential = $scope.nodeBeingEdited.promptValues.credential.id;
+ } else if ($scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential) {
+ formValues.credential_name = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.name ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.name : null;
+ formValues.credential = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.id ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.credential.id : null;
+ } else {
+ formValues.credential_name = null;
+ formValues.credential = null;
+ }
+ }
+
+ if ($scope.nodeBeingEdited.unifiedJobTemplate.ask_inventory_on_launch) {
+ if ($scope.nodeBeingEdited.promptValues && $scope.nodeBeingEdited.promptValues.inventory) {
+ formValues.inventory_name = $scope.nodeBeingEdited.promptValues.inventory.name;
+ formValues.inventory = $scope.nodeBeingEdited.promptValues.inventory.id;
+ } else if ($scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory) {
+ formValues.inventory_name = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.name ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.name : null;
+ formValues.inventory = $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.id ? $scope.nodeBeingEdited.unifiedJobTemplate.summary_fields.inventory.id : null;
+ } else {
+ formValues.inventory_name = null;
+ formValues.inventory = null;
+ }
+ }
+
+ if ($scope.nodeBeingEdited.unifiedJobTemplate.ask_job_type_on_launch) {
+ if ($scope.nodeBeingEdited.promptValues && $scope.nodeBeingEdited.promptValues.job_type) {
+ formValues.job_type = {
+ value: $scope.nodeBeingEdited.promptValues.job_type
+ };
+ } else if ($scope.nodeBeingEdited.originalNodeObj.job_type) {
+ formValues.job_type = {
+ value: $scope.nodeBeingEdited.originalNodeObj.job_type
+ };
+ } else if ($scope.nodeBeingEdited.unifiedJobTemplate.job_type) {
+ formValues.job_type = {
+ value: $scope.nodeBeingEdited.unifiedJobTemplate.job_type
+ };
+ } else {
+ formValues.job_type = {
+ value: null
+ };
+ }
+
+ }
+
+ if ($scope.nodeBeingEdited.unifiedJobTemplate.ask_limit_on_launch) {
+ if ($scope.nodeBeingEdited.promptValues && typeof $scope.nodeBeingEdited.promptValues.limit === 'string') {
+ formValues.limit = $scope.nodeBeingEdited.promptValues.limit;
+ } else if (typeof $scope.nodeBeingEdited.originalNodeObj.limit === 'string') {
+ formValues.limit = $scope.nodeBeingEdited.originalNodeObj.limit;
+ } else if (typeof $scope.nodeBeingEdited.unifiedJobTemplate.limit === 'string') {
+ formValues.limit = $scope.nodeBeingEdited.unifiedJobTemplate.limit;
+ } else {
+ formValues.limit = null;
+ }
+ }
+ if ($scope.nodeBeingEdited.unifiedJobTemplate.ask_skip_tags_on_launch) {
+ if ($scope.nodeBeingEdited.promptValues && typeof $scope.nodeBeingEdited.promptValues.skip_tags === 'string') {
+ formValues.skip_tags = $scope.nodeBeingEdited.promptValues.skip_tags;
+ } else if (typeof $scope.nodeBeingEdited.originalNodeObj.skip_tags === 'string') {
+ formValues.skip_tags = $scope.nodeBeingEdited.originalNodeObj.skip_tags;
+ } else if (typeof $scope.nodeBeingEdited.unifiedJobTemplate.skip_tags === 'string') {
+ formValues.skip_tags = $scope.nodeBeingEdited.unifiedJobTemplate.skip_tags;
+ } else {
+ formValues.skip_tags = null;
+ }
+ }
+ if ($scope.nodeBeingEdited.unifiedJobTemplate.ask_tags_on_launch) {
+ if ($scope.nodeBeingEdited.promptValues && typeof $scope.nodeBeingEdited.promptValues.job_tags === 'string') {
+ formValues.job_tags = $scope.nodeBeingEdited.promptValues.job_tags;
+ } else if (typeof $scope.nodeBeingEdited.originalNodeObj.job_tags === 'string') {
+ formValues.job_tags = $scope.nodeBeingEdited.originalNodeObj.job_tags;
+ } else if (typeof $scope.nodeBeingEdited.unifiedJobTemplate.job_tags === 'string') {
+ formValues.job_tags = $scope.nodeBeingEdited.unifiedJobTemplate.job_tags;
+ } else {
+ formValues.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;
+ }
+
+ //formValues.edgeType = $scope.nodeBeingEdited.edgeType;
+ $scope.showTypeOptions = (parent && parent.isStartNode) ? false : true;
+
+ $scope.$broadcast('setEdgeType', $scope.nodeBeingEdited.edgeType);
+
+ $scope.$broadcast('templateSelected', {
+ presetValues: formValues,
+ activeTab: $scope.workflowMakerFormConfig.activeTab
+ });
+
+ $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.nodeBeingEdited.promptValues.credential = {
+ name: responses[0].data.name,
+ id: responses[0].data.id
+ };
+
+ if (retrievingInventory) {
+ $scope.nodeBeingEdited.promptValues.inventory = {
+ name: responses[1].data.name,
+ id: responses[1].data.id
+ };
+ }
+ } else if (retrievingInventory) {
+ $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.templateSelected = function(selectedTemplate) {
+
+ $scope.selectedTemplate = angular.copy(selectedTemplate);
+
+ let formValues = {};
+
+ if ($scope.selectedTemplate.ask_credential_on_launch) {
+ if ($scope.selectedTemplate.summary_fields.credential) {
+ formValues.credential_name = $scope.selectedTemplate.summary_fields.credential.name ? $scope.selectedTemplate.summary_fields.credential.name : null;
+ formValues.credential = $scope.selectedTemplate.summary_fields.credential.id ? $scope.selectedTemplate.summary_fields.credential.id : null;
+ } else {
+ formValues.credential_name = null;
+ formValues.credential = null;
+ }
+ }
+
+ if ($scope.selectedTemplate.ask_inventory_on_launch) {
+ if ($scope.selectedTemplate.summary_fields.inventory) {
+ formValues.inventory_name = $scope.selectedTemplate.summary_fields.inventory.name ? $scope.selectedTemplate.summary_fields.inventory.name : null;
+ formValues.inventory = $scope.selectedTemplate.summary_fields.inventory.id ? $scope.selectedTemplate.summary_fields.inventory.id : null;
+ } else {
+ formValues.inventory_name = null;
+ formValues.inventory = null;
+ }
+ }
+
+ if ($scope.selectedTemplate.ask_job_type_on_launch) {
+ formValues.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) {
+ formValues.limit = $scope.selectedTemplate.limit ? $scope.selectedTemplate.limit : null;
+ }
+
+ if ($scope.selectedTemplate.ask_skip_tags_on_launch) {
+ formValues.skip_tags = $scope.selectedTemplate.skip_tags ? $scope.selectedTemplate.skip_tags : null;
+ }
+
+ if ($scope.selectedTemplate.ask_tags_on_launch) {
+ formValues.job_tags = $scope.selectedTemplate.job_tags ? $scope.selectedTemplate.job_tags : null;
+ }
+
+ // Communicate down the scope chain to our children that a template has been selected. This
+ // will handle populating the form properly as well as clearing out any previously selected
+ // templates in different lists
+ $scope.$broadcast('templateSelected', {
+ presetValues: formValues,
+ activeTab: $scope.workflowMakerFormConfig.activeTab
+ });
+ };
+
+ 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..95da492a14
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.directive.js
@@ -0,0 +1,55 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import workflowMakerController from './workflow-maker.controller';
+
+export default ['templateUrl', 'CreateDialog', 'Wait',
+ function(templateUrl, CreateDialog, Wait) {
+ return {
+ scope: {
+ treeData: '=',
+ canAddWorkflowJobTemplate: '='
+ },
+ restrict: 'E',
+ templateUrl: templateUrl('job-templates/workflow-maker/workflow-maker'),
+ controller: workflowMakerController,
+ link: function(scope) {
+ CreateDialog({
+ id: 'workflow-modal-dialog',
+ scope: 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'
+ });
+ if (scope.removeWorkflowDialogReady) {
+ scope.removeWorkflowDialogReady();
+ }
+ scope.removeWorkflowDialogReady = scope.$on('WorkflowDialogReady', function() {
+ $('#workflow-modal-dialog').dialog('open');
+
+ scope.$broadcast("refreshWorkflowChart");
+ });
+ }
+ };
+ }
+];
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..77cdd18d9c
--- /dev/null
+++ b/awx/ui/client/src/job-templates/workflow-maker/workflow-maker.partial.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
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/license/license.controller.js b/awx/ui/client/src/license/license.controller.js
index 8b0cae9269..682f1cfb01 100644
--- a/awx/ui/client/src/license/license.controller.js
+++ b/awx/ui/client/src/license/license.controller.js
@@ -13,6 +13,48 @@ export default
function( Wait, $state, $scope, $rootScope, $location, GetBasePath, Rest,
ProcessErrors, CheckLicense, moment, $window, ConfigService,
FeaturesService, pendoService, i18n){
+
+ var calcDaysRemaining = function(seconds){
+ // calculate the number of days remaining on the license
+ var duration = moment.duration(seconds, 'seconds').asDays();
+ duration = Math.floor(duration);
+ if(duration < 0 ){
+ duration = 0;
+ }
+ duration = (duration!==1) ? `${duration} Days` : `${duration} Day`;
+ return duration;
+ };
+
+
+ var calcExpiresOn = function(days){
+ // calculate the expiration date of the license
+ days = parseInt(days);
+ return moment().add(days, 'days').calendar();
+ };
+
+ var reset = function(){
+ document.getElementById('License-form').reset();
+ };
+
+ var init = function(){
+ // license/license.partial.html compares fileName
+ $scope.fileName = N_("No file selected.");
+ $scope.title = $rootScope.licenseMissing ? ("Tower " + i18n._("License")) : i18n._("License Management");
+ Wait('start');
+ ConfigService.getConfig().then(function(config){
+ $scope.license = config;
+ $scope.license.version = config.version.split('-')[0];
+ $scope.time = {};
+ $scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining);
+ $scope.time.expiresOn = calcExpiresOn($scope.time.remaining);
+ $scope.valid = CheckLicense.valid($scope.license.license_info);
+ $scope.compliant = $scope.license.license_info.compliant;
+ Wait('stop');
+ });
+ };
+
+ init();
+
$scope.getKey = function(event){
// Mimic HTML5 spec, show filename
$scope.fileName = event.target.files[0].name;
@@ -73,43 +115,5 @@ export default
});
});
};
- var calcDaysRemaining = function(seconds){
- // calculate the number of days remaining on the license
- var duration = moment.duration(seconds, 'seconds').asDays();
- duration = Math.floor(duration);
- if(duration < 0 ){
- duration = 0;
- }
- duration = (duration!==1) ? `${duration} Days` : `${duration} Day`;
- return duration;
- };
-
-
- var calcExpiresOn = function(days){
- // calculate the expiration date of the license
- days = parseInt(days);
- return moment().add(days, 'days').calendar();
- };
-
- var init = function(){
- // license/license.partial.html compares fileName
- $scope.fileName = N_("No file selected.");
- $scope.title = $rootScope.licenseMissing ? ("Tower " + i18n._("License")) : i18n._("License Management");
- Wait('start');
- ConfigService.getConfig().then(function(config){
- $scope.license = config;
- $scope.license.version = config.version.split('-')[0];
- $scope.time = {};
- $scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining);
- $scope.time.expiresOn = calcExpiresOn($scope.time.remaining);
- $scope.valid = CheckLicense.valid($scope.license.license_info);
- $scope.compliant = $scope.license.license_info.compliant;
- Wait('stop');
- });
- };
- var reset = function(){
- document.getElementById('License-form').reset();
- };
- init();
}
- ];
+];
diff --git a/awx/ui/client/src/lists.js b/awx/ui/client/src/lists.js
index 545c3189fb..f7b0288822 100644
--- a/awx/ui/client/src/lists.js
+++ b/awx/ui/client/src/lists.js
@@ -13,6 +13,7 @@ import Hosts from "./lists/Hosts";
import Inventories from "./lists/Inventories";
import InventoryGroups from "./lists/InventoryGroups";
import InventoryHosts from "./lists/InventoryHosts";
+import InventorySources from "./lists/InventorySources";
import JobEvents from "./lists/JobEvents";
import JobHosts from "./lists/JobHosts";
import JobTemplates from "./lists/JobTemplates";
@@ -38,6 +39,7 @@ export
Inventories,
InventoryGroups,
InventoryHosts,
+ InventorySources,
JobEvents,
JobHosts,
JobTemplates,
diff --git a/awx/ui/client/src/lists/InventorySources.js b/awx/ui/client/src/lists/InventorySources.js
new file mode 100644
index 0000000000..127352c72b
--- /dev/null
+++ b/awx/ui/client/src/lists/InventorySources.js
@@ -0,0 +1,30 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+
+export default
+ angular.module('InventorySourcesListDefinition', [])
+ .value('InventorySourcesList', {
+
+ name: 'workflow_inventory_sources',
+ iterator: 'inventory_source',
+ basePath: 'inventory_sources',
+ listTitle: 'Inventory Sources',
+ index: false,
+ hover: true,
+
+ fields: {
+ name: {
+ key: true,
+ label: 'Name',
+ columnClass: 'col-md-11'
+ }
+ },
+
+ actions: {},
+
+ fieldActions: {}
+ });
diff --git a/awx/ui/client/src/lists/JobTemplates.js b/awx/ui/client/src/lists/JobTemplates.js
index e809683460..0713402775 100644
--- a/awx/ui/client/src/lists/JobTemplates.js
+++ b/awx/ui/client/src/lists/JobTemplates.js
@@ -10,11 +10,12 @@ export default
.factory('JobTemplateList', ['i18n', function(i18n) {
return {
- name: 'job_templates',
- iterator: 'job_template',
- selectTitle: i18n._('Add Job Template'),
- editTitle: i18n._('Job Templates'),
- listTitle: i18n._('Job Templates'),
+ name: 'templates',
+ iterator: 'template',
+ basePath: 'unified_job_templates',
+ selectTitle: i18n._('Template'),
+ editTitle: i18n._('Templates'),
+ listTitle: i18n._('Templates'),
selectInstructions: "Click on a row to select it, and click Finished when done. Use the " +
"button to create a new job template.",
index: false,
@@ -24,7 +25,14 @@ export default
name: {
key: true,
label: i18n._('Name'),
- columnClass: 'col-lg-2 col-md-2 col-sm-4 col-xs-9'
+ columnClass: 'col-lg-2 col-md-2 col-sm-4 col-xs-9',
+ ngClick: "editJobTemplate(template)"
+ },
+ type: {
+ label: i18n._('Type'),
+ searchType: 'select',
+ searchOptions: [], // will be set by Options call to job templates resource
+ columnClass: 'col-lg-2 col-md-2 col-sm-4 hidden-xs'
},
description: {
label: i18n._('Description'),
@@ -41,73 +49,85 @@ export default
label: i18n._('Labels'),
type: 'labels',
nosort: true,
- columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs'
+ columnClass: 'List-tableCell col-lg-2 col-md-4 hidden-sm hidden-xs'
}
},
actions: {
add: {
mode: 'all', // One of: edit, select, all
- ngClick: 'addJobTemplate()',
- basePaths: ['job_templates'],
+ type: 'buttonDropdown',
+ basePaths: ['templates'],
awToolTip: i18n._('Create a new template'),
- actionClass: 'btn List-buttonSubmit',
- buttonContent: i18n._('+ ADD'),
- ngShow: 'canAdd'
+ actionClass: 'btn List-dropdownSuccess',
+ buttonContent: i18n._('ADD'),
+ options: [
+ {
+ optionContent: 'Job Template',
+ optionSref: 'templates.addJobTemplate',
+ ngShow: 'canAddJobTemplate'
+ },
+ {
+ optionContent: 'Workflow Job Template',
+ optionSref: 'templates.addWorkflowJobTemplate',
+ ngShow: 'canAddWorkflowJobTemplate'
+ }
+ ],
+ ngShow: 'canAddJobTemplate || canAddWorkflowJobTemplate'
}
},
fieldActions: {
- columnClass: 'col-lg-2 col-md-3 col-sm-3 col-xs-3',
+ columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-3',
submit: {
label: i18n._('Launch'),
mode: 'all',
- ngClick: 'submitJob(job_template.id)',
+ ngClick: 'submitJob(template)',
awToolTip: i18n._('Start a job using this template'),
dataPlacement: 'top',
- ngShow: 'job_template.summary_fields.user_capabilities.start'
+ ngShow: 'template.summary_fields.user_capabilities.start'
},
schedule: {
label: i18n._('Schedule'),
mode: 'all',
- ngClick: 'scheduleJob(job_template.id)',
+ ngClick: 'scheduleJob(template)',
awToolTip: i18n._('Schedule future job template runs'),
dataPlacement: 'top',
- ngShow: 'job_template.summary_fields.user_capabilities.schedule'
+ ngShow: 'template.summary_fields.user_capabilities.schedule'
},
copy: {
label: i18n._('Copy'),
- 'ui-sref': 'jobTemplates.copy({id: job_template.id})',
+ 'ui-sref': 'templates.copy({id: template.id})',
"class": 'btn-danger btn-xs',
awToolTip: i18n._('Copy template'),
dataPlacement: 'top',
- ngShow: 'job_template.summary_fields.user_capabilities.copy'
+ ngShow: 'template.summary_fields.user_capabilities.copy'
},
edit: {
label: i18n._('Edit'),
- ngClick: "editJobTemplate(job_template.id)",
+ ngClick: "editJobTemplate(template)",
awToolTip: i18n._('Edit template'),
"class": 'btn-default btn-xs',
dataPlacement: 'top',
- ngShow: 'job_template.summary_fields.user_capabilities.edit'
+ ngShow: 'template.summary_fields.user_capabilities.edit'
},
view: {
label: i18n._('View'),
- ngClick: "editJobTemplate(job_template.id)",
+ ngClick: "editJobTemplate(template.id)",
awToolTip: i18n._('View template'),
"class": 'btn-default btn-xs',
dataPlacement: 'top',
- ngShow: '!job_template.summary_fields.user_capabilities.edit'
+ ngShow: '!template.summary_fields.user_capabilities.edit'
},
"delete": {
label: i18n._('Delete'),
- ngClick: "deleteJobTemplate(job_template.id, job_template.name)",
+ ngClick: "deleteJobTemplate(template)",
"class": 'btn-danger btn-xs',
awToolTip: i18n._('Delete template'),
dataPlacement: 'top',
- ngShow: 'job_template.summary_fields.user_capabilities.delete'
+ ngShow: 'template.summary_fields.user_capabilities.delete'
}
}
};}]);
diff --git a/awx/ui/client/src/lists/PortalJobTemplates.js b/awx/ui/client/src/lists/PortalJobTemplates.js
index 9a9f8c0ca7..b542ac1181 100644
--- a/awx/ui/client/src/lists/PortalJobTemplates.js
+++ b/awx/ui/client/src/lists/PortalJobTemplates.js
@@ -23,7 +23,7 @@ export default
key: true,
label: i18n._('Name'),
columnClass: 'col-lg-5 col-md-5 col-sm-9 col-xs-8',
- linkTo: '/#/job_templates/{{job_template.id}}',
+ linkTo: '/#/templates/{{job_template.id}}'
},
description: {
label: i18n._('Description'),
diff --git a/awx/ui/client/src/lists/Projects.js b/awx/ui/client/src/lists/Projects.js
index ce478129ac..31d176bd98 100644
--- a/awx/ui/client/src/lists/Projects.js
+++ b/awx/ui/client/src/lists/Projects.js
@@ -11,6 +11,7 @@ export default
name: 'projects',
iterator: 'project',
+ basePath: 'projects',
selectTitle: i18n._('Add Project'),
editTitle: i18n._('Projects'),
listTitle: i18n._('Projects'),
diff --git a/awx/ui/client/src/login/authenticationServices/pendo.service.js b/awx/ui/client/src/login/authenticationServices/pendo.service.js
index 10cdbd33d8..ebcad0f03f 100644
--- a/awx/ui/client/src/login/authenticationServices/pendo.service.js
+++ b/awx/ui/client/src/login/authenticationServices/pendo.service.js
@@ -94,10 +94,10 @@ export default
},
issuePendoIdentity: function () {
- var config,
- options,
+ var options,
c = ConfigService.get(),
- config = c.license_info;
+ config = c.license_info;
+
config.analytics_status = c.analytics_status;
config.version = c.version;
config.ansible_version = c.ansible_version;
@@ -114,7 +114,7 @@ export default
});
}
else {
- $log.debug('Pendo is turned off.')
+ $log.debug('Pendo is turned off.');
}
}
};
diff --git a/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js b/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js
index 3d6e86ae02..d0e79872e1 100644
--- a/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js
+++ b/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js
@@ -1,3 +1,5 @@
+/* jshint ignore:start */
+
/*
* pendo.io Angular Module
*
@@ -25,7 +27,7 @@
setTimeout(waitFn, delay);
}
};
-
+
angular.module('pendolytics', [])
.provider('$pendolytics', function() {
diff --git a/awx/ui/client/src/login/authenticationServices/pendo/pendo.service.js b/awx/ui/client/src/login/authenticationServices/pendo/pendo.service.js
deleted file mode 100644
index 82af8ade88..0000000000
--- a/awx/ui/client/src/login/authenticationServices/pendo/pendo.service.js
+++ /dev/null
@@ -1,152 +0,0 @@
-/*************************************************
-* Copyright (c) 2015 Ansible, Inc.
-*
-* All Rights Reserved
-*************************************************/
-
-
-export default
- [ '$rootScope', '$pendolytics', 'Rest', 'GetBasePath', 'ProcessErrors', '$q',
- 'Store', '$log',
- function ($rootScope, $pendolytics, Rest, GetBasePath, ProcessErrors, $q,
- Store, $log) {
- return {
- setPendoOptions: function (config) {
- var tower_version = config.version.split('-')[0],
- options = {
- visitor: {
- id: null,
- role: null,
- email: null
- },
- account: {
- id: null,
- planLevel: config.license_type,
- planPrice: config.instance_count,
- creationDate: config.license_date,
- trial: config.trial,
- tower_version: tower_version,
- ansible_version: config.ansible_version
- }
- };
- if(config.analytics_status === 'detailed'){
- this.setDetailed(options, config);
- }
- else if(config.analytics_status === 'anonymous'){
- this.setAnonymous(options);
- }
- return options;
-
- },
-
- setDetailed: function(options, config) {
- // Detailed mode
- // VisitorId: username+hash of license_key
- // AccountId: hash of license_key from license
- // email: contact_email from license OR email from Tower account
-
- options.visitor.id = $rootScope.current_user.username + '@' + config.deployment_id;
- options.account.id = config.deployment_id;
- options.visitor.email = $rootScope.current_user.email;
- },
-
- setAnonymous: function (options) {
- //Anonymous mode
- // VisitorId:
- // AccountId:
- // email:
-
- options.visitor.id = 0;
- options.account.id = "tower.ansible.com";
- options.visitor.email = "";
- },
-
- setRole: function(options) {
- var deferred = $q.defer();
- if($rootScope.current_user.is_superuser === true){
- options.visitor.role = 'admin';
- deferred.resolve(options);
- }
- else{
- var url = GetBasePath('users') + $rootScope.current_user.id + '/admin_of_organizations/';
- Rest.setUrl(url);
- var promise = Rest.get();
- promise.then(function (response) {
- if(response.data.count > 0 ) {
- options.visitor.role = "orgadmin";
- deferred.resolve(options);
- }
- else {
- options.visitor.role = "user";
- deferred.resolve(options);
- }
- });
- promise.catch(function (response) {
- ProcessErrors($rootScope, response.data, response.status, null, {
- hdr: 'Error!',
- msg: 'Failed to get inventory name. GET returned status: ' +
- response.status });
- deferred.reject('Could not resolve pendo role.');
- });
- }
- return deferred.promise;
- },
-
- getConfig: function () {
- var config = Store('license'),
- deferred = $q.defer();
- if(_.isEmpty(config)){
- var url = GetBasePath('config');
- Rest.setUrl(url);
- var promise = Rest.get();
- promise.then(function (response) {
- config = response.data.license_info;
- config.analytics_status = response.data.analytics_status;
- config.version = response.data.version;
- config.ansible_version = response.data.ansible_version;
- if(config.analytics_status === 'detailed' || config.analytics_status === 'anonymous'){
- $pendolytics.bootstrap();
- deferred.resolve(config);
- }
- else {
- deferred.reject('Pendo is turned off.');
- }
- });
- promise.catch(function (response) {
- ProcessErrors($rootScope, response.data, response.status, null, {
- hdr: 'Error!',
- msg: 'Failed to get inventory name. GET returned status: ' +
- response.status });
- deferred.reject('Could not resolve pendo config.');
- });
- }
- else if(config.analytics_status === 'detailed' || config.analytics_status === 'anonymous'){
- $pendolytics.bootstrap();
- deferred.resolve(config);
- }
- else {
- deferred.reject('Pendo is turned off.');
- }
- return deferred.promise;
- },
-
- issuePendoIdentity: function () {
- var that = this;
- this.getConfig().then(function(config){
- var options = that.setPendoOptions(config);
- that.setRole(options).then(function(options){
- $log.debug('Pendo status is '+ config.analytics_status + '. Object below:');
- $log.debug(options);
- $pendolytics.identify(options);
- }, function(reason){
- // reject function for setRole
- $log.debug(reason);
- });
- }, function(reason){
- // reject function for getConfig
- $log.debug(reason);
- });
- }
- };
- }
-];
diff --git a/awx/ui/client/src/main-menu/main-menu.partial.html b/awx/ui/client/src/main-menu/main-menu.partial.html
index 558d733197..398488add7 100644
--- a/awx/ui/client/src/main-menu/main-menu.partial.html
+++ b/awx/ui/client/src/main-menu/main-menu.partial.html
@@ -29,10 +29,10 @@
+ href="/#/templates"
+ ng-class="{'is-currentRoute' : isCurrentState('templates')}">
- JOB TEMPLATES
+ TEMPLATES
+ ng-class="{'is-currentRoute' : isCurrentState('templates'), 'is-loggedOut' : !current_user || !current_user.username}">
- JOB TEMPLATES
+ TEMPLATES
{
+ $scope.$on('selectedOrDeselected', ()=>{
throw {name: 'NotYetImplemented'};
});
}
diff --git a/awx/ui/client/src/organizations/linkout/main.js b/awx/ui/client/src/organizations/linkout/main.js
index 0701b87b96..e6d31a156c 100644
--- a/awx/ui/client/src/organizations/linkout/main.js
+++ b/awx/ui/client/src/organizations/linkout/main.js
@@ -1,4 +1,3 @@
-import routes from './organizations-linkout.route';
import AddUsers from './addUsers/main';
export default angular.module('organizationsLinkout', [AddUsers.name]);
diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js
index 207ab2d8ec..c22b3c1049 100644
--- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js
+++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js
@@ -4,7 +4,6 @@
* All Rights Reserved
*************************************************/
-import { templateUrl } from '../../shared/template-url/template-url.factory';
import OrganizationsAdmins from './controllers/organizations-admins.controller';
import OrganizationsInventories from './controllers/organizations-inventories.controller';
import OrganizationsJobTemplates from './controllers/organizations-job-templates.controller';
diff --git a/awx/ui/client/src/partials/job-template-smart-status.html b/awx/ui/client/src/partials/job-template-smart-status.html
index a9c6365970..1c45c5fe36 100644
--- a/awx/ui/client/src/partials/job-template-smart-status.html
+++ b/awx/ui/client/src/partials/job-template-smart-status.html
@@ -1 +1 @@
-
+
diff --git a/awx/ui/client/src/scheduler/main.js b/awx/ui/client/src/scheduler/main.js
index 9474c44dce..7cb521f3c2 100644
--- a/awx/ui/client/src/scheduler/main.js
+++ b/awx/ui/client/src/scheduler/main.js
@@ -23,7 +23,7 @@ export default
// job templates
$stateExtender.addState({
name: 'jobTemplateSchedules',
- route: '/job_templates/:job_template_id/schedules',
+ route: '/templates/job_template/:id/schedules',
templateUrl: templateUrl("scheduler/scheduler"),
controller: 'schedulerListController',
data: {
@@ -32,7 +32,7 @@ export default
activityStreamId: 'id'
},
ncyBreadcrumb: {
- parent: 'jobTemplates.edit',
+ parent: 'templates.editJobTemplate',
label: 'SCHEDULES'
}
});
@@ -57,9 +57,45 @@ export default
}
});
+ // workflows
+ $stateExtender.addState({
+ name: 'workflowJobTemplateSchedules',
+ route: '/templates/workflow_job_template/:id/schedules',
+ templateUrl: templateUrl("scheduler/scheduler"),
+ controller: 'schedulerController',
+ data: {
+ activityStream: true,
+ activityStreamTarget: 'job_template',
+ activityStreamId: 'id'
+ },
+ ncyBreadcrumb: {
+ parent: 'templates.editWorkflowJobTemplate',
+ label: 'SCHEDULES'
+ }
+ });
+ $stateExtender.addState({
+ name: 'workflowJobTemplateSchedules.add',
+ route: '/add',
+ templateUrl: templateUrl("scheduler/schedulerForm"),
+ controller: 'schedulerAddController',
+ ncyBreadcrumb: {
+ parent: 'workflowJobTemplateSchedules',
+ label: 'CREATE SCHEDULE'
+ }
+ });
+ $stateExtender.addState({
+ name: 'workflowJobTemplateSchedules.edit',
+ route: '/:schedule_id',
+ templateUrl: templateUrl("scheduler/schedulerForm"),
+ controller: 'schedulerEditController',
+ ncyBreadcrumb: {
+ parent: 'workflowJobTemplateSchedules',
+ label: '{{schedule_obj.name}}'
+ }
+ });
// projects
$stateExtender.addState({
- searchPrefix: 'schedule',
+ searchPrefix: 'schedule',
name: 'projectSchedules',
route: '/projects/:id/schedules',
data: {
diff --git a/awx/ui/client/src/scheduler/schedulerList.controller.js b/awx/ui/client/src/scheduler/schedulerList.controller.js
index 4400eef5c4..b532c6d6dd 100644
--- a/awx/ui/client/src/scheduler/schedulerList.controller.js
+++ b/awx/ui/client/src/scheduler/schedulerList.controller.js
@@ -90,9 +90,8 @@ export default [
});
};
-
base = $location.path().replace(/^\//, '').split('/')[0];
- console.log(base)
+
if (base === 'management_jobs') {
$scope.base = base = 'system_job_templates';
}
diff --git a/awx/ui/client/src/shared/Modal.js b/awx/ui/client/src/shared/Modal.js
index 897b4b1a73..e37defa900 100644
--- a/awx/ui/client/src/shared/Modal.js
+++ b/awx/ui/client/src/shared/Modal.js
@@ -65,6 +65,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper'])
forms = _.chain([params.form]).flatten().compact().value(),
buttons,
id = params.id,
+ position = (params.position === undefined) ? { my: "center", at: "center", of: window } : params.position,
x, y, wh, ww;
function updateButtonStatus(isValid) {
@@ -91,7 +92,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper'])
// Set modal dimensions based on viewport width
ww = $(document).width();
- wh = $('body').height();
+ wh = $(document).height();
x = (width > ww) ? ww - 10 : width;
y = (height > wh) ? wh - 10 : height;
@@ -108,6 +109,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper'])
resizable: resizable,
draggable: draggable,
dialogClass: dialogClass,
+ position: position,
create: function () {
// Fix the close button
$('.ui-dialog[aria-describedby="' + id + '"]').find('.ui-dialog-titlebar button').empty().attr({'class': 'close'}).html(' ');
diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js
index b996f16607..dc5f01bf18 100644
--- a/awx/ui/client/src/shared/form-generator.js
+++ b/awx/ui/client/src/shared/form-generator.js
@@ -164,12 +164,21 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// Not a very good way to do this
// Form sub-states expect to target ui-views related@stateName & modal@stateName
// Also wraps mess of generated HTML in a .Panel
- wrapPanel(html){
- return ``;
+ wrapPanel(html, ignorePanel){
+ if(ignorePanel) {
+ return ``;
+ }
+ else {
+ return ``;
+ }
},
inject: function (form, options) {
@@ -377,7 +386,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// html = GenerateForm.buildHTML(JobVarsPromptForm, { mode: 'edit', modal: true, scope: scope });
this.mode = options.mode;
- this.modal = (options.modal) ? true : false;
+ //this.modal = (options.modal) ? true : false;
this.setForm(form);
return this.build(options);
},
@@ -728,7 +737,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
if ((!field.readonly) || (field.readonly && options.mode === 'edit')) {
- if((field.excludeMode === undefined || field.excludeMode !== options.mode) && field.type !== 'alertblock') {
+ if((field.excludeMode === undefined || field.excludeMode !== options.mode) && field.type !== 'alertblock' && field.type !== 'workflow-chart') {
html += "\n";
for (i = 0; i < field.options.length; i++) {
@@ -1284,11 +1294,17 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
html += (field.ngChange) ? this.attr(field, 'ngChange') : "";
html += (field.readonly) ? "disabled " : "";
html += (field.required) ? "required " : "";
+ html += (field.ngshow) ? "ng-show=\"" + field.ngShow + "\" " : "";
+ if(field.awRequiredWhen) {
+ html += field.awRequiredWhen.init ? "data-awrequired-init=\"" + field.awRequiredWhen.init + "\" " : "";
+ html += field.awRequiredWhen.reqExpression ? "aw-required-when=\"" + field.awRequiredWhen.reqExpression + "\" " : "";
+ html += field.awRequiredWhen.alwaysShowAsterisk ? "data-awrequired-always-show-asterisk=true " : "";
+ }
html += (field.ngDisabled) ? this.attr(field, 'ngDisabled') : "";
html += " > " + field.options[i].label + "\n";
html += "\n";
}
- if (field.required) {
+ if (field.required || field.awRequiredWhen) {
html += "
Please select a value.
\n";
@@ -1433,8 +1449,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// Generate HTML. Do NOT call this function directly. Called by inject(). Returns an HTML
// string to be injected into the current view.
//
- var btn, button, fld, field, html = '', i, section, group,
- tab, sectionShow, offset, width,ngDisabled, itm;
+ var btn, button, fld, field, html = '', section, group,
+ sectionShow, offset, width,ngDisabled, itm;
// title and exit button
if(!(this.form.showHeader !== undefined && this.form.showHeader === false)) {
@@ -1473,14 +1489,14 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
html += "
"; //end of Form-header
}
- if (!_.isEmpty(this.form.related)) {
+ if (!_.isEmpty(this.form.related) || !_.isEmpty(this.form.relatedButtons)) {
var collection, details = i18n._('Details');
- html += `";//tabHolder
}
- if(!_.isEmpty(this.form.related) && this.mode === "edit"){
- html += `
diff --git a/awx/ui/client/src/shared/main.js b/awx/ui/client/src/shared/main.js
index 78bc5d19f9..8db9dd8172 100644
--- a/awx/ui/client/src/shared/main.js
+++ b/awx/ui/client/src/shared/main.js
@@ -10,7 +10,6 @@ import lookupModal from './lookup/main';
import smartSearch from './smart-search/main';
import paginate from './paginate/main';
import columnSort from './column-sort/main';
-import title from './title.directive';
import lodashAsPromised from './lodash-as-promised';
import stringFilters from './string-filters/main';
import truncatedText from './truncated-text.directive';
diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js
index b8d0a4ecf5..f0276bdebd 100644
--- a/awx/ui/client/src/shared/paginate/paginate.controller.js
+++ b/awx/ui/client/src/shared/paginate/paginate.controller.js
@@ -50,9 +50,9 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q
}
function calcDataRange() {
- if ($scope.current() == 1 && $scope.dataset.count < parseInt(pageSize)) {
+ if ($scope.current() === 1 && $scope.dataset.count < parseInt(pageSize)) {
return `1 - ${$scope.dataset.count}`;
- } else if ($scope.current() == 1) {
+ } else if ($scope.current() === 1) {
return `1 - ${pageSize}`;
} else {
let floor = (($scope.current() - 1) * parseInt(pageSize)) + 1;
diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js
index 12057c1a90..1296a1394b 100644
--- a/awx/ui/client/src/shared/smart-search/queryset.service.js
+++ b/awx/ui/client/src/shared/smart-search/queryset.service.js
@@ -1,5 +1,5 @@
-export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', 'GetBasePath',
- function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory, GetBasePath) {
+export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory',
+ function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory) {
return {
// kick off building a model for a specific endpoint
// this is usually a list's basePath
diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js
index 1af67cb6ef..64933f8fbd 100644
--- a/awx/ui/client/src/shared/socket/socket.service.js
+++ b/awx/ui/client/src/shared/socket/socket.service.js
@@ -50,13 +50,13 @@ export default
$log.debug('Websocket Error Logged: ' + error); //log errors
};
- self.socket.onconnecting = function (event) {
+ self.socket.onconnecting = function () {
self.checkStatus();
$log.debug('Websocket reconnecting');
needsResubscribing = true;
};
- self.socket.onclose = function (event) {
+ self.socket.onclose = function () {
self.checkStatus();
$log.debug(`Websocket disconnected`);
};
diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js
index 0c4112aa7b..b76c5b5a36 100644
--- a/awx/ui/client/src/shared/stateDefinitions.factory.js
+++ b/awx/ui/client/src/shared/stateDefinitions.factory.js
@@ -9,8 +9,6 @@
* generateLookupNodes - Attaches to a form node. Builds an abstract '*.lookup' node with field-specific 'lookup.*' children e.g. {name: 'projects.add.lookup.organizations', ...}
*/
-import { templateUrl } from './template-url/template-url.factory';
-
export default ['$injector', '$stateExtender', '$log', function($injector, $stateExtender, $log) {
return {
/**
@@ -140,12 +138,14 @@ export default ['$injector', '$stateExtender', '$log', function($injector, $stat
let formNode, states = [];
switch (mode) {
case 'add':
+ // breadcrumbName necessary for resources that are more than one word like
+ // job templates. form.name can't have spaces in it or it busts form gen
formNode = $stateExtender.buildDefinition({
name: params.name || `${params.parent}.add`,
url: params.url || '/add',
ncyBreadcrumb: {
[params.parent ? 'parent' : null]: `${params.parent}`,
- label: `CREATE ${form.name}`
+ label: `CREATE ${form.breadcrumbName || form.name}`
},
views: {
'form': {
@@ -370,7 +370,7 @@ export default ['$injector', '$stateExtender', '$log', function($injector, $stat
// a lookup field's basePath takes precedence over generic list definition's basePath, if supplied
data: {
basePath: field.basePath || null,
- lookup: true
+ formChildState: true
},
params: {
[field.sourceModel + '_search']: {
diff --git a/awx/ui/client/src/standard-out/standard-out.controller.js b/awx/ui/client/src/standard-out/standard-out.controller.js
index 663d165cf3..ffed821d7d 100644
--- a/awx/ui/client/src/standard-out/standard-out.controller.js
+++ b/awx/ui/client/src/standard-out/standard-out.controller.js
@@ -52,7 +52,7 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams,
$scope.created_by = data.summary_fields.created_by;
$scope.project_name = (data.summary_fields.project) ? data.summary_fields.project.name : '';
$scope.inventory_name = (data.summary_fields.inventory) ? data.summary_fields.inventory.name : '';
- $scope.job_template_url = '/#/job_templates/' + data.unified_job_template;
+ $scope.job_template_url = '/#/templates/' + data.unified_job_template;
$scope.inventory_url = ($scope.inventory_name && data.inventory) ? '/#/inventories/' + data.inventory : '';
$scope.project_url = ($scope.project_name && data.project) ? '/#/projects/' + data.project : '';
$scope.credential_name = (data.summary_fields.credential) ? data.summary_fields.credential.name : '';
diff --git a/awx/ui/client/src/workflow-results/main.js b/awx/ui/client/src/workflow-results/main.js
new file mode 100644
index 0000000000..7faf366fc9
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/main.js
@@ -0,0 +1,16 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import workflowStatusBar from './workflow-status-bar/main';
+import route from './workflow-results.route.js';
+import workflowResultsService from './workflow-results.service';
+
+export default
+ angular.module('workflowResults', [workflowStatusBar.name])
+ .run(['$stateExtender', function($stateExtender) {
+ $stateExtender.addState(route);
+ }])
+ .service('workflowResultsService', workflowResultsService);
diff --git a/awx/ui/client/src/workflow-results/workflow-results.block.less b/awx/ui/client/src/workflow-results/workflow-results.block.less
new file mode 100644
index 0000000000..edd5412d2d
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-results.block.less
@@ -0,0 +1,112 @@
+@import '../shared/branding/colors.less';
+@import '../shared/branding/colors.default.less';
+@import '../shared/layouts/one-plus-two.less';
+
+@breakpoint-md: 1200px;
+@breakpoint-sm: 623px;
+
+.WorkflowResults {
+ .OnePlusTwo-container(100%, @breakpoint-md);
+
+ &.fullscreen {
+ .WorkflowResults-rightSide {
+ max-width: 100%;
+ }
+ }
+}
+
+.WorkflowResults-leftSide {
+ .OnePlusTwo-left--panel(100%, @breakpoint-md);
+ height: ~"calc(100vh - 177px)";
+}
+
+.WorkflowResults-rightSide {
+ .OnePlusTwo-right--panel(100%, @breakpoint-md);
+ height: ~"calc(100vh - 177px)";
+
+ @media (max-width: @breakpoint-md - 1px) {
+ padding-right: 15px;
+ }
+}
+
+.WorkflowResults-stdoutActionButton--active {
+ display: none;
+ visibility: hidden;
+ flex:none;
+ width:0px;
+ padding-right: 0px;
+}
+
+.WorkflowResults-panelHeader {
+ display: flex;
+ height: 30px;
+}
+
+.WorkflowResults-panelHeaderText {
+ color: @default-interface-txt;
+ flex: 1 0 auto;
+ font-size: 14px;
+ font-weight: bold;
+ margin-right: 10px;
+ text-transform: uppercase;
+}
+
+.WorkflowResults-resultRow {
+ width: 100%;
+ display: flex;
+ padding-bottom: 10px;
+ flex-wrap: wrap;
+}
+
+.WorkflowResults-resultRow--variables {
+ flex-direction: column;
+}
+
+.WorkflowResults-resultRowLabel {
+ text-transform: uppercase;
+ color: @default-interface-txt;
+ font-size: 14px;
+ font-weight: normal!important;
+ width: 30%;
+ margin-right: 20px;
+
+ @media screen and (max-width: @breakpoint-md) {
+ flex: 2.5 0 auto;
+ }
+}
+
+.WorkflowResults-resultRowLabel--fullWidth {
+ width: 100%;
+ margin-right: 0px;
+}
+
+.WorkflowResults-resultRowText {
+ width: ~"calc(70% - 20px)";
+ flex: 1 0 auto;
+ text-transform: none;
+ word-wrap: break-word;
+}
+
+.WorkflowResults-resultRowText--fullWidth {
+ width: 100%;
+}
+
+.WorkflowResults-statusResultIcon {
+ padding-left: 0px;
+ padding-right: 10px;
+}
+
+.WorkflowResults-badgeRow {
+ display: flex;
+ align-items: center;
+ margin-right: 5px;
+}
+
+.WorkflowResults-badgeTitle{
+ color: @default-interface-txt;
+ font-size: 14px;
+ margin-right: 10px;
+ font-weight: normal;
+ text-transform: uppercase;
+ margin-left: 20px;
+}
diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js
new file mode 100644
index 0000000000..d3f6eb4da9
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js
@@ -0,0 +1,188 @@
+export default ['workflowData',
+ 'workflowResultsService',
+ 'workflowDataOptions',
+ 'jobLabels',
+ 'workflowNodes',
+ '$scope',
+ 'ParseTypeChange',
+ 'ParseVariableString',
+ function(workflowData,
+ workflowResultsService,
+ workflowDataOptions,
+ jobLabels,
+ workflowNodes,
+ $scope,
+ ParseTypeChange,
+ ParseVariableString
+ ) {
+ var getTowerLinks = function() {
+ var getTowerLink = function(key) {
+ if ($scope.workflow.related[key]) {
+ return '/#/' + $scope.workflow.related[key]
+ .split('api/v1/')[1];
+ }
+ else {
+ return null;
+ }
+ };
+
+ $scope.workflow_template_link = '/#/templates/workflow_job_template/'+$scope.workflow.workflow_job_template;
+ $scope.created_by_link = getTowerLink('created_by');
+ $scope.cloud_credential_link = getTowerLink('cloud_credential');
+ $scope.network_credential_link = getTowerLink('network_credential');
+ };
+
+ var getTowerLabels = function() {
+ var getTowerLabel = function(key) {
+ if ($scope.workflowOptions && $scope.workflowOptions[key]) {
+ return $scope.workflowOptions[key].choices
+ .filter(val => val[0] === $scope.workflow[key])
+ .map(val => val[1])[0];
+ } else {
+ return null;
+ }
+ };
+
+ $scope.status_label = getTowerLabel('status');
+ $scope.type_label = getTowerLabel('job_type');
+ $scope.verbosity_label = getTowerLabel('verbosity');
+ };
+
+ // var getTotalHostCount = function(count) {
+ // return Object
+ // .keys(count).reduce((acc, i) => acc += count[i], 0);
+ // };
+
+ // put initially resolved request data on scope
+ $scope.workflow = workflowData;
+ $scope.workflow_nodes = workflowNodes;
+ $scope.workflowOptions = workflowDataOptions.actions.GET;
+ $scope.labels = jobLabels;
+
+ // turn related api browser routes into tower routes
+ getTowerLinks();
+
+ // use options labels to manipulate display of details
+ getTowerLabels();
+
+ // set up a read only code mirror for extra vars
+ $scope.variables = ParseVariableString($scope.workflow.extra_vars);
+ $scope.parseType = 'yaml';
+ ParseTypeChange({ scope: $scope,
+ field_id: 'pre-formatted-variables',
+ readOnly: true });
+
+ // Click binding for the expand/collapse button on the standard out log
+ $scope.stdoutFullScreen = false;
+ $scope.toggleStdoutFullscreen = function() {
+ $scope.stdoutFullScreen = !$scope.stdoutFullScreen;
+ };
+
+ $scope.deleteJob = function() {
+ workflowResultsService.deleteJob($scope.workflow);
+ };
+
+ $scope.cancelJob = function() {
+ workflowResultsService.cancelJob($scope.workflow);
+ };
+
+ $scope.relaunchJob = function() {
+ workflowResultsService.relaunchJob($scope);
+ };
+
+ $scope.stdoutArr = [];
+
+ // EVENT STUFF BELOW
+
+ // just putting the event queue on scope so it can be inspected in the
+ // console
+ // $scope.event_queue = eventQueue.queue;
+ // $scope.defersArr = eventQueue.populateDefers;
+
+ // This is where the async updates to the UI actually happen.
+ // Flow is event queue munging in the service -> $scope setting in here
+ // var processEvent = function(event) {
+ // // put the event in the queue
+ // eventQueue.populate(event).then(mungedEvent => {
+ // // make changes to ui based on the event returned from the queue
+ // if (mungedEvent.changes) {
+ // mungedEvent.changes.forEach(change => {
+ // // we've got a change we need to make to the UI!
+ // // update the necessary scope and make the change
+ // if (change === 'startTime' && !$scope.workflow.start) {
+ // $scope.workflow.start = mungedEvent.startTime;
+ // }
+ //
+ // if (change === 'count' && !$scope.countFinished) {
+ // // for all events that affect the host count,
+ // // update the status bar as well as the host
+ // // count badge
+ // $scope.count = mungedEvent.count;
+ // $scope.hostCount = getTotalHostCount(mungedEvent
+ // .count);
+ // }
+ //
+ // if (change === 'playCount') {
+ // $scope.playCount = mungedEvent.playCount;
+ // }
+ //
+ // if (change === 'taskCount') {
+ // $scope.taskCount = mungedEvent.taskCount;
+ // }
+ //
+ // if (change === 'finishedTime' && !$scope.workflow.finished) {
+ // $scope.workflow.finished = mungedEvent.finishedTime;
+ // }
+ //
+ // if (change === 'countFinished') {
+ // // the playbook_on_stats event actually lets
+ // // us know that we don't need to iteratively
+ // // look at event to update the host counts
+ // // any more.
+ // $scope.countFinished = true;
+ // }
+ //
+ // if(change === 'stdout'){
+ // angular
+ // .element(".JobResultsStdOut-stdoutContainer")
+ // .append($compile(mungedEvent
+ // .stdout)($scope));
+ // }
+ // });
+ // }
+ //
+ // // the changes have been processed in the ui, mark it in the queue
+ // eventQueue.markProcessed(event);
+ // });
+ // };
+
+ // PULL! grab completed event data and process each event
+ // TODO: implement retry logic in case one of these requests fails
+ // var getEvents = function(url) {
+ // workflowResultsService.getEvents(url)
+ // .then(events => {
+ // events.results.forEach(event => {
+ // // get the name in the same format as the data
+ // // coming over the websocket
+ // event.event_name = event.event;
+ // processEvent(event);
+ // });
+ // if (events.next) {
+ // getEvents(events.next);
+ // }
+ // });
+ // };
+ // getEvents($scope.job.related.job_events);
+
+ // // Processing of job_events messages from the websocket
+ // $scope.$on(`ws-job_events-${$scope.workflow.id}`, function(e, data) {
+ // processEvent(data);
+ // });
+
+ // Processing of job-status messages from the websocket
+ $scope.$on(`ws-jobs`, function(e, data) {
+ if (parseInt(data.unified_job_id, 10) === parseInt($scope.workflow.id,10)) {
+ $scope.workflow.status = data.status;
+ }
+ });
+}];
diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html
new file mode 100644
index 0000000000..9612719221
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Started
+
+
+ {{ workflow.started | longDate }}
+
+
+
+
+
+
+ Finished
+
+
+ {{ (workflow.finished |
+ longDate) || "Not Finished" }}
+
+
+
+
+
+
+
+
+
+ Job Type
+
+
+ Workflow Job
+
+
+
+
+
+
+
+
+
+
+ Extra Variables
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ workflow.name }}
+
+
+
+
+
+
+ Total Jobs
+
+
+ {{ workflow_nodes.length || 0}}
+
+
+
+
+ Elapsed
+
+
+ {{ job.elapsed * 1000 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/src/workflow-results/workflow-results.route.js b/awx/ui/client/src/workflow-results/workflow-results.route.js
new file mode 100644
index 0000000000..4d15d50777
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-results.route.js
@@ -0,0 +1,114 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import {templateUrl} from '../shared/template-url/template-url.factory';
+
+import workflowResultsController from './workflow-results.controller';
+
+export default {
+ name: 'workflowResults',
+ url: '/workflows/:id',
+ ncyBreadcrumb: {
+ parent: 'jobs',
+ label: '{{ job.id }} - {{ job.name }}'
+ },
+ data: {
+ socket: {
+ "groups":{
+ "jobs": ["status_changed", "summary"],
+ // not sure if you're gonna need to use job_events
+ // or if y'all will come up w/ a new socket group specifically
+ // for workflows
+ // "job_events": []
+ }
+ }
+ },
+ templateUrl: templateUrl('workflow-results/workflow-results'),
+ controller: workflowResultsController,
+ resolve: {
+ // the GET for the particular workflow
+ workflowData: ['Rest', 'GetBasePath', '$stateParams', '$q', '$state', 'Alert', function(Rest, GetBasePath, $stateParams, $q, $state, Alert) {
+ Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id);
+ var defer = $q.defer();
+ Rest.get()
+ .then(function(data) {
+ defer.resolve(data.data);
+ }, function(data) {
+ defer.reject(data);
+
+ if (data.status === 404) {
+ Alert('Job Not Found', 'Cannot find job.', 'alert-info');
+ } else if (data.status === 403) {
+ Alert('Insufficient Permissions', 'You do not have permission to view this job.', 'alert-info');
+ }
+
+ $state.go('jobs');
+ });
+ return defer.promise;
+ }],
+ // after the GET for the job, this helps us keep the status bar from
+ // flashing as rest data comes in. Provides the list of workflow nodes
+ workflowNodes: ['workflowData', 'Rest', '$q', function(workflowData, Rest, $q) {
+ var defer = $q.defer();
+ Rest.setUrl(workflowData.related.workflow_nodes);
+ Rest.get()
+ .success(function(data) {
+ defer.resolve(data.results);
+ })
+ .error(function() {
+ // TODO: handle this
+ //defer.resolve(data);
+ });
+ return defer.promise;
+ }],
+ // GET for the particular jobs labels to be displayed in the
+ // left-hand pane
+ jobLabels: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) {
+ var getNext = function(data, arr, resolve) {
+ Rest.setUrl(data.next);
+ Rest.get()
+ .success(function (data) {
+ if (data.next) {
+ getNext(data, arr.concat(data.results), resolve);
+ } else {
+ resolve.resolve(arr.concat(data.results)
+ .map(val => val.name));
+ }
+ });
+ };
+
+ var seeMoreResolve = $q.defer();
+
+ Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id + '/labels/');
+ Rest.get()
+ .success(function(data) {
+ if (data.next) {
+ getNext(data, data.results, seeMoreResolve);
+ } else {
+ seeMoreResolve.resolve(data.results
+ .map(val => val.name));
+ }
+ });
+
+ return seeMoreResolve.promise;
+ }],
+ // OPTIONS request for the workflow. Used to make things like the
+ // verbosity data in the left-hand pane prettier than just an
+ // integer
+ workflowDataOptions: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) {
+ Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id);
+ var defer = $q.defer();
+ Rest.options()
+ .then(function(data) {
+ defer.resolve(data.data);
+ }, function(data) {
+ defer.reject(data);
+ });
+ return defer.promise;
+ }]
+ }
+
+};
diff --git a/awx/ui/client/src/workflow-results/workflow-results.service.js b/awx/ui/client/src/workflow-results/workflow-results.service.js
new file mode 100644
index 0000000000..cf7ba70af8
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-results.service.js
@@ -0,0 +1,108 @@
+/*************************************************
+* Copyright (c) 2016 Ansible, Inc.
+*
+* All Rights Reserved
+*************************************************/
+
+
+export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'InitiatePlaybookRun', function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybookRun) {
+ var val = {
+ deleteJob: function(workflow) {
+ Prompt({
+ hdr: 'Delete Job',
+ body: `
+ Are you sure you want to delete the workflow below?
+
+
+ #${workflow.id} ${$filter('sanitize')(workflow.name)}
+
`,
+ action: function() {
+ Wait('start');
+ Rest.setUrl(workflow.url);
+ Rest.destroy()
+ .success(function() {
+ Wait('stop');
+ $('#prompt-modal').modal('hide');
+ $state.go('jobs');
+ })
+ .error(function(obj, status) {
+ Wait('stop');
+ $('#prompt-modal').modal('hide');
+ ProcessErrors(null, obj, status, null, {
+ hdr: 'Error!',
+ msg: `Could not delete job.
+ Returned status: ${status}`
+ });
+ });
+ },
+ actionText: 'DELETE'
+ });
+ },
+ cancelJob: function(workflow) {
+ var doCancel = function() {
+ Rest.setUrl(workflow.url + 'cancel');
+ Rest.post({})
+ .success(function() {
+ Wait('stop');
+ $('#prompt-modal').modal('hide');
+ })
+ .error(function(obj, status) {
+ Wait('stop');
+ $('#prompt-modal').modal('hide');
+ ProcessErrors(null, obj, status, null, {
+ hdr: 'Error!',
+ msg: `Could not cancel workflow.
+ Returned status: ${status}`
+ });
+ });
+ };
+
+ Prompt({
+ hdr: 'Cancel Workflow',
+ body: `
+ Are you sure you want to cancel the workflow below?
+
+
+ #${workflow.id} ${$filter('sanitize')(workflow.name)}
+
`,
+ action: function() {
+ Wait('start');
+ Rest.setUrl(workflow.url + 'cancel');
+ Rest.get()
+ .success(function(data) {
+ if (data.can_cancel === true) {
+ doCancel();
+ } else {
+ $('#prompt-modal').modal('hide');
+ ProcessErrors(null, data, null, null, {
+ hdr: 'Error!',
+ msg: `Job has completed,
+ unabled to be canceled.`
+ });
+ }
+ });
+ Rest.destroy()
+ .success(function() {
+ Wait('stop');
+ $('#prompt-modal').modal('hide');
+ })
+ .error(function(obj, status) {
+ Wait('stop');
+ $('#prompt-modal').modal('hide');
+ ProcessErrors(null, obj, status, null, {
+ hdr: 'Error!',
+ msg: `Could not cancel workflow.
+ Returned status: ${status}`
+ });
+ });
+ },
+ actionText: 'CANCEL'
+ });
+ },
+ relaunchJob: function(scope) {
+ InitiatePlaybookRun({ scope: scope, id: scope.workflow.id,
+ relaunch: true });
+ }
+ };
+ return val;
+}];
diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/main.js b/awx/ui/client/src/workflow-results/workflow-status-bar/main.js
new file mode 100644
index 0000000000..251258fc70
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-status-bar/main.js
@@ -0,0 +1,11 @@
+/*************************************************
+ * Copyright (c) 2015 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+import workflowStatusBar from './workflow-status-bar.directive';
+
+export default
+ angular.module('workflowStatusBarDirective', [])
+ .directive('workflowStatusBar', workflowStatusBar);
diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less
new file mode 100644
index 0000000000..38e57d4883
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less
@@ -0,0 +1,80 @@
+@import '../../shared/branding/colors.default.less';
+
+.WorkflowStatusBar {
+ display: flex;
+ flex: 0 0 auto;
+ width: 100%;
+ margin-top: 10px;
+}
+
+.WorkflowStatusBar-ok,
+.WorkflowStatusBar-changed,
+.WorkflowStatusBar-unreachable,
+.WorkflowStatusBar-failures,
+.WorkflowStatusBar-skipped,
+.WorkflowStatusBar-noData {
+ height: 15px;
+ border-top: 5px solid @default-bg;
+ border-bottom: 5px solid @default-bg;
+}
+
+.WorkflowStatusBar-ok {
+ background-color: @default-succ;
+ display: flex;
+ flex: 0 0 auto;
+}
+
+.WorkflowStatusBar-changed {
+ background-color: @default-warning;
+ flex: 0 0 auto;
+}
+
+.WorkflowStatusBar-unreachable {
+ background-color: @default-unreachable;
+ flex: 0 0 auto;
+}
+
+.WorkflowStatusBar-failures {
+ background-color: @default-err;
+ flex: 0 0 auto;
+}
+
+.WorkflowStatusBar-skipped {
+ background-color: @default-link;
+ flex: 0 0 auto;
+}
+
+.WorkflowStatusBar-noData {
+ background-color: @default-icon-hov;
+ flex: 1 0 auto;
+}
+
+.WorkflowStatusBar-tooltipLabel {
+ text-transform: uppercase;
+ margin-right: 15px;
+}
+
+.WorkflowStatusBar-tooltipBadge {
+ border-radius: 5px;
+}
+
+.WorkflowStatusBar-tooltipBadge--ok {
+ background-color: @default-succ;
+}
+
+.WorkflowStatusBar-tooltipBadge--unreachable {
+ background-color: @default-unreachable;
+}
+
+.WorkflowStatusBar-tooltipBadge--skipped {
+ background-color: @default-link;
+}
+
+.WorkflowStatusBar-tooltipBadge--changed {
+ background-color: @default-warning;
+}
+
+.WorkflowStatusBar-tooltipBadge--failures {
+ background-color: @default-err;
+
+}
diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js
new file mode 100644
index 0000000000..a6899eb0da
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js
@@ -0,0 +1,43 @@
+/*************************************************
+ * Copyright (c) 2016 Ansible, Inc.
+ *
+ * All Rights Reserved
+ *************************************************/
+
+// import WorkflowStatusBarController from './host-status-bar.controller';
+export default [ 'templateUrl',
+ function(templateUrl) {
+ return {
+ scope: true,
+ templateUrl: templateUrl('workflow-results/workflow-status-bar/workflow-status-bar'),
+ restrict: 'E',
+ // controller: standardOutLogController,
+ link: function(scope) {
+ // as count is changed by event data coming in,
+ // update the host status bar
+ scope.$watch('count', function(val) {
+ if (val) {
+ Object.keys(val).forEach(key => {
+ // reposition the hosts status bar by setting
+ // the various flex values to the count of
+ // those hosts
+ $(`.WorkflowStatusBar-${key}`)
+ .css('flex', `${val[key]} 0 auto`);
+
+ // set the tooltip to give how many hosts of
+ // each type
+ if (val[key] > 0) {
+ scope[`${key}CountTip`] = `${key} ${val[key]} `;
+ }
+ });
+
+ // if there are any hosts that have finished, don't
+ // show default grey bar
+ scope.hostsFinished = (Object
+ .keys(val)
+ .filter(key => (val[key] > 0)).length > 0);
+ }
+ });
+ }
+ };
+}];
diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html
new file mode 100644
index 0000000000..e0efddc7b6
--- /dev/null
+++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html
@@ -0,0 +1,26 @@
+
diff --git a/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js b/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js
new file mode 100644
index 0000000000..5d542622cd
--- /dev/null
+++ b/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js
@@ -0,0 +1,269 @@
+'use strict';
+
+describe('Controller: JobTemplatesList', () => {
+ // Setup
+ let scope,
+ rootScope,
+ state,
+ JobTemplatesListController,
+ ClearScope,
+ GetChoices,
+ Alert,
+ Prompt,
+ InitiatePlaybookRun,
+ rbacUiControlService,
+ canAddDeferred,
+ q,
+ JobTemplateService,
+ deleteWorkflowJobTemplateDeferred,
+ deleteJobTemplateDeferred,
+ Dataset;
+
+ beforeEach(angular.mock.module('Tower'));
+ beforeEach(angular.mock.module('jobTemplates', ($provide) => {
+
+ state = jasmine.createSpyObj('state', [
+ '$get',
+ 'transitionTo',
+ 'go'
+ ]);
+
+ state.params = {
+ id: 1
+ };
+
+ rbacUiControlService = {
+ canAdd: function(){
+ return angular.noop;
+ }
+ };
+
+ JobTemplateService = {
+ deleteWorkflowJobTemplate: function(){
+ return angular.noop;
+ },
+ deleteJobTemplate: function(){
+ return angular.noop;
+ }
+ };
+
+ Dataset = {
+ data: {
+ results: []
+ }
+ };
+
+ ClearScope = jasmine.createSpy('ClearScope');
+ GetChoices = jasmine.createSpy('GetChoices');
+ Alert = jasmine.createSpy('Alert');
+ Prompt = jasmine.createSpy('Prompt').and.callFake(function(args) {
+ args.action();
+ });
+ InitiatePlaybookRun = jasmine.createSpy('InitiatePlaybookRun');
+
+ $provide.value('ClearScope', ClearScope);
+ $provide.value('GetChoices', GetChoices);
+ $provide.value('Alert', Alert);
+ $provide.value('Prompt', Prompt);
+ $provide.value('state', state);
+ $provide.value('InitiatePlaybookRun', InitiatePlaybookRun);
+ }));
+
+ beforeEach(angular.mock.inject( ($rootScope, $controller, $q, _state_, _ConfigService_, _ClearScope_, _GetChoices_, _Alert_, _Prompt_, _InitiatePlaybookRun_) => {
+ scope = $rootScope.$new();
+ rootScope = $rootScope;
+ q = $q;
+ state = _state_;
+ ClearScope = _ClearScope_;
+ GetChoices = _GetChoices_;
+ Alert = _Alert_;
+ Prompt = _Prompt_;
+ InitiatePlaybookRun = _InitiatePlaybookRun_;
+ canAddDeferred = q.defer();
+ deleteWorkflowJobTemplateDeferred = q.defer();
+ deleteJobTemplateDeferred = q.defer();
+
+ rbacUiControlService.canAdd = jasmine.createSpy('canAdd').and.returnValue(canAddDeferred.promise);
+
+ JobTemplateService.deleteWorkflowJobTemplate = jasmine.createSpy('deleteWorkflowJobTemplate').and.returnValue(deleteWorkflowJobTemplateDeferred.promise);
+ JobTemplateService.deleteJobTemplate = jasmine.createSpy('deleteJobTemplate').and.returnValue(deleteJobTemplateDeferred.promise);
+
+ JobTemplatesListController = $controller('JobTemplatesListController', {
+ $scope: scope,
+ $rootScope: rootScope,
+ $state: state,
+ ClearScope: ClearScope,
+ GetChoices: GetChoices,
+ Alert: Alert,
+ Prompt: Prompt,
+ InitiatePlaybookRun: InitiatePlaybookRun,
+ rbacUiControlService: rbacUiControlService,
+ JobTemplateService: JobTemplateService,
+ Dataset: Dataset
+ });
+ }));
+
+ describe('scope.editJobTemplate()', () => {
+
+ it('should call Alert when template param is not present', ()=>{
+ scope.editJobTemplate();
+ expect(Alert).toHaveBeenCalledWith('Error: Unable to edit template', 'Template parameter is missing');
+ });
+
+ it('should transition to templates.editJobTemplate when type is "Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Job Template",
+ id: 1
+ };
+
+ scope.editJobTemplate(testTemplate);
+ expect(state.transitionTo).toHaveBeenCalledWith('templates.editJobTemplate', {job_template_id: 1});
+ });
+
+ it('should transition to templates.templates.editWorkflowJobTemplate when type is "Workflow Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Workflow Job Template",
+ id: 1
+ };
+
+ scope.editJobTemplate(testTemplate);
+ expect(state.transitionTo).toHaveBeenCalledWith('templates.editWorkflowJobTemplate', {workflow_job_template_id: 1});
+ });
+
+ it('should call Alert when type is not "Job Template" or "Workflow Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Some Other Type",
+ id: 1
+ };
+
+ scope.editJobTemplate(testTemplate);
+ expect(Alert).toHaveBeenCalledWith('Error: Unable to determine template type', 'We were unable to determine this template\'s type while routing to edit.');
+ });
+
+ });
+
+ describe('scope.deleteJobTemplate()', () => {
+
+ it('should call Alert when template param is not present', ()=>{
+ scope.deleteJobTemplate();
+ expect(Alert).toHaveBeenCalledWith('Error: Unable to delete template', 'Template parameter is missing');
+ });
+
+ it('should call Prompt if template param is present', ()=>{
+
+ var testTemplate = {
+ id: 1,
+ name: "Test Template"
+ };
+
+ scope.deleteJobTemplate(testTemplate);
+ expect(Prompt).toHaveBeenCalled();
+ });
+
+ it('should call JobTemplateService.deleteWorkflowJobTemplate when the user takes affirmative action on the delete modal and type = "Workflow Job Template"', ()=>{
+ // Note that Prompt has been mocked up above to immediately call the callback function that gets passed in
+ // which is how we access the private function in the controller
+
+ var testTemplate = {
+ id: 1,
+ name: "Test Template",
+ type: "Workflow Job Template"
+ };
+
+ scope.deleteJobTemplate(testTemplate);
+ expect(JobTemplateService.deleteWorkflowJobTemplate).toHaveBeenCalled();
+ });
+
+ it('should call JobTemplateService.deleteJobTemplate when the user takes affirmative action on the delete modal and type = "Workflow Job Template"', ()=>{
+ // Note that Prompt has been mocked up above to immediately call the callback function that gets passed in
+ // which is how we access the private function in the controller
+
+ var testTemplate = {
+ id: 1,
+ name: "Test Template",
+ type: "Job Template"
+ };
+
+ scope.deleteJobTemplate(testTemplate);
+ expect(JobTemplateService.deleteJobTemplate).toHaveBeenCalled();
+ });
+
+ });
+
+ describe('scope.submitJob()', () => {
+
+ it('should call Alert when template param is not present', ()=>{
+ scope.submitJob();
+ expect(Alert).toHaveBeenCalledWith('Error: Unable to launch template', 'Template parameter is missing');
+ });
+
+ it('should call InitiatePlaybookRun when type is "Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Job Template",
+ id: 1
+ };
+
+ scope.submitJob(testTemplate);
+ expect(InitiatePlaybookRun).toHaveBeenCalled();
+ });
+
+ xit('should call [something] when type is "Workflow Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Workflow Job Template",
+ id: 1
+ };
+
+ scope.submitJob(testTemplate);
+ expect([something]).toHaveBeenCalled();
+ });
+
+ it('should call Alert when type is not "Job Template" or "Workflow Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Some Other Type",
+ id: 1
+ };
+
+ scope.submitJob(testTemplate);
+ expect(Alert).toHaveBeenCalledWith('Error: Unable to determine template type', 'We were unable to determine this template\'s type while launching.');
+ });
+
+ });
+
+ describe('scope.scheduleJob()', () => {
+
+ it('should transition to jobTemplateSchedules when type is "Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Job Template",
+ id: 1
+ };
+
+ scope.scheduleJob(testTemplate);
+ expect(state.go).toHaveBeenCalledWith('jobTemplateSchedules', {id: 1});
+ });
+
+ it('should transition to workflowJobTemplateSchedules when type is "Workflow Job Template"', ()=>{
+
+ var testTemplate = {
+ type: "Workflow Job Template",
+ id: 1
+ };
+
+ scope.scheduleJob(testTemplate);
+ expect(state.go).toHaveBeenCalledWith('workflowJobTemplateSchedules', {id: 1});
+ });
+
+ it('should call Alert when template param is not present', ()=>{
+ scope.scheduleJob();
+ expect(Alert).toHaveBeenCalledWith('Error: Unable to schedule job', 'Template parameter is missing');
+ });
+
+ });
+
+});
diff --git a/awx/ui/tests/spec/workflows/workflow-add.controller-test.js b/awx/ui/tests/spec/workflows/workflow-add.controller-test.js
new file mode 100644
index 0000000000..a631ac66ff
--- /dev/null
+++ b/awx/ui/tests/spec/workflows/workflow-add.controller-test.js
@@ -0,0 +1,161 @@
+'use strict';
+
+describe('Controller: WorkflowAdd', () => {
+ // Setup
+ let scope,
+ state,
+ WorkflowAdd,
+ ClearScope,
+ Alert,
+ GenerateForm,
+ JobTemplateService,
+ q,
+ getLabelsDeferred,
+ createWorkflowJobTemplateDeferred,
+ httpBackend,
+ ProcessErrors,
+ CreateSelect2,
+ Wait,
+ ParseTypeChange,
+ ToJSON;
+
+ beforeEach(angular.mock.module('Tower'));
+ beforeEach(angular.mock.module('jobTemplates', ($provide) => {
+
+ state = jasmine.createSpyObj('state', [
+ '$get',
+ 'transitionTo',
+ 'go'
+ ]);
+
+ GenerateForm = jasmine.createSpyObj('GenerateForm', [
+ 'inject',
+ 'reset',
+ 'clearApiErrors',
+ 'applyDefaults'
+ ]);
+
+ JobTemplateService = {
+ getLabelOptions: function(){
+ return angular.noop;
+ },
+ createWorkflowJobTemplate: function(){
+ return angular.noop;
+ }
+ };
+
+ ClearScope = jasmine.createSpy('ClearScope');
+ Alert = jasmine.createSpy('Alert');
+ ProcessErrors = jasmine.createSpy('ProcessErrors');
+ CreateSelect2 = jasmine.createSpy('CreateSelect2');
+ Wait = jasmine.createSpy('Wait');
+ ParseTypeChange = jasmine.createSpy('ParseTypeChange');
+ ToJSON = jasmine.createSpy('ToJSON');
+
+ $provide.value('ClearScope', ClearScope);
+ $provide.value('Alert', Alert);
+ $provide.value('GenerateForm', GenerateForm);
+ $provide.value('state', state);
+ $provide.value('ProcessErrors', ProcessErrors);
+ $provide.value('CreateSelect2', CreateSelect2);
+ $provide.value('Wait', Wait);
+ $provide.value('ParseTypeChange', ParseTypeChange);
+ $provide.value('ToJSON', ToJSON);
+ }));
+
+ beforeEach(angular.mock.inject( ($rootScope, $controller, $q, $httpBackend, _state_, _ConfigService_, _ClearScope_, _GetChoices_, _Alert_, _GenerateForm_, _ProcessErrors_, _CreateSelect2_, _Wait_, _ParseTypeChange_, _ToJSON_) => {
+ scope = $rootScope.$new();
+ state = _state_;
+ q = $q;
+ ClearScope = _ClearScope_;
+ Alert = _Alert_;
+ GenerateForm = _GenerateForm_;
+ httpBackend = $httpBackend;
+ ProcessErrors = _ProcessErrors_;
+ CreateSelect2 = _CreateSelect2_;
+ Wait = _Wait_;
+ getLabelsDeferred = q.defer();
+ createWorkflowJobTemplateDeferred = q.defer();
+ ParseTypeChange = _ParseTypeChange_;
+ ToJSON = _ToJSON_;
+
+ JobTemplateService.getLabelOptions = jasmine.createSpy('getLabelOptions').and.returnValue(getLabelsDeferred.promise);
+ JobTemplateService.createWorkflowJobTemplate = jasmine.createSpy('createWorkflowJobTemplate').and.returnValue(createWorkflowJobTemplateDeferred.promise);
+
+ WorkflowAdd = $controller('WorkflowAdd', {
+ $scope: scope,
+ $state: state,
+ ClearScope: ClearScope,
+ Alert: Alert,
+ GenerateForm: GenerateForm,
+ JobTemplateService: JobTemplateService,
+ ProcessErrors: ProcessErrors,
+ CreateSelect2: CreateSelect2,
+ Wait: Wait,
+ ParseTypeChange: ParseTypeChange,
+ ToJSON
+ });
+ }));
+
+ it('should call ClearScope', ()=>{
+ expect(ClearScope).toHaveBeenCalled();
+ });
+
+ it('should get/set the label options and select2-ify the input', ()=>{
+ // Resolve JobTemplateService.getLabelsForJobTemplate
+ getLabelsDeferred.resolve({
+ foo: "bar"
+ });
+ // We expect the digest cycle to fire off this call to /static/config.js so we go ahead and handle it
+ httpBackend.expectGET('/static/config.js').respond(200);
+ scope.$digest();
+ expect(scope.labelOptions).toEqual({
+ foo: "bar"
+ });
+ expect(CreateSelect2).toHaveBeenCalledWith({
+ element:'#workflow_job_template_labels',
+ multiple: true,
+ addNew: true
+ });
+ });
+
+ it('should call ProcessErrors when getLabelsForJobTemplate returns a rejected promise', ()=>{
+ // Reject JobTemplateService.getLabelsForJobTemplate
+ getLabelsDeferred.reject({
+ data: "mockedData",
+ status: 400
+ });
+ // We expect the digest cycle to fire off this call to /static/config.js so we go ahead and handle it
+ httpBackend.expectGET('/static/config.js').respond(200);
+ scope.$digest();
+ expect(ProcessErrors).toHaveBeenCalled();
+ });
+
+ describe('scope.formSave()', () => {
+
+ it('should call JobTemplateService.createWorkflowJobTemplate', ()=>{
+ scope.name = "Test Workflow";
+ scope.description = "This is a test description";
+ scope.formSave();
+ expect(JobTemplateService.createWorkflowJobTemplate).toHaveBeenCalledWith({
+ name: "Test Workflow",
+ description: "This is a test description",
+ labels: undefined,
+ organization: undefined,
+ variables: undefined,
+ extra_vars: undefined
+ });
+ });
+
+ });
+
+ describe('scope.formCancel()', () => {
+
+ it('should transition to templates', ()=>{
+ scope.formCancel();
+ expect(state.transitionTo).toHaveBeenCalledWith('templates');
+ });
+
+ });
+
+});
diff --git a/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js b/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js
new file mode 100644
index 0000000000..0486356249
--- /dev/null
+++ b/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js
@@ -0,0 +1,58 @@
+'use strict';
+
+describe('Controller: WorkflowMaker', () => {
+ // Setup
+ let scope,
+ WorkflowMakerController,
+ WorkflowHelpService;
+
+ beforeEach(angular.mock.module('Tower'));
+ beforeEach(angular.mock.module('jobTemplates', ($provide) => {
+
+ WorkflowHelpService = jasmine.createSpyObj('WorkflowHelpService', [
+ 'closeDialog',
+ 'addPlaceholderNode',
+ 'getSiblingConnectionTypes'
+ ]);
+
+ $provide.value('WorkflowHelpService', WorkflowHelpService);
+
+ }));
+
+ beforeEach(angular.mock.inject( ($rootScope, $controller, _WorkflowHelpService_) => {
+ scope = $rootScope.$new();
+ scope.treeData = {
+ data: {
+ id: 1,
+ canDelete: false,
+ canEdit: false,
+ canAddTo: true,
+ isStartNode: true,
+ unifiedJobTemplate: {
+ name: "Workflow Launch"
+ },
+ children: [],
+ deletedNodes: [],
+ totalNodes: 0
+ },
+ nextIndex: 2
+ };
+ WorkflowHelpService = _WorkflowHelpService_;
+
+ WorkflowMakerController = $controller('WorkflowMakerController', {
+ $scope: scope,
+ WorkflowHelpService: WorkflowHelpService
+ });
+
+ }));
+
+ describe('scope.saveWorkflowMaker()', () => {
+
+ it('should close the dialog', ()=>{
+ scope.saveWorkflowMaker();
+ expect(WorkflowHelpService.closeDialog).toHaveBeenCalled();
+ });
+
+ });
+
+});
diff --git a/awx/ui/webpack.config.js b/awx/ui/webpack.config.js
index 78ee691d9c..0b279d7fbf 100644
--- a/awx/ui/webpack.config.js
+++ b/awx/ui/webpack.config.js
@@ -123,6 +123,7 @@ var release = {
'jsyaml': 'js-yaml',
'jsonlint': 'codemirror.jsonlint'
}),
+ new webpack.DefinePlugin({ $ENV: {} }),
new webpack.optimize.CommonsChunkPlugin('vendor', 'tower.vendor.js'),
new webpack.optimize.UglifyJsPlugin({
mangle: false
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 072eeed380..b57a325194 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -2,7 +2,7 @@ git+https://github.com/ansible/ansiconv.git@tower_1.0.0#egg=ansiconv
amqp==1.4.9
anyjson==0.3.3
appdirs==1.4.0
-asgi_amqp==0.3.1
+asgi-amqp==0.3.1
azure==2.0.0rc5
Babel==2.2.0
baron==0.6.2
@@ -10,11 +10,11 @@ billiard==3.3.0.16
boto==2.43.0
celery==3.1.23
cffi==1.7.0
-channels==0.17.2
+channels==0.17.3
cliff==1.15.0
cmd2==0.6.8
daphne==0.15.0
-d2to1==0.2.11 # TODO: Still needed?
+d2to1==0.2.11
defusedxml==0.4.1
Django==1.8.16
debtcollector==1.2.0
@@ -36,7 +36,7 @@ django-taggit==0.17.6
git+https://github.com/ansible/dm.xmlsec.binding.git@master#egg=dm.xmlsec.binding
dogpile.core==0.4.1
funcsigs==0.4
-gevent==1.1
+gevent==1.1.0
gevent-websocket==0.9.5
git+https://github.com/ansible/django-qsstats-magic.git@tower_0.7.2#egg=django-qsstats-magic
greenlet==0.4.9
diff --git a/tools/docker-compose/unit-tests/Dockerfile b/tools/docker-compose/unit-tests/Dockerfile
index dc4a10bcc4..4658c5d32e 100644
--- a/tools/docker-compose/unit-tests/Dockerfile
+++ b/tools/docker-compose/unit-tests/Dockerfile
@@ -1,39 +1,13 @@
-# FROM gcr.io/ansible-tower-engineering/tower_devel:latest
-FROM centos
+FROM gcr.io/ansible-tower-engineering/tower_devel:latest
-RUN yum install -y epel-release
+RUN yum install -y bzip2 gcc-c++
-RUN yum install -y \
- bzip2 \
- python-pip \
- python-virtualenv \
- make \
- swig \
- git \
- libffi-devel \
- openssl-devel \
- libxml2-devel \
- xmlsec1-devel \
- xmlsec1-openssl-devel \
- gcc \
- gcc-c++ \
- /usr/bin/pg_config \
- openldap-devel \
- postgresql-devel \
- rabbitmq-server \
- libtool-ltdl-devel \
- fontconfig
+# We need to install dependencies somewhere other than /ansible-tower.
+# Anything in /ansible-tower will be overwritten by the bind-mount.
+# We switch the WORKDIR to /ansible-tower further down.
+WORKDIR "/tmp/"
-# NOTE: The following steps work for tower-3.0.0
-# RUN curl -LO https://rpm.nodesource.com/pub_0.12/el/7/x86_64/nodejs-0.12.9-1nodesource.el7.centos.x86_64.rpm
-# RUN yum install -y nodejs-0.12.9-1nodesource.el7.centos.x86_64.rpm
-# RUN rm nodejs-0.12.9-1nodesource.el7.centos.x86_64.rpm
-
-# Remove the 2 lines below and uncomment the 3 lines above to build
-# RPMs with the old JS build system.
-RUN curl --silent --location https://rpm.nodesource.com/setup_6.x | bash -
-
-RUN yum install -y nodejs
+# Build front-end deps
# https://github.com/npm/npm/issues/9863
RUN cd $(npm root -g)/npm \
@@ -42,41 +16,15 @@ RUN cd $(npm root -g)/npm \
RUN npm install -g npm@3.10.7
-# We need to install dependencies somewhere other than /ansible-tower.
-# Anything in /ansible-tower will be overwritten by the bind-mount.
-# We switch the WORKDIR to /ansible-tower further down.
-WORKDIR "/tmp/ansible-tower"
-
-# Copy requirements files
-# NOTE: '*' is not used as it invalidates docker caching
-COPY requirements/requirements.txt requirements/
-COPY requirements/requirements_ansible.txt requirements/
-COPY requirements/requirements_dev.txt requirements/
-COPY requirements/requirements_jenkins.txt requirements/
-
-
-# Copy __init__.py so the Makefile can retrieve `awx.__version__`
-COPY awx/__init__.py awx/
-
-# Copy Makefile
-COPY Makefile .
-
-# Install tower runtime virtualenvs
-ENV SWIG_FEATURES="-cpperraswarn -includeall -I/usr/include/openssl"
-RUN make requirements
-
-# Install tower test requirements
-ENV VENV_BASE=""
-RUN make requirements_jenkins
-
-# Build front-end deps
COPY awx/ui/package.json awx/ui/
RUN npm set progress=false
+# Copy __init__.py so the Makefile can retrieve `awx.__version__`
+COPY awx/__init__.py awx/
RUN make ui-deps
-WORKDIR "/ansible-tower"
+WORKDIR "/tower_devel"
# This entrypoint script takes care of moving the node_modules
# into the bind-mount, then exec's to whatever was passed as the CMD.
diff --git a/tools/docker-compose/unit-tests/docker-compose.yml b/tools/docker-compose/unit-tests/docker-compose.yml
index 9e59aadd5f..243726ae8d 100644
--- a/tools/docker-compose/unit-tests/docker-compose.yml
+++ b/tools/docker-compose/unit-tests/docker-compose.yml
@@ -11,4 +11,4 @@ services:
TEST_DIRS: awx/main/tests/unit awx/main/tests/functional
command: ["make test"]
volumes:
- - ../../../:/ansible-tower
+ - ../../../:/tower_devel
diff --git a/tools/docker-compose/unit-tests/entrypoint b/tools/docker-compose/unit-tests/entrypoint
index 0fcf42881b..cb7fbe142e 100644
--- a/tools/docker-compose/unit-tests/entrypoint
+++ b/tools/docker-compose/unit-tests/entrypoint
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
-mkdir -p /ansible-tower/awx/ui/
-mv -n /tmp/ansible-tower/awx/ui/node_modules /ansible-tower/awx/ui
+mkdir -p /tower_devel/awx/ui/
+mv -n /tmp/awx/ui/node_modules /tmp/awx/ui/.deps_built /tower_devel/awx/ui
exec $@