Fixes missing credential types and makes credential type drop down a typeahead component

This commit is contained in:
Alex Corey
2021-01-20 10:20:30 -05:00
parent 2ef08b1d13
commit 4a2a6949a8
5 changed files with 163 additions and 119 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
@@ -14,9 +14,6 @@ import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
function CredentialAdd({ me }) { function CredentialAdd({ me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const history = useHistory(); const history = useHistory();
const { const {
@@ -85,34 +82,38 @@ function CredentialAdd({ me }) {
history.push(`/credentials/${credentialId}/details`); history.push(`/credentials/${credentialId}/details`);
} }
}, [credentialId, history]); }, [credentialId, history]);
const { isLoading, error, request: loadData, result } = useRequest(
useEffect(() => { useCallback(async () => {
const loadData = async () => { const { data } = await CredentialTypesAPI.read({ page_size: 200 });
try { const credTypes = data.results;
if (data.next && data.next.includes('page=2')) {
const { const {
data: { results: loadedCredentialTypes }, data: { results },
} = await CredentialTypesAPI.read(); } = await CredentialTypesAPI.read({
setCredentialTypes( page_size: 200,
loadedCredentialTypes.reduce((credentialTypesMap, credentialType) => { page: 2,
credentialTypesMap[credentialType.id] = credentialType; });
return credentialTypesMap; credTypes.concat(results);
}, {})
);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
} }
};
const creds = credTypes.reduce((credentialTypesMap, credentialType) => {
credentialTypesMap[credentialType.id] = credentialType;
return credentialTypesMap;
}, {});
return creds;
}, []),
{}
);
useEffect(() => {
loadData(); loadData();
}, []); }, [loadData]);
const handleCancel = () => { const handleCancel = () => {
history.push('/credentials'); history.push('/credentials');
}; };
const handleSubmit = async values => { const handleSubmit = async values => {
await submitRequest(values, credentialTypes); await submitRequest(values, result);
}; };
if (error) { if (error) {
@@ -126,7 +127,7 @@ function CredentialAdd({ me }) {
</PageSection> </PageSection>
); );
} }
if (isLoading) { if (isLoading && !result) {
return ( return (
<PageSection> <PageSection>
<Card> <Card>
@@ -144,7 +145,7 @@ function CredentialAdd({ me }) {
<CredentialForm <CredentialForm
onCancel={handleCancel} onCancel={handleCancel}
onSubmit={handleSubmit} onSubmit={handleSubmit}
credentialTypes={credentialTypes} credentialTypes={result}
submitError={submitError} submitError={submitError}
/> />
</CardBody> </CardBody>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useState, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
import { object } from 'prop-types'; import { object } from 'prop-types';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
import { import {
@@ -13,11 +13,8 @@ import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
function CredentialEdit({ credential, me }) { function CredentialEdit({ credential, me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const [inputSources, setInputSources] = useState({});
const history = useHistory(); const history = useHistory();
const { id: credId } = useParams();
const { error: submitError, request: submitRequest, result } = useRequest( const { error: submitError, request: submitRequest, result } = useRequest(
useCallback( useCallback(
@@ -55,7 +52,7 @@ function CredentialEdit({ credential, me }) {
input_field_name: fieldName, input_field_name: fieldName,
metadata: fieldValue.inputs, metadata: fieldValue.inputs,
source_credential: fieldValue.credential.id, source_credential: fieldValue.credential.id,
target_credential: credential.id, target_credential: credId,
}); });
} }
if (fieldValue.touched) { if (fieldValue.touched) {
@@ -88,7 +85,7 @@ function CredentialEdit({ credential, me }) {
modifiedData.user = me.id; modifiedData.user = me.id;
} }
const [{ data }] = await Promise.all([ const [{ data }] = await Promise.all([
CredentialsAPI.update(credential.id, modifiedData), CredentialsAPI.update(credId, modifiedData),
...destroyInputSources(), ...destroyInputSources(),
]); ]);
@@ -96,7 +93,7 @@ function CredentialEdit({ credential, me }) {
return data; return data;
}, },
[me, credential.id] [me, credId]
) )
); );
@@ -105,56 +102,63 @@ function CredentialEdit({ credential, me }) {
history.push(`/credentials/${result.id}/details`); history.push(`/credentials/${result.id}/details`);
} }
}, [result, history]); }, [result, history]);
const {
isLoading,
error,
request: loadData,
result: { credentialTypes, loadedInputSources },
} = useRequest(
useCallback(async () => {
const [
{ data },
{
data: { results },
},
] = await Promise.all([
CredentialTypesAPI.read({ page_size: 200 }),
CredentialsAPI.readInputSources(credId, { page_size: 200 }),
]);
const credTypes = data.results;
if (data.next && data.next.includes('page=2')) {
const {
data: { results: additionalCredTypes },
} = await CredentialTypesAPI.read({
page_size: 200,
page: 2,
});
credTypes.concat([...additionalCredTypes]);
}
const creds = credTypes.reduce((credentialTypesMap, credentialType) => {
credentialTypesMap[credentialType.id] = credentialType;
return credentialTypesMap;
}, {});
const inputSources = results.reduce((inputSourcesMap, inputSource) => {
inputSourcesMap[inputSource.input_field_name] = inputSource;
return inputSourcesMap;
}, {});
return { credentialTypes: creds, loadedInputSources: inputSources };
}, [credId]),
{ credentialTypes: {}, loadedInputSources: {} }
);
useEffect(() => { useEffect(() => {
const loadData = async () => {
try {
const [
{
data: { results: loadedCredentialTypes },
},
{
data: { results: loadedInputSources },
},
] = await Promise.all([
CredentialTypesAPI.read(),
CredentialsAPI.readInputSources(credential.id, { page_size: 200 }),
]);
setCredentialTypes(
loadedCredentialTypes.reduce((credentialTypesMap, credentialType) => {
credentialTypesMap[credentialType.id] = credentialType;
return credentialTypesMap;
}, {})
);
setInputSources(
loadedInputSources.reduce((inputSourcesMap, inputSource) => {
inputSourcesMap[inputSource.input_field_name] = inputSource;
return inputSourcesMap;
}, {})
);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
loadData(); loadData();
}, [credential.id]); }, [loadData]);
const handleCancel = () => { const handleCancel = () => {
const url = `/credentials/${credential.id}/details`; const url = `/credentials/${credId}/details`;
history.push(`${url}`); history.push(`${url}`);
}; };
const handleSubmit = async values => { const handleSubmit = async values => {
await submitRequest(values, credentialTypes, inputSources); await submitRequest(values, credentialTypes, loadedInputSources);
}; };
if (error) { if (error) {
return <ContentError error={error} />; return <ContentError error={error} />;
} }
if (isLoading) { if (isLoading && !credentialTypes) {
return <ContentLoading />; return <ContentLoading />;
} }
@@ -165,7 +169,7 @@ function CredentialEdit({ credential, me }) {
onSubmit={handleSubmit} onSubmit={handleSubmit}
credential={credential} credential={credential}
credentialTypes={credentialTypes} credentialTypes={credentialTypes}
inputSources={inputSources} inputSources={loadedInputSources}
submitError={submitError} submitError={submitError}
/> />
</CardBody> </CardBody>

View File

@@ -14,6 +14,12 @@ import {
import CredentialEdit from './CredentialEdit'; import CredentialEdit from './CredentialEdit';
jest.mock('../../../api'); jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 3,
}),
}));
const mockCredential = { const mockCredential = {
id: 3, id: 3,

View File

@@ -3,26 +3,28 @@ import { Formik, useField, useFormikContext } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { arrayOf, func, object, shape } from 'prop-types'; import { arrayOf, func, object, shape } from 'prop-types';
import { ActionGroup, Button, Form, FormGroup } from '@patternfly/react-core'; import {
ActionGroup,
Button,
Form,
FormGroup,
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField'; import FormField, { FormSubmitError } from '../../../components/FormField';
import { import {
FormColumnLayout, FormColumnLayout,
FormFullWidthLayout, FormFullWidthLayout,
} from '../../../components/FormLayout'; } from '../../../components/FormLayout';
import AnsibleSelect from '../../../components/AnsibleSelect';
import { required } from '../../../util/validators'; import { required } from '../../../util/validators';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
import TypeInputsSubForm from './TypeInputsSubForm'; import TypeInputsSubForm from './TypeInputsSubForm';
import ExternalTestModal from './ExternalTestModal'; import ExternalTestModal from './ExternalTestModal';
function CredentialFormFields({ function CredentialFormFields({ i18n, credentialTypes }) {
i18n, const { setFieldValue, initialValues, setFieldTouched } = useFormikContext();
credentialTypes, const [isSelectOpen, setIsSelectOpen] = useState(false);
formik,
initialValues,
}) {
const { setFieldValue } = useFormikContext();
const [credTypeField, credTypeMeta, credTypeHelpers] = useField({ const [credTypeField, credTypeMeta, credTypeHelpers] = useField({
name: 'credential_type', name: 'credential_type',
validate: required(i18n._(t`Select a value for this field`), i18n), validate: required(i18n._(t`Select a value for this field`), i18n),
@@ -30,7 +32,7 @@ function CredentialFormFields({
const isGalaxyCredential = const isGalaxyCredential =
!!credTypeField.value && !!credTypeField.value &&
credentialTypes[credTypeField.value].kind === 'galaxy'; credentialTypes[credTypeField.value]?.kind === 'galaxy';
const [orgField, orgMeta, orgHelpers] = useField({ const [orgField, orgMeta, orgHelpers] = useField({
name: 'organization', name: 'organization',
@@ -52,16 +54,14 @@ function CredentialFormFields({
}) })
.sort((a, b) => (a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1)); .sort((a, b) => (a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1));
const resetSubFormFields = (newCredentialType, form) => { const resetSubFormFields = newCredentialType => {
const fields = credentialTypes[newCredentialType].inputs.fields || []; const fields = credentialTypes[newCredentialType].inputs.fields || [];
fields.forEach( fields.forEach(
({ ask_at_runtime, type, id, choices, default: defaultValue }) => { ({ ask_at_runtime, type, id, choices, default: defaultValue }) => {
if ( if (parseInt(newCredentialType, 10) === initialValues.credential_type) {
parseInt(newCredentialType, 10) === form.initialValues.credential_type setFieldValue(`inputs.${id}`, initialValues.inputs[id]);
) {
form.setFieldValue(`inputs.${id}`, initialValues.inputs[id]);
if (ask_at_runtime) { if (ask_at_runtime) {
form.setFieldValue( setFieldValue(
`passwordPrompts.${id}`, `passwordPrompts.${id}`,
initialValues.passwordPrompts[id] initialValues.passwordPrompts[id]
); );
@@ -69,24 +69,24 @@ function CredentialFormFields({
} else { } else {
switch (type) { switch (type) {
case 'string': case 'string':
form.setFieldValue(`inputs.${id}`, defaultValue || ''); setFieldValue(`inputs.${id}`, defaultValue || '');
break; break;
case 'boolean': case 'boolean':
form.setFieldValue(`inputs.${id}`, defaultValue || false); setFieldValue(`inputs.${id}`, defaultValue || false);
break; break;
default: default:
break; break;
} }
if (choices) { if (choices) {
form.setFieldValue(`inputs.${id}`, defaultValue); setFieldValue(`inputs.${id}`, defaultValue);
} }
if (ask_at_runtime) { if (ask_at_runtime) {
form.setFieldValue(`passwordPrompts.${id}`, false); setFieldValue(`passwordPrompts.${id}`, false);
} }
} }
form.setFieldTouched(`inputs.${id}`, false); setFieldTouched(`inputs.${id}`, false);
} }
); );
}; };
@@ -133,23 +133,27 @@ function CredentialFormFields({
} }
label={i18n._(t`Credential Type`)} label={i18n._(t`Credential Type`)}
> >
<AnsibleSelect <Select
{...credTypeField} aria-label={i18n._(t`Credential Type`)}
isOpen={isSelectOpen}
variant={SelectVariant.typeahead}
id="credential-type" id="credential-type"
data={[ onToggle={setIsSelectOpen}
{ onSelect={(event, value) => {
value: '',
key: '',
label: i18n._(t`Choose a Credential Type`),
isDisabled: true,
},
...credentialTypeOptions,
]}
onChange={(event, value) => {
credTypeHelpers.setValue(value); credTypeHelpers.setValue(value);
resetSubFormFields(value, formik); resetSubFormFields(value);
}} }}
/> selections={credTypeField.value}
placeholder={i18n._(t`Select a credential Type`)}
isCreatable={false}
maxHeight="300px"
>
{credentialTypeOptions.map(credType => (
<SelectOption key={credType.value} value={credType.value}>
{credType.label}
</SelectOption>
))}
</Select>
</FormGroup> </FormGroup>
{credTypeField.value !== undefined && {credTypeField.value !== undefined &&
credTypeField.value !== '' && credTypeField.value !== '' &&
@@ -177,7 +181,7 @@ function CredentialForm({
name: credential.name || '', name: credential.name || '',
description: credential.description || '', description: credential.description || '',
organization: credential?.summary_fields?.organization || null, organization: credential?.summary_fields?.organization || null,
credential_type: credential.credential_type || '', credential_type: credential?.credential_type || '',
inputs: {}, inputs: {},
passwordPrompts: {}, passwordPrompts: {},
}; };
@@ -235,8 +239,6 @@ function CredentialForm({
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout> <FormColumnLayout>
<CredentialFormFields <CredentialFormFields
formik={formik}
initialValues={initialValues}
credentialTypes={credentialTypes} credentialTypes={credentialTypes}
i18n={i18n} i18n={i18n}
{...rest} {...rest}

View File

@@ -137,15 +137,28 @@ describe('<CredentialForm />', () => {
test('should display cred type subform when scm type select has a value', async () => { test('should display cred type subform when scm type select has a value', async () => {
await act(async () => { await act(async () => {
await wrapper await wrapper
.find('AnsibleSelect[id="credential-type"]') .find('Select[aria-label="Credential Type"]')
.invoke('onChange')(null, 1); .invoke('onToggle')();
}); });
wrapper.update(); wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 1);
});
wrapper.update();
machineFieldExpects(); machineFieldExpects();
await act(async () => { await act(async () => {
await wrapper await wrapper
.find('AnsibleSelect[id="credential-type"]') .find('Select[aria-label="Credential Type"]')
.invoke('onChange')(null, 2); .invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 2);
}); });
wrapper.update(); wrapper.update();
sourceFieldExpects(); sourceFieldExpects();
@@ -154,8 +167,14 @@ describe('<CredentialForm />', () => {
test('should update expected fields when gce service account json file uploaded', async () => { test('should update expected fields when gce service account json file uploaded', async () => {
await act(async () => { await act(async () => {
await wrapper await wrapper
.find('AnsibleSelect[id="credential-type"]') .find('Select[aria-label="Credential Type"]')
.invoke('onChange')(null, 10); .invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 10);
}); });
wrapper.update(); wrapper.update();
gceFieldExpects(); gceFieldExpects();
@@ -215,8 +234,14 @@ describe('<CredentialForm />', () => {
test('should show error when error thrown parsing JSON', async () => { test('should show error when error thrown parsing JSON', async () => {
await act(async () => { await act(async () => {
await wrapper await wrapper
.find('AnsibleSelect[id="credential-type"]') .find('Select[aria-label="Credential Type"]')
.invoke('onChange')(null, 10); .invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 10);
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('#credential-gce-file-helper').text()).toBe( expect(wrapper.find('#credential-gce-file-helper').text()).toBe(
@@ -246,8 +271,14 @@ describe('<CredentialForm />', () => {
test('should show Test button when external credential type is selected', async () => { test('should show Test button when external credential type is selected', async () => {
await act(async () => { await act(async () => {
await wrapper await wrapper
.find('AnsibleSelect[id="credential-type"]') .find('Select[aria-label="Credential Type"]')
.invoke('onChange')(null, 21); .invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 21);
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('Button[children="Test"]').length).toBe(1); expect(wrapper.find('Button[children="Test"]').length).toBe(1);