diff --git a/awx/ui/client/src/access/roleList.block.less b/awx/ui/client/src/access/roleList.block.less index 8bc4dd38de..b3e1d635c9 100644 --- a/awx/ui/client/src/access/roleList.block.less +++ b/awx/ui/client/src/access/roleList.block.less @@ -38,6 +38,8 @@ .RoleList-deleteContainer { border: 1px solid @default-second-border; + border-left-color: @default-bg; + background-color: @default-bg; border-top-right-radius: 5px; border-bottom-right-radius: 5px; padding: 0 5px; diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index 9799fb76a8..2ea6ea2df8 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -189,6 +189,18 @@ export default dataPlacement: "right", dataContainer: "body" }, + labels: { + label: 'Labels', + type: 'select', + ngOptions: 'label.label for label in labelOptions track by label.value', + multiSelect: true, + addRequired: false, + editRequired: false, + dataTitle: 'Labels', + dataPlacement: 'right', + awPopOver: 'You can add labels to a job template to aid in filtering', + dataContainer: 'body' + }, variables: { label: 'Extra Variables', type: 'textarea', diff --git a/awx/ui/client/src/job-templates/add/job-templates-add.controller.js b/awx/ui/client/src/job-templates/add/job-templates-add.controller.js index 3863a6fc84..01b80551c6 100644 --- a/awx/ui/client/src/job-templates/add/job-templates-add.controller.js +++ b/awx/ui/client/src/job-templates/add/job-templates-add.controller.js @@ -11,14 +11,14 @@ 'GetBasePath', 'InventoryList', 'CredentialList', 'ProjectList', 'LookUpInit', 'md5Setup', 'ParseTypeChange', 'Wait', 'Empty', 'ToJSON', 'CallbackHelpInit', 'initSurvey', 'Prompt', 'GetChoices', '$state', - 'CreateSelect2', + 'CreateSelect2', '$q', function( Refresh, $filter, $scope, $rootScope, $compile, $location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, ProcessErrors, ReturnToCaller, ClearScope, GetBasePath, InventoryList, CredentialList, ProjectList, LookUpInit, md5Setup, ParseTypeChange, Wait, Empty, ToJSON, CallbackHelpInit, SurveyControllerInit, Prompt, GetChoices, - $state, CreateSelect2 + $state, CreateSelect2, $q ) { ClearScope(); @@ -113,7 +113,7 @@ } $scope.removeChoicesReady = $scope.$on('choicesReadyVerbosity', function () { selectCount++; - if (selectCount === 2) { + if (selectCount === 3) { var verbosity; // this sets the default options for the selects as specified by the controller. for (verbosity in $scope.verbosity_options) { @@ -145,7 +145,11 @@ element:'#job_templates_job_type', multiple: false }); - + CreateSelect2({ + element:'#job_templates_labels', + multiple: true, + addNew: true + }); CreateSelect2({ element:'#playbook-select', multiple: false @@ -178,6 +182,21 @@ callback: 'choicesReadyVerbosity' }); + Rest.setUrl('api/v1/labels'); + Rest.get() + .success(function (data) { + $scope.labelOptions = data.results + .map((i) => ({label: i.name, value: i.id})); + $scope.$emit("choicesReadyVerbosity"); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to get labels. GET returned ' + + 'status: ' + status + }); + }); + // Update playbook select whenever project value changes selectPlaybook = function (oldValue, newValue) { var url; @@ -265,12 +284,6 @@ } }; - - // $scope.selectPlaybookUnregister = $scope.$watch('project_name', function (newval, oldval) { - // selectPlaybook(oldval, newval); - // checkSCMStatus(oldval, newval); - // }); - // Register a watcher on project_name if ($scope.selectPlaybookUnregister) { $scope.selectPlaybookUnregister(); @@ -311,14 +324,126 @@ } $scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) { Wait('stop'); - if (data.related && data.related.callback) { - Alert('Callback URL', '

Host callbacks are enabled for this template. The callback URL is:

'+ - '

' + $scope.callback_server_path + data.related.callback + '

'+ - '

The host configuration key is: ' + $filter('sanitize')(data.host_config_key) + '

', 'alert-info', saveCompleted, null, null, null, true); - } - else { - saveCompleted(); + if (data.related && + data.related.callback) { + Alert('Callback URL', +` +

Host callbacks are enabled for this template. The callback URL is:

+

+ + ${$scope.callback_server_path} + ${data.related.callback} + +

+

The host configuration key is: + + ${$filter('sanitize')(data.host_config_key)} + +

+`, + 'alert-info', saveCompleted, null, null, + null, true); } + var orgDefer = $q.defer(); + var associationDefer = $q.defer(); + + Rest.setUrl(data.related.labels); + + var currentLabels = Rest.get() + .then(function(data) { + return data.data.results + .map(val => val.id); + }); + + currentLabels.then(function (current) { + 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(data.related.labels); + + var defers = []; + for (var i = 0; i < toPost.length; i++) { + defers.push(Rest.post(toPost[i])); + } + $q.all(defers) + .then(function() { + $scope.addedItem = data.id; + + Refresh({ + scope: $scope, + set: 'job_templates', + iterator: 'job_template', + url: $scope.current_url + }); + + if($scope.survey_questions && + $scope.survey_questions.length > 0){ + //once the job template information + // is saved we submit the survey + // info to the correct endpoint + var url = data.url+ 'survey_spec/'; + Rest.setUrl(url); + Rest.post({ name: $scope.survey_name, + description: $scope.survey_description, + spec: $scope.survey_questions }) + .success(function () { + Wait('stop'); + }) + .error(function (data, + status) { + ProcessErrors( + $scope, + data, + status, + form, + { + hdr: 'Error!', + msg: 'Failed to add new ' + + 'survey. Post returned ' + + 'status: ' + + status + }); + }); + } + + saveCompleted(); + }); + }); + }); }); // Save @@ -327,11 +452,13 @@ $scope.invalid_survey = false; // users can't save a survey with a scan job - if($scope.job_type.value === "scan" && $scope.survey_enabled === true){ + if($scope.job_type.value === "scan" && + $scope.survey_enabled === true){ $scope.survey_enabled = false; } // Can't have a survey enabled without a survey - if($scope.survey_enabled === true && $scope.survey_exists!==true){ + if($scope.survey_enabled === true && + $scope.survey_exists!==true){ $scope.survey_enabled = false; } @@ -341,70 +468,61 @@ try { for (fld in form.fields) { - if (form.fields[fld].type === 'select' && fld !== 'playbook') { + if (form.fields[fld].type === 'select' && + fld !== 'playbook') { data[fld] = $scope[fld].value; } else { - if (fld !== 'variables' && fld !== 'survey') { + if (fld !== 'variables' && + fld !== 'survey') { data[fld] = $scope[fld]; } } } - data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); - if(data.job_type === 'scan' && $scope.default_scan === true){ + data.extra_vars = ToJSON($scope.parseType, + $scope.variables, true); + if(data.job_type === 'scan' && + $scope.default_scan === true){ data.project = ""; data.playbook = ""; } - // We only want to set the survey_enabled flag to true for this job template if a survey exists - // and it's been enabled. By default, survey_enabled is explicitly set to true but if no survey - // is created then we don't want it enabled. - data.survey_enabled = ($scope.survey_enabled && $scope.survey_exists) ? $scope.survey_enabled : false; + // We only want to set the survey_enabled flag to + // true for this job template if a survey exists + // and it's been enabled. By default, + // survey_enabled is explicitly set to true but + // if no survey is created then we don't want + // it enabled. + data.survey_enabled = ($scope.survey_enabled && + $scope.survey_exists) ? $scope.survey_enabled : false; + + $scope.newLabels = $("#job_templates_labels > option") + .filter("[data-select2-tag=true]") + .map((i, val) => ({name: $(val).text()})); + Rest.setUrl(defaultUrl); Rest.post(data) .success(function(data) { - $scope.$emit('templateSaveSuccess', data); - - $scope.addedItem = data.id; - - Refresh({ - scope: $scope, - set: 'job_templates', - iterator: 'job_template', - url: $scope.current_url - }); - - if($scope.survey_questions && $scope.survey_questions.length > 0){ - //once the job template information is saved we submit the survey info to the correct endpoint - var url = data.url+ 'survey_spec/'; - Rest.setUrl(url); - Rest.post({ name: $scope.survey_name, description: $scope.survey_description, spec: $scope.survey_questions }) - .success(function () { - Wait('stop'); - - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to add new survey. Post returned status: ' + status }); - }); - } - - + $scope.$emit('templateSaveSuccess', + data); }) .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to add new job template. POST returned status: ' + status - }); + ProcessErrors($scope, data, status, form, + { + hdr: 'Error!', + msg: 'Failed to add new job ' + + 'template. POST returned status: ' + + status + }); }); } catch (err) { Wait('stop'); - Alert("Error", "Error parsing extra variables. Parser returned: " + err); + Alert("Error", "Error parsing extra variables. " + + "Parser returned: " + err); } - }; $scope.formCancel = function () { $state.transitionTo('jobTemplates'); }; } - ]; diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js index c8a2bdcd95..04cdfb80e5 100644 --- a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js @@ -21,7 +21,7 @@ export default 'SchedulesControllerInit', 'JobsControllerInit', 'JobsListUpdate', 'GetChoices', 'SchedulesListInit', 'SchedulesList', 'CallbackHelpInit', 'PlaybookRun' , 'initSurvey', '$state', 'CreateSelect2', - 'ToggleNotification', 'NotificationsListInit', + 'ToggleNotification', 'NotificationsListInit', '$q', function( $filter, $scope, $rootScope, $compile, $location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, @@ -31,7 +31,7 @@ export default Empty, Prompt, ParseVariableString, ToJSON, SchedulesControllerInit, JobsControllerInit, JobsListUpdate, GetChoices, SchedulesListInit, SchedulesList, CallbackHelpInit, PlaybookRun, SurveyControllerInit, $state, - CreateSelect2, ToggleNotification, NotificationsListInit + CreateSelect2, ToggleNotification, NotificationsListInit, $q ) { ClearScope(); @@ -232,11 +232,6 @@ export default multiple: false }); - CreateSelect2({ - element:'#job_templates_verbosity', - multiple: false - }); - for (var set in relatedSets) { $scope.search(relatedSets[set].iterator); } @@ -353,7 +348,7 @@ export default } $scope.removeChoicesReady = $scope.$on('choicesReady', function() { choicesCount++; - if (choicesCount === 4) { + if (choicesCount === 5) { $scope.$emit('LoadJobs'); } }); @@ -392,6 +387,41 @@ export default callback: 'choicesReady' }); + Rest.setUrl('api/v1/labels'); + Wait("start"); + Rest.get() + .success(function (data) { + $scope.labelOptions = data.results + .map((i) => ({label: i.name, value: i.id})); + $scope.$emit("choicesReady"); + Rest.setUrl(defaultUrl + $state.params.template_id + + "/labels"); + Rest.get() + .success(function(data) { + var opts = data.results + .map(i => ({id: i.id + "", + test: i.name})); + CreateSelect2({ + element:'#job_templates_labels', + multiple: true, + addNew: true, + opts: opts + }); + Wait("stop"); + }); + CreateSelect2({ + element:'#job_templates_verbosity', + multiple: false + }); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to get labels. GET returned ' + + 'status: ' + status + }); + }); + function saveCompleted() { $state.go('jobTemplates', null, {reload: true}); } @@ -401,34 +431,105 @@ export default } $scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) { Wait('stop'); - if ($scope.allow_callbacks && ($scope.host_config_key !== master.host_config_key || $scope.callback_url !== master.callback_url)) { - if (data.related && data.related.callback) { - Alert('Callback URL', '

Host callbacks are enabled for this template. The callback URL is:

'+ - '

' + $scope.callback_server_path + data.related.callback + '

'+ - '

The host configuration key is: ' + $filter('sanitize')(data.host_config_key) + '

', 'alert-info', saveCompleted, null, null, null, true); - } - else { - saveCompleted(); - } - } - else { - saveCompleted(); + if (data.related && + data.related.callback) { + Alert('Callback URL', +` +

Host callbacks are enabled for this template. The callback URL is:

+

+ + ${$scope.callback_server_path} + ${data.related.callback} + +

+

The host configuration key is: + + ${$filter('sanitize')(data.host_config_key)} + +

+`, + 'alert-info', saveCompleted, null, null, + null, true); } + var orgDefer = $q.defer(); + var associationDefer = $q.defer(); + + Rest.setUrl(data.related.labels); + + var currentLabels = Rest.get() + .then(function(data) { + return data.data.results + .map(val => val.id); + }); + + currentLabels.then(function (current) { + 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(data.related.labels); + + var defers = []; + for (var i = 0; i < toPost.length; i++) { + defers.push(Rest.post(toPost[i])); + } + $q.all(defers) + .then(function() { + saveCompleted(); + }); + }); + }); }); // Save changes to the parent + // Save $scope.formSave = function () { var fld, data = {}; $scope.invalid_survey = false; // users can't save a survey with a scan job - if($scope.job_type.value === "scan" && $scope.survey_enabled === true){ + if($scope.job_type.value === "scan" && + $scope.survey_enabled === true){ $scope.survey_enabled = false; } // Can't have a survey enabled without a survey - if($scope.survey_enabled === true && $scope.survey_exists!==true){ + if($scope.survey_enabled === true && + $scope.survey_exists!==true){ $scope.survey_enabled = false; } @@ -437,21 +538,38 @@ export default Wait('start'); try { - // Make sure we have valid variable data - data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); - if(data.extra_vars === undefined ){ - throw 'undefined variables'; - } for (fld in form.fields) { - if (form.fields[fld].type === 'select' && fld !== 'playbook') { + if (form.fields[fld].type === 'select' && + fld !== 'playbook') { data[fld] = $scope[fld].value; } else { - if (fld !== 'variables' && fld !== 'callback_url') { + if (fld !== 'variables' && + fld !== 'survey') { data[fld] = $scope[fld]; } } } - Rest.setUrl(defaultUrl + id + '/'); + data.extra_vars = ToJSON($scope.parseType, + $scope.variables, true); + if(data.job_type === 'scan' && + $scope.default_scan === true){ + data.project = ""; + data.playbook = ""; + } + // We only want to set the survey_enabled flag to + // true for this job template if a survey exists + // and it's been enabled. By default, + // survey_enabled is explicitly set to true but + // if no survey is created then we don't want + // it enabled. + data.survey_enabled = ($scope.survey_enabled && + $scope.survey_exists) ? $scope.survey_enabled : false; + + $scope.newLabels = $("#job_templates_labels > option") + .filter("[data-select2-tag=true]") + .map((i, val) => ({name: $(val).text()})); + + Rest.setUrl(defaultUrl + $state.params.template_id); Rest.put(data) .success(function (data) { $scope.$emit('templateSaveSuccess', data); @@ -460,12 +578,11 @@ export default ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to update job template. PUT returned status: ' + status }); }); - } catch (err) { Wait('stop'); - Alert("Error", "Error parsing extra variables. Parser returned: " + err); + Alert("Error", "Error parsing extra variables. " + + "Parser returned: " + err); } - }; $scope.formCancel = function () { @@ -474,7 +591,7 @@ export default var defaultUrl = GetBasePath('job_templates') + $state.params.template_id; Rest.setUrl(defaultUrl); Rest.destroy() - .success(function(res){ + .success(function(){ $state.go('jobTemplates', null, {reload: true, notify:true}); }) .error(function(res, status){ diff --git a/awx/ui/client/src/job-templates/labels/labelsList.block.less b/awx/ui/client/src/job-templates/labels/labelsList.block.less new file mode 100644 index 0000000000..a736558b18 --- /dev/null +++ b/awx/ui/client/src/job-templates/labels/labelsList.block.less @@ -0,0 +1,74 @@ +/** @define LabelList */ +@import "../shared/branding/colors.default.less"; + +.LabelList { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.LabelList-tagContainer { + display: flex; + max-width: 100%; +} + +.LabelList-tag { + border-radius: 5px; + padding: 2px 10px; + margin: 4px 0px; + border: 1px solid @default-second-border; + font-size: 12px; + color: @default-interface-txt; + text-transform: uppercase; + background-color: @default-bg; + margin-right: 5px; + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.LabelList-tag--deletable { + margin-right: 0px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border-right: 0; + max-wdith: ~"calc(100% - 23px)"; +} + +.LabelList-deleteContainer { + border: 1px solid @default-second-border; + border-left-color: @default-bg; + background-color: @default-bg; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + padding: 0 5px; + margin: 4px 0px; + margin-right: 5px; + align-items: center; + display: flex; + cursor: pointer; +} + +.LabelList-tagDelete { + font-size: 13px; + color: @default-icon; +} + +.LabelList-name { + flex: initial; + max-width: 100%; +} + +.LabelList-tag--deletable > .LabelList-name { + max-width: ~"calc(100% - 23px)"; +} + +.LabelList-deleteContainer:hover, { + border-color: @default-err; + background-color: @default-err; +} + +.LabelList-deleteContainer:hover > .LabelList-tagDelete { + color: @default-bg; +} diff --git a/awx/ui/client/src/job-templates/labels/labelsList.directive.js b/awx/ui/client/src/job-templates/labels/labelsList.directive.js new file mode 100644 index 0000000000..6df8b36025 --- /dev/null +++ b/awx/ui/client/src/job-templates/labels/labelsList.directive.js @@ -0,0 +1,45 @@ +/* jshint unused: vars */ +export default + [ 'templateUrl', + 'Wait', + 'Rest', + 'GetBasePath', + 'ProcessErrors', + 'Prompt', + function(templateUrl, Wait, Rest, GetBasePath, ProcessErrors, Prompt) { + return { + restrict: 'E', + scope: false, + templateUrl: templateUrl('job-templates/labels/labelsList'), + link: function(scope, element, attrs) { + scope.labels = scope. + job_template.summary_fields.labels; + + scope.deleteLabel = function(templateId, templateName, labelId, labelName) { + var action = function () { + $('#prompt-modal').modal('hide'); + Wait('start'); + var url = GetBasePath("job_templates") + templateId + "/labels/"; + Rest.setUrl(url); + Rest.post({"disassociate": true, "id": labelId}) + .success(function () { + Wait('stop'); + scope.search("job_template"); + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', + msg: 'Could not disacssociate label from JT. Call to ' + url + ' failed. DELETE returned status: ' + status }); + }); + }; + + Prompt({ + hdr: 'Remove Label from ' + templateName, + body: '
Confirm the removal of the ' + labelName + ' label.
', + action: action, + actionText: 'REMOVE' + }); + }; + } + }; + } + ]; diff --git a/awx/ui/client/src/job-templates/labels/labelsList.partial.html b/awx/ui/client/src/job-templates/labels/labelsList.partial.html new file mode 100644 index 0000000000..901ff98109 --- /dev/null +++ b/awx/ui/client/src/job-templates/labels/labelsList.partial.html @@ -0,0 +1,10 @@ +
+
+ {{ label.name }} +
+
+ +
+
diff --git a/awx/ui/client/src/job-templates/labels/main.js b/awx/ui/client/src/job-templates/labels/main.js new file mode 100644 index 0000000000..6d325ff42c --- /dev/null +++ b/awx/ui/client/src/job-templates/labels/main.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import labelsList from './labelsList.directive'; + +export default + angular.module('labels', []) + .directive('labelsList', labelsList); diff --git a/awx/ui/client/src/job-templates/main.js b/awx/ui/client/src/job-templates/main.js index 4bbf4c1125..d19bbe3637 100644 --- a/awx/ui/client/src/job-templates/main.js +++ b/awx/ui/client/src/job-templates/main.js @@ -11,9 +11,10 @@ import jobTemplatesList from './list/main'; import jobTemplatesAdd from './add/main'; import jobTemplatesEdit from './edit/main'; import jobTemplatesCopy from './copy/main'; +import labels from './labels/main'; export default - angular.module('jobTemplates', - [surveyMaker.name, jobTemplatesList.name, jobTemplatesAdd.name, - jobTemplatesEdit.name, jobTemplatesCopy.name]) + angular.module('jobTemplates', + [surveyMaker.name, jobTemplatesList.name, jobTemplatesAdd.name, + jobTemplatesEdit.name, jobTemplatesCopy.name, labels.name]) .service('deleteJobTemplate', deleteJobTemplate); diff --git a/awx/ui/client/src/lists/JobTemplates.js b/awx/ui/client/src/lists/JobTemplates.js index 8b091e2372..5217ad42c9 100644 --- a/awx/ui/client/src/lists/JobTemplates.js +++ b/awx/ui/client/src/lists/JobTemplates.js @@ -23,19 +23,28 @@ export default name: { key: true, label: 'Name', - columnClass: 'col-lg-3 col-md-3 col-sm-4 col-xs-4' + columnClass: 'col-lg-2 col-md-2 col-sm-4 col-xs-9' }, description: { label: 'Description', - columnClass: 'col-lg-3 col-md-3 hidden-sm hidden-xs' + columnClass: 'col-lg-2 hidden-md hidden-sm hidden-xs' }, smart_status: { label: 'Activity', - columnClass: 'List-tableCell col-lg-4 col-md-4 col-sm-5 col-xs-5', + columnClass: 'List-tableCell col-lg-3 col-md-4 hidden-sm hidden-xs', searchable: false, nosort: true, ngInclude: "'/static/partials/job-template-smart-status.html'", type: 'template' + }, + labels: { + label: 'Labels', + type: 'labels', + nosort: true, + columnClass: 'List-tableCell col-lg-3 col-md-3 hidden-sm hidden-xs', + searchType: 'related', + sourceModel: 'labels', + sourceField: 'name' } }, @@ -52,7 +61,7 @@ export default fieldActions: { - columnClass: 'col-lg-2 col-md-2 col-sm-3 col-xs-3', + columnClass: 'col-lg-2 col-md-3 col-sm-3 col-xs-3', submit: { label: 'Launch', diff --git a/awx/ui/client/src/search/tagSearch.block.less b/awx/ui/client/src/search/tagSearch.block.less index 7e89864562..29296b7cb3 100644 --- a/awx/ui/client/src/search/tagSearch.block.less +++ b/awx/ui/client/src/search/tagSearch.block.less @@ -196,6 +196,8 @@ .TagSearch-deleteContainer { border: 1px solid @default-second-border; + border-left-color: @default-bg; + background-color: @default-bg; border-top-right-radius: 5px; border-bottom-right-radius: 5px; padding: 0 5px; diff --git a/awx/ui/client/src/search/tagSearch.service.js b/awx/ui/client/src/search/tagSearch.service.js index 4f8f7fa48a..6a4f2d0dc4 100644 --- a/awx/ui/client/src/search/tagSearch.service.js +++ b/awx/ui/client/src/search/tagSearch.service.js @@ -3,9 +3,13 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', function(R // parse the field config object to return // one of the searchTypes (for the left dropdown) this.buildType = function (field, key, id) { + var obj = {}; // build the value (key) var value; - if (typeof(field.key) === String) { + if (field.sourceModel && field.sourceField) { + value = field.sourceModel + '__' + field.sourceField; + obj.related = true; + } else if (typeof(field.key) === String) { value = field.key; } else { value = key; @@ -27,23 +31,19 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', function(R type = 'text'; } + obj.id = id; + obj.value = value; + obj.label = label; + obj.type = type; + + + // return the built option if (type === 'select') { - return { - id: id, - value: value, - label: label, - type: type, - typeOptions: typeOptions - }; - } else { - return { - id: id, - value: value, - label: label, - type: type - }; + obj.typeOptions = typeOptions; } + + return obj; }; // given the fields that are searchable, @@ -116,6 +116,45 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', function(R // returns the url with filter params this.updateFilteredUrl = function(basePath, tags, pageSize) { + // remove the chain directive from all the urls that might have + // been added previously + tags = (tags || []).map(function(val) { + if (val.url.indexOf("chain__") !== -1) { + val.url = val.url.substring(("chain__").length); + } + return val; + }); + + // separate those tags with the related: true attribute + var separateRelated = _.partition(tags, function(i) { + return i.related; + }); + + var relatedTags = separateRelated[0]; + var nonRelatedTags = separateRelated[1]; + + if (relatedTags.length > 1) { + // separate query params that need the change directive + // but have different keys + var chainGroups = _.groupBy(relatedTags, function(i) { + return i.value; + }); + + // iterate over those groups and add the "chain__" to the + // beginning of all but the first of each url + relatedTags = _.flatten(_.map(chainGroups, function(group) { + return group.map(function(val, i) { + if (i !== 0) { + val.url = "chain__" + val.url; + } + return val; + }); + })); + + // combine the related and non related tags after chainifying + tags = relatedTags.concat(nonRelatedTags); + } + return basePath + "?" + (tags || []).map(function (t) { return t.url; diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index 44fe9ab2ee..c6b5db0016 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -616,7 +616,8 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) options = params.opts, multiple = (params.multiple!==undefined) ? params.multiple : true, placeholder = params.placeholder, - customDropdownAdapter = (params.customDropdownAdapter!==undefined) ? params.customDropdownAdapter : true; + customDropdownAdapter = (params.customDropdownAdapter!==undefined) ? params.customDropdownAdapter : true, + addNew = params.addNew; $.fn.select2.amd.require([ 'select2/utils', @@ -624,11 +625,12 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) 'select2/dropdown/search', 'select2/dropdown/attachContainer', 'select2/dropdown/closeOnSelect', - 'select2/dropdown/minimumResultsForSearch' - ], function (Utils, Dropdown, Search, AttachContainer, CloseOnSelect, MinimumResultsForSearch) { + 'select2/dropdown/minimumResultsForSearch', + 'select2/data/tokenizer' + ], function (Utils, Dropdown, Search, AttachContainer, CloseOnSelect, MinimumResultsForSearch, Tokenizer) { var CustomAdapter = - _.reduce([Search, AttachContainer, CloseOnSelect, MinimumResultsForSearch], + _.reduce([Search, AttachContainer, CloseOnSelect, MinimumResultsForSearch, Tokenizer], function(Adapter, Decorator) { return Utils.Decorate(Adapter, Decorator); }, Dropdown); @@ -639,14 +641,20 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) containerCssClass: 'Form-dropDown', width: '100%', minimumResultsForSearch: Infinity, - } + }; // multiple-choice directive calls select2 but needs to do so without this custom adapter // to allow the element to be draggable on survey preview. - if(customDropdownAdapter) { + if (customDropdownAdapter) { config.dropdownAdapter = CustomAdapter; } + if (addNew) { + $(element).prepend(""); + config.tags = true; + config.tokenSeparators = []; + } + $(element).select2(config); if(options){ @@ -884,7 +892,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) } ]) .factory('ParamPass', function() { - var savedData = undefined; + var savedData; function set(data) { savedData = data; @@ -899,5 +907,5 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) return { set: set, get: get - } + }; }); diff --git a/awx/ui/client/src/shared/generator-helpers.js b/awx/ui/client/src/shared/generator-helpers.js index 146dc25353..e23b7a9a6b 100644 --- a/awx/ui/client/src/shared/generator-helpers.js +++ b/awx/ui/client/src/shared/generator-helpers.js @@ -448,12 +448,29 @@ angular.module('GeneratorHelpers', [systemStatus.name]) options = params.options, base = params.base, field = list.fields[fld], - html = ''; + html = '', + classList; if (field.type !== undefined && field.type === 'DropDown') { html = DropDown(params); } else if (field.type === 'role') { - html += ""; + classList = (field.columnClass) ? + Attr(field, 'columnClass') : ""; + html += ` + + + + + `; + } else if (field.type === 'labels') { + classList = (field.columnClass) ? + Attr(field, 'columnClass') : ""; + html += ` + + + + + `; } else if (field.type === 'badgeCount') { html = BadgeCount(params); } else if (field.type === 'badgeOnly') {