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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import 'styled-components/macro';
import React from 'react';
import { oneOf } from 'prop-types';
import { Label } from '@patternfly/react-core';
import { Label, Tooltip } from '@patternfly/react-core';
import {
CheckCircleIcon,
ExclamationCircleIcon,
@ -48,15 +48,19 @@ const icons = {
canceled: ExclamationTriangleIcon,
};
export default function StatusLabel({ status }) {
export default function StatusLabel({ status, tooltipContent = '' }) {
const label = status.charAt(0).toUpperCase() + status.slice(1);
const color = colors[status] || 'grey';
const Icon = icons[status];
return (
<Label variant="outline" color={color} icon={Icon ? <Icon /> : null}>
{label}
</Label>
<>
<Tooltip content={tooltipContent} position="top">
<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 { PageSection, Card } from '@patternfly/react-core';
import { CardBody } from '../../../components/Card';
@ -14,9 +14,6 @@ import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest';
function CredentialAdd({ me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
const history = useHistory();
const {
@ -85,34 +82,38 @@ function CredentialAdd({ me }) {
history.push(`/credentials/${credentialId}/details`);
}
}, [credentialId, history]);
useEffect(() => {
const loadData = async () => {
try {
const { isLoading, error, request: loadData, result } = useRequest(
useCallback(async () => {
const { data } = await CredentialTypesAPI.read({ page_size: 200 });
const credTypes = data.results;
if (data.next && data.next.includes('page=2')) {
const {
data: { results: loadedCredentialTypes },
} = await CredentialTypesAPI.read();
setCredentialTypes(
loadedCredentialTypes.reduce((credentialTypesMap, credentialType) => {
credentialTypesMap[credentialType.id] = credentialType;
return credentialTypesMap;
}, {})
);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
data: { results },
} = await CredentialTypesAPI.read({
page_size: 200,
page: 2,
});
credTypes.concat(results);
}
};
const creds = credTypes.reduce((credentialTypesMap, credentialType) => {
credentialTypesMap[credentialType.id] = credentialType;
return credentialTypesMap;
}, {});
return creds;
}, []),
{}
);
useEffect(() => {
loadData();
}, []);
}, [loadData]);
const handleCancel = () => {
history.push('/credentials');
};
const handleSubmit = async values => {
await submitRequest(values, credentialTypes);
await submitRequest(values, result);
};
if (error) {
@ -126,7 +127,7 @@ function CredentialAdd({ me }) {
</PageSection>
);
}
if (isLoading) {
if (isLoading && !result) {
return (
<PageSection>
<Card>
@ -144,7 +145,7 @@ function CredentialAdd({ me }) {
<CredentialForm
onCancel={handleCancel}
onSubmit={handleSubmit}
credentialTypes={credentialTypes}
credentialTypes={result}
submitError={submitError}
/>
</CardBody>

View File

@ -1,5 +1,5 @@
import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import React, { useCallback, useEffect } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { object } from 'prop-types';
import { CardBody } from '../../../components/Card';
import {
@ -13,11 +13,8 @@ import CredentialForm from '../shared/CredentialForm';
import useRequest from '../../../util/useRequest';
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 { id: credId } = useParams();
const { error: submitError, request: submitRequest, result } = useRequest(
useCallback(
@ -55,7 +52,7 @@ function CredentialEdit({ credential, me }) {
input_field_name: fieldName,
metadata: fieldValue.inputs,
source_credential: fieldValue.credential.id,
target_credential: credential.id,
target_credential: credId,
});
}
if (fieldValue.touched) {
@ -88,7 +85,7 @@ function CredentialEdit({ credential, me }) {
modifiedData.user = me.id;
}
const [{ data }] = await Promise.all([
CredentialsAPI.update(credential.id, modifiedData),
CredentialsAPI.update(credId, modifiedData),
...destroyInputSources(),
]);
@ -96,7 +93,7 @@ function CredentialEdit({ credential, me }) {
return data;
},
[me, credential.id]
[me, credId]
)
);
@ -105,56 +102,63 @@ function CredentialEdit({ credential, me }) {
history.push(`/credentials/${result.id}/details`);
}
}, [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(() => {
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();
}, [credential.id]);
}, [loadData]);
const handleCancel = () => {
const url = `/credentials/${credential.id}/details`;
const url = `/credentials/${credId}/details`;
history.push(`${url}`);
};
const handleSubmit = async values => {
await submitRequest(values, credentialTypes, inputSources);
await submitRequest(values, credentialTypes, loadedInputSources);
};
if (error) {
return <ContentError error={error} />;
}
if (isLoading) {
if (isLoading && !credentialTypes) {
return <ContentLoading />;
}
@ -165,7 +169,7 @@ function CredentialEdit({ credential, me }) {
onSubmit={handleSubmit}
credential={credential}
credentialTypes={credentialTypes}
inputSources={inputSources}
inputSources={loadedInputSources}
submitError={submitError}
/>
</CardBody>

View File

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

View File

@ -3,26 +3,41 @@ import { Formik, useField, useFormikContext } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
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 {
FormColumnLayout,
FormFullWidthLayout,
} from '../../../components/FormLayout';
import AnsibleSelect from '../../../components/AnsibleSelect';
import { required } from '../../../util/validators';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
import TypeInputsSubForm from './TypeInputsSubForm';
import ExternalTestModal from './ExternalTestModal';
function CredentialFormFields({
i18n,
credentialTypes,
formik,
initialValues,
}) {
const { setFieldValue } = useFormikContext();
const Select = styled(PFSelect)`
ul {
max-width: 495px;
}
`;
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({
name: 'credential_type',
validate: required(i18n._(t`Select a value for this field`), i18n),
@ -30,7 +45,7 @@ function CredentialFormFields({
const isGalaxyCredential =
!!credTypeField.value &&
credentialTypes[credTypeField.value].kind === 'galaxy';
credentialTypes[credTypeField.value]?.kind === 'galaxy';
const [orgField, orgMeta, orgHelpers] = useField({
name: 'organization',
@ -52,16 +67,14 @@ function CredentialFormFields({
})
.sort((a, b) => (a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1));
const resetSubFormFields = (newCredentialType, form) => {
const resetSubFormFields = newCredentialType => {
const fields = credentialTypes[newCredentialType].inputs.fields || [];
fields.forEach(
({ ask_at_runtime, type, id, choices, default: defaultValue }) => {
if (
parseInt(newCredentialType, 10) === form.initialValues.credential_type
) {
form.setFieldValue(`inputs.${id}`, initialValues.inputs[id]);
if (parseInt(newCredentialType, 10) === initialValues.credential_type) {
setFieldValue(`inputs.${id}`, initialValues.inputs[id]);
if (ask_at_runtime) {
form.setFieldValue(
setFieldValue(
`passwordPrompts.${id}`,
initialValues.passwordPrompts[id]
);
@ -69,24 +82,24 @@ function CredentialFormFields({
} else {
switch (type) {
case 'string':
form.setFieldValue(`inputs.${id}`, defaultValue || '');
setFieldValue(`inputs.${id}`, defaultValue || '');
break;
case 'boolean':
form.setFieldValue(`inputs.${id}`, defaultValue || false);
setFieldValue(`inputs.${id}`, defaultValue || false);
break;
default:
break;
}
if (choices) {
form.setFieldValue(`inputs.${id}`, defaultValue);
setFieldValue(`inputs.${id}`, defaultValue);
}
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`)}
>
<AnsibleSelect
{...credTypeField}
id="credential-type"
data={[
{
value: '',
key: '',
label: i18n._(t`Choose a Credential Type`),
isDisabled: true,
},
...credentialTypeOptions,
]}
onChange={(event, value) => {
<Select
aria-label={i18n._(t`Credential Type`)}
isOpen={isSelectOpen}
variant={SelectVariant.typeahead}
ouiaId="credential-select"
onToggle={setIsSelectOpen}
onSelect={(event, 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>
{credTypeField.value !== undefined &&
credTypeField.value !== '' &&
@ -177,7 +196,7 @@ function CredentialForm({
name: credential.name || '',
description: credential.description || '',
organization: credential?.summary_fields?.organization || null,
credential_type: credential.credential_type || '',
credential_type: credential?.credential_type || '',
inputs: {},
passwordPrompts: {},
};
@ -235,8 +254,6 @@ function CredentialForm({
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<CredentialFormFields
formik={formik}
initialValues={initialValues}
credentialTypes={credentialTypes}
i18n={i18n}
{...rest}

View File

@ -137,15 +137,28 @@ describe('<CredentialForm />', () => {
test('should display cred type subform when scm type select has a value', async () => {
await act(async () => {
await wrapper
.find('AnsibleSelect[id="credential-type"]')
.invoke('onChange')(null, 1);
.find('Select[aria-label="Credential Type"]')
.invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 1);
});
wrapper.update();
machineFieldExpects();
await act(async () => {
await wrapper
.find('AnsibleSelect[id="credential-type"]')
.invoke('onChange')(null, 2);
.find('Select[aria-label="Credential Type"]')
.invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 2);
});
wrapper.update();
sourceFieldExpects();
@ -154,8 +167,14 @@ describe('<CredentialForm />', () => {
test('should update expected fields when gce service account json file uploaded', async () => {
await act(async () => {
await wrapper
.find('AnsibleSelect[id="credential-type"]')
.invoke('onChange')(null, 10);
.find('Select[aria-label="Credential Type"]')
.invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 10);
});
wrapper.update();
gceFieldExpects();
@ -215,8 +234,14 @@ describe('<CredentialForm />', () => {
test('should show error when error thrown parsing JSON', async () => {
await act(async () => {
await wrapper
.find('AnsibleSelect[id="credential-type"]')
.invoke('onChange')(null, 10);
.find('Select[aria-label="Credential Type"]')
.invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 10);
});
wrapper.update();
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 () => {
await act(async () => {
await wrapper
.find('AnsibleSelect[id="credential-type"]')
.invoke('onChange')(null, 21);
.find('Select[aria-label="Credential Type"]')
.invoke('onToggle')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('Select[aria-label="Credential Type"]')
.invoke('onSelect')(null, 21);
});
wrapper.update();
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';
}
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 (
<Tr id={inventory.id} aria-labelledby={labelId}>
<Td
@ -75,7 +88,9 @@ function InventoryListItem({
)}
</Td>
<Td dataLabel={i18n._(t`Status`)}>
{inventory.kind !== 'smart' && <StatusLabel status={syncStatus} />}
{inventory.kind !== 'smart' && (
<StatusLabel status={syncStatus} tooltipContent={tooltipContent} />
)}
</Td>
<Td dataLabel={i18n._(t`Type`)}>
{inventory.kind === 'smart'

View File

@ -7,24 +7,33 @@ import InventoryListItem from './InventoryListItem';
jest.mock('../../../api/models/Inventories');
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(
<table>
<tbody>
<InventoryListItem
inventory={{
id: 1,
name: 'Inventory',
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
user_capabilities: {
edit: true,
},
},
}}
inventory={inventory}
detailUrl="/inventories/inventory/1"
isSelected
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', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<InventoryListItem
inventory={{
id: 1,
name: 'Inventory',
kind: '',
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
user_capabilities: {
edit: true,
},
},
}}
inventory={inventory}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
@ -61,6 +95,9 @@ describe('<InventoryListItem />', () => {
</table>
);
expect(wrapper.find('StatusLabel').length).toBe(1);
expect(wrapper.find('StatusLabel').prop('tooltipContent')).toBe(
`${inventory.inventory_sources_with_failures} sources with sync failures.`
);
expect(
wrapper
.find('Td')
@ -72,7 +109,7 @@ describe('<InventoryListItem />', () => {
.find('Td')
.at(2)
.text()
).toBe('Disabled');
).toBe('Error');
expect(
wrapper
.find('Td')
@ -92,19 +129,7 @@ describe('<InventoryListItem />', () => {
<table>
<tbody>
<InventoryListItem
inventory={{
id: 1,
name: 'Inventory',
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
user_capabilities: {
edit: true,
},
},
}}
inventory={inventory}
detailUrl="/inventories/inventory/1"
isSelected
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
label={i18n._(t`Created`)}
date={job.created}

View File

@ -77,6 +77,18 @@ describe('<JobDetail />', () => {
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"]');
expect(statusDetail.find('StatusIcon SuccessfulTop')).toHaveLength(1);
expect(statusDetail.find('StatusIcon SuccessfulBottom')).toHaveLength(1);

View File

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

View File

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

View File

@ -50,10 +50,23 @@ export function requiredEmail(i18n) {
if (!value) {
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,
combine,
regExp,
requiredEmail,
} from './validators';
const i18n = { _: val => val };
@ -187,4 +188,14 @@ describe('validators', () => {
expect(regExp(i18n)('ok')).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:
description:
- Setting this option will change the existing name (looked up via the name field.
required: True
type: str
credential:
description:

View File

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

View File

@ -150,7 +150,8 @@ spec:
virtualenv -p {{ custom_venv.python | default(custom_venvs_python) }} \
{{ custom_venvs_path }}/{{ custom_venv.name }} &&
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 }}" &&
{% if custom_venv.python_modules is defined %}
{{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U \

View File

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

View File

@ -1,7 +1,7 @@
adal==1.2.2 # via msrestazure
aiohttp==3.6.2 # via -r /awx_devel/requirements/requirements.in
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
asciichartpy==1.5.25 # via -r /awx_devel/requirements/requirements.in
asgiref==3.2.5 # via channels, channels-redis, daphne