From 4b95297bd4af478cd0d4d1e3d483ab45e10ce538 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 21 May 2020 14:43:19 -0400 Subject: [PATCH] Adds basic credential plugin support to relevant fields in the static credential forms. --- awx/ui_next/src/api/index.js | 19 ++- .../src/api/models/CredentialInputSources.js | 10 ++ awx/ui_next/src/api/models/Credentials.js | 7 + .../components/FormField/PasswordField.jsx | 54 +----- .../components/FormField/PasswordInput.jsx | 71 ++++++++ awx/ui_next/src/components/FormField/index.js | 1 + .../CredentialAdd/CredentialAdd.jsx | 33 +++- .../CredentialEdit/CredentialEdit.jsx | 93 ++++++++-- .../CredentialEdit/CredentialEdit.test.jsx | 1 + .../Credential/shared/CredentialForm.jsx | 12 +- .../CredentialPluginField.jsx | 103 ++++++++++++ .../CredentialPluginPrompt.jsx | 60 +++++++ .../CredentialsStep.jsx | 101 +++++++++++ .../CredentialPluginPrompt/MetadataStep.jsx | 159 ++++++++++++++++++ .../CredentialPluginPrompt/index.js | 3 + .../CredentialPluginSelected.jsx | 54 ++++++ .../shared/CredentialPlugins/index.js | 2 + .../GoogleComputeEngineSubForm.jsx | 33 ++-- .../CredentialSubForms/SharedFields.jsx | 37 ++-- 19 files changed, 756 insertions(+), 97 deletions(-) create mode 100644 awx/ui_next/src/api/models/CredentialInputSources.js create mode 100644 awx/ui_next/src/components/FormField/PasswordInput.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index e5f1f34557..6a48a6ce21 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,5 +1,6 @@ import AdHocCommands from './models/AdHocCommands'; import Config from './models/Config'; +import CredentialInputSources from './models/CredentialInputSources'; import CredentialTypes from './models/CredentialTypes'; import Credentials from './models/Credentials'; import Groups from './models/Groups'; @@ -14,8 +15,8 @@ import Labels from './models/Labels'; import Me from './models/Me'; import NotificationTemplates from './models/NotificationTemplates'; import Organizations from './models/Organizations'; -import Projects from './models/Projects'; import ProjectUpdates from './models/ProjectUpdates'; +import Projects from './models/Projects'; import Root from './models/Root'; import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; @@ -24,14 +25,15 @@ import UnifiedJobTemplates from './models/UnifiedJobTemplates'; import UnifiedJobs from './models/UnifiedJobs'; import Users from './models/Users'; import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates'; -import WorkflowJobs from './models/WorkflowJobs'; import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes'; import WorkflowJobTemplates from './models/WorkflowJobTemplates'; +import WorkflowJobs from './models/WorkflowJobs'; const AdHocCommandsAPI = new AdHocCommands(); const ConfigAPI = new Config(); -const CredentialsAPI = new Credentials(); +const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialTypesAPI = new CredentialTypes(); +const CredentialsAPI = new Credentials(); const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); @@ -44,8 +46,8 @@ const LabelsAPI = new Labels(); const MeAPI = new Me(); const NotificationTemplatesAPI = new NotificationTemplates(); const OrganizationsAPI = new Organizations(); -const ProjectsAPI = new Projects(); const ProjectUpdatesAPI = new ProjectUpdates(); +const ProjectsAPI = new Projects(); const RootAPI = new Root(); const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); @@ -54,15 +56,16 @@ const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); const UnifiedJobsAPI = new UnifiedJobs(); const UsersAPI = new Users(); const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates(); -const WorkflowJobsAPI = new WorkflowJobs(); const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes(); const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); +const WorkflowJobsAPI = new WorkflowJobs(); export { AdHocCommandsAPI, ConfigAPI, - CredentialsAPI, + CredentialInputSourcesAPI, CredentialTypesAPI, + CredentialsAPI, GroupsAPI, HostsAPI, InstanceGroupsAPI, @@ -75,8 +78,8 @@ export { MeAPI, NotificationTemplatesAPI, OrganizationsAPI, - ProjectsAPI, ProjectUpdatesAPI, + ProjectsAPI, RootAPI, SchedulesAPI, SystemJobsAPI, @@ -85,7 +88,7 @@ export { UnifiedJobsAPI, UsersAPI, WorkflowApprovalTemplatesAPI, - WorkflowJobsAPI, WorkflowJobTemplateNodesAPI, WorkflowJobTemplatesAPI, + WorkflowJobsAPI, }; diff --git a/awx/ui_next/src/api/models/CredentialInputSources.js b/awx/ui_next/src/api/models/CredentialInputSources.js new file mode 100644 index 0000000000..ec09cba267 --- /dev/null +++ b/awx/ui_next/src/api/models/CredentialInputSources.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class CredentialInputSources extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/credential_input_sources/'; + } +} + +export default CredentialInputSources; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js index 9b31506956..ec7f97812d 100644 --- a/awx/ui_next/src/api/models/Credentials.js +++ b/awx/ui_next/src/api/models/Credentials.js @@ -6,6 +6,7 @@ class Credentials extends Base { this.baseUrl = '/api/v2/credentials/'; this.readAccessList = this.readAccessList.bind(this); + this.readInputSources = this.readInputSources.bind(this); } readAccessList(id, params) { @@ -13,6 +14,12 @@ class Credentials extends Base { params, }); } + + readInputSources(id, params) { + return this.http.get(`${this.baseUrl}${id}/input_sources/`, { + params, + }); + } } export default Credentials; diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx index d865a8b70c..c813ca29c1 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -1,29 +1,14 @@ -import React, { useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; import { useField } from 'formik'; -import { - Button, - ButtonVariant, - FormGroup, - InputGroup, - TextInput, - Tooltip, -} from '@patternfly/react-core'; -import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; +import { FormGroup, InputGroup } from '@patternfly/react-core'; +import { PasswordInput } from '.'; function PasswordField(props) { - const { id, name, label, validate, isRequired, isDisabled, i18n } = props; - const [inputType, setInputType] = useState('password'); - const [field, meta] = useField({ name, validate }); - + const { id, name, label, validate, isRequired } = props; + const [, meta] = useField({ name, validate }); const isValid = !(meta.touched && meta.error); - const handlePasswordToggle = () => { - setInputType(inputType === 'text' ? 'password' : 'text'); - }; - return ( - - - - { - field.onChange(event); - }} - /> + ); @@ -79,4 +39,4 @@ PasswordField.defaultProps = { isDisabled: false, }; -export default withI18n()(PasswordField); +export default PasswordField; diff --git a/awx/ui_next/src/components/FormField/PasswordInput.jsx b/awx/ui_next/src/components/FormField/PasswordInput.jsx new file mode 100644 index 0000000000..993ee9a523 --- /dev/null +++ b/awx/ui_next/src/components/FormField/PasswordInput.jsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { + Button, + ButtonVariant, + TextInput, + Tooltip, +} from '@patternfly/react-core'; +import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; + +function PasswordInput(props) { + const { id, name, validate, isRequired, isDisabled, i18n } = props; + const [inputType, setInputType] = useState('password'); + const [field, meta] = useField({ name, validate }); + + const isValid = !(meta.touched && meta.error); + + const handlePasswordToggle = () => { + setInputType(inputType === 'text' ? 'password' : 'text'); + }; + + return ( + <> + + + + { + field.onChange(event); + }} + /> + + ); +} + +PasswordInput.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + validate: PropTypes.func, + isRequired: PropTypes.bool, + isDisabled: PropTypes.bool, +}; + +PasswordInput.defaultProps = { + validate: () => {}, + isRequired: false, + isDisabled: false, +}; + +export default withI18n()(PasswordInput); diff --git a/awx/ui_next/src/components/FormField/index.js b/awx/ui_next/src/components/FormField/index.js index 563f8519eb..fd0c95dafd 100644 --- a/awx/ui_next/src/components/FormField/index.js +++ b/awx/ui_next/src/components/FormField/index.js @@ -2,4 +2,5 @@ export { default } from './FormField'; export { default as CheckboxField } from './CheckboxField'; export { default as FieldTooltip } from './FieldTooltip'; export { default as PasswordField } from './PasswordField'; +export { default as PasswordInput } from './PasswordInput'; export { default as FormSubmitError } from './FormSubmitError'; diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx index e42b3faec7..e75581e463 100644 --- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx @@ -5,7 +5,11 @@ import { CardBody } from '../../../components/Card'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; -import { CredentialTypesAPI, CredentialsAPI } from '../../../api'; +import { + CredentialInputSourcesAPI, + CredentialTypesAPI, + CredentialsAPI, +} from '../../../api'; import CredentialForm from '../shared/CredentialForm'; function CredentialAdd({ me }) { @@ -38,16 +42,41 @@ function CredentialAdd({ me }) { }; const handleSubmit = async values => { - const { organization, ...remainingValues } = values; + const { inputs, organization, ...remainingValues } = values; + let pluginInputs = []; + const inputEntries = Object.entries(inputs); + for (const [key, value] of inputEntries) { + if (value.credential && value.inputs) { + pluginInputs.push([key, value]); + delete inputs[key]; + } + } + setFormSubmitError(null); + try { const { data: { id: credentialId }, } = await CredentialsAPI.create({ user: (me && me.id) || null, organization: (organization && organization.id) || null, + inputs: inputs || {}, ...remainingValues, }); + const inputSourceRequests = []; + for (const [key, value] of pluginInputs) { + if (value.credential && value.inputs) { + inputSourceRequests.push( + CredentialInputSourcesAPI.create({ + input_field_name: key, + metadata: value.inputs, + source_credential: value.credential.id, + target_credential: credentialId, + }) + ); + } + } + await Promise.all(inputSourceRequests); const url = `/credentials/${credentialId}/details`; history.push(`${url}`); } catch (err) { diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx index 71409638f6..05980f4ec6 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx @@ -3,7 +3,11 @@ import { useHistory } from 'react-router-dom'; import { object } from 'prop-types'; import { CardBody } from '../../../components/Card'; -import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import { + CredentialsAPI, + CredentialInputSourcesAPI, + CredentialTypesAPI, +} from '../../../api'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; import CredentialForm from '../shared/CredentialForm'; @@ -12,18 +16,32 @@ function CredentialEdit({ credential, me }) { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [credentialTypes, setCredentialTypes] = useState(null); + const [inputSources, setInputSources] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); useEffect(() => { const loadData = async () => { try { - const { - data: { results: loadedCredentialTypes }, - } = await CredentialTypesAPI.read({ - or__namespace: ['gce', 'scm', 'ssh'], - }); + const [ + { + data: { results: loadedCredentialTypes }, + }, + { + data: { results: loadedInputSources }, + }, + ] = await Promise.all([ + CredentialTypesAPI.read({ + or__namespace: ['gce', 'scm', 'ssh'], + }), + CredentialsAPI.readInputSources(credential.id, { page_size: 200 }), + ]); setCredentialTypes(loadedCredentialTypes); + const inputSourcesMap = {}; + loadedInputSources.forEach(inputSource => { + inputSourcesMap[inputSource.input_field_name] = inputSource; + }); + setInputSources(inputSourcesMap); } catch (err) { setError(err); } finally { @@ -31,7 +49,7 @@ function CredentialEdit({ credential, me }) { } }; loadData(); - }, []); + }, [credential.id]); const handleCancel = () => { const url = `/credentials/${credential.id}/details`; @@ -39,20 +57,62 @@ function CredentialEdit({ credential, me }) { history.push(`${url}`); }; + const createAndUpdateInputSources = pluginInputs => + Object.entries(pluginInputs).map(([fieldName, fieldValue]) => { + if (!inputSources[fieldName]) { + return CredentialInputSourcesAPI.create({ + input_field_name: fieldName, + metadata: fieldValue.inputs, + source_credential: fieldValue.credential.id, + target_credential: credential.id, + }); + } else if (fieldValue.touched) { + return CredentialInputSourcesAPI.update(inputSources[fieldName].id, { + metadata: fieldValue.inputs, + source_credential: fieldValue.credential.id, + }); + } + + return null; + }); + + const destroyInputSources = inputs => { + const destroyRequests = []; + Object.values(inputSources).forEach(inputSource => { + const { id, input_field_name } = inputSource; + if (!inputs[input_field_name]?.credential) { + destroyRequests.push(CredentialInputSourcesAPI.destroy(id)); + } + }); + return destroyRequests; + }; + const handleSubmit = async values => { - const { organization, ...remainingValues } = values; + const { inputs, organization, ...remainingValues } = values; + let pluginInputs = {}; + const inputEntries = Object.entries(inputs); + for (const [key, value] of inputEntries) { + if (value.credential && value.inputs) { + pluginInputs[key] = value; + delete inputs[key]; + } + } setFormSubmitError(null); try { - const { - data: { id: credentialId }, - } = await CredentialsAPI.update(credential.id, { - user: (me && me.id) || null, - organization: (organization && organization.id) || null, - ...remainingValues, - }); - const url = `/credentials/${credentialId}/details`; + await Promise.all([ + CredentialsAPI.update(credential.id, { + user: (me && me.id) || null, + organization: (organization && organization.id) || null, + inputs: inputs || {}, + ...remainingValues, + }), + ...destroyInputSources(pluginInputs), + ]); + await Promise.all(createAndUpdateInputSources(pluginInputs)); + const url = `/credentials/${credential.id}/details`; history.push(`${url}`); } catch (err) { + console.log(err); setFormSubmitError(err); } }; @@ -72,6 +132,7 @@ function CredentialEdit({ credential, me }) { onSubmit={handleSubmit} credential={credential} credentialTypes={credentialTypes} + inputSources={inputSources} submitError={formSubmitError} /> diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx index 4b0ab68187..3d4ce756cb 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx @@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({ }); CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } }); +CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } }); describe('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index bccfa50583..ddd4ccaa5f 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Formik, useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { func, shape } from 'prop-types'; +import { arrayOf, func, object, shape } from 'prop-types'; import { Form, FormGroup, Title } from '@patternfly/react-core'; import FormField, { FormSubmitError } from '../../../components/FormField'; import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; @@ -124,6 +124,7 @@ function CredentialFormFields({ function CredentialForm({ credential = {}, credentialTypes, + inputSources, onSubmit, onCancel, submitError, @@ -147,6 +148,13 @@ function CredentialForm({ }, }; + Object.values(inputSources).forEach(inputSource => { + initialValues.inputs[inputSource.input_field_name] = { + credential: inputSource.summary_fields.source_credential, + inputs: inputSource.metadata, + }; + }); + const scmCredentialTypeId = Object.keys(credentialTypes) .filter(key => credentialTypes[key].namespace === 'scm') .map(key => credentialTypes[key].id)[0]; @@ -232,10 +240,12 @@ CredentialForm.proptype = { handleSubmit: func.isRequired, handleCancel: func.isRequired, credential: shape({}), + inputSources: arrayOf(object), }; CredentialForm.defaultProps = { credential: {}, + inputSources: [], }; export default withI18n()(CredentialForm); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx new file mode 100644 index 0000000000..096827fde7 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { + Button, + ButtonVariant, + FormGroup, + InputGroup, + Tooltip, +} from '@patternfly/react-core'; +import { KeyIcon } from '@patternfly/react-icons'; +import { CredentialPluginPrompt } from './CredentialPluginPrompt'; +import { CredentialPluginSelected } from '.'; + +function CredentialPluginField(props) { + const { + children, + id, + name, + label, + validate, + isRequired, + isDisabled, + i18n, + } = props; + const [showPluginWizard, setShowPluginWizard] = useState(false); + const [field, meta, helpers] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + return ( + + {field.value.credential ? ( + helpers.setValue('')} + onEditPlugin={() => setShowPluginWizard(true)} + /> + ) : ( + + {React.cloneElement(children, { + ...field, + isRequired, + onChange: (_, event) => { + field.onChange(event); + }, + })} + + + + + )} + {showPluginWizard && ( + setShowPluginWizard(false)} + onSubmit={val => { + val.touched = true; + helpers.setValue(val); + setShowPluginWizard(false); + }} + /> + )} + + ); +} + +CredentialPluginField.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + validate: PropTypes.func, + isRequired: PropTypes.bool, + isDisabled: PropTypes.bool, +}; + +CredentialPluginField.defaultProps = { + validate: () => {}, + isRequired: false, + isDisabled: false, +}; + +export default withI18n()(CredentialPluginField); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx new file mode 100644 index 0000000000..d132c58997 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Formik, useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Wizard } from '@patternfly/react-core'; +import { CredentialsStep, MetadataStep } from './'; + +function CredentialPluginWizard({ i18n, handleSubmit, onClose }) { + const [selectedCredential] = useField('credential'); + const steps = [ + { + id: 1, + name: i18n._(t`Credential`), + component: , + }, + { + id: 2, + name: i18n._(t`Metadata`), + component: , + canJumpTo: !!selectedCredential.value, + nextButtonText: i18n._(t`OK`), + }, + ]; + + return ( + + ); +} + +function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) { + return ( + + {({ handleSubmit }) => ( + + )} + + ); +} + +CredentialPluginPrompt.propTypes = {}; + +CredentialPluginPrompt.defaultProps = {}; + +export default withI18n()(CredentialPluginPrompt); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx new file mode 100644 index 0000000000..a59f894470 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx @@ -0,0 +1,101 @@ +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { CredentialsAPI } from '../../../../../api'; +import CheckboxListItem from '../../../../../components/CheckboxListItem'; +import ContentError from '../../../../../components/ContentError'; +import DataListToolbar from '../../../../../components/DataListToolbar'; +import PaginatedDataList from '../../../../../components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '../../../../../util/qs'; +import useRequest from '../../../../../util/useRequest'; + +const QS_CONFIG = getQSConfig('credential', { + page: 1, + page_size: 5, + order_by: 'name', + credential_type__kind: 'external', +}); + +function CredentialsStep({ i18n }) { + const [selectedCredential, , selectedCredentialHelper] = useField( + 'credential' + ); + const history = useHistory(); + + const { + result: { credentials, count }, + error: credentialsError, + isLoading: isCredentialsLoading, + request: fetchCredentials, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { data } = await CredentialsAPI.read({ + ...params, + }); + return { + credentials: data.results, + count: data.count, + }; + }, [history.location.search]), + { credentials: [], count: 0 } + ); + + useEffect(() => { + fetchCredentials(); + }, [fetchCredentials]); + + if (credentialsError) { + return ; + } + + return ( + selectedCredentialHelper.setValue(row)} + qsConfig={QS_CONFIG} + renderItem={credential => ( + selectedCredentialHelper.setValue(credential)} + onDeselect={() => selectedCredentialHelper.setValue(null)} + isRadio + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} + /> + ); +} + +export default withI18n()(CredentialsStep); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx new file mode 100644 index 0000000000..2114904ef2 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField, useFormikContext } from 'formik'; +import styled from 'styled-components'; +import { Button, Form, FormGroup, Tooltip } from '@patternfly/react-core'; +import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; +import { CredentialTypesAPI } from '../../../../../api'; +import AnsibleSelect from '../../../../../components/AnsibleSelect'; +import ContentError from '../../../../../components/ContentError'; +import ContentLoading from '../../../../../components/ContentLoading'; +import FormField from '../../../../../components/FormField'; +import { FormFullWidthLayout } from '../../../../../components/FormLayout'; +import useRequest from '../../../../../util/useRequest'; +import { required } from '../../../../../util/validators'; + +const QuestionCircleIcon = styled(PFQuestionCircleIcon)` + margin-left: 10px; +`; + +const TestButton = styled(Button)` + margin-top: 20px; +`; + +function MetadataStep({ i18n }) { + const form = useFormikContext(); + const [selectedCredential] = useField('credential'); + const [inputValues] = useField('inputs'); + + const { + result: fields, + error, + isLoading, + request: fetchMetadataOptions, + } = useRequest( + useCallback(async () => { + const { + data: { + inputs: { required, metadata }, + }, + } = await CredentialTypesAPI.readDetail( + selectedCredential.value.credential_type || + selectedCredential.value.credential_type_id + ); + metadata.forEach(field => { + if (inputValues.value[field.id]) { + form.initialValues.inputs[field.id] = inputValues.value[field.id]; + } else { + if (field.type === 'string' && field.choices) { + form.initialValues.inputs[field.id] = + field.default || field.choices[0]; + } else { + form.initialValues.inputs[field.id] = ''; + } + } + if (required && required.includes(field.id)) { + field.required = true; + } + }); + return metadata; + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, []), + [] + ); + + useEffect(() => { + fetchMetadataOptions(); + }, [fetchMetadataOptions]); + + const testMetadata = () => { + alert('not implemented'); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + <> + {fields.length > 0 && ( +
+ + {fields.map(field => { + if (field.type === 'string') { + if (field.choices) { + return ( + + {field.help_text && ( + + + + )} + { + return { + value: choice, + key: choice, + label: choice, + }; + })} + onChange={(event, value) => { + form.setFieldValue(`inputs.${field.id}`, value); + }} + validate={field.required ? required(null, i18n) : null} + /> + + ); + } + + return ( + + ); + } + + return null; + })} + +
+ )} + + testMetadata()} + > + {i18n._(t`Test`)} + + + + ); +} + +export default withI18n()(MetadataStep); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js new file mode 100644 index 0000000000..467b3f3936 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js @@ -0,0 +1,3 @@ +export { default as CredentialPluginPrompt } from './CredentialPluginPrompt'; +export { default as CredentialsStep } from './CredentialsStep'; +export { default as MetadataStep } from './MetadataStep'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx new file mode 100644 index 0000000000..2ea3ca84d0 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t, Trans } from '@lingui/macro'; +import styled from 'styled-components'; +import { Button, ButtonVariant, Tooltip } from '@patternfly/react-core'; +import { KeyIcon } from '@patternfly/react-icons'; +import CredentialChip from '../../../../components/CredentialChip'; + +const SelectedCredential = styled.div` + display: flex; + justify-content: space-between; + margin-top: 10px; + background-color: white; + border-bottom-color: var(--pf-global--BorderColor--200); +`; + +const SpacedCredentialChip = styled(CredentialChip)` + margin: 5px 8px; +`; + +function CredentialPluginSelected({ + i18n, + credential, + onEditPlugin, + onClearPlugin, +}) { + return ( + <> +

+ + This field will be retrieved from an external secret management system + using the following credential: + +

+ + + + + + + + ); +} + +export default withI18n()(CredentialPluginSelected); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js new file mode 100644 index 0000000000..033586567f --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js @@ -0,0 +1,2 @@ +export { default as CredentialPluginSelected } from './CredentialPluginSelected'; +export { default as CredentialPluginField } from './CredentialPluginField'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx index 89584b956c..2622106afb 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx @@ -2,13 +2,18 @@ import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; -import { FileUpload, FormGroup } from '@patternfly/react-core'; -import FormField from '../../../../components/FormField'; +import { + FileUpload, + FormGroup, + TextArea, + TextInput, +} from '@patternfly/react-core'; import { FormColumnLayout, FormFullWidthLayout, } from '../../../../components/FormLayout'; import { required } from '../../../../util/validators'; +import { CredentialPluginField } from '../CredentialPlugins'; const GoogleComputeEngineSubForm = ({ i18n }) => { const [fileError, setFileError] = useState(null); @@ -91,30 +96,38 @@ const GoogleComputeEngineSubForm = ({ i18n }) => { }} /> - - + + + + > + + - + > +