From d10e727b3cd80c9e25693cdd01e301416bc9a2c8 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 22 Oct 2019 16:00:02 -0400 Subject: [PATCH] Adds CredentialLookUp to JT Form --- awx/ui_next/src/api/index.js | 6 + awx/ui_next/src/api/models/CredentialTypes.js | 10 ++ awx/ui_next/src/api/models/Credentials.js | 10 ++ awx/ui_next/src/api/models/JobTemplates.js | 8 + .../components/Lookup/CredentialsLookup.jsx | 165 ++++++++++++++++++ awx/ui_next/src/components/Lookup/Lookup.jsx | 151 ++++++++++------ awx/ui_next/src/components/Lookup/index.js | 1 + .../components/SelectedList/SelectedList.jsx | 31 ++-- .../JobTemplateAdd/JobTemplateAdd.jsx | 9 + .../JobTemplateEdit/JobTemplateEdit.jsx | 30 +++- .../Template/shared/JobTemplateForm.jsx | 22 ++- 11 files changed, 375 insertions(+), 68 deletions(-) create mode 100644 awx/ui_next/src/api/models/CredentialTypes.js create mode 100644 awx/ui_next/src/api/models/Credentials.js create mode 100644 awx/ui_next/src/components/Lookup/CredentialsLookup.jsx diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 7d05309b3a..9d76cc8bd8 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,5 +1,7 @@ import AdHocCommands from './models/AdHocCommands'; import Config from './models/Config'; +import CredentialTypes from './models/CredentialTypes' +import Credentials from './models/Credentials' import InstanceGroups from './models/InstanceGroups'; import Inventories from './models/Inventories'; import InventorySources from './models/InventorySources'; @@ -23,6 +25,8 @@ import WorkflowJobTemplates from './models/WorkflowJobTemplates'; const AdHocCommandsAPI = new AdHocCommands(); const ConfigAPI = new Config(); +const CredentialsAPI = new Credentials(); +const CredentialTypesAPI = new CredentialTypes(); const InstanceGroupsAPI = new InstanceGroups(); const InventoriesAPI = new Inventories(); const InventorySourcesAPI = new InventorySources(); @@ -47,6 +51,8 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); export { AdHocCommandsAPI, ConfigAPI, + CredentialsAPI, + CredentialTypesAPI, InstanceGroupsAPI, InventoriesAPI, InventorySourcesAPI, diff --git a/awx/ui_next/src/api/models/CredentialTypes.js b/awx/ui_next/src/api/models/CredentialTypes.js new file mode 100644 index 0000000000..65906cdcbd --- /dev/null +++ b/awx/ui_next/src/api/models/CredentialTypes.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class CredentialTypes extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/credential_types/'; + } +} + +export default CredentialTypes; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js new file mode 100644 index 0000000000..2e3634b4e5 --- /dev/null +++ b/awx/ui_next/src/api/models/Credentials.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Credentials extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/credentials/'; + } +} + +export default Credentials; diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js index cff6858db7..9941ad220d 100644 --- a/awx/ui_next/src/api/models/JobTemplates.js +++ b/awx/ui_next/src/api/models/JobTemplates.js @@ -44,6 +44,14 @@ class JobTemplates extends InstanceGroupsMixin(NotificationsMixin(Base)) { readCredentials(id, params) { return this.http.get(`${this.baseUrl}${id}/credentials/`, { params }); } + + associateCredentials(id, credential) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { id: credential }); + } + + disassociateCredentials(id, credential) { + return this.http.post(`${this.baseUrl}${id}/credentials/`, { id: credential, disassociate: true }); + } } export default JobTemplates; diff --git a/awx/ui_next/src/components/Lookup/CredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialsLookup.jsx new file mode 100644 index 0000000000..1648d7d910 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/CredentialsLookup.jsx @@ -0,0 +1,165 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { FormGroup, Tooltip } from '@patternfly/react-core'; +import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import { CredentialsAPI, CredentialTypesAPI } from '@api'; +import Lookup from '@components/Lookup'; + +const QuestionCircleIcon = styled(PFQuestionCircleIcon)` + margin-left: 10px; +`; + +class CredentialsLookup extends React.Component { + constructor(props) { + super(props) + + this.state = { + credentials: props.credentials, + selectedCredentialType: {label: "Machine", id: 1, kind: 'ssh'}, + credentialTypes: [] + } + + this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(this); + this.loadCredentials = this.loadCredentials.bind(this); + this.loadCredentialTypes = this.loadCredentialTypes.bind(this); + this.toggleCredential = this.toggleCredential.bind(this); + } + + componentDidMount() { + this.loadCredentials({page: 1, page_size: 5, order_by:'name'}) + this.loadCredentialTypes(); + } + + componentDidUpdate(prevState) { + const {selectedType} = this.state + if (prevState.selectedType !== selectedType) { + Promise.all([this.loadCredentials()]); + } + } + + async loadCredentialTypes() { + const {onError} =this.props + try { + const { data } = await CredentialTypesAPI.read() + const acceptableTypes = ["machine", "cloud", "net", "ssh", "vault"] + const credentialTypes =[] + data.results + .forEach(cred => { + acceptableTypes.forEach(aT => { + if (aT === cred.kind) { + // This object has several repeated values as some of it's children + // require different field values. + cred = { + id: cred.id, kind: cred.kind, type: cred.namespace, + value: cred.name, label: cred.name, isDisabled: false + } + credentialTypes.push(cred) + }}) + }) + this.setState({ credentialTypes }) + } catch (err){ + onError(err) + } + } + + async loadCredentials(params) { + const { selectedCredentialType } = this.state; + params.credential_type = selectedCredentialType.id || 1 + return CredentialsAPI.read(params) + } + + handleCredentialTypeSelect(value, type) { + const {credentialTypes} = this.state + const selectedType = credentialTypes.filter(item => item.label === type) + this.setState({selectedCredentialType: selectedType[0]}) + } + + toggleCredential(item) { + const { credentials: stateToUpdate, selectedCredentialType } = this.state; + const { onChange } = this.props; + const index = stateToUpdate.findIndex( + credential => credential.id === item.id + ) + if (index > -1) { + const newCredentialsList = stateToUpdate.filter(cred => cred.id !== item.id) + this.setState({ credentials: newCredentialsList }); + onChange(newCredentialsList) + return; + } + + const credentialTypeOccupied = stateToUpdate.some(cred => cred.kind === item.kind); + if (selectedCredentialType.value === "Vault" || !credentialTypeOccupied ) { + item.credentialType = selectedCredentialType + this.setState({ credentials: [...stateToUpdate, item] }) + onChange([...stateToUpdate, item]) + } else { + const credsList = [...stateToUpdate] + const occupyingCredIndex = stateToUpdate.findIndex(occupyingCred => + occupyingCred.kind === item.kind) + credsList.splice(occupyingCredIndex, 1, item) + this.setState({ credentials: credsList }) + onChange(credsList) + } + } + + render() { + const { tooltip, i18n } = this.props; + const { credentials, selectedCredentialType, credentialTypes } = this.state; + + return ( + + {tooltip && ( + + + + )} + {credentialTypes && ( + {}} + getItems={this.loadCredentials} + qsNamespace="credentials" + columns={[ + { + name: i18n._(t`Name`), + key: 'name', + isSortable: true, + isSearchable: true, + }, + ]} + sortedColumnKey="name" + /> + )} + + ); + } +} + +CredentialsLookup.propTypes = { + tooltip: PropTypes.string, + onChange: PropTypes.func.isRequired, +}; + +CredentialsLookup.defaultProps = { + tooltip: '', +}; +export { CredentialsLookup as _CredentialsLookup }; + +export default withI18n()(withRouter(CredentialsLookup)); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index 874571fcb0..4342bf5fbe 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -15,16 +15,19 @@ import { ButtonVariant, InputGroup as PFInputGroup, Modal, + ToolbarItem, } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import AnsibleSelect from '../AnsibleSelect' import PaginatedDataList from '../PaginatedDataList'; +import VerticalSeperator from '../VerticalSeparator' import DataListToolbar from '../DataListToolbar'; import CheckboxListItem from '../CheckboxListItem'; import SelectedList from '../SelectedList'; -import { ChipGroup, Chip } from '../Chip'; +import { ChipGroup, Chip, CredentialChip } from '../Chip'; import { getQSConfig, parseQueryString } from '../../util/qs'; const SearchButton = styled(Button)` @@ -65,32 +68,49 @@ class Lookup extends React.Component { results: [], count: 0, error: null, + isDropdownOpen: false }; - this.qsConfig = getQSConfig(props.qsNamespace, { - page: 1, - page_size: 5, - order_by: props.sortedColumnKey, - }); + this.qsConfig = getQSConfig(props.qsNamespace, { + page: 1, + page_size: 5, + order_by: props.sortedColumnKey, + }); this.handleModalToggle = this.handleModalToggle.bind(this); this.toggleSelected = this.toggleSelected.bind(this); this.saveModal = this.saveModal.bind(this); this.getData = this.getData.bind(this); this.clearQSParams = this.clearQSParams.bind(this); + this.toggleDropdown = this.toggleDropdown.bind(this); } componentDidMount() { - this.getData(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { + const {onLoadCredentialTypes} = this.props + ; + if (onLoadCredentialTypes) { + Promise.all([onLoadCredentialTypes(), this.getData()]); + } else { this.getData(); } } + componentDidUpdate(prevProps) { + const { location, selectedCategory } = this.props; + if ((location !== prevProps.location) || + (prevProps.selectedCategory !== selectedCategory)) { + this.getData(); + } + } + + toggleDropdown() { + const { isDropdownOpen } = this.state; + this.setState({isDropdownOpen: !isDropdownOpen}) + } + assertCorrectValueType() { - const { multiple, value } = this.props; + const { multiple, value, selectCategoryOptions } = this.props; + if (selectCategoryOptions) { + return + } if (!multiple && Array.isArray(value)) { throw new Error( 'Lookup value must not be an array unless `multiple` is set' @@ -110,30 +130,31 @@ class Lookup extends React.Component { this.setState({ error: false }); try { - const { data } = await getItems(queryParams); + const { data } = await getItems(queryParams); const { results, count } = data; - this.setState({ - results, - count, - }); + this.setState({ + results, + count, + }); } catch (err) { this.setState({ error: true }); } } toggleSelected(row) { - const { name, onLookupSave, multiple } = this.props; + const { name, onLookupSave, multiple, onToggleItem, selectCategoryOptions } = this.props; const { - lookupSelectedItems: updatedSelectedItems, - isModalOpen, + lookupSelectedItems: updatedSelectedItems, isModalOpen } = this.state; const selectedIndex = updatedSelectedItems.findIndex( selectedRow => selectedRow.id === row.id - ); - + ); if (multiple) { + if (selectCategoryOptions) { + onToggleItem(row, isModalOpen) + } if (selectedIndex > -1) { updatedSelectedItems.splice(selectedIndex, 1); this.setState({ lookupSelectedItems: updatedSelectedItems }); @@ -150,13 +171,13 @@ class Lookup extends React.Component { // This handles the case where the user removes chips from the lookup input // while the modal is closed if (!isModalOpen) { - onLookupSave(updatedSelectedItems, name); - } + onLookupSave(updatedSelectedItems, name) + }; } handleModalToggle() { const { isModalOpen } = this.state; - const { value, multiple } = this.props; + const { value, multiple, selectCategory } = this.props; // Resets the selected items from parent state whenever modal is opened // This handles the case where the user closes/cancels the modal and // opens it again @@ -168,6 +189,9 @@ class Lookup extends React.Component { this.setState({ lookupSelectedItems }); } else { this.clearQSParams(); + if (selectCategory) { + selectCategory(null, "Machine"); + } } this.setState(prevState => ({ isModalOpen: !prevState.isModalOpen, @@ -177,11 +201,11 @@ class Lookup extends React.Component { saveModal() { const { onLookupSave, name, multiple } = this.props; const { lookupSelectedItems } = this.state; - const value = multiple - ? lookupSelectedItems - : lookupSelectedItems[0] || null; - onLookupSave(value, name); - this.handleModalToggle(); + const value = multiple ? lookupSelectedItems : lookupSelectedItems[0] || null; + + this.handleModalToggle(); + onLookupSave(value, name); + } clearQSParams() { @@ -201,6 +225,7 @@ class Lookup extends React.Component { count, } = this.state; const { + form, id, lookupHeader, value, @@ -208,27 +233,40 @@ class Lookup extends React.Component { multiple, name, onBlur, + selectCategory, required, i18n, + selectCategoryOptions, + selectedCategory } = this.props; - const header = lookupHeader || i18n._(t`Items`); const canDelete = !required || (multiple && value.length > 1); - - const chips = value ? ( - - {(multiple ? value : [value]).map(chip => ( - this.toggleSelected(chip)} - isReadOnly={!canDelete} - > - {chip.name} - - ))} - - ) : null; - + const chips = () => { + return (selectCategoryOptions && selectCategoryOptions.length > 0) ? ( + + {(multiple ? value : [value]).map(chip => ( + this.toggleSelected(chip)} + isReadOnly={!canDelete} + credential={chip} + /> + ))} + + ) : ( + + {(multiple ? value : [value]).map(chip => ( + this.toggleSelected(chip)} + isReadOnly={!canDelete} + > + {chip.name} + + ))} + + ) + } return ( @@ -240,7 +278,9 @@ class Lookup extends React.Component { > - {chips} + + {value ? chips(value) : null} + , ]} > + {(selectCategoryOptions && selectCategoryOptions.length > 0) && ( + + Selected Category + + + + )} i.id === item.id)} + isSelected={selectCategoryOptions ? value.some(i => i.id === item.id) + : lookupSelectedItems.some(i => i.id === item.id)} onSelect={() => this.toggleSelected(item)} - isRadio={!multiple} + isRadio={!multiple || ((selectCategoryOptions && selectCategoryOptions.length) && selectedCategory.value !== "Vault")} /> )} renderToolbar={props => } @@ -288,10 +336,11 @@ class Lookup extends React.Component { {lookupSelectedItems.length > 0 && ( 0} /> )} {error ?
error
: ''} diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index 99e10ac27f..5620da1a89 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -2,3 +2,4 @@ export { default } from './Lookup'; export { default as InstanceGroupsLookup } from './InstanceGroupsLookup'; export { default as InventoryLookup } from './InventoryLookup'; export { default as ProjectLookup } from './ProjectLookup'; +export { default as CredentialsLookup } from './CredentialsLookup'; diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index 6fcaf05939..e773404054 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Split as PFSplit, SplitItem } from '@patternfly/react-core'; import styled from 'styled-components'; -import { ChipGroup, Chip } from '../Chip'; +import { ChipGroup, Chip, CredentialChip } from '../Chip'; import VerticalSeparator from '../VerticalSeparator'; const Split = styled(PFSplit)` @@ -27,22 +27,33 @@ class SelectedList extends Component { onRemove, displayKey, isReadOnly, + isCredentialList } = this.props; + const chips = isCredentialList ? selected.map(item => ( + onRemove(item)} + credential={item} + > + {item[displayKey]} + + )) : selected.map(item => ( + onRemove(item)} + > + {item[displayKey]} + + )) return ( {label} - {selected.map(item => ( - onRemove(item)} - > - {item[displayKey]} - - ))} + {chips} diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx index 0623ad7ab8..9d25ad3fc7 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx @@ -22,6 +22,7 @@ function JobTemplateAdd({ history, i18n }) { organizationId, instanceGroups, initialInstanceGroups, + credentials, ...remainingValues } = values; @@ -33,6 +34,7 @@ function JobTemplateAdd({ history, i18n }) { await Promise.all([ submitLabels(id, labels, organizationId), submitInstanceGroups(id, instanceGroups), + submitCredentials(id, credentials) ]); history.push(`/templates/${type}/${id}/details`); } catch (error) { @@ -60,6 +62,13 @@ function JobTemplateAdd({ history, i18n }) { return Promise.all(associatePromises); } + function submitCredentials(templateId, credentials = []) { + const associateCredentials = credentials.map(cred => + JobTemplatesAPI.associateCredentials(templateId, cred.id) + ) + return Promise.all(associateCredentials) + } + function handleCancel() { history.push(`/templates`); } diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index a0cf904ab2..5272a46149 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -109,6 +109,8 @@ class JobTemplateEdit extends Component { organizationId, instanceGroups, initialInstanceGroups, + credentials, + initialCredentials, ...remainingValues } = values; @@ -118,6 +120,7 @@ class JobTemplateEdit extends Component { await Promise.all([ this.submitLabels(labels, organizationId), this.submitInstanceGroups(instanceGroups, initialInstanceGroups), + this.submitCredentials(credentials) ]); history.push(this.detailsUrl); } catch (formSubmitError) { @@ -154,13 +157,30 @@ class JobTemplateEdit extends Component { async submitInstanceGroups(groups, initialGroups) { const { template } = this.props; const { added, removed } = getAddedAndRemoved(initialGroups, groups); - const associatePromises = added.map(group => - JobTemplatesAPI.associateInstanceGroup(template.id, group.id) - ); - const disassociatePromises = removed.map(group => + const disassociatePromises = await removed.map(group => JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id) ); - return Promise.all([...associatePromises, ...disassociatePromises]); + const associatePromises = await added.map(group => + JobTemplatesAPI.associateInstanceGroup(template.id, group.id) + ); + return Promise.all([...disassociatePromises, ...associatePromises, ]); + } + + async submitCredentials(newCredentials) { + const { template } = this.props; + const { added, removed } = getAddedAndRemoved( + template.summary_fields.credentials, + newCredentials + ); + const disassociateCredentials = removed.map(cred => + JobTemplatesAPI.disassociateCredentials(template.id, cred.id) + ); + const disassociatePromise = await Promise.all(disassociateCredentials); + const associateCredentials = added.map(cred => + JobTemplatesAPI.associateCredentials(template.id, cred.id) + ) + const associatePromise = Promise.all(associateCredentials) + return Promise.all([disassociatePromise, associatePromise]) } handleCancel() { diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index bffdf85cef..df37d1319a 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -27,6 +27,7 @@ import { InventoryLookup, InstanceGroupsLookup, ProjectLookup, + CredentialsLookup } from '@components/Lookup'; import { JobTemplatesAPI } from '@api'; import LabelSelect from './LabelSelect'; @@ -62,6 +63,7 @@ class JobTemplateForm extends Component { inventory: null, labels: { results: [] }, project: null, + credentials: [], }, isNew: true, }, @@ -84,7 +86,8 @@ class JobTemplateForm extends Component { const { validateField } = this.props; this.setState({ contentError: null, hasContentLoading: true }); // TODO: determine when LabelSelect has finished loading labels - Promise.all([this.loadRelatedInstanceGroups()]).then(() => { + Promise.all([this.loadRelatedInstanceGroups(), + ]).then(() => { this.setState({ hasContentLoading: false }); validateField('project'); }); @@ -149,7 +152,6 @@ class JobTemplateForm extends Component { isDisabled: false, }, ]; - const verbosityOptions = [ { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, @@ -319,6 +321,20 @@ class JobTemplateForm extends Component { )} /> + + ( + this.setState({ contentError: err })} + credentials={template.summary_fields.credentials} + onChange={value => (form.setFieldValue('credentials', value))} + tooltip={i18n._(t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`)} + /> + )} + /> + props.handleSubmit(values),