Merge pull request #39 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan
2021-02-14 01:29:54 -06:00
committed by GitHub
22 changed files with 487 additions and 338 deletions

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { number, shape } from 'prop-types'; import { number, shape } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
@@ -32,40 +32,12 @@ function canLaunchWithoutPrompt(launchData) {
); );
} }
class LaunchButton extends React.Component { function LaunchButton({ resource, i18n, children, history }) {
static propTypes = { const [showLaunchPrompt, setShowLaunchPrompt] = useState(false);
resource: shape({ const [launchConfig, setLaunchConfig] = useState(null);
id: number.isRequired, const [surveyConfig, setSurveyConfig] = useState(null);
}).isRequired, const [error, setError] = useState(null);
}; const handleLaunch = async () => {
constructor(props) {
super(props);
this.state = {
showLaunchPrompt: false,
launchConfig: null,
launchError: false,
surveyConfig: null,
};
this.handleLaunch = this.handleLaunch.bind(this);
this.launchWithParams = this.launchWithParams.bind(this);
this.handleRelaunch = this.handleRelaunch.bind(this);
this.handleLaunchErrorClose = this.handleLaunchErrorClose.bind(this);
this.handlePromptErrorClose = this.handlePromptErrorClose.bind(this);
}
handleLaunchErrorClose() {
this.setState({ launchError: null });
}
handlePromptErrorClose() {
this.setState({ showLaunchPrompt: false });
}
async handleLaunch() {
const { resource } = this.props;
const readLaunch = const readLaunch =
resource.type === 'workflow_job_template' resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(resource.id) ? WorkflowJobTemplatesAPI.readLaunch(resource.id)
@@ -75,33 +47,27 @@ class LaunchButton extends React.Component {
? WorkflowJobTemplatesAPI.readSurvey(resource.id) ? WorkflowJobTemplatesAPI.readSurvey(resource.id)
: JobTemplatesAPI.readSurvey(resource.id); : JobTemplatesAPI.readSurvey(resource.id);
try { try {
const { data: launchConfig } = await readLaunch; const { data: launch } = await readLaunch;
setLaunchConfig(launch);
let surveyConfig = null; if (launch.survey_enabled) {
if (launchConfig.survey_enabled) {
const { data } = await readSurvey; const { data } = await readSurvey;
surveyConfig = data; setSurveyConfig(data);
} }
if (canLaunchWithoutPrompt(launchConfig)) { if (canLaunchWithoutPrompt(launch)) {
this.launchWithParams({}); launchWithParams({});
} else { } else {
this.setState({ setShowLaunchPrompt(true);
showLaunchPrompt: true,
launchConfig,
surveyConfig,
});
} }
} catch (err) { } catch (err) {
this.setState({ launchError: err }); setError(err);
} }
} };
async launchWithParams(params) { const launchWithParams = async params => {
try { try {
const { history, resource } = this.props;
let jobPromise; let jobPromise;
if (resource.type === 'job_template') { if (resource.type === 'job_template') {
@@ -117,13 +83,11 @@ class LaunchButton extends React.Component {
const { data: job } = await jobPromise; const { data: job } = await jobPromise;
history.push(`/jobs/${job.id}/output`); history.push(`/jobs/${job.id}/output`);
} catch (launchError) { } catch (launchError) {
this.setState({ launchError }); setError(launchError);
} }
} };
async handleRelaunch() {
const { history, resource } = this.props;
const handleRelaunch = async () => {
let readRelaunch; let readRelaunch;
let relaunch; let relaunch;
@@ -145,6 +109,7 @@ class LaunchButton extends React.Component {
try { try {
const { data: relaunchConfig } = await readRelaunch; const { data: relaunchConfig } = await readRelaunch;
setLaunchConfig(relaunchConfig);
if ( if (
!relaunchConfig.passwords_needed_to_start || !relaunchConfig.passwords_needed_to_start ||
relaunchConfig.passwords_needed_to_start.length === 0 relaunchConfig.passwords_needed_to_start.length === 0
@@ -165,53 +130,47 @@ class LaunchButton extends React.Component {
const { data: job } = await relaunch; const { data: job } = await relaunch;
history.push(`/jobs/${job.id}/output`); history.push(`/jobs/${job.id}/output`);
} else { } else {
this.setState({ setShowLaunchPrompt(true);
showLaunchPrompt: true,
launchConfig: relaunchConfig,
});
} }
} catch (err) { } catch (err) {
this.setState({ launchError: err }); setError(err);
} }
} };
render() { return (
const { <Fragment>
launchError, {children({
showLaunchPrompt, handleLaunch,
launchConfig, handleRelaunch,
surveyConfig, })}
} = this.state; {error && (
const { resource, i18n, children } = this.props; <AlertModal
return ( isOpen={error}
<Fragment> variant="error"
{children({ title={i18n._(t`Error!`)}
handleLaunch: this.handleLaunch, onClose={() => setError(null)}
handleRelaunch: this.handleRelaunch, >
})} {i18n._(t`Failed to launch job.`)}
{launchError && ( <ErrorDetail error={error} />
<AlertModal </AlertModal>
isOpen={launchError} )}
variant="error" {showLaunchPrompt && (
title={i18n._(t`Error!`)} <LaunchPrompt
onClose={this.handleLaunchErrorClose} launchConfig={launchConfig}
> surveyConfig={surveyConfig}
{i18n._(t`Failed to launch job.`)} resource={resource}
<ErrorDetail error={launchError} /> onLaunch={launchWithParams}
</AlertModal> onCancel={() => setShowLaunchPrompt(false)}
)} />
{showLaunchPrompt && ( )}
<LaunchPrompt </Fragment>
launchConfig={launchConfig} );
surveyConfig={surveyConfig}
resource={resource}
onLaunch={this.launchWithParams}
onCancel={() => this.setState({ showLaunchPrompt: false })}
/>
)}
</Fragment>
);
}
} }
LaunchButton.propTypes = {
resource: shape({
id: number.isRequired,
}).isRequired,
};
export default withI18n()(withRouter(LaunchButton)); export default withI18n()(withRouter(LaunchButton));

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils'; import { sleep } from '../../../testUtils/testUtils';
@@ -69,7 +70,7 @@ describe('LaunchButton', () => {
} }
); );
const button = wrapper.find('button'); const button = wrapper.find('button');
button.prop('onClick')(); await act(() => button.prop('onClick')());
expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0); await sleep(0);
expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1, {});
@@ -106,7 +107,7 @@ describe('LaunchButton', () => {
} }
); );
const button = wrapper.find('button'); const button = wrapper.find('button');
button.prop('onClick')(); await act(() => button.prop('onClick')());
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0); await sleep(0);
expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {});
@@ -143,7 +144,7 @@ describe('LaunchButton', () => {
} }
); );
const button = wrapper.find('button'); const button = wrapper.find('button');
button.prop('onClick')(); await act(() => button.prop('onClick')());
expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1); expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0); await sleep(0);
expect(JobsAPI.relaunch).toHaveBeenCalledWith(1); expect(JobsAPI.relaunch).toHaveBeenCalledWith(1);
@@ -180,7 +181,7 @@ describe('LaunchButton', () => {
} }
); );
const button = wrapper.find('button'); const button = wrapper.find('button');
button.prop('onClick')(); await act(() => button.prop('onClick')());
expect(WorkflowJobsAPI.readRelaunch).toHaveBeenCalledWith(1); expect(WorkflowJobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0); await sleep(0);
expect(WorkflowJobsAPI.relaunch).toHaveBeenCalledWith(1); expect(WorkflowJobsAPI.relaunch).toHaveBeenCalledWith(1);
@@ -218,7 +219,7 @@ describe('LaunchButton', () => {
} }
); );
const button = wrapper.find('button'); const button = wrapper.find('button');
button.prop('onClick')(); await act(() => button.prop('onClick')());
expect(ProjectsAPI.readLaunchUpdate).toHaveBeenCalledWith(5); expect(ProjectsAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0); await sleep(0);
expect(ProjectsAPI.launchUpdate).toHaveBeenCalledWith(5); expect(ProjectsAPI.launchUpdate).toHaveBeenCalledWith(5);
@@ -256,7 +257,7 @@ describe('LaunchButton', () => {
} }
); );
const button = wrapper.find('button'); const button = wrapper.find('button');
button.prop('onClick')(); await act(() => button.prop('onClick')());
expect(InventorySourcesAPI.readLaunchUpdate).toHaveBeenCalledWith(5); expect(InventorySourcesAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0); await sleep(0);
expect(InventorySourcesAPI.launchUpdate).toHaveBeenCalledWith(5); expect(InventorySourcesAPI.launchUpdate).toHaveBeenCalledWith(5);
@@ -280,7 +281,7 @@ describe('LaunchButton', () => {
}) })
); );
expect(wrapper.find('Modal').length).toBe(0); expect(wrapper.find('Modal').length).toBe(0);
wrapper.find('button').prop('onClick')(); await act(() => wrapper.find('button').prop('onClick')());
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect(wrapper.find('Modal').length).toBe(1); expect(wrapper.find('Modal').length).toBe(1);

View File

@@ -123,6 +123,7 @@ function Lookup(props) {
</ChipGroup> </ChipGroup>
</ChipHolder> </ChipHolder>
</InputGroup> </InputGroup>
<Modal <Modal
variant="large" variant="large"
title={i18n._(t`Select ${header || i18n._(t`Items`)}`)} title={i18n._(t`Select ${header || i18n._(t`Items`)}`)}
@@ -138,7 +139,12 @@ function Lookup(props) {
> >
{i18n._(t`Select`)} {i18n._(t`Select`)}
</Button>, </Button>,
<Button key="cancel" variant="link" onClick={closeModal}> <Button
key="cancel"
variant="link"
onClick={closeModal}
aria-label={i18n._(t`Cancel lookup`)}
>
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
</Button>, </Button>,
]} ]}

View File

@@ -1,7 +1,7 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React from 'react'; import React from 'react';
import { oneOf } from 'prop-types'; import { oneOf } from 'prop-types';
import { Label } from '@patternfly/react-core'; import { Label, Tooltip } from '@patternfly/react-core';
import { import {
CheckCircleIcon, CheckCircleIcon,
ExclamationCircleIcon, ExclamationCircleIcon,
@@ -48,15 +48,19 @@ const icons = {
canceled: ExclamationTriangleIcon, canceled: ExclamationTriangleIcon,
}; };
export default function StatusLabel({ status }) { export default function StatusLabel({ status, tooltipContent = '' }) {
const label = status.charAt(0).toUpperCase() + status.slice(1); const label = status.charAt(0).toUpperCase() + status.slice(1);
const color = colors[status] || 'grey'; const color = colors[status] || 'grey';
const Icon = icons[status]; const Icon = icons[status];
return ( return (
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}> <>
{label} <Tooltip content={tooltipContent} position="top">
</Label> <Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
{label}
</Label>
</Tooltip>
</>
); );
} }

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,41 @@ 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 as PFSelect,
SelectOption as PFSelectOption,
SelectVariant,
} from '@patternfly/react-core';
import styled from 'styled-components';
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({ const Select = styled(PFSelect)`
i18n, ul {
credentialTypes, max-width: 495px;
formik, }
initialValues, `;
}) {
const { setFieldValue } = useFormikContext();
const SelectOption = styled(PFSelectOption)`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
function CredentialFormFields({ i18n, credentialTypes }) {
const { setFieldValue, initialValues, setFieldTouched } = useFormikContext();
const [isSelectOpen, setIsSelectOpen] = useState(false);
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 +45,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 +67,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 +82,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 +146,29 @@ function CredentialFormFields({
} }
label={i18n._(t`Credential Type`)} label={i18n._(t`Credential Type`)}
> >
<AnsibleSelect <Select
{...credTypeField} aria-label={i18n._(t`Credential Type`)}
id="credential-type" isOpen={isSelectOpen}
data={[ variant={SelectVariant.typeahead}
{ ouiaId="credential-select"
value: '', onToggle={setIsSelectOpen}
key: '', onSelect={(event, value) => {
label: i18n._(t`Choose a Credential Type`),
isDisabled: true,
},
...credentialTypeOptions,
]}
onChange={(event, value) => {
credTypeHelpers.setValue(value); credTypeHelpers.setValue(value);
resetSubFormFields(value, formik); resetSubFormFields(value);
setIsSelectOpen(false);
}} }}
/> selections={credTypeField.value}
placeholder={i18n._(t`Select a credential Type`)}
isCreatable={false}
maxHeight="300px"
width="100%"
>
{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 +196,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 +254,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);

View File

@@ -55,6 +55,19 @@ function InventoryListItem({
inventory.inventory_sources_with_failures > 0 ? 'error' : 'success'; inventory.inventory_sources_with_failures > 0 ? 'error' : 'success';
} }
let tooltipContent = '';
if (inventory.has_inventory_sources) {
if (inventory.inventory_sources_with_failures > 0) {
tooltipContent = i18n._(
t`${inventory.inventory_sources_with_failures} sources with sync failures.`
);
} else {
tooltipContent = i18n._(t`No inventory sync failures.`);
}
} else {
tooltipContent = i18n._(t`Not configured for inventory sync.`);
}
return ( return (
<Tr id={inventory.id} aria-labelledby={labelId}> <Tr id={inventory.id} aria-labelledby={labelId}>
<Td <Td
@@ -75,7 +88,9 @@ function InventoryListItem({
)} )}
</Td> </Td>
<Td dataLabel={i18n._(t`Status`)}> <Td dataLabel={i18n._(t`Status`)}>
{inventory.kind !== 'smart' && <StatusLabel status={syncStatus} />} {inventory.kind !== 'smart' && (
<StatusLabel status={syncStatus} tooltipContent={tooltipContent} />
)}
</Td> </Td>
<Td dataLabel={i18n._(t`Type`)}> <Td dataLabel={i18n._(t`Type`)}>
{inventory.kind === 'smart' {inventory.kind === 'smart'

View File

@@ -7,24 +7,33 @@ import InventoryListItem from './InventoryListItem';
jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/Inventories');
describe('<InventoryListItem />', () => { describe('<InventoryListItem />', () => {
test('initially renders succesfully', () => { const inventory = {
id: 1,
name: 'Inventory',
kind: '',
has_active_failures: true,
total_hosts: 10,
hosts_with_active_failures: 4,
has_inventory_sources: true,
total_inventory_sources: 4,
inventory_sources_with_failures: 5,
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
user_capabilities: {
edit: true,
},
},
};
test('initially renders successfully', () => {
mountWithContexts( mountWithContexts(
<table> <table>
<tbody> <tbody>
<InventoryListItem <InventoryListItem
inventory={{ inventory={inventory}
id: 1,
name: 'Inventory',
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
user_capabilities: {
edit: true,
},
},
}}
detailUrl="/inventories/inventory/1" detailUrl="/inventories/inventory/1"
isSelected isSelected
onSelect={() => {}} onSelect={() => {}}
@@ -34,25 +43,50 @@ describe('<InventoryListItem />', () => {
); );
}); });
test('should render not configured tooltip', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<InventoryListItem
inventory={{ ...inventory, has_inventory_sources: false }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
);
expect(wrapper.find('StatusLabel').prop('tooltipContent')).toBe(
'Not configured for inventory sync.'
);
});
test('should render success tooltip', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<InventoryListItem
inventory={{ ...inventory, inventory_sources_with_failures: 0 }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
);
expect(wrapper.find('StatusLabel').prop('tooltipContent')).toBe(
'No inventory sync failures.'
);
});
test('should render prompt list item data', () => { test('should render prompt list item data', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<table> <table>
<tbody> <tbody>
<InventoryListItem <InventoryListItem
inventory={{ inventory={inventory}
id: 1,
name: 'Inventory',
kind: '',
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
user_capabilities: {
edit: true,
},
},
}}
detailUrl="/inventories/inventory/1" detailUrl="/inventories/inventory/1"
isSelected isSelected
onSelect={() => {}} onSelect={() => {}}
@@ -61,6 +95,9 @@ describe('<InventoryListItem />', () => {
</table> </table>
); );
expect(wrapper.find('StatusLabel').length).toBe(1); expect(wrapper.find('StatusLabel').length).toBe(1);
expect(wrapper.find('StatusLabel').prop('tooltipContent')).toBe(
`${inventory.inventory_sources_with_failures} sources with sync failures.`
);
expect( expect(
wrapper wrapper
.find('Td') .find('Td')
@@ -72,7 +109,7 @@ describe('<InventoryListItem />', () => {
.find('Td') .find('Td')
.at(2) .at(2)
.text() .text()
).toBe('Disabled'); ).toBe('Error');
expect( expect(
wrapper wrapper
.find('Td') .find('Td')
@@ -92,19 +129,7 @@ describe('<InventoryListItem />', () => {
<table> <table>
<tbody> <tbody>
<InventoryListItem <InventoryListItem
inventory={{ inventory={inventory}
id: 1,
name: 'Inventory',
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
user_capabilities: {
edit: true,
},
},
}}
detailUrl="/inventories/inventory/1" detailUrl="/inventories/inventory/1"
isSelected isSelected
onSelect={() => {}} onSelect={() => {}}

View File

@@ -281,6 +281,42 @@ function JobDetail({ job, i18n }) {
} }
/> />
)} )}
{job.job_tags && job.job_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Job Tags`)}
value={
<ChipGroup
numChips={5}
totalChips={job.job_tags.split(',').length}
>
{job.job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
{job.skip_tags && job.skip_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Skip Tags`)}
value={
<ChipGroup
numChips={5}
totalChips={job.skip_tags.split(',').length}
>
{job.skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
<UserDateDetail <UserDateDetail
label={i18n._(t`Created`)} label={i18n._(t`Created`)}
date={job.created} date={job.created}

View File

@@ -77,6 +77,18 @@ describe('<JobDetail />', () => {
mockJobData.summary_fields.credentials[0] mockJobData.summary_fields.credentials[0]
); );
expect(
wrapper
.find('Detail[label="Job Tags"]')
.containsAnyMatchingElements([<span>a</span>, <span>b</span>])
).toEqual(true);
expect(
wrapper
.find('Detail[label="Skip Tags"]')
.containsAnyMatchingElements([<span>c</span>, <span>d</span>])
).toEqual(true);
const statusDetail = wrapper.find('Detail[label="Status"]'); const statusDetail = wrapper.find('Detail[label="Status"]');
expect(statusDetail.find('StatusIcon SuccessfulTop')).toHaveLength(1); expect(statusDetail.find('StatusIcon SuccessfulTop')).toHaveLength(1);
expect(statusDetail.find('StatusIcon SuccessfulBottom')).toHaveLength(1); expect(statusDetail.find('StatusIcon SuccessfulBottom')).toHaveLength(1);

View File

@@ -1,6 +1,6 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
@@ -578,7 +578,7 @@ class JobOutput extends Component {
} }
render() { render() {
const { job, i18n } = this.props; const { job } = this.props;
const { const {
contentError, contentError,
@@ -666,64 +666,72 @@ class JobOutput extends Component {
</CardBody> </CardBody>
{showCancelPrompt && {showCancelPrompt &&
['pending', 'waiting', 'running'].includes(jobStatus) && ( ['pending', 'waiting', 'running'].includes(jobStatus) && (
<AlertModal <I18n>
isOpen={showCancelPrompt} {({ i18n }) => (
variant="danger" <AlertModal
onClose={this.handleCancelClose} isOpen={showCancelPrompt}
title={i18n._(t`Cancel Job`)}
label={i18n._(t`Cancel Job`)}
actions={[
<Button
id="cancel-job-confirm-button"
key="delete"
variant="danger" variant="danger"
isDisabled={cancelInProgress} onClose={this.handleCancelClose}
aria-label={i18n._(t`Cancel job`)} title={i18n._(t`Cancel Job`)}
onClick={this.handleCancelConfirm} label={i18n._(t`Cancel Job`)}
actions={[
<Button
id="cancel-job-confirm-button"
key="delete"
variant="danger"
isDisabled={cancelInProgress}
aria-label={i18n._(t`Cancel job`)}
onClick={this.handleCancelConfirm}
>
{i18n._(t`Cancel job`)}
</Button>,
<Button
id="cancel-job-return-button"
key="cancel"
variant="secondary"
aria-label={i18n._(t`Return`)}
onClick={this.handleCancelClose}
>
{i18n._(t`Return`)}
</Button>,
]}
> >
{i18n._(t`Cancel job`)} {i18n._(
</Button>, t`Are you sure you want to submit the request to cancel this job?`
<Button )}
id="cancel-job-return-button" </AlertModal>
key="cancel"
variant="secondary"
aria-label={i18n._(t`Return`)}
onClick={this.handleCancelClose}
>
{i18n._(t`Return`)}
</Button>,
]}
>
{i18n._(
t`Are you sure you want to submit the request to cancel this job?`
)} )}
</AlertModal> </I18n>
)} )}
{cancelError && ( {cancelError && (
<> <I18n>
<AlertModal {({ i18n }) => (
isOpen={cancelError} <AlertModal
variant="danger" isOpen={cancelError}
onClose={() => this.setState({ cancelError: null })} variant="danger"
title={i18n._(t`Job Cancel Error`)} onClose={() => this.setState({ cancelError: null })}
label={i18n._(t`Job Cancel Error`)} title={i18n._(t`Job Cancel Error`)}
> label={i18n._(t`Job Cancel Error`)}
<ErrorDetail error={cancelError} /> >
</AlertModal> <ErrorDetail error={cancelError} />
</> </AlertModal>
)}
</I18n>
)} )}
{deletionError && ( {deletionError && (
<> <I18n>
<AlertModal {({ i18n }) => (
isOpen={deletionError} <AlertModal
variant="danger" isOpen={deletionError}
onClose={() => this.setState({ deletionError: null })} variant="danger"
title={i18n._(t`Job Delete Error`)} onClose={() => this.setState({ deletionError: null })}
label={i18n._(t`Job Delete Error`)} title={i18n._(t`Job Delete Error`)}
> label={i18n._(t`Job Delete Error`)}
<ErrorDetail error={deletionError} /> >
</AlertModal> <ErrorDetail error={deletionError} />
</> </AlertModal>
)}
</I18n>
)} )}
</Fragment> </Fragment>
); );
@@ -731,4 +739,4 @@ class JobOutput extends Component {
} }
export { JobOutput as _JobOutput }; export { JobOutput as _JobOutput };
export default withI18n()(withRouter(JobOutput)); export default withRouter(JobOutput);

View File

@@ -101,9 +101,9 @@
"limit": "", "limit": "",
"verbosity": 0, "verbosity": 0,
"extra_vars": "{\"num_messages\": 94}", "extra_vars": "{\"num_messages\": 94}",
"job_tags": "", "job_tags": "a,b",
"force_handlers": false, "force_handlers": false,
"skip_tags": "", "skip_tags": "c,d",
"start_at_task": "", "start_at_task": "",
"timeout": 0, "timeout": 0,
"use_fact_cache": false, "use_fact_cache": false,

View File

@@ -50,10 +50,23 @@ export function requiredEmail(i18n) {
if (!value) { if (!value) {
return i18n._(t`This field must not be blank`); return i18n._(t`This field must not be blank`);
} }
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value)) {
return i18n._(t`Invalid email address`); // This isn't a perfect validator. It's likely to let a few
// invalid (though unlikely) email addresses through.
// This is ok, because the server will always do strict validation for us.
const splitVals = value.split('@');
if (splitVals.length >= 2) {
if (splitVals[0] && splitVals[1]) {
// We get here if the string has an '@' that is enclosed by
// non-empty substrings
return undefined;
}
} }
return undefined;
return i18n._(t`Invalid email address`);
}; };
} }

View File

@@ -8,6 +8,7 @@ import {
url, url,
combine, combine,
regExp, regExp,
requiredEmail,
} from './validators'; } from './validators';
const i18n = { _: val => val }; const i18n = { _: val => val };
@@ -187,4 +188,14 @@ describe('validators', () => {
expect(regExp(i18n)('ok')).toBeUndefined(); expect(regExp(i18n)('ok')).toBeUndefined();
expect(regExp(i18n)('[^a-zA-Z]')).toBeUndefined(); expect(regExp(i18n)('[^a-zA-Z]')).toBeUndefined();
}); });
test('email validator rejects obviously invalid email ', () => {
expect(requiredEmail(i18n)('foobar321')).toEqual({
id: 'Invalid email address',
});
});
test('bob has email', () => {
expect(requiredEmail(i18n)('bob@localhost')).toBeUndefined();
});
}); });

View File

@@ -31,7 +31,6 @@ options:
new_name: new_name:
description: description:
- Setting this option will change the existing name (looked up via the name field. - Setting this option will change the existing name (looked up via the name field.
required: True
type: str type: str
credential: credential:
description: description:

View File

@@ -169,7 +169,7 @@ def test_falsy_value(run_module, admin_user, base_inventory):
result = run_module('tower_inventory_source', dict( result = run_module('tower_inventory_source', dict(
name='falsy-test', name='falsy-test',
inventory=base_inventory.name, inventory=base_inventory.name,
# source='ec2', source='ec2',
update_on_launch=False update_on_launch=False
), admin_user) ), admin_user)

View File

@@ -150,7 +150,8 @@ spec:
virtualenv -p {{ custom_venv.python | default(custom_venvs_python) }} \ virtualenv -p {{ custom_venv.python | default(custom_venvs_python) }} \
{{ custom_venvs_path }}/{{ custom_venv.name }} && {{ custom_venvs_path }}/{{ custom_venv.name }} &&
source {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/activate && source {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/activate &&
{{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U psutil \ {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U pip &&
{{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U psutil \
"ansible=={{ custom_venv.python_ansible_version }}" && "ansible=={{ custom_venv.python_ansible_version }}" &&
{% if custom_venv.python_modules is defined %} {% if custom_venv.python_modules is defined %}
{{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U \ {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U \

View File

@@ -1,5 +1,5 @@
aiohttp aiohttp
ansible-runner>=1.4.6 ansible-runner>=1.4.7
ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading
asciichartpy asciichartpy
autobahn>=20.12.3 # CVE-2020-35678 autobahn>=20.12.3 # CVE-2020-35678

View File

@@ -1,7 +1,7 @@
adal==1.2.2 # via msrestazure adal==1.2.2 # via msrestazure
aiohttp==3.6.2 # via -r /awx_devel/requirements/requirements.in aiohttp==3.6.2 # via -r /awx_devel/requirements/requirements.in
aioredis==1.3.1 # via channels-redis aioredis==1.3.1 # via channels-redis
ansible-runner==1.4.6 # via -r /awx_devel/requirements/requirements.in ansible-runner==1.4.7 # via -r /awx_devel/requirements/requirements.in
ansiconv==1.0.0 # via -r /awx_devel/requirements/requirements.in ansiconv==1.0.0 # via -r /awx_devel/requirements/requirements.in
asciichartpy==1.5.25 # via -r /awx_devel/requirements/requirements.in asciichartpy==1.5.25 # via -r /awx_devel/requirements/requirements.in
asgiref==3.2.5 # via channels, channels-redis, daphne asgiref==3.2.5 # via channels, channels-redis, daphne