From ceef7f57af17fd87030f9257c4d70aa32a9c77d3 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 4 Mar 2019 09:28:07 -0500 Subject: [PATCH] add input source creation ui --- .../credentials/add-credentials.controller.js | 250 ++++++++------ .../add-edit-credentials.view.html | 3 +- .../credentials/credentials.strings.js | 14 +- .../edit-credentials.controller.js | 320 ++++++++++++------ .../external-test-modal.component.js | 23 ++ .../external-test-modal.partial.html | 13 + .../credentials/external-test.component.js | 37 -- .../credentials/external-test.partial.html | 41 --- awx/ui/client/features/credentials/index.js | 4 +- .../input-source-lookup.component.js | 68 +--- .../input-source-lookup.partial.html | 240 ++++--------- awx/ui/client/lib/components/_index.less | 1 + .../action/action-button.component.js | 33 ++ .../action/action-button.partial.html | 3 + .../lib/components/easy-modal/_index.less | 27 ++ .../easy-modal/easy-modal.component.js | 29 ++ .../easy-modal/easy-modal.partial.html | 19 ++ .../lib/components/form/form.directive.js | 2 +- awx/ui/client/lib/components/index.js | 6 + .../lookup-list/lookup-list.component.js | 51 +++ .../lookup-list/lookup-list.partial.html | 90 +++++ .../src/templates/prompt/prompt.block.less | 11 - 22 files changed, 759 insertions(+), 526 deletions(-) create mode 100644 awx/ui/client/features/credentials/external-test-modal.component.js create mode 100644 awx/ui/client/features/credentials/external-test-modal.partial.html delete mode 100644 awx/ui/client/features/credentials/external-test.component.js delete mode 100644 awx/ui/client/features/credentials/external-test.partial.html create mode 100644 awx/ui/client/lib/components/action/action-button.component.js create mode 100644 awx/ui/client/lib/components/action/action-button.partial.html create mode 100644 awx/ui/client/lib/components/easy-modal/_index.less create mode 100644 awx/ui/client/lib/components/easy-modal/easy-modal.component.js create mode 100644 awx/ui/client/lib/components/easy-modal/easy-modal.partial.html create mode 100644 awx/ui/client/lib/components/lookup-list/lookup-list.component.js create mode 100644 awx/ui/client/lib/components/lookup-list/lookup-list.partial.html diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js index 2acad45ae9..a097b8716e 100644 --- a/awx/ui/client/features/credentials/add-credentials.controller.js +++ b/awx/ui/client/features/credentials/add-credentials.controller.js @@ -11,10 +11,13 @@ function AddCredentialsController ( Wait, $filter, CredentialType, + GetBasePath, + Rest, ) { const vm = this || {}; const { me, credential, credentialType, organization } = models; + const isExternal = credentialType.get('kind') === 'external'; vm.mode = 'add'; vm.strings = strings; @@ -44,44 +47,6 @@ function AddCredentialsController ( vm.form.credential_type._placeholder = strings.get('inputs.CREDENTIAL_TYPE_PLACEHOLDER'); vm.isTestable = credentialType.get('kind') === 'external'; - vm.inputSources = { - field: null, - credentialId: null, - credentialTypeId: null, - credentialTypeName: null, - tabs: { - credential: { - _active: true, - _disabled: false, - }, - metadata: { - _active: false, - _disabled: false, - } - }, - metadata: {}, - form: { - inputs: { - _get: () => vm.inputSources.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - items: [], - }; - vm.externalTest = { - metadata: null, - form: { - inputs: { - _get: () => vm.externalTest.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - }; - const gceFileInputSchema = { id: 'gce_service_account_key', type: 'file', @@ -106,12 +71,20 @@ function AddCredentialsController ( become._isDynamic = true; become._choices = Array.from(apiConfig.become_methods, method => method[0]); } - vm.isTestable = credentialType.get('kind') === 'external'; + vm.isTestable = (credentialType.get('kind') === 'external'); vm.getSubmitData = getSubmitData; + vm.inputSources.items = []; + const linkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + fields = fields.map((field) => { - if (credentialType.get('kind') !== 'external') { - field.tagMode = true; + field.tagMode = credentialType.get('kind') !== 'external'; + if (linkedFieldNames.includes(field.id)) { + field.asTag = true; + const { summary_fields } = vm.inputSources.items + .find(({ input_field_name }) => input_field_name === field.id); + field._value = summary_fields.source_credential.name; } return field; }); @@ -129,120 +102,184 @@ function AddCredentialsController ( _key: 'inputs' }; + vm.externalTest = { + form: { + inputs: { + _get: () => vm.externalTest.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + metadataInputs: null, + }; + vm.inputSources = { + tabs: { + credential: { + _active: true, + _disabled: false, + }, + metadata: { + _active: false, + _disabled: false, + } + }, + form: { + inputs: { + _get: () => vm.inputSources.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + field: null, + credentialTypeId: null, + credentialTypeName: null, + credentialId: null, + credentialName: null, + metadataInputs: null, + initialItems: credential.get('related.input_sources.results'), + items: credential.get('related.input_sources.results'), + }; + vm.onInputSourceClear = (field) => { vm.form[field].tagMode = true; vm.form[field].asTag = false; + vm.form[field]._value = ''; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field); }; - vm.setTab = (name) => { + function setInputSourceTab (name) { const metaIsActive = name === 'metadata'; vm.inputSources.tabs.credential._active = !metaIsActive; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = metaIsActive; vm.inputSources.tabs.metadata._disabled = false; - }; + } - vm.unsetTabs = () => { + function unsetInputSourceTabs () { vm.inputSources.tabs.credential._active = false; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = false; vm.inputSources.tabs.metadata._disabled = false; - }; + } vm.onInputSourceOpen = (field) => { - vm.inputSources.field = field; - vm.setTab('credential'); const sourceItem = vm.inputSources.items .find(({ input_field_name }) => input_field_name === field); if (sourceItem) { const { source_credential, summary_fields } = sourceItem; - const { source_credential: { credential_type_id } } = summary_fields; + const { source_credential: { credential_type_id, name } } = summary_fields; vm.inputSources.credentialId = source_credential; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type_id; vm.inputSources._value = credential_type_id; } + setInputSourceTab('credential'); + vm.inputSources.field = field; }; vm.onInputSourceClose = () => { vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.credentialId = null; + vm.inputSources.credentialName = null; + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); }; + /** + * Extract the current set of input values from the metadata form and reshape them to a + * metadata object that can be sent to the api later or reloaded when re-opening the form. + */ + function getMetadataFormSubmitData ({ inputs }) { + const metadata = Object.assign({}, ...inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + return metadata; + } + vm.onInputSourceNext = () => { const { field, credentialId, credentialTypeId } = vm.inputSources; Wait('start'); new CredentialType('get', credentialTypeId) .then(model => { model.mergeInputProperties('metadata'); - vm.inputSources.metadata = model.get('inputs.metadata'); + vm.inputSources.metadataInputs = model.get('inputs.metadata'); vm.inputSources.credentialTypeName = model.get('name'); const [metavals] = vm.inputSources.items .filter(({ input_field_name }) => input_field_name === field) .filter(({ source_credential }) => source_credential === credentialId) .map(({ metadata }) => metadata); Object.keys(metavals || {}).forEach(key => { - const obj = vm.inputSources.metadata.find(o => o.id === key); + const obj = vm.inputSources.metadataInputs.find(o => o.id === key); if (obj) obj._value = metavals[key]; }); - vm.setTab('metadata'); + setInputSourceTab('metadata'); }) .finally(() => Wait('stop')); }; vm.onInputSourceSelect = () => { - const { field, credentialId } = vm.inputSources; + const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources; + const metadata = getMetadataFormSubmitData(vm.inputSources.form); vm.inputSources.items = vm.inputSources.items .filter(({ input_field_name }) => input_field_name !== field) .concat([{ + metadata, input_field_name: field, source_credential: credentialId, target_credential: credential.get('id'), + summary_fields: { + source_credential: { + name: credentialName, + credential_type_id: credentialTypeId + } + }, }]); vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); + vm.form[field]._value = credentialName; + vm.form[field].asTag = true; }; vm.onInputSourceTabSelect = (name) => { if (name === 'metadata') { vm.onInputSourceNext(); } else { - vm.setTab('credential'); + setInputSourceTab('credential'); } }; - vm.onInputSourceRowClick = ({ id, credential_type }) => { + vm.onInputSourceRowClick = ({ id, credential_type, name }) => { vm.inputSources.credentialId = id; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type; vm.inputSources._value = credential_type; }; vm.onInputSourceTest = () => { - const metadata = Object.assign({}, ...vm.inputSources.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); + const metadata = getMetadataFormSubmitData(vm.inputSources.form); const name = $filter('sanitize')(vm.inputSources.credentialTypeName); const endpoint = `${vm.inputSources.credentialId}/test/`; - - return vm.runTest({ name, model: credential, endpoint, data: { metadata } }); + return runTest({ name, model: credential, endpoint, data: { metadata } }); }; - vm.onExternalTestClick = () => { + function onExternalTestOpen () { credentialType.mergeInputProperties('metadata'); - vm.externalTest.metadata = credentialType.get('inputs.metadata'); - }; + vm.externalTest.metadataInputs = credentialType.get('inputs.metadata'); + } + vm.form.secondary = onExternalTestOpen; vm.onExternalTestClose = () => { - vm.externalTest.metadata = null; + vm.externalTest.metadataInputs = null; }; vm.onExternalTest = () => { const name = $filter('sanitize')(credentialType.get('name')); const { inputs } = vm.getSubmitData(); - const metadata = Object.assign({}, ...vm.externalTest.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); + const metadata = getMetadataFormSubmitData(vm.externalTest.form); let model; if (credential.get('credential_type') !== credentialType.get('id')) { @@ -252,49 +289,57 @@ function AddCredentialsController ( } const endpoint = `${model.get('id')}/test/`; - return vm.runTest({ name, model, endpoint, data: { inputs, metadata } }); + return runTest({ name, model, endpoint, data: { inputs, metadata } }); }; - vm.form.secondary = vm.onExternalTestClick; - vm.runTest = ({ name, model, endpoint, data: { inputs, metadata } }) => { + vm.filterInputSourceCredentialResults = (data) => { + if (isExternal) { + data.results = data.results.filter(({ id }) => id !== credential.get('id')); + } + return data; + }; + + function runTest ({ name, model, endpoint, data: { inputs, metadata } }) { return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) .then(() => { + const icon = 'fa-check-circle'; + const msg = strings.get('edit.TEST_PASSED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.success({ - content: vm.buildTestNotificationContent({ - name, - icon: 'fa-check-circle', - msg: strings.get('edit.TEST_PASSED'), - }), + content, dismissButton: false, dismissOnTimeout: true }); }) .catch(({ data }) => { - const msg = data.inputs - ? `${$filter('sanitize')(data.inputs)}` - : strings.get('edit.TEST_FAILED'); + const icon = 'fa-exclamation-triangle'; + const msg = data.inputs || strings.get('edit.TEST_FAILED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.danger({ - content: vm.buildTestNotificationContent({ - name, - msg, - icon: 'fa-exclamation-triangle' - }), + content, dismissButton: false, dismissOnTimeout: true }); }); - }; + } - vm.buildTestNotificationContent = ({ name, msg, icon }) => ( - `
+ function buildTestNotificationContent ({ name, msg, icon }) { + const sanitize = $filter('sanitize'); + const content = `
- ${name}: ${msg} + ${sanitize(name)}: ${sanitize(msg)}
-
` - ); +
`; + return content; + } + + function createInputSource (data) { + Rest.setUrl(GetBasePath('credential_input_sources')); + return Rest.post(data); + } vm.form.save = data => { data.user = me.get('id'); @@ -303,14 +348,25 @@ function AddCredentialsController ( delete data.inputs[gceFileInputSchema.id]; } - const filteredInputs = _.omit(data.inputs, (value) => value === ''); + const updatedLinkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + const sourcesToAssociate = [...vm.inputSources.items]; + + // remove inputs with empty string values + let filteredInputs = _.omit(data.inputs, (value) => value === ''); + // remove inputs that are to be linked to an external credential + filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); data.inputs = filteredInputs; - return credential.request('post', { data }); + return credential.request('post', { data }) + .then(() => { + sourcesToAssociate.forEach(obj => { obj.target_credential = credential.get('id'); }); + return Promise.all(sourcesToAssociate.map(createInputSource)); + }); }; - vm.form.onSaveSuccess = res => { - $state.go('credentials.edit', { credential_id: res.data.id }, { reload: true }); + vm.form.onSaveSuccess = () => { + $state.go('credentials.edit', { credential_id: credential.get('id') }, { reload: true }); }; vm.gceOnFileInputChanged = (value, oldValue) => { @@ -381,6 +437,8 @@ AddCredentialsController.$inject = [ 'Wait', '$filter', 'CredentialTypeModel', + 'GetBasePath', + 'Rest', ]; export default AddCredentialsController; diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html index 914c98dcfa..45c6568552 100644 --- a/awx/ui/client/features/credentials/add-edit-credentials.view.html +++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html @@ -54,9 +54,10 @@ on-tab-select="vm.onInputSourceTabSelect" on-row-click="vm.onInputSourceRowClick" on-test="vm.onInputSourceTest" + results-filter="vm.filterInputSourceCredentialResults" /> vm.inputSources.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - items: credential.get('related.input_sources.results'), - }; - vm.externalTest = { - metadata: null, - form: { - inputs: { - _get: () => vm.externalTest.metadata, - _reference: 'vm.form.inputs', - _key: 'inputs', - _source: { _value: {} }, - } - }, - }; - const gceFileInputSchema = { id: 'gce_service_account_key', type: 'file', @@ -175,16 +140,30 @@ function EditCredentialsController ( } } + vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); + vm.getSubmitData = getSubmitData; + + vm.inputSources.initialItems = credential.get('related.input_sources.results'); + if (credential.get('credential_type') !== credentialType.get('id')) { + vm.inputSources.items = []; + } else { + vm.inputSources.items = credential.get('related.input_sources.results'); + } + + const linkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + fields = fields.map((field) => { - if (isEditable && credentialType.get('kind') !== 'external') { - field.tagMode = true; + field.tagMode = isEditable && credentialType.get('kind') !== 'external'; + if (linkedFieldNames.includes(field.id)) { + field.asTag = true; + const { summary_fields } = vm.inputSources.items + .find(({ input_field_name }) => input_field_name === field.id); + field._value = summary_fields.source_credential.name; } return field; }); - vm.isTestable = (isEditable && credentialType.get('kind') === 'external'); - vm.getSubmitData = getSubmitData; - return fields; }, _onRemoveTag ({ id }) { @@ -200,121 +179,228 @@ function EditCredentialsController ( title: true, }; + vm.externalTest = { + form: { + inputs: { + _get: () => vm.externalTest.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + metadataInputs: null, + }; + vm.inputSources = { + tabs: { + credential: { + _active: true, + _disabled: false, + }, + metadata: { + _active: false, + _disabled: false, + } + }, + form: { + inputs: { + _get: () => vm.inputSources.metadataInputs, + _reference: 'vm.form.inputs', + _key: 'inputs', + _source: { _value: {} }, + }, + }, + field: null, + credentialTypeId: null, + credentialTypeName: null, + credentialId: null, + credentialName: null, + metadataInputs: null, + initialItems: credential.get('related.input_sources.results'), + items: credential.get('related.input_sources.results'), + }; + vm.onInputSourceClear = (field) => { vm.form[field].tagMode = true; vm.form[field].asTag = false; + vm.form[field]._value = ''; + vm.inputSources.items = vm.inputSources.items + .filter(({ input_field_name }) => input_field_name !== field); }; - vm.setTab = (name) => { + function setInputSourceTab (name) { const metaIsActive = name === 'metadata'; vm.inputSources.tabs.credential._active = !metaIsActive; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = metaIsActive; vm.inputSources.tabs.metadata._disabled = false; - }; + } - vm.unsetTabs = () => { + function unsetInputSourceTabs () { vm.inputSources.tabs.credential._active = false; vm.inputSources.tabs.credential._disabled = false; vm.inputSources.tabs.metadata._active = false; vm.inputSources.tabs.metadata._disabled = false; - }; + } vm.onInputSourceOpen = (field) => { - vm.inputSources.field = field; - vm.setTab('credential'); + // We get here when the input source lookup modal for a field is opened. If source + // credential and metadata values for this field already exist in the initial API data + // or from it being set during a prior visit to the lookup, we initialize the lookup with + // these values here before opening it. const sourceItem = vm.inputSources.items .find(({ input_field_name }) => input_field_name === field); if (sourceItem) { const { source_credential, summary_fields } = sourceItem; - const { source_credential: { credential_type_id } } = summary_fields; + const { source_credential: { credential_type_id, name } } = summary_fields; vm.inputSources.credentialId = source_credential; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type_id; vm.inputSources._value = credential_type_id; } + setInputSourceTab('credential'); + vm.inputSources.field = field; }; vm.onInputSourceClose = () => { + // We get here if the lookup was closed or canceled so we clear the state for the lookup + // and metadata form without storing any changes. vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.credentialId = null; + vm.inputSources.credentialName = null; + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); }; + /** + * Extract the current set of input values from the metadata form and reshape them to a + * metadata object that can be sent to the api later or reloaded when re-opening the form. + */ + function getMetadataFormSubmitData ({ inputs }) { + const metadata = Object.assign({}, ...inputs._group + .filter(({ _value }) => _value !== undefined) + .map(({ id, _value }) => ({ [id]: _value }))); + return metadata; + } + vm.onInputSourceNext = () => { const { field, credentialId, credentialTypeId } = vm.inputSources; Wait('start'); new CredentialType('get', credentialTypeId) .then(model => { model.mergeInputProperties('metadata'); - vm.inputSources.metadata = model.get('inputs.metadata'); + vm.inputSources.metadataInputs = model.get('inputs.metadata'); vm.inputSources.credentialTypeName = model.get('name'); + // Pre-populate the input values for the metadata form if state for this specific + // field_name->source_credential link already exists. This occurs one of two ways: + // + // 1. This field->source_credential link already exists in the API and so we're + // reflecting the current state as it exists on the backend. + // 2. The metadata form for this specific field->source_credential combination was + // set during a prior visit to this lookup and so we're reflecting the most + // recent set of (unsaved) metadata values provided by the user for this field. + // + // Note: Prior state for a given credential input field is only set for one source + // credential at a time. Linking a field to a source credential will remove all + // other prior input state for that field. const [metavals] = vm.inputSources.items .filter(({ input_field_name }) => input_field_name === field) .filter(({ source_credential }) => source_credential === credentialId) .map(({ metadata }) => metadata); Object.keys(metavals || {}).forEach(key => { - const obj = vm.inputSources.metadata.find(o => o.id === key); + const obj = vm.inputSources.metadataInputs.find(o => o.id === key); if (obj) obj._value = metavals[key]; }); - vm.setTab('metadata'); + setInputSourceTab('metadata'); }) .finally(() => Wait('stop')); }; vm.onInputSourceSelect = () => { - const { field, credentialId } = vm.inputSources; + const { field, credentialId, credentialName, credentialTypeId } = vm.inputSources; + const metadata = getMetadataFormSubmitData(vm.inputSources.form); + // Remove any input source objects already stored for this field then store the metadata + // and currently selected source credential as a valid credential input source object that + // can be sent to the api later or reloaded into the form if it is reopened. vm.inputSources.items = vm.inputSources.items .filter(({ input_field_name }) => input_field_name !== field) .concat([{ + metadata, input_field_name: field, source_credential: credentialId, target_credential: credential.get('id'), + summary_fields: { + source_credential: { + name: credentialName, + credential_type_id: credentialTypeId + } + }, }]); + // Now that we've extracted and stored the selected source credential and metadata values + // for this field, we clear the state for the source credential lookup and metadata form. vm.inputSources.field = null; - vm.inputSources.metadata = null; - vm.unsetTabs(); + vm.inputSources.metadataInputs = null; + unsetInputSourceTabs(); + // We've linked this field to a credential, so display value as a credential tag + vm.form[field]._value = credentialName; + vm.form[field].asTag = true; }; vm.onInputSourceTabSelect = (name) => { if (name === 'metadata') { + // Clicking on the metadata tab should have identical behavior to clicking the 'next' + // button, so we pass-through to the same handler here. vm.onInputSourceNext(); } else { - vm.setTab('credential'); + setInputSourceTab('credential'); } }; - vm.onInputSourceRowClick = ({ id, credential_type }) => { + vm.onInputSourceRowClick = ({ id, credential_type, name }) => { vm.inputSources.credentialId = id; + vm.inputSources.credentialName = name; vm.inputSources.credentialTypeId = credential_type; vm.inputSources._value = credential_type; }; vm.onInputSourceTest = () => { - const metadata = Object.assign({}, ...vm.inputSources.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); + // We get here if the test button on the metadata form for the field of a non-external + // credential was used. All input values for the external credential are already stored + // on the backend, so we are only testing how it works with a set of metadata before + // linking it. + const metadata = getMetadataFormSubmitData(vm.inputSources.form); const name = $filter('sanitize')(vm.inputSources.credentialTypeName); const endpoint = `${vm.inputSources.credentialId}/test/`; - - return vm.runTest({ name, model: credential, endpoint, data: { metadata } }); + return runTest({ name, model: credential, endpoint, data: { metadata } }); }; - vm.onExternalTestClick = () => { + function onExternalTestOpen () { + // We get here if test button on the top-level form for an external credential type was + // used. We load the metadata schema for this particular external credential type and + // use it to generate and open a form for submitting test values. credentialType.mergeInputProperties('metadata'); - vm.externalTest.metadata = credentialType.get('inputs.metadata'); - }; + vm.externalTest.metadataInputs = credentialType.get('inputs.metadata'); + } + vm.form.secondary = onExternalTestOpen; vm.onExternalTestClose = () => { - vm.externalTest.metadata = null; + // We get here if the metadata test form for an external credential type was canceled or + // closed so we clear the form state and close without submitting any data to the test api, + vm.externalTest.metadataInputs = null; }; vm.onExternalTest = () => { const name = $filter('sanitize')(credentialType.get('name')); const { inputs } = vm.getSubmitData(); - const metadata = Object.assign({}, ...vm.externalTest.form.inputs._group - .filter(({ _value }) => _value !== undefined) - .map(({ id, _value }) => ({ [id]: _value }))); - + const metadata = getMetadataFormSubmitData(vm.externalTest.form); + // We get here if the test button on the top-level form for an external credential type was + // used. We need to see if the currently selected credential type is the one loaded from + // the api when we initialized the view or if its type was changed on the form and hasn't + // been saved. If the credential type hasn't been changed, it means some of the input + // values for the credential may be stored in the backend and not in the form, so we need + // to use the test endpoint for the credential. If the credential type has been changed, + // the user must provide a complete set of input values for the credential to save their + // changes, so we use the generic test endpoint for the credental type as if we were + // testing a completely new and unsaved credential. let model; if (credential.get('credential_type') !== credentialType.get('id')) { model = credentialType; @@ -323,49 +409,65 @@ function EditCredentialsController ( } const endpoint = `${model.get('id')}/test/`; - return vm.runTest({ name, model, endpoint, data: { inputs, metadata } }); + return runTest({ name, model, endpoint, data: { inputs, metadata } }); }; - vm.form.secondary = vm.onExternalTestClick; - vm.runTest = ({ name, model, endpoint, data: { inputs, metadata } }) => { + vm.filterInputSourceCredentialResults = (data) => { + // If an external credential is changed to have a non-external `credential_type` while + // editing, we avoid showing a self-reference in the list of selectable external + // credentials for input fields by filtering it out here. + if (isExternal) { + data.results = data.results.filter(({ id }) => id !== credential.get('id')); + } + return data; + }; + + function runTest ({ name, model, endpoint, data: { inputs, metadata } }) { return model.http.post({ url: endpoint, data: { inputs, metadata }, replace: false }) .then(() => { + const icon = 'fa-check-circle'; + const msg = strings.get('edit.TEST_PASSED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.success({ - content: vm.buildTestNotificationContent({ - name, - icon: 'fa-check-circle', - msg: strings.get('edit.TEST_PASSED'), - }), + content, dismissButton: false, dismissOnTimeout: true }); }) .catch(({ data }) => { - const msg = data.inputs - ? `${$filter('sanitize')(data.inputs)}` - : strings.get('edit.TEST_FAILED'); + const icon = 'fa-exclamation-triangle'; + const msg = data.inputs || strings.get('edit.TEST_FAILED'); + const content = buildTestNotificationContent({ name, icon, msg }); ngToast.danger({ - content: vm.buildTestNotificationContent({ - name, - msg, - icon: 'fa-exclamation-triangle' - }), + content, dismissButton: false, dismissOnTimeout: true }); }); - }; + } - vm.buildTestNotificationContent = ({ name, msg, icon }) => ( - `
+ function buildTestNotificationContent ({ name, msg, icon }) { + const sanitize = $filter('sanitize'); + const content = `
- ${name}: ${msg} + ${sanitize(name)}: ${sanitize(msg)}
-
` - ); +
`; + return content; + } + + function deleteInputSource ({ id }) { + Rest.setUrl(`${GetBasePath('credential_input_sources')}${id}/`); + return Rest.destroy(); + } + + function createInputSource (data) { + Rest.setUrl(GetBasePath('credential_input_sources')); + return Rest.post(data); + } /** * If a credential's `credential_type` is changed while editing, the inputs associated with @@ -380,10 +482,32 @@ function EditCredentialsController ( delete data.inputs[gceFileInputSchema.id]; } - const filteredInputs = _.omit(data.inputs, (value) => value === ''); + const initialLinkedFieldNames = vm.inputSources.initialItems + .map(({ input_field_name }) => input_field_name); + const updatedLinkedFieldNames = vm.inputSources.items + .map(({ input_field_name }) => input_field_name); + + const fieldsToDisassociate = [...initialLinkedFieldNames] + .filter(name => !updatedLinkedFieldNames.includes(name)); + const fieldsToAssociate = [...updatedLinkedFieldNames] + .filter(name => !initialLinkedFieldNames.includes(name)); + + const sourcesToDisassociate = [...fieldsToDisassociate] + .map(name => vm.inputSources.initialItems + .find(({ input_field_name }) => input_field_name === name)); + const sourcesToAssociate = [...fieldsToAssociate] + .map(name => vm.inputSources.items + .find(({ input_field_name }) => input_field_name === name)); + + // remove inputs with empty string values + let filteredInputs = _.omit(data.inputs, (value) => value === ''); + // remove inputs that are to be linked to an external credential + filteredInputs = _.omit(filteredInputs, updatedLinkedFieldNames); data.inputs = filteredInputs; - return credential.request('put', { data }); + return Promise.all(sourcesToDisassociate.map(deleteInputSource)) + .then(() => credential.request('put', { data })) + .then(() => Promise.all(sourcesToAssociate.map(createInputSource))); }; vm.form.onSaveSuccess = () => { @@ -450,6 +574,8 @@ EditCredentialsController.$inject = [ 'Wait', '$filter', 'CredentialTypeModel', + 'GetBasePath', + 'Rest', ]; export default EditCredentialsController; diff --git a/awx/ui/client/features/credentials/external-test-modal.component.js b/awx/ui/client/features/credentials/external-test-modal.component.js new file mode 100644 index 0000000000..8600cc45f1 --- /dev/null +++ b/awx/ui/client/features/credentials/external-test-modal.component.js @@ -0,0 +1,23 @@ +const templateUrl = require('~features/credentials/external-test-modal.partial.html'); + +function ExternalTestModalController (strings) { + const vm = this || {}; + + vm.strings = strings; + vm.title = strings.get('externalTest.TITLE'); +} + +ExternalTestModalController.$inject = [ + 'CredentialsStrings', +]; + +export default { + templateUrl, + controller: ExternalTestModalController, + controllerAs: 'vm', + bindings: { + onClose: '=', + onSubmit: '=', + form: '=', + }, +}; diff --git a/awx/ui/client/features/credentials/external-test-modal.partial.html b/awx/ui/client/features/credentials/external-test-modal.partial.html new file mode 100644 index 0000000000..c44ef4487e --- /dev/null +++ b/awx/ui/client/features/credentials/external-test-modal.partial.html @@ -0,0 +1,13 @@ + + + + + + {{::vm.strings.get('CLOSE')}} + + + {{::vm.strings.get('RUN')}} + + + + diff --git a/awx/ui/client/features/credentials/external-test.component.js b/awx/ui/client/features/credentials/external-test.component.js deleted file mode 100644 index 0c4fda3659..0000000000 --- a/awx/ui/client/features/credentials/external-test.component.js +++ /dev/null @@ -1,37 +0,0 @@ -const templateUrl = require('~features/credentials/external-test.partial.html'); - -function ExternalTestModalController ($scope, $element, strings) { - const vm = this || {}; - let overlay; - - vm.strings = strings; - vm.title = 'Test External Credential'; - - vm.$onInit = () => { - const [el] = $element; - overlay = el.querySelector('#external-test-modal'); - vm.show(); - }; - - vm.show = () => { - overlay.style.display = 'block'; - overlay.style.opacity = 1; - }; -} - -ExternalTestModalController.$inject = [ - '$scope', - '$element', - 'CredentialsStrings', -]; - -export default { - templateUrl, - controller: ExternalTestModalController, - controllerAs: 'vm', - bindings: { - onClose: '=', - onSubmit: '=', - form: '=', - }, -}; diff --git a/awx/ui/client/features/credentials/external-test.partial.html b/awx/ui/client/features/credentials/external-test.partial.html deleted file mode 100644 index 3d866c42f1..0000000000 --- a/awx/ui/client/features/credentials/external-test.partial.html +++ /dev/null @@ -1,41 +0,0 @@ - diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index 2c13f645ed..d4d49da8ba 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -3,7 +3,7 @@ import AddController from './add-credentials.controller'; import EditController from './edit-credentials.controller'; import CredentialsStrings from './credentials.strings'; import InputSourceLookupComponent from './input-source-lookup.component'; -import ExternalTestComponent from './external-test.component'; +import ExternalTestModalComponent from './external-test-modal.component'; const MODULE_NAME = 'at.features.credentials'; @@ -143,7 +143,7 @@ angular .service('LegacyCredentialsService', LegacyCredentials) .service('CredentialsStrings', CredentialsStrings) .component('atInputSourceLookup', InputSourceLookupComponent) - .component('atExternalCredentialTest', ExternalTestComponent) + .component('atExternalCredentialTest', ExternalTestModalComponent) .run(CredentialsRun); export default MODULE_NAME; diff --git a/awx/ui/client/features/credentials/input-source-lookup.component.js b/awx/ui/client/features/credentials/input-source-lookup.component.js index 1ac781734c..beea7e72ec 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.component.js +++ b/awx/ui/client/features/credentials/input-source-lookup.component.js @@ -1,76 +1,13 @@ const templateUrl = require('~features/credentials/input-source-lookup.partial.html'); -function InputSourceLookupController ($scope, $element, $http, GetBasePath, qs, strings) { +function InputSourceLookupController (strings) { const vm = this || {}; - let overlay; vm.strings = strings; - vm.name = 'credential'; - vm.title = 'Set Input Source'; - - vm.$onInit = () => { - const [el] = $element; - overlay = el.querySelector('#input-source-lookup'); - - const defaultParams = { - order_by: 'name', - credential_type__kind: 'external', - page_size: 5 - }; - vm.setDefaultParams(defaultParams); - vm.setData({ results: [], count: 0 }); - $http({ method: 'GET', url: GetBasePath(`${vm.name}s`), params: defaultParams }) - .then(({ data }) => { - vm.setData(data); - vm.show(); - }); - }; - - vm.show = () => { - overlay.style.display = 'block'; - overlay.style.opacity = 1; - }; - - vm.close = () => { - vm.onClose(); - }; - - vm.next = () => { - vm.onNext(); - }; - - vm.select = () => { - vm.onSelect(); - }; - - vm.test = () => { - vm.onTest(); - }; - - vm.setData = ({ results, count }) => { - vm.dataset = { results, count }; - vm.collection = vm.dataset.results; - }; - - vm.setDefaultParams = (params) => { - vm.list = { name: vm.name, iterator: vm.name }; - vm.defaultParams = params; - vm.queryset = params; - }; - - vm.toggle_row = (obj) => { - vm.onRowClick(obj); - }; - - vm.onCredentialTabClick = () => vm.onTabSelect('credential'); + vm.title = strings.get('inputSources.TITLE'); } InputSourceLookupController.$inject = [ - '$scope', - '$element', - '$http', - 'GetBasePath', - 'QuerySet', 'CredentialsStrings', ]; @@ -88,5 +25,6 @@ export default { onTest: '=', selectedId: '=', form: '=', + resultsFilter: '=', }, }; diff --git a/awx/ui/client/features/credentials/input-source-lookup.partial.html b/awx/ui/client/features/credentials/input-source-lookup.partial.html index 4998201715..336a8e62c0 100644 --- a/awx/ui/client/features/credentials/input-source-lookup.partial.html +++ b/awx/ui/client/features/credentials/input-source-lookup.partial.html @@ -1,174 +1,66 @@ - + + + + {{::vm.strings.get('inputSources.CREDENTIAL')}} + + + {{::vm.strings.get('inputSources.METADATA')}} + + + + + + + + + {{::vm.strings.get('TEST')}} + + + {{::vm.strings.get('CANCEL')}} + + + {{::vm.strings.get('NEXT')}} + + + {{::vm.strings.get('OK')}} + + + + diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index ba606cad79..b065777948 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -4,6 +4,7 @@ @import 'layout/_index'; @import 'list/_index'; @import 'modal/_index'; +@import 'easy-modal/_index'; @import 'panel/_index'; @import 'popover/_index'; @import 'relaunchButton/_index'; diff --git a/awx/ui/client/lib/components/action/action-button.component.js b/awx/ui/client/lib/components/action/action-button.component.js new file mode 100644 index 0000000000..d77bf218db --- /dev/null +++ b/awx/ui/client/lib/components/action/action-button.component.js @@ -0,0 +1,33 @@ +const templateUrl = require('~components/action/action-button.partial.html'); + +function ActionButtonController () { + const vm = this || {}; + vm.$onInit = () => { + const { variant } = vm; + + if (variant === 'primary') { + vm.color = 'success'; + vm.fill = ''; + } + + if (variant === 'secondary') { + vm.color = 'info'; + vm.fill = ''; + } + + if (variant === 'tertiary') { + vm.color = 'default'; + vm.fill = 'Hollow'; + } + }; +} + +export default { + templateUrl, + controller: ActionButtonController, + controllerAs: 'vm', + transclude: true, + bindings: { + variant: '@', + }, +}; diff --git a/awx/ui/client/lib/components/action/action-button.partial.html b/awx/ui/client/lib/components/action/action-button.partial.html new file mode 100644 index 0000000000..b205af475e --- /dev/null +++ b/awx/ui/client/lib/components/action/action-button.partial.html @@ -0,0 +1,3 @@ + diff --git a/awx/ui/client/lib/components/easy-modal/_index.less b/awx/ui/client/lib/components/easy-modal/_index.less new file mode 100644 index 0000000000..942803309b --- /dev/null +++ b/awx/ui/client/lib/components/easy-modal/_index.less @@ -0,0 +1,27 @@ +.at-EasyModal-body { + font-size: @at-font-size; + padding: @at-padding-panel 0; +} + +.at-EasyModal-dismiss { + .at-mixin-ButtonIcon(); + font-size: @at-font-size-modal-dismiss; + color: @at-color-icon-dismiss; + text-align: right; +} + +.at-EasyModal-heading { + margin: 0; + overflow: visible; + + & > .at-EasyModal-dismiss { + margin: 0; + } +} + +.at-EasyModal-title { + margin: 0; + padding: 0; + + .at-mixin-Heading(@at-font-size-modal-heading); +} diff --git a/awx/ui/client/lib/components/easy-modal/easy-modal.component.js b/awx/ui/client/lib/components/easy-modal/easy-modal.component.js new file mode 100644 index 0000000000..cf78db312c --- /dev/null +++ b/awx/ui/client/lib/components/easy-modal/easy-modal.component.js @@ -0,0 +1,29 @@ +const templateUrl = require('~components/easy-modal/easy-modal.partial.html'); + +const overlaySelector = '.at-EasyModal'; + +function EasyModalController ($element) { + const vm = this || {}; + + vm.$onInit = () => { + const [el] = $element; + const overlay = el.querySelector(overlaySelector); + overlay.style.display = 'block'; + overlay.style.opacity = 1; + }; +} + +EasyModalController.$inject = [ + '$element', +]; + +export default { + templateUrl, + controller: EasyModalController, + controllerAs: 'vm', + transclude: true, + bindings: { + title: '=', + onClose: '=', + }, +}; diff --git a/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html b/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html new file mode 100644 index 0000000000..2c44ab0ae0 --- /dev/null +++ b/awx/ui/client/lib/components/easy-modal/easy-modal.partial.html @@ -0,0 +1,19 @@ + diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js index 7aaa90aa1a..a270b0835e 100644 --- a/awx/ui/client/lib/components/form/form.directive.js +++ b/awx/ui/client/lib/components/form/form.directive.js @@ -30,7 +30,6 @@ function AtFormController (eventService, strings) { ({ modal } = scope[scope.ns]); vm.state.disabled = scope.state.disabled; - vm.setListeners(); }; @@ -203,6 +202,7 @@ function AtFormController (eventService, strings) { if (isValid !== vm.state.isValid) { vm.state.isValid = isValid; + scope.state.isValid = vm.state.isValid; } }; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 078bd16a06..b9dca8673c 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -1,8 +1,10 @@ import atLibServices from '~services'; import actionGroup from '~components/action/action-group.directive'; +import actionButton from '~components/action/action-button.component'; import divider from '~components/utility/divider.directive'; import dynamicSelect from '~components/input/dynamic-select.directive'; +import easyModal from '~components/easy-modal/easy-modal.component'; import form from '~components/form/form.directive'; import formAction from '~components/form/action.directive'; import inputCheckbox from '~components/input/checkbox.directive'; @@ -20,6 +22,7 @@ import inputTextareaSecret from '~components/input/textarea-secret.directive'; import launchTemplate from '~components/launchTemplateButton/launchTemplateButton.component'; import layout from '~components/layout/layout.directive'; import list from '~components/list/list.directive'; +import lookupList from '~components/lookup-list/lookup-list.component'; import modal from '~components/modal/modal.directive'; import panel from '~components/panel/panel.directive'; import panelBody from '~components/panel/body.directive'; @@ -53,8 +56,10 @@ angular atCodeMirror ]) .directive('atActionGroup', actionGroup) + .component('atActionButton', actionButton) .directive('atDivider', divider) .directive('atDynamicSelect', dynamicSelect) + .component('atEasyModal', easyModal) .directive('atForm', form) .directive('atFormAction', formAction) .directive('atInputCheckbox', inputCheckbox) @@ -72,6 +77,7 @@ angular .component('atLaunchTemplate', launchTemplate) .directive('atLayout', layout) .directive('atList', list) + .component('atLookupList', lookupList) .directive('atListToolbar', toolbar) .component('atRelaunch', relaunch) .directive('atRow', row) diff --git a/awx/ui/client/lib/components/lookup-list/lookup-list.component.js b/awx/ui/client/lib/components/lookup-list/lookup-list.component.js new file mode 100644 index 0000000000..207456ba84 --- /dev/null +++ b/awx/ui/client/lib/components/lookup-list/lookup-list.component.js @@ -0,0 +1,51 @@ +const templateUrl = require('~components/lookup-list/lookup-list.partial.html'); + +function LookupListController (GetBasePath, Rest, strings) { + const vm = this || {}; + + vm.strings = strings; + + vm.$onInit = () => { + const params = vm.baseParams; + setBaseParams(params); + setData({ results: [], count: 0 }); + + const resultsFilter = vm.resultsFilter || (data => data); + Rest.setUrl(GetBasePath(`${vm.resourceName}s`)); + Rest.get({ params }) + .then(({ data }) => { + setData(resultsFilter(data)); + }); + }; + + function setData ({ results, count }) { + vm.dataset = { results, count }; + vm.collection = vm.dataset.results; + } + + function setBaseParams (params) { + vm.list = { name: vm.resourceName, iterator: vm.resourceName }; + vm.defaultParams = params; + vm.queryset = params; + } +} + +LookupListController.$inject = [ + 'GetBasePath', + 'Rest', + 'ComponentsStrings', +]; + +export default { + templateUrl, + controller: LookupListController, + controllerAs: 'vm', + bindings: { + onSelect: '=', + onRowClick: '=', + selectedId: '=', + resourceName: '@', + baseParams: '=', + resultsFilter: '=', + }, +}; diff --git a/awx/ui/client/lib/components/lookup-list/lookup-list.partial.html b/awx/ui/client/lib/components/lookup-list/lookup-list.partial.html new file mode 100644 index 0000000000..a2fcee30c1 --- /dev/null +++ b/awx/ui/client/lib/components/lookup-list/lookup-list.partial.html @@ -0,0 +1,90 @@ +
+
+
+ + +
+
+
+ {{::vm.strings.get('NO_MATCH')}} +
+
+
+ {{::vm.strings.get('NO_ITEMS')}} +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ {{ obj.name }} +
+
+
+
+
+
+ + +
diff --git a/awx/ui/client/src/templates/prompt/prompt.block.less b/awx/ui/client/src/templates/prompt/prompt.block.less index d9f7e3b6fd..c5670874ed 100644 --- a/awx/ui/client/src/templates/prompt/prompt.block.less +++ b/awx/ui/client/src/templates/prompt/prompt.block.less @@ -44,17 +44,6 @@ height: 30px; min-width: 85px; } - -.Prompt-infoButton{ - .at-mixin-ButtonColor('at-color-info', 'at-color-default'); - text-transform: uppercase; - border-radius: 5px; - padding-left:15px; - padding-right: 15px; - height: 30px; - min-width: 85px; - margin-right: 20px; -} .Prompt-defaultButton:hover{ background-color: @btn-bg-hov; color: @btn-txt;