diff --git a/awx/ui/client/features/templates/list-templates.controller.js b/awx/ui/client/features/templates/list-templates.controller.js
index 1f6e156e4a..927e807656 100644
--- a/awx/ui/client/features/templates/list-templates.controller.js
+++ b/awx/ui/client/features/templates/list-templates.controller.js
@@ -1,200 +1,406 @@
-function ListTemplatesController (model, JobTemplate, WorkflowJobTemplate, strings, $state, $scope, rbacUiControlService, Dataset, $filter, Alert, InitiatePlaybookRun, Prompt, Wait, ProcessErrors, TemplateCopyService, $q, Empty, i18n, PromptService) {
- const vm = this || {},
- unifiedJobTemplate = model,
- jobTemplate = new JobTemplate(),
- workflowJobTemplate = new WorkflowJobTemplate();
+/** ***********************************************
+ * Copyright (c) 2018 Ansible, Inc.
+ *
+ * All Rights Reserved
+ ************************************************ */
+const JOB_TEMPLATE_ALIASES = ['job_template', 'Job Template'];
+const WORKFLOW_TEMPLATE_ALIASES = ['workflow_job_template', 'Workflow Job Template'];
+
+const isJobTemplate = ({ type }) => JOB_TEMPLATE_ALIASES.indexOf(type) > -1;
+const isWorkflowTemplate = ({ type }) => WORKFLOW_TEMPLATE_ALIASES.indexOf(type) > -1;
+const mapChoices = choices => Object.assign(...choices.map(([k, v]) => ({[k]: v})));
+
+function ListTemplatesController(
+ $filter,
+ $scope,
+ $state,
+ Alert,
+ Dataset,
+ InitiatePlaybookRun,
+ ProcessErrors,
+ Prompt,
+ PromptService,
+ resolvedModels,
+ strings,
+ Wait,
+) {
+ const vm = this || {};
+ const [jobTemplate, workflowTemplate] = resolvedModels;
+
+ const choices = workflowTemplate.options('actions.GET.type.choices')
+ .concat(jobTemplate.options('actions.GET.type.choices'));
vm.strings = strings;
-
- // TODO: add the permission based functionality to the base model
- $scope.canAdd = false;
- rbacUiControlService.canAdd("job_templates")
- .then(function(params) {
- $scope.canAddJobTemplate = params.canAdd;
- });
- rbacUiControlService.canAdd("workflow_job_templates")
- .then(function(params) {
- $scope.canAddWorkflowJobTemplate = params.canAdd;
- });
- $scope.$watchGroup(["canAddJobTemplate", "canAddWorkflowJobTemplate"], function() {
- if ($scope.canAddJobTemplate || $scope.canAddWorkflowJobTemplate) {
- $scope.canAdd = true;
- } else {
- $scope.canAdd = false;
- }
- });
-
- $scope.list = {
- iterator: 'template',
- name: 'templates'
- };
- $scope.collection = {
- basePath: 'unified_job_templates',
- iterator: 'template'
- };
- $scope[`${$scope.list.iterator}_dataset`] = Dataset.data;
- $scope[$scope.list.name] = $scope[`${$scope.list.iterator}_dataset`].results;
- $scope.$on('updateDataset', function(e, dataset) {
- $scope[`${$scope.list.iterator}_dataset`] = dataset;
- $scope[$scope.list.name] = dataset.results;
- });
-
- // get modified date and user who modified it
- vm.getModified = function(template) {
- let val = "";
- if (template.modified) {
- val += $filter('longDate')(template.modified);
- }
- if (_.has(template, 'summary_fields.modified_by.username')) {
- val += ` by ${template.summary_fields.modified_by.username}`;
- }
- if (val === "") {
- val = undefined;
- }
- return val;
- };
-
- // get last ran date and user who ran it
- vm.getRan = function(template) {
- let val = "";
- if (template.last_job_run) {
- val += $filter('longDate')(template.last_job_run);
- }
-
- // TODO: when API gives back a user who last ran the job in summary fields, uncomment and
- // update this code
- // if (template && template.summary_fields && template.summary_fields.modified_by &&
- // template.summary_fields.modified_by.username) {
- // val += ` by ${template.summary_fields.modified_by.username}`;
- // }
-
- if (val === "") {
- val = undefined;
- }
- return val;
- };
-
- // get pretified template type names from options
- vm.templateTypes = unifiedJobTemplate.options('actions.GET.type.choices')
- .reduce((acc, i) => {
- acc[i[0]] = i[1];
- return acc;
- }, {});
-
- // get if you should show the active indicator for the row or not
- // TODO: edit indicator doesn't update when you enter edit route after initial load right now
+ vm.templateTypes = mapChoices(choices);
vm.activeId = parseInt($state.params.job_template_id || $state.params.workflow_template_id);
- vm.submitJob = function(template) {
- if(template) {
- if(template.type && (template.type === 'Job Template' || template.type === 'job_template')) {
- let jobTemplate = new JobTemplate();
+ $scope.canAddJobTemplate = jobTemplate.options('actions.POST')
+ $scope.canAddWorkflowJobTemplate = workflowTemplate.options('actions.POST')
+ $scope.canAdd = ($scope.canAddJobTemplate || $scope.canAddWorkflowJobTemplate);
- $q.all([jobTemplate.optionsLaunch(template.id), jobTemplate.getLaunch(template.id)])
- .then((responses) => {
- if(jobTemplate.canLaunchWithoutPrompt()) {
- jobTemplate.postLaunch({id: template.id})
- .then((launchRes) => {
- $state.go('jobResult', { id: launchRes.data.job }, { reload: true });
- });
- } else {
+ // smart-search
+ const name = 'templates';
+ const iterator = 'template';
+ const key = 'template_dataset';
- if(responses[1].data.survey_enabled) {
+ $scope.list = { iterator, name };
+ $scope.collection = { iterator, basePath: 'unified_job_templates' };
+ $scope[key] = Dataset.data;
+ $scope[name] = Dataset.data.results;
+ $scope.$on('updateDataset', (e, dataset) => {
+ $scope[key] = dataset;
+ $scope[name] = dataset.results;
+ });
- // go out and get the survey questions
- jobTemplate.getSurveyQuestions(template.id)
- .then((surveyQuestionRes) => {
-
- let processed = PromptService.processSurveyQuestions({
- surveyQuestions: surveyQuestionRes.data.spec
- });
-
- $scope.promptData = {
- launchConf: responses[1].data,
- launchOptions: responses[0].data,
- surveyQuestions: processed.surveyQuestions,
- template: template.id,
- prompts: PromptService.processPromptValues({
- launchConf: responses[1].data,
- launchOptions: responses[0].data
- }),
- triggerModalOpen: true
- };
- });
- }
- else {
- $scope.promptData = {
- launchConf: responses[1].data,
- launchOptions: responses[0].data,
- template: template.id,
- prompts: PromptService.processPromptValues({
- launchConf: responses[1].data,
- launchOptions: responses[0].data
- }),
- triggerModalOpen: true
- };
- }
- }
- });
- }
- 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.');
- }
+ vm.runTemplate = template => {
+ if (!template) {
+ Alert(strings.get('error.LAUNCH'), strings.get('alert.MISSING_PARAMETER'));
+ return;
}
- else {
- Alert('Error: Unable to launch template', 'Template parameter is missing');
+
+ if (isJobTemplate(template)) {
+ runJobTemplate(template);
+ } else if (isWorkflowTemplate(template)) {
+ runWorkflowTemplate(template);
+ } else {
+ Alert(strings.get('error.UNKNOWN'), strings.get('alert.UNKNOWN_LAUNCH'));
}
};
- $scope.launchJob = () => {
+ vm.scheduleTemplate = template => {
+ if (!template) {
+ Alert(strings.get('error.SCHEDULE'), strings.get('alert.MISSING_PARAMETER'));
+ return;
+ }
- let jobLaunchData = {
+ if (isJobTemplate(template)) {
+ $state.go('jobTemplateSchedules', { id: template.id });
+ } else if (isWorkflowTemplate(template)) {
+ $state.go('workflowJobTemplateSchedules', { id: template.id });
+ } else {
+ Alert(strings.get('error.UNKNOWN'), strings.get('alert.UNKNOWN_SCHEDULE'));
+ }
+ };
+
+ vm.deleteTemplate = template => {
+ if (!template) {
+ Alert(strings.get('error.DELETE'), strings.get('alert.MISSING_PARAMETER'));
+ return;
+ }
+
+ if (isWorkflowTemplate(template)) {
+ displayWorkflowTemplateDeletePrompt(template);
+ } else if (isJobTemplate(template)) {
+ jobTemplate.getDependentResourceCounts(template.id)
+ .then(counts => displayJobTemplateDeletePrompt(template, counts));
+ } else {
+ Alert(strings.get('error.UNKNOWN'), strings.get('alert.UNKNOWN_DELETE'));
+ }
+ };
+
+ vm.copyTemplate = template => {
+ if (!template) {
+ Alert(strings.get('error.COPY'), strings.get('alert.MISSING_PARAMETER'));
+ return;
+ }
+
+ if (isJobTemplate(template)) {
+ copyJobTemplate(template);
+ } else if (isWorkflowTemplate(template)) {
+ copyWorkflowTemplate(template);
+ } else {
+ Alert(strings.get('error.UNKNOWN'), strings.get('alert.UNKNOWN_COPY'));
+ }
+ };
+
+ vm.getModified = template => {
+ const modified = _.get(template, 'modified');
+
+ if (!modified) {
+ return undefined;
+ }
+
+ let html = $filter('longDate')(modified);
+
+ const { username, id } = _.get(template, 'summary_fields.modified_by', {});
+
+ if (username && id) {
+ html += ` by ${$filter('sanitize')(username)}`;
+ }
+
+ return html;
+ };
+
+ vm.getLastRan = template => {
+ const lastJobRun = _.get(template, 'last_job_run');
+
+ if (!lastJobRun) {
+ return undefined;
+ }
+
+ let html = $filter('longDate')(modified);
+
+ // TODO: uncomment and update when last job run user is returned by api
+ // const { username, id } = _.get(template, 'summary_fields.last_job_run_by', {});
+
+ // if (username && id) {
+ // html += ` by ${$filter('sanitize')(username)}`;
+ //}
+
+ return html;
+ };
+
+ function createErrorHandler(path, action) {
+ return ({ data, status }) => {
+ const hdr = strings.get('error.HEADER');
+ const msg = strings.get('error.CALL', { path, action, status });
+ ProcessErrors($scope, data, status, null, { hdr, msg });
+ };
+ }
+
+ function copyJobTemplate(template) {
+ Wait('start');
+ jobTemplate
+ .create('get', template.id)
+ .then(model => model.copy())
+ .then(({ id }) => {
+ const params = { job_template_id: id };
+ $state.go('templates.editJobTemplate', params, { reload: true });
+ })
+ .catch(createErrorHandler('copy job template', 'POST'))
+ .finally(() => Wait('stop'));
+ };
+
+ function copyWorkflowTemplate(template) {
+ Wait('start');
+ workflowTemplate
+ .create('get', template.id)
+ .then(model => model.extend('get', 'copy'))
+ .then(model => {
+ const action = () => {
+ Wait('start');
+ model.copy()
+ .then(({ id }) => {
+ const params = { workflow_job_template_id: id };
+ $state.go('templates.editWorkflowJobTemplate', params, { reload: true });
+ })
+ .catch(createErrorHandler('copy workflow', 'POST'))
+ .finally(() => Wait('stop'));
+ };
+
+ if (model.get('related.copy.can_copy_without_user_input')) {
+ action();
+ } else if (model.get('related.copy.can_copy')) {
+ Prompt({
+ action,
+ actionText: strings.get('COPY'),
+ body: buildWorkflowCopyPromptHTML(model.get('related.copy')),
+ class: 'Modal-primaryButton',
+ hdr: strings.get('actions.COPY_WORKFLOW'),
+ });
+ } else {
+ Alert(strings.get('error.COPY'), strings.get('alert.NO_PERMISSION'));
+ }
+ })
+ .catch(createErrorHandler('copy workflow', 'GET'))
+ .finally(() => Wait('stop'));
+ }
+
+ function handleSuccessfulDelete(template) {
+ const { page } = _.get($state.params, 'template_search');
+ let reloadListStateParams = null;
+
+ if ($scope.templates.length === 1 && !_.isEmpty(page) && page !== '1') {
+ reloadListStateParams = _.cloneDeep($state.params);
+ const pageNumber = (parseInt(reloadListStateParams.template_search.page, 0) - 1);
+ reloadListStateParams.template_search.page = pageNumber.toString();
+ }
+
+ if (parseInt($state.params.job_template_id, 0) === template.id) {
+ $state.go('templates', reloadListStateParams, { reload: true });
+ } else if (parseInt($state.params.workflow_job_template_id, 0) === template.id) {
+ $state.go('templates', reloadListStateParams, { reload: true });
+ } else {
+ $state.go('.', reloadListStateParams, { reload: true });
+ }
+ }
+
+ function displayJobTemplateDeletePrompt(template, counts) {
+ Prompt({
+ action() {
+ $('#prompt-modal').modal('hide');
+ Wait('start');
+ jobTemplate
+ .request('delete', template.id)
+ .then(() => handleSuccessfulDelete(template))
+ .catch(createErrorHandler('delete template', 'DELETE'))
+ .finally(() => Wait('stop'));
+ },
+ hdr: strings.get('DELETE'),
+ resourceName: $filter('sanitize')(template.name),
+ body: buildJobTemplateDeletePromptHTML(counts),
+ });
+ }
+
+ function displayWorkflowTemplateDeletePrompt(template) {
+ Prompt({
+ action() {
+ $('#prompt-modal').modal('hide');
+ Wait('start');
+ workflowTemplate
+ .request('delete', template.id)
+ .then(() => handleSuccessfulDelete(template))
+ .catch(createErrorHandler('delete template', 'DELETE'))
+ .finally(() => Wait('stop'))
+ },
+ hdr: strings.get('DELETE'),
+ resourceName: $filter('sanitize')(template.name),
+ body: strings.get('deleteResource.CONFIRM', 'workflow template'),
+ });
+ }
+
+ function buildJobTemplateDeletePromptHTML(counts) {
+ const buildCount = count => `${count}`;
+ const buildLabel = label => `
+ ${$filter('sanitize')(label)}`;
+ const buildCountLabel = ({ count, label }) => `
+ ${buildLabel(label)}${buildCount(count)}
`;
+
+ const displayedCounts = counts.filter(({ count }) => count > 0);
+
+ const html = `
+ ${displayedCounts.length ? strings.get('deleteResource.USED_BY', 'job template') : ''}
+ ${strings.get('deleteResource.CONFIRM', 'job template')}
+ ${displayedCounts.map(buildCountLabel).join('')}
+ `;
+
+ return html;
+ }
+
+ function buildWorkflowCopyPromptHTML(data) {
+ const pull = (data, param) => _.get(data, param, []).map($filter('sanitize'));
+
+ const credentials = pull(data, 'credentials_unable_to_copy');
+ const inventories = pull(data, 'inventories_unable_to_copy');
+ const templates = pull(data, 'templates_unable_to_copy');
+
+ const html = `
+
+ ${strings.get('warnings.WORKFLOW_RESTRICTED_COPY')}
+
+
+ ${templates.length ? `
Unified Job Templates
` : ''}
+ ${templates.map(item => `- ${item}
`).join('')}
+ ${templates.length ? `
` : ''}
+
+
+ ${credentials.length ? `
Credentials
` : ''}
+ ${credentials.map(item => `- ${item}
`).join('')}
+ ${credentials.length ? `
` : ''}
+
+
+ ${inventories.length ? `
Inventories
` : ''}
+ ${inventories.map(item => `- ${item}
`).join('')}
+ ${inventories.length ? `
` : ''}
+
+ `;
+
+ return html;
+ }
+
+ function runJobTemplate(template) {
+ const selectedJobTemplate = jobTemplate.create();
+ const preLaunchPromises = [
+ selectedJobTemplate.getLaunch(template.id),
+ selectedJobTemplate.optionsLaunch(template.id),
+ ];
+
+ Promise.all(preLaunchPromises)
+ .then(([launchData, launchOptions]) => {
+ if (selectedJobTemplate.canLaunchWithoutPrompt()) {
+ return selectedJobTemplate
+ .postLaunch({ id: template.id })
+ .then(({ data }) => {
+ $state.go('jobResult', { id: data.job }, { reload: true })
+ });
+ }
+
+ const promptData = {
+ launchConf: launchData.data,
+ launchOptions: launchOptions.data,
+ template: template.id,
+ prompts: PromptService.processPromptValues({
+ launchConf: launchData.data,
+ launchOptions: launchOptions.data
+ }),
+ triggerModalOpen: true,
+ };
+
+ if (launchData.data.survey_enabled) {
+ selectedJobTemplate.getSurveyQuestions(template.id)
+ .then(({ data }) => {
+ const processed = PromptService.processSurveyQuestions({ surveyQuestions: data.spec });
+ promptData.surveyQuestions = processed.surveyQuestions;
+ $scope.promptData = promptData;
+ });
+ } else {
+ $scope.promptData = promptData;
+ }
+ });
+ }
+
+ function runWorkflowTemplate(template) {
+ InitiatePlaybookRun({ scope: $scope, id: template.id, job_type: 'workflow_job_template' });
+ }
+
+ $scope.launchJob = () => {
+ const jobLaunchData = {
extra_vars: $scope.promptData.extraVars
};
- let jobTemplate = new JobTemplate();
-
- if($scope.promptData.launchConf.ask_tags_on_launch){
+ if ($scope.promptData.launchConf.ask_tags_on_launch){
jobLaunchData.job_tags = $scope.promptData.prompts.tags.value.map(a => a.value).join();
}
- if($scope.promptData.launchConf.ask_skip_tags_on_launch){
+
+ if ($scope.promptData.launchConf.ask_skip_tags_on_launch){
jobLaunchData.skip_tags = $scope.promptData.prompts.skipTags.value.map(a => a.value).join();
}
- if($scope.promptData.launchConf.ask_limit_on_launch && _.has($scope, 'promptData.prompts.limit.value')){
+
+ if ($scope.promptData.launchConf.ask_limit_on_launch && _.has($scope, 'promptData.prompts.limit.value')){
jobLaunchData.limit = $scope.promptData.prompts.limit.value;
}
- if($scope.promptData.launchConf.ask_job_type_on_launch && _.has($scope, 'promptData.prompts.jobType.value.value')) {
+
+ if ($scope.promptData.launchConf.ask_job_type_on_launch && _.has($scope, 'promptData.prompts.jobType.value.value')) {
jobLaunchData.job_type = $scope.promptData.prompts.jobType.value.value;
}
- if($scope.promptData.launchConf.ask_verbosity_on_launch && _.has($scope, 'promptData.prompts.verbosity.value.value')) {
+
+ if ($scope.promptData.launchConf.ask_verbosity_on_launch && _.has($scope, 'promptData.prompts.verbosity.value.value')) {
jobLaunchData.verbosity = $scope.promptData.prompts.verbosity.value.value;
}
- if($scope.promptData.launchConf.ask_inventory_on_launch && !Empty($scope.promptData.prompts.inventory.value.id)){
+
+ if ($scope.promptData.launchConf.ask_inventory_on_launch && !_.isEmpty($scope.promptData.prompts.inventory.value.id)){
jobLaunchData.inventory_id = $scope.promptData.prompts.inventory.value.id;
}
- if($scope.promptData.launchConf.ask_credential_on_launch){
+
+ if ($scope.promptData.launchConf.ask_credential_on_launch){
jobLaunchData.credentials = [];
$scope.promptData.prompts.credentials.value.forEach((credential) => {
jobLaunchData.credentials.push(credential.id);
});
}
- if($scope.promptData.launchConf.ask_diff_mode_on_launch && _.has($scope, 'promptData.prompts.diffMode.value')) {
+
+ if ($scope.promptData.launchConf.ask_diff_mode_on_launch && _.has($scope, 'promptData.prompts.diffMode.value')) {
jobLaunchData.diff_mode = $scope.promptData.prompts.diffMode.value;
}
- if($scope.promptData.prompts.credentials.passwords) {
+ if ($scope.promptData.prompts.credentials.passwords) {
_.forOwn($scope.promptData.prompts.credentials.passwords, (val, key) => {
- if(!jobLaunchData.credential_passwords) {
+ if (!jobLaunchData.credential_passwords) {
jobLaunchData.credential_passwords = {};
}
- if(key === "ssh_key_unlock") {
+ if (key === "ssh_key_unlock") {
jobLaunchData.credential_passwords.ssh_key_unlock = val.value;
- } else if(key !== "vault") {
+ } else if (key !== "vault") {
jobLaunchData.credential_passwords[`${key}_password`] = val.value;
} else {
_.each(val, (vaultCred) => {
@@ -209,265 +415,30 @@ function ListTemplatesController (model, JobTemplate, WorkflowJobTemplate, strin
delete jobLaunchData.extra_vars;
}
- jobTemplate.postLaunch({
+ jobTemplate.create().postLaunch({
id: $scope.promptData.template,
launchData: jobLaunchData
- }).then((launchRes) => {
+ })
+ .then((launchRes) => {
$state.go('jobResult', { id: launchRes.data.job }, { reload: true });
- }).catch(({data, status}) => {
- ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'),
- msg: i18n.sprintf(i18n._('Failed to launch job template. POST returned: %d'), $scope.promptData.template, status) });
- });
- };
-
- vm.scheduleJob = (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');
- }
- };
-
- vm.scheduleJob = (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');
- }
- };
-
- vm.copyTemplate = function(template) {
-
- if(template) {
- if(template.type && template.type === 'job_template') {
- Wait('start');
- TemplateCopyService.get(template.id)
- .then(function(response){
- TemplateCopyService.set(response.data.results)
- .then(function(results){
- Wait('stop');
- if(results.type && results.type === 'job_template') {
- $state.go('templates.editJobTemplate', {job_template_id: results.id}, {reload: true});
- }
- })
- .catch(({data, status}) => {
- ProcessErrors($scope, data, status, null, {
- hdr: 'Error!',
- msg: 'Call failed. Return status: ' + status
- });
- });
- })
- .catch(({data, status}) => {
- ProcessErrors($scope, data, status, null, {hdr: 'Error!',
- msg: 'Call failed. Return status: '+ status});
- });
- }
- else if(template.type && template.type === 'workflow_job_template') {
- TemplateCopyService.getWorkflowCopy(template.id)
- .then(function(result) {
-
- if(result.data.can_copy) {
- if(result.data.can_copy_without_user_input) {
- // Go ahead and copy the workflow - the user has full priveleges on all the resources
- TemplateCopyService.copyWorkflow(template.id)
- .then(function(result) {
- $state.go('templates.editWorkflowJobTemplate', {workflow_job_template_id: result.data.id}, {reload: true});
- })
- .catch(function (response) {
- Wait('stop');
- ProcessErrors($scope, response.data, response.status, null, { hdr: 'Error!',
- msg: 'Call to copy workflow job template failed. Return status: ' + response.status + '.'});
- });
- }
- else {
-
- let bodyHtml = `
-
- You do not have access to all resources used by this workflow. Resources that you don\'t have access to will not be copied and will result in an incomplete workflow.
-
- `;
-
- // List the unified job templates user can not access
- if (result.data.templates_unable_to_copy.length > 0) {
- bodyHtml += '
Unified Job Templates that can not be copied
';
- _.forOwn(result.data.templates_unable_to_copy, function(ujt) {
- if(ujt) {
- bodyHtml += '- ' + ujt + '
';
- }
- });
- bodyHtml += '
';
- }
- // List the prompted inventories user can not access
- if (result.data.inventories_unable_to_copy.length > 0) {
- bodyHtml += '
Node prompted inventories that can not be copied
';
- _.forOwn(result.data.inventories_unable_to_copy, function(inv) {
- if(inv) {
- bodyHtml += '- ' + inv + '
';
- }
- });
- bodyHtml += '
';
- }
- // List the prompted credentials user can not access
- if (result.data.credentials_unable_to_copy.length > 0) {
- bodyHtml += '
Node prompted credentials that can not be copied
';
- _.forOwn(result.data.credentials_unable_to_copy, function(cred) {
- if(cred) {
- bodyHtml += '- ' + cred + '
';
- }
- });
- bodyHtml += '
';
- }
-
- bodyHtml += '
';
-
-
- Prompt({
- hdr: 'Copy Workflow',
- body: bodyHtml,
- action: function() {
- $('#prompt-modal').modal('hide');
- Wait('start');
- TemplateCopyService.copyWorkflow(template.id)
- .then(function(result) {
- Wait('stop');
- $state.go('templates.editWorkflowJobTemplate', {workflow_job_template_id: result.data.id}, {reload: true});
- }, function (data) {
- Wait('stop');
- ProcessErrors($scope, data, status, null, { hdr: 'Error!',
- msg: 'Call to copy template failed. POST returned status: ' + status });
- });
- },
- actionText: 'COPY',
- class: 'Modal-primaryButton'
- });
- }
- }
- else {
- Alert('Error: Unable to copy workflow job template', 'You do not have permission to perform this action.');
- }
- }, function (data) {
- Wait('stop');
- ProcessErrors($scope, data, status, null, { hdr: 'Error!',
- msg: 'Call to copy template failed. GET returned status: ' + status });
- });
- }
- else {
- // Something went wrong - Let the user know that we're unable to copy 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 copying.');
- }
- }
- else {
- Alert('Error: Unable to copy job', 'Template parameter is missing');
- }
- };
-
- vm.deleteTemplate = function(template) {
- var action = function() {
- $('#prompt-modal').modal('hide');
- Wait('start');
-
- let deleteComplete = () => {
- let reloadListStateParams = null;
-
- if($scope.templates.length === 1 && $state.params.template_search && !_.isEmpty($state.params.template_search.page) && $state.params.template_search.page !== '1') {
- reloadListStateParams = _.cloneDeep($state.params);
- reloadListStateParams.template_search.page = (parseInt(reloadListStateParams.template_search.page)-1).toString();
- }
-
- if (parseInt($state.params.template_id) === template.id) {
- $state.go("^", reloadListStateParams, { reload: true });
- } else {
- $state.go('.', reloadListStateParams, {reload: true});
- }
- };
-
- if(template.type === "job_template") {
- jobTemplate.request('delete', template.id)
- .then(() => {
- deleteComplete();
- })
- .catch(({data, status}) => {
- ProcessErrors($scope, data, status, null, {
- hdr: strings.get('error.HEADER'),
- msg: strings.get('error.CALL', {path: "" + unifiedJobTemplate.path + template.id, status})
- });
- })
- .finally(function() {
- Wait('stop');
- });
- } else if(template.type === "workflow_job_template") {
- workflowJobTemplate.request('delete', template.id)
- .then(() => {
- deleteComplete();
- })
- .catch(({data, status}) => {
- ProcessErrors($scope, data, status, null, {
- hdr: strings.get('error.HEADER'),
- msg: strings.get('error.CALL', {path: "" + unifiedJobTemplate.path + template.id, status})
- });
- })
- .finally(function() {
- Wait('stop');
- });
- }
-
- };
-
- let deleteModalBody = `${strings.get('deleteResource.CONFIRM', 'template')}
`;
-
- Prompt({
- hdr: strings.get('deleteResource.HEADER'),
- resourceName: $filter('sanitize')(template.name),
- body: deleteModalBody,
- action: action,
- actionText: 'DELETE'
- });
+ })
+ .catch(createErrorHandler('launch job template', 'POST'))
};
}
ListTemplatesController.$inject = [
- 'resolvedModels',
- 'JobTemplateModel',
- 'WorkflowJobTemplateModel',
- 'TemplatesStrings',
- '$state',
- '$scope',
- 'rbacUiControlService',
- 'Dataset',
'$filter',
+ '$scope',
+ '$state',
'Alert',
+ 'Dataset',
'InitiatePlaybookRun',
- 'Prompt',
- 'Wait',
'ProcessErrors',
- 'TemplateCopyService',
- '$q',
- 'Empty',
- 'i18n',
- 'PromptService'
+ 'Prompt',
+ 'PromptService',
+ 'resolvedModels',
+ 'TemplatesStrings',
+ 'Wait',
];
export default ListTemplatesController;
diff --git a/awx/ui/client/features/templates/list.route.js b/awx/ui/client/features/templates/list.route.js
index 384164b421..2750a0e8a7 100644
--- a/awx/ui/client/features/templates/list.route.js
+++ b/awx/ui/client/features/templates/list.route.js
@@ -2,14 +2,6 @@ import ListController from './list-templates.controller';
const listTemplate = require('~features/templates/list.view.html');
import { N_ } from '../../src/i18n';
-function TemplatesResolve (UnifiedJobTemplate) {
- return new UnifiedJobTemplate(['get', 'options']);
-}
-
-TemplatesResolve.$inject = [
- 'UnifiedJobTemplateModel'
-];
-
export default {
name: 'templates',
route: '/templates',
@@ -32,10 +24,10 @@ export default {
},
params: {
template_search: {
+ dynamic: true,
value: {
- type: 'workflow_job_template,job_template'
- },
- dynamic: true
+ type: 'workflow_job_template,job_template',
+ },
}
},
searchPrefix: 'template',
@@ -43,16 +35,34 @@ export default {
'@': {
controller: ListController,
templateUrl: listTemplate,
- controllerAs: 'vm'
+ controllerAs: 'vm',
}
},
resolve: {
- resolvedModels: TemplatesResolve,
- Dataset: ['TemplateList', 'QuerySet', '$stateParams', 'GetBasePath',
- function(list, qs, $stateParams, GetBasePath) {
- let path = GetBasePath(list.basePath) || GetBasePath(list.name);
- return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ resolvedModels: [
+ 'JobTemplateModel',
+ 'WorkflowJobTemplateModel',
+ (JobTemplate, WorkflowJobTemplate) => {
+ const models = [
+ new JobTemplate(['options']),
+ new WorkflowJobTemplate(['options']),
+ ];
+ return Promise.all(models);
+ },
+ ],
+ Dataset: [
+ '$stateParams',
+ 'Wait',
+ 'GetBasePath',
+ 'QuerySet',
+ ($stateParams, Wait, GetBasePath, qs) => {
+ const searchParam = $stateParams.template_search;
+ const searchPath = GetBasePath('unified_job_templates');
+
+ Wait('start');
+ return qs.search(searchPath, searchParam)
+ .finally(() => Wait('stop'))
}
- ]
+ ],
}
};
diff --git a/awx/ui/client/features/templates/list.view.html b/awx/ui/client/features/templates/list.view.html
index d22d64cfb4..7759287085 100644
--- a/awx/ui/client/features/templates/list.view.html
+++ b/awx/ui/client/features/templates/list.view.html
@@ -89,16 +89,16 @@
+ value="{{ vm.getLastRan(template) }}">
-
-
res.data);
+}
+
/**
* `create` is called on instantiation of every model. Models can be
* instantiated empty or with `GET` and/or `OPTIONS` requests that yield data.
@@ -517,6 +536,7 @@ function BaseModel (resource, settings) {
this.set = set;
this.unset = unset;
this.extend = extend;
+ this.copy = copy;
this.getDependentResourceCounts = getDependentResourceCounts;
this.http = {
diff --git a/awx/ui/client/lib/models/NotificationTemplate.js b/awx/ui/client/lib/models/NotificationTemplate.js
new file mode 100644
index 0000000000..6418a7184d
--- /dev/null
+++ b/awx/ui/client/lib/models/NotificationTemplate.js
@@ -0,0 +1,21 @@
+let Base;
+
+function NotificationTemplateModel (method, resource, config) {
+ Base.call(this, 'notification_templates');
+
+ this.Constructor = NotificationTemplateModel;
+
+ return this.create(method, resource, config);
+}
+
+function NotificationTemplateModelLoader (BaseModel) {
+ Base = BaseModel;
+
+ return NotificationTemplateModel;
+}
+
+NotificationTemplateModelLoader.$inject = [
+ 'BaseModel'
+];
+
+export default NotificationTemplateModelLoader;
diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js
index 6b45bcad52..950d0d2c73 100644
--- a/awx/ui/client/lib/models/index.js
+++ b/awx/ui/client/lib/models/index.js
@@ -4,22 +4,22 @@ import Base from '~models/Base';
import Config from '~models/Config';
import Credential from '~models/Credential';
import CredentialType from '~models/CredentialType';
-import Me from '~models/Me';
-import Organization from '~models/Organization';
-import Project from '~models/Project';
-import JobTemplate from '~models/JobTemplate';
-import WorkflowJobTemplateNode from '~models/WorkflowJobTemplateNode';
import Instance from '~models/Instance';
import InstanceGroup from '~models/InstanceGroup';
-import InventorySource from '~models/InventorySource';
import Inventory from '~models/Inventory';
import InventoryScript from '~models/InventoryScript';
+import InventorySource from '~models/InventorySource';
import Job from '~models/Job';
+import JobTemplate from '~models/JobTemplate';
+import Me from '~models/Me';
+import ModelsStrings from '~models/models.strings';
+import NotificationTemplate from '~models/NotificationTemplate';
+import Organization from '~models/Organization';
+import Project from '~models/Project';
+import UnifiedJobTemplate from '~models/UnifiedJobTemplate';
import WorkflowJob from '~models/WorkflowJob';
import WorkflowJobTemplate from '~models/WorkflowJobTemplate';
-
-import ModelsStrings from '~models/models.strings';
-import UnifiedJobTemplate from '~models/UnifiedJobTemplate';
+import WorkflowJobTemplateNode from '~models/WorkflowJobTemplateNode';
const MODULE_NAME = 'at.lib.models';
@@ -31,21 +31,21 @@ angular
.service('ConfigModel', Config)
.service('CredentialModel', Credential)
.service('CredentialTypeModel', CredentialType)
- .service('MeModel', Me)
- .service('OrganizationModel', Organization)
- .service('ProjectModel', Project)
- .service('JobTemplateModel', JobTemplate)
- .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode)
- .service('InstanceModel', Instance)
.service('InstanceGroupModel', InstanceGroup)
- .service('InventorySourceModel', InventorySource)
+ .service('InstanceModel', Instance)
.service('InventoryModel', Inventory)
.service('InventoryScriptModel', InventoryScript)
- .service('WorkflowJobTemplateModel', WorkflowJobTemplate)
- .service('ModelsStrings', ModelsStrings)
- .service('UnifiedJobTemplateModel', UnifiedJobTemplate)
+ .service('InventorySourceModel', InventorySource)
.service('JobModel', Job)
+ .service('JobTemplateModel', JobTemplate)
+ .service('MeModel', Me)
+ .service('ModelsStrings', ModelsStrings)
+ .service('NotificationTemplate', NotificationTemplate)
+ .service('OrganizationModel', Organization)
+ .service('ProjectModel', Project)
+ .service('UnifiedJobTemplateModel', UnifiedJobTemplate)
.service('WorkflowJobModel', WorkflowJob)
- .service('WorkflowJobTemplateModel', WorkflowJobTemplate);
+ .service('WorkflowJobTemplateModel', WorkflowJobTemplate)
+ .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode);
export default MODULE_NAME;
diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js
index d17533f5b6..11192aba29 100644
--- a/awx/ui/client/lib/services/base-string.service.js
+++ b/awx/ui/client/lib/services/base-string.service.js
@@ -67,15 +67,18 @@ function BaseStringService (namespace) {
this.OFF = t.s('OFF');
this.YAML = t.s('YAML');
this.JSON = t.s('JSON');
+ this.DELETE = t.s('DELETE');
+ this.COPY = t.s('COPY');
this.deleteResource = {
HEADER: t.s('Delete'),
USED_BY: resourceType => t.s('The {{ resourceType }} is currently being used by other resources.', { resourceType }),
CONFIRM: resourceType => t.s('Are you sure you want to delete this {{ resourceType }}?', { resourceType })
};
+
this.error = {
HEADER: t.s('Error!'),
- CALL: ({ path, status }) => t.s('Call to {{ path }} failed. DELETE returned status: {{ status }}.', { path, status })
+ CALL: ({ path, action, status }) => t.s('Call to {{ path }} failed. {{ action }} returned status: {{ status }}.', { path, action, status }),
};
this.ALERT = ({ header, body }) => t.s('{{ header }} {{ body }}', { header, body });
diff --git a/awx/ui/client/src/credentials/credentials.list.js b/awx/ui/client/src/credentials/credentials.list.js
index 36f87541a7..f381927a89 100644
--- a/awx/ui/client/src/credentials/credentials.list.js
+++ b/awx/ui/client/src/credentials/credentials.list.js
@@ -69,7 +69,14 @@ export default ['i18n', function(i18n) {
dataPlacement: 'top',
ngShow: 'credential.summary_fields.user_capabilities.edit'
},
-
+ copy: {
+ label: i18n._('Copy'),
+ ngClick: 'copyCredential(credential)',
+ "class": 'btn-danger btn-xs',
+ awToolTip: i18n._('Copy credential'),
+ dataPlacement: 'top',
+ ngShow: 'credential.summary_fields.user_capabilities.edit'
+ },
view: {
ngClick: "editCredential(credential.id)",
label: i18n._('View'),
diff --git a/awx/ui/client/src/credentials/list/credentials-list.controller.js b/awx/ui/client/src/credentials/list/credentials-list.controller.js
index 6222f37000..2e8c6e87e8 100644
--- a/awx/ui/client/src/credentials/list/credentials-list.controller.js
+++ b/awx/ui/client/src/credentials/list/credentials-list.controller.js
@@ -89,6 +89,21 @@ export default ['$scope', 'Rest', 'CredentialList', 'Prompt', 'ProcessErrors', '
}
}
+ $scope.copyCredential = credential => {
+ Wait('start');
+ new Credential('get', credential.id)
+ .then(model => model.copy())
+ .then(({ id }) => {
+ const params = { credential_id: id };
+ $state.go('credentials.edit', params, { reload: true });
+ })
+ .catch(({ data, status }) => {
+ const params = { hdr: 'Error!', msg: `Call to copy failed. Return status: ${status}` };
+ ProcessErrors($scope, data, status, null, params);
+ })
+ .finally(() => Wait('stop'));
+ };
+
$scope.addCredential = function() {
$state.go('credentials.add');
};
diff --git a/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js b/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js
index f675fd57ff..f90b953ab6 100644
--- a/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js
+++ b/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js
@@ -100,6 +100,14 @@ export default ['i18n', function(i18n) {
dataPlacement: 'top',
ngShow: '!inventory.pending_deletion && inventory.summary_fields.user_capabilities.edit'
},
+ copy: {
+ label: i18n._('Copy'),
+ ngClick: 'copyInventory(inventory)',
+ "class": 'btn-danger btn-xs',
+ awToolTip: i18n._('Copy inventory'),
+ dataPlacement: 'top',
+ ngShow: 'inventory.summary_fields.user_capabilities.edit'
+ },
view: {
label: i18n._('View'),
ngClick: 'editInventory(inventory)',
diff --git a/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js b/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js
index 0846a21561..7b22a22dba 100644
--- a/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js
+++ b/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js
@@ -73,6 +73,18 @@ function InventoriesList($scope,
inventory.linkToDetails = (inventory.kind && inventory.kind === 'smart') ? `inventories.editSmartInventory({smartinventory_id:${inventory.id}})` : `inventories.edit({inventory_id:${inventory.id}})`;
}
+ $scope.copyInventory = inventory => {
+ Wait('start');
+ new Inventory('get', inventory.id)
+ .then(model => model.copy())
+ .then(copy => $scope.editInventory(copy))
+ .catch(({ data, status }) => {
+ const params = { hdr: 'Error!', msg: `Call to copy failed. Return status: ${status}` };
+ ProcessErrors($scope, data, status, null, params);
+ })
+ .finally(() => Wait('stop'));
+ };
+
$scope.editInventory = function (inventory) {
if(inventory.kind && inventory.kind === 'smart') {
$state.go('inventories.editSmartInventory', {smartinventory_id: inventory.id});
diff --git a/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js b/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js
index d81977d49d..07d8492a77 100644
--- a/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js
+++ b/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js
@@ -57,6 +57,14 @@ export default ['i18n', function(i18n){
dataPlacement: 'top',
ngShow: 'inventory_script.summary_fields.user_capabilities.edit'
},
+ copy: {
+ label: i18n._('Copy'),
+ ngClick: 'copyCustomInv(inventory_script)',
+ "class": 'btn-danger btn-xs',
+ awToolTip: i18n._('Copy inventory scruot'),
+ dataPlacement: 'top',
+ ngShow: 'inventory_script.summary_fields.user_capabilities.edit'
+ },
view: {
ngClick: "editCustomInv(inventory_script.id)",
label: i18n._('View'),
diff --git a/awx/ui/client/src/inventory-scripts/list/list.controller.js b/awx/ui/client/src/inventory-scripts/list/list.controller.js
index a9d1140fb0..b4307b54c8 100644
--- a/awx/ui/client/src/inventory-scripts/list/list.controller.js
+++ b/awx/ui/client/src/inventory-scripts/list/list.controller.js
@@ -47,6 +47,21 @@ export default ['$rootScope', '$scope', 'Wait', 'InventoryScriptsList',
});
};
+ $scope.copyCustomInv = inventoryScript => {
+ Wait('start');
+ new InventoryScript('get', inventoryScript.id)
+ .then(model => model.copy())
+ .then(({ id }) => {
+ const params = { inventory_script_id: id };
+ $state.go('inventoryScripts.edit', params, { reload: true });
+ })
+ .catch(({ data, status }) => {
+ const params = { hdr: 'Error!', msg: `Call to copy failed. Return status: ${status}` };
+ ProcessErrors($scope, data, status, null, params);
+ })
+ .finally(() => Wait('stop'));
+ };
+
$scope.deleteCustomInv = function(id, name) {
var action = function() {
diff --git a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js
index 6c3eb4b5bf..8e588d9671 100644
--- a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js
+++ b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js
@@ -7,12 +7,12 @@
export default ['$scope', 'Wait', 'NotificationTemplatesList',
'GetBasePath', 'Rest', 'ProcessErrors', 'Prompt', '$state',
'ngToast', '$filter', 'Dataset', 'rbacUiControlService',
- 'i18n',
+ 'i18n', 'NotificationTemplate',
function(
$scope, Wait, NotificationTemplatesList,
GetBasePath, Rest, ProcessErrors, Prompt, $state,
ngToast, $filter, Dataset, rbacUiControlService,
- i18n) {
+ i18n, NotificationTemplate) {
var defaultUrl = GetBasePath('notification_templates'),
list = NotificationTemplatesList;
@@ -31,7 +31,7 @@
$scope.list = list;
$scope[`${list.iterator}_dataset`] = Dataset.data;
$scope[list.name] = $scope[`${list.iterator}_dataset`].results;
- }
+ }
$scope.$on(`notification_template_options`, function(event, data){
$scope.options = data.data.actions.GET;
@@ -88,6 +88,24 @@
notification_template.template_status_html = html;
}
+ $scope.copyNotification = notificationTemplate => {
+ Wait('start');
+ new NotificationTemplate('get', notificationTemplate.id)
+ .then(model => model.copy())
+ .then(({ id }) => {
+ const params = {
+ notification_template_id: id,
+ notification_template: this.notification_templates
+ };
+ $state.go('notifications.edit', params, { reload: true });
+ })
+ .catch(({ data, status }) => {
+ const params = { hdr: 'Error!', msg: `Call to copy failed. Return status: ${status}` };
+ ProcessErrors($scope, data, status, null, params);
+ })
+ .finally(() => Wait('stop'));
+ };
+
$scope.testNotification = function() {
var name = $filter('sanitize')(this.notification_template.name),
pending_retries = 10;
@@ -152,6 +170,7 @@
}
};
+
$scope.addNotification = function() {
$state.go('notifications.add');
};
diff --git a/awx/ui/client/src/notifications/notificationTemplates.list.js b/awx/ui/client/src/notifications/notificationTemplates.list.js
index fc0b3dd484..e8bf90ffa6 100644
--- a/awx/ui/client/src/notifications/notificationTemplates.list.js
+++ b/awx/ui/client/src/notifications/notificationTemplates.list.js
@@ -77,6 +77,14 @@ export default ['i18n', function(i18n){
dataPlacement: 'top',
ngShow: 'notification_template.summary_fields.user_capabilities.edit'
},
+ copy: {
+ label: i18n._('Copy'),
+ ngClick: 'copyNotification(notification_template)',
+ "class": 'btn-danger btn-xs',
+ awToolTip: i18n._('Copy notification'),
+ dataPlacement: 'top',
+ ngShow: 'notification_template.summary_fields.user_capabilities.edit'
+ },
view: {
ngClick: "editNotification(notification_template.id)",
label: i18n._('View'),
diff --git a/awx/ui/client/src/projects/list/projects-list.controller.js b/awx/ui/client/src/projects/list/projects-list.controller.js
index f129fe5f7a..2fc7a60794 100644
--- a/awx/ui/client/src/projects/list/projects-list.controller.js
+++ b/awx/ui/client/src/projects/list/projects-list.controller.js
@@ -154,6 +154,21 @@ export default ['$scope', '$rootScope', '$log', 'Rest', 'Alert',
}
});
+ $scope.copyProject = project => {
+ Wait('start');
+ new Project('get', project.id)
+ .then(model => model.copy())
+ .then(({ id }) => {
+ const params = { project_id: id };
+ $state.go('projects.edit', params, { reload: true });
+ })
+ .catch(({ data, status }) => {
+ const params = { hdr: 'Error!', msg: `Call to copy failed. Return status: ${status}` };
+ ProcessErrors($scope, data, status, null, params);
+ })
+ .finally(() => Wait('stop'));
+ };
+
$scope.showSCMStatus = function(id) {
// Refresh the project list
var project = Find({ list: $scope.projects, key: 'id', val: id });
diff --git a/awx/ui/client/src/projects/projects.list.js b/awx/ui/client/src/projects/projects.list.js
index 7c445408b8..b05d923a8b 100644
--- a/awx/ui/client/src/projects/projects.list.js
+++ b/awx/ui/client/src/projects/projects.list.js
@@ -100,6 +100,14 @@ export default ['i18n', function(i18n) {
dataPlacement: 'top',
ngShow: "project.summary_fields.user_capabilities.schedule"
},
+ copy: {
+ label: i18n._('Copy'),
+ ngClick: 'copyProject(project)',
+ "class": 'btn-danger btn-xs',
+ awToolTip: i18n._('Copy project'),
+ dataPlacement: 'top',
+ ngShow: 'project.summary_fields.user_capabilities.edit'
+ },
edit: {
ngClick: "editProject(project.id)",
awToolTip: i18n._('Edit the project'),
diff --git a/awx/ui/client/src/templates/copy-template/template-copy.service.js b/awx/ui/client/src/templates/copy-template/template-copy.service.js
deleted file mode 100644
index d4ac1e8adf..0000000000
--- a/awx/ui/client/src/templates/copy-template/template-copy.service.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/*************************************************
- * Copyright (c) 2016 Ansible, Inc.
- *
- * All Rights Reserved
- *************************************************/
-
- export default
- ['$rootScope', 'Rest', 'ProcessErrors', 'GetBasePath', 'moment',
- function($rootScope, Rest, ProcessErrors, GetBasePath, moment){
- return {
- get: function(id){
- var defaultUrl = GetBasePath('job_templates') + '?id=' + id;
- Rest.setUrl(defaultUrl);
- return Rest.get()
- .then(response => response)
- .catch((error) => {
- ProcessErrors($rootScope, error.response, error.status, null, {hdr: 'Error!',
- msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
- });
- },
- getSurvey: function(endpoint){
- Rest.setUrl(endpoint);
- return Rest.get();
- },
- copySurvey: function(source, target){
- return this.getSurvey(source.related.survey_spec).then( (response) => {
- Rest.setUrl(target.related.survey_spec);
- return Rest.post(response.data);
- });
- },
- set: function(results){
- var defaultUrl = GetBasePath('job_templates');
- var self = this;
- Rest.setUrl(defaultUrl);
- var name = this.buildName(results[0].name);
- results[0].name = name + ' @ ' + moment().format('h:mm:ss a'); // 2:49:11 pm
- return Rest.post(results[0])
- .then((response) => {
- // also copy any associated survey_spec
- if (results[0].summary_fields.survey){
- return self.copySurvey(results[0], response.data).then( () => response.data);
- }
- else {
- return response.data;
- }
- })
- .catch(({res, status}) => {
- ProcessErrors($rootScope, res, status, null, {hdr: 'Error!',
- msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
- });
- },
- buildName: function(name){
- var result = name.split('@')[0];
- return result;
- },
- getWorkflowCopy: function(id) {
- let url = GetBasePath('workflow_job_templates');
-
- url = url + id + '/copy';
-
- Rest.setUrl(url);
- return Rest.get();
- },
- copyWorkflow: function(id) {
- let url = GetBasePath('workflow_job_templates');
-
- url = url + id + '/copy';
-
- Rest.setUrl(url);
- return Rest.post();
- }
- };
- }
- ];
diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js
index abc320451a..5d1eee77d8 100644
--- a/awx/ui/client/src/templates/main.js
+++ b/awx/ui/client/src/templates/main.js
@@ -15,7 +15,6 @@ import workflowChart from './workflows/workflow-chart/main';
import workflowMaker from './workflows/workflow-maker/main';
import workflowControls from './workflows/workflow-controls/main';
import workflowService from './workflows/workflow.service';
-import templateCopyService from './copy-template/template-copy.service';
import WorkflowForm from './workflows.form';
import CompletedJobsList from './completed-jobs.list';
import InventorySourcesList from './inventory-sources.list';
@@ -29,7 +28,6 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
])
.service('TemplatesService', templatesService)
.service('WorkflowService', workflowService)
- .service('TemplateCopyService', templateCopyService)
.factory('WorkflowForm', WorkflowForm)
.factory('CompletedJobsList', CompletedJobsList)
// TODO: currently being kept arround for rbac selection, templates within projects and orgs, etc.
diff --git a/awx/ui/test/e2e/api.js b/awx/ui/test/e2e/api.js
index ba03b1fd21..47559c8961 100644
--- a/awx/ui/test/e2e/api.js
+++ b/awx/ui/test/e2e/api.js
@@ -77,6 +77,4 @@ module.exports = {
post,
patch,
put,
- all: axios.all,
- spread: axios.spread
};
diff --git a/awx/ui/test/e2e/fixtures.js b/awx/ui/test/e2e/fixtures.js
index caaabb5ff3..ecb45db9dc 100644
--- a/awx/ui/test/e2e/fixtures.js
+++ b/awx/ui/test/e2e/fixtures.js
@@ -3,18 +3,15 @@ import uuid from 'uuid';
import { AWX_E2E_PASSWORD } from './settings';
import {
- all,
get,
post,
- spread
} from './api';
const session = `e2e-${uuid().substr(0, 8)}`;
-
const store = {};
-const getOrCreate = (endpoint, data) => {
- const identifier = Object.keys(data).find(key => ['name', 'username'].includes(key));
+const getOrCreate = (endpoint, data, unique = ['name', 'username', 'id']) => {
+ const identifier = Object.keys(data).find(key => unique.includes(key));
if (identifier === undefined) {
throw new Error('A unique key value must be provided.');
@@ -22,12 +19,10 @@ const getOrCreate = (endpoint, data) => {
const identity = data[identifier];
- if (store[endpoint] && store[endpoint][identity]) {
- return store[endpoint][identity].then(created => created.data);
- }
+ store[endpoint] = store[endpoint] || {};
- if (!store[endpoint]) {
- store[endpoint] = {};
+ if (store[endpoint][identity]) {
+ return store[endpoint][identity].then(created => created.data);
}
const query = { params: { [identifier]: identity } };
@@ -61,13 +56,8 @@ const getInventory = (namespace = session) => getOrganization(namespace)
.then(organization => getOrCreate('/inventories/', {
name: `${namespace}-inventory`,
description: namespace,
- organization: organization.id,
- }).then(inventory => getOrCreate('/hosts/', {
- name: `${namespace}-host`,
- description: namespace,
- inventory: inventory.id,
- variables: JSON.stringify({ ansible_connection: 'local' }),
- }).then(() => inventory)));
+ organization: organization.id
+ }));
const getHost = (namespace = session) => getInventory(namespace)
.then(inventory => getOrCreate('/hosts/', {
@@ -75,7 +65,7 @@ const getHost = (namespace = session) => getInventory(namespace)
description: namespace,
inventory: inventory.id,
variables: JSON.stringify({ ansible_connection: 'local' }),
- }).then((host) => host));
+ }));
const getInventoryScript = (namespace = session) => getOrganization(namespace)
.then(organization => getOrCreate('/inventory_scripts/', {
@@ -91,14 +81,14 @@ const getInventorySource = (namespace = session) => {
getInventoryScript(namespace)
];
- return all(promises)
- .then(spread((inventory, inventoryScript) => getOrCreate('/inventory_sources/', {
+ return Promise.all(promises)
+ .then(([inventory, inventoryScript]) => getOrCreate('/inventory_sources/', {
name: `${namespace}-inventory-source-custom`,
description: namespace,
source: 'custom',
inventory: inventory.id,
source_script: inventoryScript.id
- })));
+ }));
};
const getAdminAWSCredential = (namespace = session) => {
@@ -109,8 +99,8 @@ const getAdminAWSCredential = (namespace = session) => {
})
];
- return all(promises)
- .then(spread((me, credentialType) => {
+ return Promise.all(promises)
+ .then(([me, credentialType]) => {
const [admin] = me.data.results;
return getOrCreate('/credentials/', {
@@ -124,7 +114,7 @@ const getAdminAWSCredential = (namespace = session) => {
security_token: 'AAAAAAAAAAAAAAAA'
}
});
- }));
+ });
};
const getAdminMachineCredential = (namespace = session) => {
@@ -133,17 +123,16 @@ const getAdminMachineCredential = (namespace = session) => {
getOrCreate('/credential_types/', { name: 'Machine' })
];
- return all(promises)
- .then(spread((me, credentialType) => {
+ return Promise.all(promises)
+ .then(([me, credentialType]) => {
const [admin] = me.data.results;
-
return getOrCreate('/credentials/', {
name: `${namespace}-credential-machine-admin`,
description: namespace,
credential_type: credentialType.id,
user: admin.id
});
- }));
+ });
};
const getTeam = (namespace = session) => getOrganization(namespace)
@@ -235,15 +224,57 @@ const getJobTemplate = (namespace = session) => {
getUpdatedProject(namespace)
];
- return all(promises)
- .then(spread((inventory, credential, project) => getOrCreate('/job_templates/', {
+ return Promise.all(promises)
+ .then(([inventory, credential, project]) => getOrCreate('/job_templates/', {
name: `${namespace}-job-template`,
description: namespace,
inventory: inventory.id,
credential: credential.id,
project: project.id,
playbook: 'hello_world.yml',
- })));
+ }));
+};
+
+const getWorkflowTemplate = (namespace = session) => {
+ const endpoint = '/workflow_job_templates/';
+
+ const workflowTemplatePromise = getOrganization(namespace)
+ .then(organization => getOrCreate(endpoint, {
+ name: `${namespace}-workflow-template`,
+ organization: organization.id,
+ variables: '---',
+ extra_vars: '',
+ }));
+
+ const resources = [
+ workflowTemplatePromise,
+ getInventorySource(namespace),
+ getUpdatedProject(namespace),
+ getJobTemplate(namespace),
+ ];
+
+ const workflowNodePromise = Promise.all(resources)
+ .then(([workflowTemplate, source, project, jobTemplate]) => {
+ const workflowNodes = workflowTemplate.related.workflow_nodes;
+ const unique = 'unified_job_template';
+
+ const nodes = [
+ getOrCreate(workflowNodes, { [unique]: project.id }, [unique]),
+ getOrCreate(workflowNodes, { [unique]: jobTemplate.id }, [unique]),
+ getOrCreate(workflowNodes, { [unique]: source.id }, [unique]),
+ ];
+
+ const createSuccessNodes = ([projectNode, jobNode, sourceNode]) => Promise.all([
+ getOrCreate(projectNode.related.success_nodes, { id: jobNode.id }),
+ getOrCreate(jobNode.related.success_nodes, { id: sourceNode.id }),
+ ]);
+
+ return Promise.all(nodes)
+ .then(createSuccessNodes);
+ });
+
+ return Promise.all([workflowTemplatePromise, workflowNodePromise])
+ .then(([workflowTemplate, nodes]) => workflowTemplate);
};
const getAuditor = (namespace = session) => getOrganization(namespace)
@@ -287,10 +318,10 @@ const getJobTemplateAdmin = (namespace = session) => {
}));
const assignRolePromise = Promise.all([userPromise, rolePromise])
- .then(spread((user, role) => post(`/api/v2/roles/${role.id}/users/`, { id: user.id })));
+ .then(([user, role]) => post(`/api/v2/roles/${role.id}/users/`, { id: user.id }));
return Promise.all([userPromise, assignRolePromise])
- .then(spread(user => user));
+ .then(([user, assignment]) => user);
};
const getProjectAdmin = (namespace = session) => {
@@ -310,10 +341,10 @@ const getProjectAdmin = (namespace = session) => {
}));
const assignRolePromise = Promise.all([userPromise, rolePromise])
- .then(spread((user, role) => post(`/api/v2/roles/${role.id}/users/`, { id: user.id })));
+ .then(([user, role]) => post(`/api/v2/roles/${role.id}/users/`, { id: user.id }));
return Promise.all([userPromise, assignRolePromise])
- .then(spread(user => user));
+ .then(([user, assignment]) => user);
};
const getInventorySourceSchedule = (namespace = session) => getInventorySource(namespace)
@@ -334,21 +365,23 @@ module.exports = {
getAdminAWSCredential,
getAdminMachineCredential,
getAuditor,
+ getHost,
getInventory,
getInventoryScript,
getInventorySource,
getInventorySourceSchedule,
+ getJob,
getJobTemplate,
getJobTemplateAdmin,
getJobTemplateSchedule,
getNotificationTemplate,
- getOrCreate,
getOrganization,
+ getOrCreate,
+ getProject,
getProjectAdmin,
getSmartInventory,
getTeam,
getUpdatedProject,
getUser,
- getJob,
- getHost,
+ getWorkflowTemplate,
};
diff --git a/awx/ui/test/e2e/tests/test-auditor-read-only-forms.js b/awx/ui/test/e2e/tests/test-auditor-read-only-forms.js
index ecce0836c5..9a9fc07225 100644
--- a/awx/ui/test/e2e/tests/test-auditor-read-only-forms.js
+++ b/awx/ui/test/e2e/tests/test-auditor-read-only-forms.js
@@ -1,5 +1,3 @@
-import { all } from '../api';
-
import {
getAdminAWSCredential,
getAdminMachineCredential,
@@ -49,7 +47,7 @@ module.exports = {
getUpdatedProject().then(obj => { data.project = obj; })
];
- all(promises)
+ Promise.all(promises)
.then(() => {
client.useCss();
diff --git a/awx/ui/test/e2e/tests/test-credentials-list-actions.js b/awx/ui/test/e2e/tests/test-credentials-list-actions.js
new file mode 100644
index 0000000000..11f075dc79
--- /dev/null
+++ b/awx/ui/test/e2e/tests/test-credentials-list-actions.js
@@ -0,0 +1,50 @@
+import { getAdminMachineCredential } from '../fixtures';
+
+const data = {};
+
+module.exports = {
+ before: (client, done) => {
+ getAdminMachineCredential('test-actions')
+ .then(obj => { data.credential = obj; })
+ .then(done);
+ },
+ 'copy credential': client => {
+ const credentials = client.page.credentials();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ credentials.navigate();
+ credentials.waitForElementVisible('div.spinny');
+ credentials.waitForElementNotVisible('div.spinny');
+
+ credentials.section.list.expect.element('smart-search').visible;
+ credentials.section.list.expect.element('smart-search input').enabled;
+
+ credentials.section.list
+ .sendKeys('smart-search input', `id:>${data.credential.id - 1} id:<${data.credential.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ credentials.waitForElementVisible('div.spinny');
+ credentials.waitForElementNotVisible('div.spinny');
+
+ credentials.expect.element(`#credentials_table tr[id="${data.credential.id}"]`).visible;
+ credentials.expect.element('i[class*="copy"]').visible;
+ credentials.expect.element('i[class*="copy"]').enabled;
+
+ credentials.click('i[class*="copy"]');
+ credentials.waitForElementVisible('div.spinny');
+ credentials.waitForElementNotVisible('div.spinny');
+
+ credentials.expect.element('div[ui-view="edit"] form').visible;
+ credentials.section.edit.expect.element('@title').visible;
+ credentials.section.edit.expect.element('@title').text.contain(data.credential.name);
+ credentials.section.edit.expect.element('@title').text.not.equal(data.credential.name);
+ credentials.section.edit.section.details.expect.element('@save').visible;
+ credentials.section.edit.section.details.expect.element('@save').enabled;
+
+ client.end();
+ }
+};
diff --git a/awx/ui/test/e2e/tests/test-inventories-list-actions.js b/awx/ui/test/e2e/tests/test-inventories-list-actions.js
new file mode 100644
index 0000000000..280608b197
--- /dev/null
+++ b/awx/ui/test/e2e/tests/test-inventories-list-actions.js
@@ -0,0 +1,50 @@
+import { getInventory } from '../fixtures';
+
+const data = {};
+
+module.exports = {
+ before: (client, done) => {
+ getInventory('test-actions')
+ .then(obj => { data.inventory = obj; })
+ .then(done);
+ },
+ 'copy inventory': client => {
+ const inventories = client.page.inventories();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ inventories.navigate();
+ inventories.waitForElementVisible('div.spinny');
+ inventories.waitForElementNotVisible('div.spinny');
+
+ inventories.section.list.expect.element('smart-search').visible;
+ inventories.section.list.section.search.expect.element('@input').enabled;
+
+ inventories.section.list.section.search
+ .sendKeys('@input', `id:>${data.inventory.id - 1} id:<${data.inventory.id + 1}`)
+ .sendKeys('@input', client.Keys.ENTER);
+
+ inventories.waitForElementVisible('div.spinny');
+ inventories.waitForElementNotVisible('div.spinny');
+
+ inventories.expect.element(`#inventories_table tr[id="${data.inventory.id}"]`).visible;
+ inventories.expect.element('i[class*="copy"]').visible;
+ inventories.expect.element('i[class*="copy"]').enabled;
+
+ inventories.click('i[class*="copy"]');
+ inventories.waitForElementVisible('div.spinny');
+ inventories.waitForElementNotVisible('div.spinny');
+
+ inventories.expect.element('#inventory_form').visible;
+ inventories.section.editStandardInventory.expect.element('@title').visible;
+ inventories.section.editStandardInventory.expect.element('@title').text.contain(data.inventory.name);
+ inventories.section.editStandardInventory.expect.element('@title').text.not.equal(data.inventory.name);
+ inventories.expect.element('@save').visible;
+ inventories.expect.element('@save').enabled;
+
+ client.end();
+ }
+};
diff --git a/awx/ui/test/e2e/tests/test-inventory-scripts-list-actions.js b/awx/ui/test/e2e/tests/test-inventory-scripts-list-actions.js
new file mode 100644
index 0000000000..51c52798ea
--- /dev/null
+++ b/awx/ui/test/e2e/tests/test-inventory-scripts-list-actions.js
@@ -0,0 +1,50 @@
+import { getInventoryScript } from '../fixtures';
+
+const data = {};
+
+module.exports = {
+ before: (client, done) => {
+ getInventoryScript('test-actions')
+ .then(obj => { data.inventoryScript = obj; })
+ .then(done);
+ },
+ 'copy inventory script': client => {
+ const inventoryScripts = client.page.inventoryScripts();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ inventoryScripts.navigate();
+ inventoryScripts.waitForElementVisible('div.spinny');
+ inventoryScripts.waitForElementNotVisible('div.spinny');
+
+ inventoryScripts.section.list.expect.element('smart-search').visible;
+ inventoryScripts.section.list.expect.element('smart-search input').enabled;
+
+ inventoryScripts.section.list
+ .sendKeys('smart-search input', `id:>${data.inventoryScript.id - 1} id:<${data.inventoryScript.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ inventoryScripts.waitForElementVisible('div.spinny');
+ inventoryScripts.waitForElementNotVisible('div.spinny');
+
+ inventoryScripts.expect.element(`#inventory_scripts_table tr[id="${data.inventoryScript.id}"]`).visible;
+ inventoryScripts.expect.element('i[class*="copy"]').visible;
+ inventoryScripts.expect.element('i[class*="copy"]').enabled;
+
+ inventoryScripts.click('i[class*="copy"]');
+ inventoryScripts.waitForElementVisible('div.spinny');
+ inventoryScripts.waitForElementNotVisible('div.spinny');
+
+ inventoryScripts.expect.element('#inventory_script_form').visible;
+ inventoryScripts.section.edit.expect.element('@title').visible;
+ inventoryScripts.section.edit.expect.element('@title').text.contain(data.inventoryScript.name);
+ inventoryScripts.section.edit.expect.element('@title').text.not.equal(data.inventoryScript.name);
+ inventoryScripts.expect.element('@save').visible;
+ inventoryScripts.expect.element('@save').enabled;
+
+ client.end();
+ }
+};
diff --git a/awx/ui/test/e2e/tests/test-notifications-list-actions.js b/awx/ui/test/e2e/tests/test-notifications-list-actions.js
new file mode 100644
index 0000000000..fc32d1553b
--- /dev/null
+++ b/awx/ui/test/e2e/tests/test-notifications-list-actions.js
@@ -0,0 +1,50 @@
+import { getNotificationTemplate } from '../fixtures';
+
+const data = {};
+
+module.exports = {
+ before: (client, done) => {
+ getNotificationTemplate('test-actions')
+ .then(obj => { data.notification = obj; })
+ .then(done);
+ },
+ 'copy notification template': client => {
+ const notifications = client.page.notificationTemplates();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ notifications.navigate();
+ notifications.waitForElementVisible('div.spinny');
+ notifications.waitForElementNotVisible('div.spinny');
+
+ notifications.section.list.expect.element('smart-search').visible;
+ notifications.section.list.expect.element('smart-search input').enabled;
+
+ notifications.section.list
+ .sendKeys('smart-search input', `id:>${data.notification.id - 1} id:<${data.notification.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ notifications.waitForElementVisible('div.spinny');
+ notifications.waitForElementNotVisible('div.spinny');
+
+ notifications.expect.element(`#notification_templates_table tr[id="${data.notification.id}"]`).visible;
+ notifications.expect.element('i[class*="copy"]').visible;
+ notifications.expect.element('i[class*="copy"]').enabled;
+
+ notifications.click('i[class*="copy"]');
+ notifications.waitForElementVisible('div.spinny');
+ notifications.waitForElementNotVisible('div.spinny');
+
+ notifications.expect.element('#notification_template_form').visible;
+ notifications.section.edit.expect.element('@title').visible;
+ notifications.section.edit.expect.element('@title').text.contain(data.notification.name);
+ notifications.section.edit.expect.element('@title').text.not.equal(data.notification.name);
+ notifications.expect.element('@save').visible;
+ notifications.expect.element('@save').enabled;
+
+ client.end();
+ }
+};
diff --git a/awx/ui/test/e2e/tests/test-projects-list-actions.js b/awx/ui/test/e2e/tests/test-projects-list-actions.js
new file mode 100644
index 0000000000..e8bd6c0bc3
--- /dev/null
+++ b/awx/ui/test/e2e/tests/test-projects-list-actions.js
@@ -0,0 +1,51 @@
+import { getProject } from '../fixtures';
+
+const data = {};
+
+module.exports = {
+ before: (client, done) => {
+ getProject('test-actions')
+ .then(obj => { data.project = obj; })
+ .then(done);
+ },
+ 'copy project': client => {
+ const projects = client.page.projects();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ projects.navigate();
+ projects.waitForElementVisible('div.spinny');
+ projects.waitForElementNotVisible('div.spinny');
+
+ projects.section.list.expect.element('smart-search').visible;
+ projects.section.list.section.search.expect.element('@input').enabled;
+
+ projects.section.list.section.search
+ .sendKeys('@input', `id:>${data.project.id - 1} id:<${data.project.id + 1}`)
+ .sendKeys('@input', client.Keys.ENTER);
+
+ projects.waitForElementVisible('div.spinny');
+ projects.waitForElementNotVisible('div.spinny');
+
+ projects.section.list.expect.element('@badge').text.equal('1');
+ projects.expect.element(`#projects_table tr[id="${data.project.id}"]`).visible;
+ projects.expect.element('i[class*="copy"]').visible;
+ projects.expect.element('i[class*="copy"]').enabled;
+
+ projects.click('i[class*="copy"]');
+ projects.waitForElementVisible('div.spinny');
+ projects.waitForElementNotVisible('div.spinny');
+
+ projects.expect.element('#project_form').visible;
+ projects.section.edit.expect.element('@title').visible;
+ projects.section.edit.expect.element('@title').text.contain(data.project.name);
+ projects.section.edit.expect.element('@title').text.not.equal(data.project.name);
+ projects.expect.element('@save').visible;
+ projects.expect.element('@save').enabled;
+
+ client.end();
+ }
+};
diff --git a/awx/ui/test/e2e/tests/test-templates-copy-delete-warnings.js b/awx/ui/test/e2e/tests/test-templates-copy-delete-warnings.js
new file mode 100644
index 0000000000..4feb1eb346
--- /dev/null
+++ b/awx/ui/test/e2e/tests/test-templates-copy-delete-warnings.js
@@ -0,0 +1,214 @@
+import { post } from '../api';
+import {
+ getInventoryScript,
+ getInventorySource,
+ getJobTemplate,
+ getOrCreate,
+ getOrganization,
+ getProject,
+ getUser,
+ getWorkflowTemplate,
+} from '../fixtures';
+
+let data;
+
+const promptHeader = '#prompt-header';
+const promptWarning = '#prompt-body';
+const promptResource = 'span[class="Prompt-warningResourceTitle"]';
+const promptResourceCount = 'span[class="badge List-titleBadge"]';
+const promptCancelButton = '#prompt_cancel_btn';
+const promptActionButton = '#prompt_action_btn';
+const promptCloseButton = '#prompt-header + div i[class*="times-circle"]';
+
+module.exports = {
+ before: (client, done) => {
+ const resources = [
+ getUser('test-warnings'),
+ getOrganization('test-warnings'),
+ getWorkflowTemplate('test-warnings'),
+ getProject('test-warnings'),
+ getJobTemplate('test-warnings'),
+ getInventoryScript('external-org'),
+ getInventorySource('external-org'),
+ ];
+
+ Promise.all(resources)
+ .then(([user, org, workflow, project, template, script, source]) => {
+ const unique = 'unified_job_template';
+ const nodes = workflow.related.workflow_nodes;
+ const nodePromise = getOrCreate(nodes, { [unique]: source.id }, [unique]);
+
+ const permissions = [
+ [org, 'admin_role'],
+ [workflow, 'admin_role'],
+ [project, 'admin_role'],
+ [template, 'admin_role'],
+ [script, 'read_role'],
+ ];
+
+ const assignments = permissions
+ .map(([resource, name]) => resource.summary_fields.object_roles[name])
+ .map(role => `/api/v2/roles/${role.id}/users/`)
+ .map(url => post(url, { id: user.id }))
+ .concat([nodePromise]);
+
+ Promise.all(assignments)
+ .then(() => { data = { user, project, source, template, workflow }; })
+ .then(done);
+ });
+ },
+ 'verify job template delete warning': client => {
+ const templates = client.page.templates();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ templates.navigate();
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('smart-search').visible;
+ templates.expect.element('smart-search input').enabled;
+
+ templates
+ .sendKeys('smart-search input', `id:>${data.template.id - 1} id:<${data.template.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1');
+ templates.expect.element(`#row-${data.template.id}`).visible;
+ templates.expect.element('i[class*="trash"]').visible;
+ templates.expect.element('i[class*="trash"]').enabled;
+
+ templates.click('i[class*="trash"]');
+
+ templates.expect.element(promptHeader).visible;
+ templates.expect.element(promptWarning).visible;
+ templates.expect.element(promptResource).visible;
+ templates.expect.element(promptResourceCount).visible;
+ templates.expect.element(promptCancelButton).visible;
+ templates.expect.element(promptActionButton).visible;
+ templates.expect.element(promptCloseButton).visible;
+
+ templates.expect.element(promptCancelButton).enabled;
+ templates.expect.element(promptActionButton).enabled;
+ templates.expect.element(promptCloseButton).enabled;
+
+ templates.expect.element(promptHeader).text.contain('DELETE');
+ templates.expect.element(promptHeader).text.contain(`${data.template.name.toUpperCase()}`);
+
+ templates.expect.element(promptWarning).text.contain('job template');
+
+ templates.expect.element(promptResource).text.contain('Workflow Job Template Nodes');
+ templates.expect.element(promptResourceCount).text.contain('1');
+
+ templates.click(promptCancelButton);
+
+ templates.expect.element(promptHeader).not.visible;
+
+ client.end();
+ },
+ 'verify workflow template delete warning': client => {
+ const templates = client.page.templates();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ templates.navigate();
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('smart-search').visible;
+ templates.expect.element('smart-search input').enabled;
+
+ templates
+ .sendKeys('smart-search input', `id:>${data.workflow.id - 1} id:<${data.workflow.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1');
+ templates.expect.element(`#row-${data.workflow.id}`).visible;
+ templates.expect.element('i[class*="trash"]').visible;
+ templates.expect.element('i[class*="trash"]').enabled;
+
+ templates.click('i[class*="trash"]');
+
+ templates.expect.element(promptHeader).visible;
+ templates.expect.element(promptWarning).visible;
+ templates.expect.element(promptCancelButton).visible;
+ templates.expect.element(promptActionButton).visible;
+ templates.expect.element(promptCloseButton).visible;
+
+ templates.expect.element(promptCancelButton).enabled;
+ templates.expect.element(promptActionButton).enabled;
+ templates.expect.element(promptCloseButton).enabled;
+
+ templates.expect.element(promptHeader).text.contain('DELETE');
+ templates.expect.element(promptHeader).text.contain(`${data.workflow.name.toUpperCase()}`);
+
+ templates.expect.element(promptWarning).text.contain('workflow template');
+
+ templates.click(promptCancelButton);
+
+ templates.expect.element(promptHeader).not.visible;
+
+ client.end();
+ },
+ 'verify workflow restricted copy warning': client => {
+ const templates = client.page.templates();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login(data.user.username);
+ client.waitForAngular();
+
+ templates.navigate();
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('smart-search').visible;
+ templates.expect.element('smart-search input').enabled;
+
+ templates
+ .sendKeys('smart-search input', `id:>${data.workflow.id - 1} id:<${data.workflow.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1');
+ templates.expect.element(`#row-${data.workflow.id}`).visible;
+ templates.expect.element('i[class*="copy"]').visible;
+ templates.expect.element('i[class*="copy"]').enabled;
+
+ templates.click('i[class*="copy"]');
+
+ templates.expect.element(promptHeader).visible;
+ templates.expect.element(promptWarning).visible;
+ templates.expect.element(promptCancelButton).visible;
+ templates.expect.element(promptActionButton).visible;
+ templates.expect.element(promptCloseButton).visible;
+
+ templates.expect.element(promptCancelButton).enabled;
+ templates.expect.element(promptActionButton).enabled;
+ templates.expect.element(promptCloseButton).enabled;
+
+ templates.expect.element(promptHeader).text.contain('COPY WORKFLOW');
+ templates.expect.element(promptWarning).text.contain('Unified Job Templates');
+ templates.expect.element(promptWarning).text.contain(`${data.source.name}`);
+
+ templates.click(promptCancelButton);
+
+ templates.expect.element(promptHeader).not.visible;
+
+ client.end();
+ },
+};
diff --git a/awx/ui/test/e2e/tests/test-templates-list-actions.js b/awx/ui/test/e2e/tests/test-templates-list-actions.js
new file mode 100644
index 0000000000..000338126e
--- /dev/null
+++ b/awx/ui/test/e2e/tests/test-templates-list-actions.js
@@ -0,0 +1,161 @@
+import {
+ getInventorySource,
+ getJobTemplate,
+ getProject,
+ getWorkflowTemplate
+} from '../fixtures';
+
+let data;
+
+module.exports = {
+ before: (client, done) => {
+ const resources = [
+ getInventorySource('test-actions'),
+ getJobTemplate('test-actions'),
+ getProject('test-actions'),
+ getWorkflowTemplate('test-actions'),
+ ];
+
+ Promise.all(resources)
+ .then(([source, template, project, workflow]) => {
+ data = { source, template, project, workflow };
+ done();
+ });
+ },
+ 'copy job template': client => {
+ const templates = client.page.templates();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ templates.navigate();
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('smart-search').visible;
+ templates.expect.element('smart-search input').enabled;
+
+ templates
+ .sendKeys('smart-search input', `id:>${data.template.id - 1} id:<${data.template.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1');
+ templates.expect.element(`#row-${data.template.id}`).visible;
+ templates.expect.element('i[class*="copy"]').visible;
+ templates.expect.element('i[class*="copy"]').enabled;
+
+ templates.click('i[class*="copy"]');
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('#job_template_form').visible;
+ templates.section.addJobTemplate.expect.element('@title').visible;
+ templates.section.addJobTemplate.expect.element('@title').text.contain(data.template.name);
+ templates.section.addJobTemplate.expect.element('@title').text.not.equal(data.template.name);
+ templates.expect.element('@save').visible;
+ templates.expect.element('@save').enabled;
+
+ client.end();
+ },
+ 'copy workflow template': client => {
+ const templates = client.page.templates();
+
+ client.useCss();
+ client.resizeWindow(1200, 800);
+ client.login();
+ client.waitForAngular();
+
+ templates.navigate();
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('smart-search').visible;
+ templates.expect.element('smart-search input').enabled;
+
+ templates
+ .sendKeys('smart-search input', `id:>${data.workflow.id - 1} id:<${data.workflow.id + 1}`)
+ .sendKeys('smart-search input', client.Keys.ENTER);
+
+ templates.waitForElementVisible('div.spinny');
+ templates.waitForElementNotVisible('div.spinny');
+
+ templates.expect.element('.at-Panel-headingTitleBadge').text.equal('1');
+ templates.expect.element(`#row-${data.workflow.id}`).visible;
+ templates.expect.element('i[class*="copy"]').visible;
+ templates.expect.element('i[class*="copy"]').enabled;
+
+ templates
+ .click('i[class*="copy"]')
+ .waitForElementVisible('div.spinny')
+ .waitForElementNotVisible('div.spinny')
+ .waitForAngular();
+
+ templates.expect.element('#workflow_job_template_form').visible;
+ templates.section.editWorkflowJobTemplate.expect.element('@title').visible;
+ templates.section.editWorkflowJobTemplate.expect.element('@title').text.contain(data.workflow.name);
+ templates.section.editWorkflowJobTemplate.expect.element('@title').text.not.equal(data.workflow.name);
+
+ templates.expect.element('@save').visible;
+ templates.expect.element('@save').enabled;
+
+ client
+ .useXpath()
+ .pause(1000)
+ .waitForElementVisible('//*[text()=" Workflow Editor"]')
+ .click('//*[text()=" Workflow Editor"]')
+ .useCss()
+ .waitForElementVisible('div.spinny')
+ .waitForElementNotVisible('div.spinny')
+ .waitForAngular();
+
+ client.expect.element('#workflow-modal-dialog').visible;
+ client.expect.element('#workflow-modal-dialog span[class^="badge"]').visible;
+ client.expect.element('#workflow-modal-dialog span[class^="badge"]').text.equal('3');
+ client.expect.element('div[class="WorkflowMaker-title"]').visible;
+ client.expect.element('div[class="WorkflowMaker-title"]').text.contain(data.workflow.name);
+ client.expect.element('div[class="WorkflowMaker-title"]').text.not.equal(data.workflow.name);
+
+ client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').visible;
+ client.expect.element('#workflow-modal-dialog i[class*="fa-cog"]').enabled;
+
+ client.click('#workflow-modal-dialog i[class*="fa-cog"]');
+
+ client.waitForElementVisible('workflow-controls');
+ client.waitForElementVisible('div[class*="-zoomPercentage"]');
+
+ client.click('i[class*="fa-home"]').expect.element('div[class*="-zoomPercentage"]').text.equal('100%');
+ client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('90%');
+ client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('80%');
+ client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('70%');
+ client.click('i[class*="fa-minus"]').expect.element('div[class*="-zoomPercentage"]').text.equal('60%');
+
+ client.expect.element('#node-1').visible;
+ client.expect.element('#node-2').visible;
+ client.expect.element('#node-3').visible;
+ client.expect.element('#node-4').visible;
+
+ client.expect.element('#node-1 text').text.not.equal('').after(5000);
+ client.expect.element('#node-2 text').text.not.equal('').after(5000);
+ client.expect.element('#node-3 text').text.not.equal('').after(5000);
+ client.expect.element('#node-4 text').text.not.equal('').after(5000);
+
+ const checkNodeText = (selector, text) => client.getText(selector, ({ value }) => {
+ client.assert.equal(text.indexOf(value.replace('...', '')) >= 0, true);
+ });
+
+ checkNodeText('#node-1 text', 'START');
+ checkNodeText('#node-2 text', data.project.name);
+ checkNodeText('#node-3 text', data.template.name);
+ checkNodeText('#node-4 text', data.source.name);
+
+ templates.expect.element('@save').visible;
+ templates.expect.element('@save').enabled;
+
+ client.end();
+ }
+};
diff --git a/awx/ui/test/e2e/tests/test-xss.js b/awx/ui/test/e2e/tests/test-xss.js
index 16d84cd8ba..5d15bdfc99 100644
--- a/awx/ui/test/e2e/tests/test-xss.js
+++ b/awx/ui/test/e2e/tests/test-xss.js
@@ -113,7 +113,7 @@ module.exports = {
client.expect.element('.at-Panel smart-search').visible;
client.expect.element('.at-Panel smart-search input').enabled;
- client.sendKeys('.at-Panel smart-search input', `id:${data.jobTemplate.id}`);
+ client.sendKeys('.at-Panel smart-search input', `id:>${data.jobTemplate.id - 1} id:<${data.jobTemplate.id + 1}`);
client.sendKeys('.at-Panel smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').not.visible;
@@ -176,7 +176,7 @@ module.exports = {
client.expect.element('div[ui-view="related"]').visible;
client.expect.element('div[ui-view="related"] smart-search input').enabled;
- client.sendKeys('div[ui-view="related"] smart-search input', `id:${adminRole.id}`);
+ client.sendKeys('div[ui-view="related"] smart-search input', `id:>${adminRole.id - 1} id:<${adminRole.id + 1}`);
client.sendKeys('div[ui-view="related"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').not.visible;
@@ -231,7 +231,7 @@ module.exports = {
client.expect.element('div[class^="Panel"] smart-search').visible;
client.expect.element('div[class^="Panel"] smart-search input').enabled;
- client.sendKeys('div[class^="Panel"] smart-search input', `id:${data.notification.id}`);
+ client.sendKeys('div[class^="Panel"] smart-search input', `id:>${data.notification.id - 1} id:<${data.notification.id + 1}`);
client.sendKeys('div[class^="Panel"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').visible;
@@ -282,7 +282,7 @@ module.exports = {
client.expect.element('div[class^="Panel"] smart-search').visible;
client.expect.element('div[class^="Panel"] smart-search input').enabled;
- client.sendKeys('div[class^="Panel"] smart-search input', `id:${data.organization.id}`);
+ client.sendKeys('div[class^="Panel"] smart-search input', `id:>${data.organization.id - 1} id:<${data.organization.id + 1}`);
client.sendKeys('div[class^="Panel"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').visible;
@@ -333,7 +333,7 @@ module.exports = {
client.expect.element('div[class^="Panel"] smart-search').visible;
client.expect.element('div[class^="Panel"] smart-search input').enabled;
- client.sendKeys('div[class^="Panel"] smart-search input', `id:${data.inventory.id}`);
+ client.sendKeys('div[class^="Panel"] smart-search input', `id:>${data.inventory.id - 1} id:<${data.inventory.id + 1}`);
client.sendKeys('div[class^="Panel"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').visible;
@@ -393,7 +393,7 @@ module.exports = {
client.expect.element('div[class^="Panel"] smart-search').visible;
client.expect.element('div[class^="Panel"] smart-search input').enabled;
- client.sendKeys('div[class^="Panel"] smart-search input', `id:${data.inventoryScript.id}`);
+ client.sendKeys('div[class^="Panel"] smart-search input', `id:>${data.inventoryScript.id - 1} id:<${data.inventoryScript.id + 1}`);
client.sendKeys('div[class^="Panel"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').visible;
@@ -455,7 +455,7 @@ module.exports = {
client.expect.element('div[ui-view="related"]').visible;
client.expect.element('div[ui-view="related"] smart-search input').enabled;
- client.sendKeys('div[ui-view="related"] smart-search input', `id:${data.user.id}`);
+ client.sendKeys('div[ui-view="related"] smart-search input', `id:>${data.user.id - 1} id:<${data.user.id + 1}`);
client.sendKeys('div[ui-view="related"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').not.visible;
@@ -514,7 +514,7 @@ module.exports = {
client.expect.element('div[class^="Panel"] smart-search').visible;
client.expect.element('div[class^="Panel"] smart-search input').enabled;
- client.sendKeys('div[class^="Panel"] smart-search input', `id:${data.project.id}`);
+ client.sendKeys('div[class^="Panel"] smart-search input', `id:>${data.project.id - 1} id:<${data.project.id + 1}`);
client.sendKeys('div[class^="Panel"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').visible;
@@ -566,7 +566,7 @@ module.exports = {
client.expect.element('div[ui-view="list"] smart-search').visible;
client.expect.element('div[ui-view="list"] smart-search input').enabled;
- client.sendKeys('div[ui-view="list"] smart-search input', `id:${data.credential.id}`);
+ client.sendKeys('div[ui-view="list"] smart-search input', `id:>${data.credential.id - 1} id:<${data.credential.id + 1}`);
client.sendKeys('div[ui-view="list"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').visible;
@@ -618,7 +618,7 @@ module.exports = {
client.expect.element('div[class^="Panel"] smart-search').visible;
client.expect.element('div[class^="Panel"] smart-search input').enabled;
- client.sendKeys('div[class^="Panel"] smart-search input', `id:${data.team.id}`);
+ client.sendKeys('div[class^="Panel"] smart-search input', `id:>${data.team.id - 1} id:<${data.team.id + 1}`);
client.sendKeys('div[class^="Panel"] smart-search input', client.Keys.ENTER);
client.expect.element('div.spinny').visible;
@@ -684,7 +684,6 @@ module.exports = {
.contains('<div id="xss" class="xss">test</div>');
});
});
- client.end();
},
'check host recent jobs popup for unsanitized content': client => {
const itemRow = `#hosts_table tr[id="${data.host.id}"]`;
diff --git a/awx/ui/test/unit/index.js b/awx/ui/test/unit/index.js
index 6fd7b1b02e..7c8967ef87 100644
--- a/awx/ui/test/unit/index.js
+++ b/awx/ui/test/unit/index.js
@@ -1,2 +1,2 @@
import './components';
-
+import './models';
diff --git a/awx/ui/test/unit/models/base.unit.js b/awx/ui/test/unit/models/base.unit.js
new file mode 100644
index 0000000000..f721c12a0a
--- /dev/null
+++ b/awx/ui/test/unit/models/base.unit.js
@@ -0,0 +1,25 @@
+describe('Models | BaseModel', () => {
+ let baseModel;
+
+ beforeEach(() => {
+ angular.mock.module('at.lib.services');
+ angular.mock.module('at.lib.models');
+ });
+
+ beforeEach(angular.mock.inject(($injector) => {
+ baseModel = new ($injector.get('BaseModel'))('test');
+ }));
+
+ describe('parseRequestConfig', () => {
+ it('always returns the expected configuration', () => {
+ const { parseRequestConfig } = baseModel;
+ const data = { name: 'foo' };
+
+ expect(parseRequestConfig('get')).toEqual({ method: 'get', resource: undefined });
+ expect(parseRequestConfig('get', 1)).toEqual({ method: 'get', resource: 1 });
+ expect(parseRequestConfig('post', { data })).toEqual({ method: 'post', data });
+ expect(parseRequestConfig(['get', 'post'], [1, 2], { data }))
+ .toEqual({ resource: [1, 2], method: ['get', 'post'] });
+ });
+ });
+});
diff --git a/awx/ui/test/unit/models/index.js b/awx/ui/test/unit/models/index.js
new file mode 100644
index 0000000000..a1a9b3452a
--- /dev/null
+++ b/awx/ui/test/unit/models/index.js
@@ -0,0 +1,5 @@
+// Import angular and angular-mocks to the global scope
+import 'angular-mocks';
+
+// Import tests
+import './base.unit';