mirror of
https://github.com/ansible/awx.git
synced 2026-02-23 22:16:00 -03:30
Merge pull request #291 from marshmalien/skeleton-template-edit-form
Skeleton template edit form
This commit is contained in:
@@ -20,7 +20,8 @@ class AnsibleSelect extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { label, value, data, defaultSelected, i18n } = this.props;
|
const { value, data, i18n } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSelect
|
<FormSelect
|
||||||
value={value}
|
value={value}
|
||||||
@@ -28,15 +29,12 @@ class AnsibleSelect extends React.Component {
|
|||||||
aria-label={i18n._(t`Select Input`)}
|
aria-label={i18n._(t`Select Input`)}
|
||||||
>
|
>
|
||||||
{data.map((datum) => (
|
{data.map((datum) => (
|
||||||
datum === defaultSelected ? (
|
<FormSelectOption
|
||||||
<FormSelectOption
|
key={datum.key}
|
||||||
key=""
|
value={datum.value}
|
||||||
value=""
|
label={datum.label}
|
||||||
label={i18n._(t`Use Default ${label}`)}
|
isDisabled={datum.isDisabled}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<FormSelectOption key={datum} value={datum} label={datum} />
|
|
||||||
)
|
|
||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
);
|
);
|
||||||
@@ -45,15 +43,10 @@ class AnsibleSelect extends React.Component {
|
|||||||
|
|
||||||
AnsibleSelect.defaultProps = {
|
AnsibleSelect.defaultProps = {
|
||||||
data: [],
|
data: [],
|
||||||
label: 'Ansible Select',
|
|
||||||
defaultSelected: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
AnsibleSelect.propTypes = {
|
AnsibleSelect.propTypes = {
|
||||||
data: PropTypes.arrayOf(PropTypes.string),
|
data: PropTypes.arrayOf(PropTypes.object),
|
||||||
defaultSelected: PropTypes.string,
|
|
||||||
label: PropTypes.string,
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
value: PropTypes.string.isRequired,
|
value: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ import React from 'react';
|
|||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect';
|
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect';
|
||||||
|
|
||||||
const label = 'test select';
|
const mockData = [
|
||||||
const mockData = ['/venv/baz/', '/venv/ansible/'];
|
{
|
||||||
|
label: 'Baz',
|
||||||
|
value: '/venv/baz/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Default',
|
||||||
|
value: '/venv/ansible/'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
describe('<AnsibleSelect />', () => {
|
describe('<AnsibleSelect />', () => {
|
||||||
test('initially renders succesfully', async () => {
|
test('initially renders succesfully', async () => {
|
||||||
mountWithContexts(
|
mountWithContexts(
|
||||||
@@ -11,7 +20,6 @@ describe('<AnsibleSelect />', () => {
|
|||||||
value="foo"
|
value="foo"
|
||||||
name="bar"
|
name="bar"
|
||||||
onChange={() => { }}
|
onChange={() => { }}
|
||||||
label={label}
|
|
||||||
data={mockData}
|
data={mockData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -24,7 +32,6 @@ describe('<AnsibleSelect />', () => {
|
|||||||
value="foo"
|
value="foo"
|
||||||
name="bar"
|
name="bar"
|
||||||
onChange={() => { }}
|
onChange={() => { }}
|
||||||
label={label}
|
|
||||||
data={mockData}
|
data={mockData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -33,17 +40,17 @@ describe('<AnsibleSelect />', () => {
|
|||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Returns correct select options if defaultSelected props is passed', () => {
|
test('Returns correct select options', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<AnsibleSelect
|
<AnsibleSelect
|
||||||
value="foo"
|
value="foo"
|
||||||
name="bar"
|
name="bar"
|
||||||
onChange={() => { }}
|
onChange={() => { }}
|
||||||
label={label}
|
|
||||||
data={mockData}
|
data={mockData}
|
||||||
defaultSelected={mockData[1]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,29 +4,31 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
ActionGroup as PFActionGroup,
|
ActionGroup as PFActionGroup,
|
||||||
Toolbar,
|
|
||||||
ToolbarGroup,
|
|
||||||
Button
|
Button
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
const ActionGroup = styled(PFActionGroup)`
|
const ActionGroup = styled(PFActionGroup)`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
justify-content: flex-end;
|
||||||
justify-content: flex-end;
|
--pf-c-form__group--m-action--MarginTop: 0;
|
||||||
--pf-c-form__group--m-action--MarginTop: 0;
|
|
||||||
|
.pf-c-form__actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
& > button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => (
|
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => (
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<Toolbar>
|
<Button aria-label={i18n._(t`Save`)} variant="primary" type="submit" onClick={onSubmit} isDisabled={submitDisabled}>{i18n._(t`Save`)}</Button>
|
||||||
<ToolbarGroup css="margin-right: 20px">
|
<Button aria-label={i18n._(t`Cancel`)} variant="secondary" type="button" onClick={onCancel}>{i18n._(t`Cancel`)}</Button>
|
||||||
<Button aria-label={i18n._(t`Save`)} variant="primary" type="submit" onClick={onSubmit} isDisabled={submitDisabled}>{i18n._(t`Save`)}</Button>
|
|
||||||
</ToolbarGroup>
|
|
||||||
<ToolbarGroup>
|
|
||||||
<Button aria-label={i18n._(t`Cancel`)} variant="secondary" type="button" onClick={onCancel}>{i18n._(t`Cancel`)}</Button>
|
|
||||||
</ToolbarGroup>
|
|
||||||
</Toolbar>
|
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Field } from 'formik';
|
import { Field } from 'formik';
|
||||||
import { FormGroup, TextInput } from '@patternfly/react-core';
|
import { FormGroup, TextInput, Tooltip } from '@patternfly/react-core';
|
||||||
|
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||||
|
margin-left: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
function FormField (props) {
|
function FormField (props) {
|
||||||
const { id, name, label, validate, isRequired, ...rest } = props;
|
const { id, name, label, tooltip, validate, isRequired, ...rest } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
@@ -21,6 +27,15 @@ function FormField (props) {
|
|||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
label={label}
|
label={label}
|
||||||
>
|
>
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip
|
||||||
|
position="right"
|
||||||
|
content={tooltip}
|
||||||
|
>
|
||||||
|
<QuestionCircleIcon />
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
id={id}
|
id={id}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
@@ -45,12 +60,14 @@ FormField.propTypes = {
|
|||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
validate: PropTypes.func,
|
validate: PropTypes.func,
|
||||||
isRequired: PropTypes.bool,
|
isRequired: PropTypes.bool,
|
||||||
|
tooltip: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
FormField.defaultProps = {
|
FormField.defaultProps = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
validate: () => {},
|
validate: () => {},
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
|
tooltip: null
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FormField;
|
export default FormField;
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ describe('<OrganizationAdd />', () => {
|
|||||||
{ context: { config } }
|
{ context: { config } }
|
||||||
).find('AnsibleSelect');
|
).find('AnsibleSelect');
|
||||||
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
||||||
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
|
expect(wrapper.find('FormSelectOption')).toHaveLength(3);
|
||||||
|
expect(wrapper.find('FormSelectOption').first().prop('value')).toEqual('/venv/ansible/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnsibleSelect component does not render if there are 0 virtual environments', () => {
|
test('AnsibleSelect component does not render if there are 0 virtual environments', () => {
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ class OrganizationForm extends Component {
|
|||||||
render () {
|
render () {
|
||||||
const { organization, handleCancel, i18n, me } = this.props;
|
const { organization, handleCancel, i18n, me } = this.props;
|
||||||
const { instanceGroups, formIsValid, error } = this.state;
|
const { instanceGroups, formIsValid, error } = this.state;
|
||||||
const defaultVenv = '/venv/ansible/';
|
const defaultVenv = {
|
||||||
|
label: i18n._(t`Use Default Ansible Environment`),
|
||||||
|
value: '/venv/ansible/',
|
||||||
|
key: 'default'
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
@@ -133,7 +137,10 @@ class OrganizationForm extends Component {
|
|||||||
{(
|
{(
|
||||||
<Tooltip
|
<Tooltip
|
||||||
position="right"
|
position="right"
|
||||||
content="The maximum number of hosts allowed to be managed by this organization. Value defaults to 0 which means no limit. Refer to the Ansible documentation for more details."
|
content={i18n._(t`The maximum number of hosts allowed
|
||||||
|
to be managed by this organization. Value defaults to
|
||||||
|
0 which means no limit. Refer to the Ansible
|
||||||
|
documentation for more details.`)}
|
||||||
>
|
>
|
||||||
<QuestionCircleIcon />
|
<QuestionCircleIcon />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -156,9 +163,10 @@ class OrganizationForm extends Component {
|
|||||||
label={i18n._(t`Ansible Environment`)}
|
label={i18n._(t`Ansible Environment`)}
|
||||||
>
|
>
|
||||||
<AnsibleSelect
|
<AnsibleSelect
|
||||||
data={custom_virtualenvs}
|
data={[defaultVenv, ...custom_virtualenvs
|
||||||
defaultSelected={defaultVenv}
|
.filter(datum => datum !== defaultVenv.value)
|
||||||
label={i18n._(t`Ansible Environment`)}
|
.map(datum => ({ label: datum, value: datum, key: datum }))
|
||||||
|
]}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ describe('<OrganizationForm />', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
||||||
expect(wrapper.find('FormSelectOption')).toHaveLength(2);
|
expect(wrapper.find('FormSelectOption')).toHaveLength(3);
|
||||||
|
expect(wrapper.find('FormSelectOption').first().prop('value')).toEqual('/venv/ansible/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls handleSubmit when form submitted', async () => {
|
test('calls handleSubmit when form submitted', async () => {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class JobTemplateDetail extends Component {
|
|||||||
},
|
},
|
||||||
hasTemplateLoading,
|
hasTemplateLoading,
|
||||||
i18n,
|
i18n,
|
||||||
|
match,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { instanceGroups, hasContentLoading, contentError } = this.state;
|
const { instanceGroups, hasContentLoading, contentError } = this.state;
|
||||||
const verbosityOptions = [
|
const verbosityOptions = [
|
||||||
@@ -307,7 +308,7 @@ class JobTemplateDetail extends Component {
|
|||||||
{summary_fields.user_capabilities.edit && (
|
{summary_fields.user_capabilities.edit && (
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/home"
|
to={`/templates/${match.params.templateType}/${match.params.id}/edit`}
|
||||||
aria-label={i18n._(t`Edit`)}
|
aria-label={i18n._(t`Edit`)}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,37 +1,58 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { Card, CardHeader, PageSection } from '@patternfly/react-core';
|
import {
|
||||||
import { Switch, Route, Redirect, withRouter } from 'react-router-dom';
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
PageSection,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import {
|
||||||
|
Switch,
|
||||||
|
Route,
|
||||||
|
Redirect,
|
||||||
|
withRouter,
|
||||||
|
} from 'react-router-dom';
|
||||||
import CardCloseButton from '@components/CardCloseButton';
|
import CardCloseButton from '@components/CardCloseButton';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import RoutedTabs from '@components/RoutedTabs';
|
import RoutedTabs from '@components/RoutedTabs';
|
||||||
import JobTemplateDetail from './JobTemplateDetail';
|
import JobTemplateDetail from './JobTemplateDetail';
|
||||||
import { JobTemplatesAPI } from '@api';
|
import { JobTemplatesAPI } from '@api';
|
||||||
|
import TemplateEdit from './TemplateEdit';
|
||||||
|
|
||||||
class Template extends Component {
|
class Template extends Component {
|
||||||
constructor (props) {
|
constructor (props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
hasContentError: false,
|
hasContentError: false,
|
||||||
hasContentLoading: true,
|
hasContentLoading: true,
|
||||||
template: {}
|
template: null,
|
||||||
};
|
};
|
||||||
this.readTemplate = this.readTemplate.bind(this);
|
this.loadTemplate = this.loadTemplate.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
async componentDidMount () {
|
||||||
this.readTemplate();
|
await this.loadTemplate();
|
||||||
}
|
}
|
||||||
|
|
||||||
async readTemplate () {
|
async componentDidUpdate (prevProps) {
|
||||||
|
const { location } = this.props;
|
||||||
|
if (location !== prevProps.location) {
|
||||||
|
await this.loadTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTemplate () {
|
||||||
const { setBreadcrumb, match } = this.props;
|
const { setBreadcrumb, match } = this.props;
|
||||||
const { id } = match.params;
|
const { id } = match.params;
|
||||||
|
|
||||||
|
this.setState({ hasContentError: false, hasContentLoading: true });
|
||||||
try {
|
try {
|
||||||
const { data } = await JobTemplatesAPI.readDetail(id);
|
const { data } = await JobTemplatesAPI.readDetail(id);
|
||||||
setBreadcrumb(data);
|
setBreadcrumb(data);
|
||||||
this.setState({ template: data });
|
this.setState({
|
||||||
|
template: data,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this.setState({ hasContentError: true });
|
this.setState({ hasContentError: true });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -40,8 +61,17 @@ class Template extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { match, i18n, history } = this.props;
|
const {
|
||||||
const { hasContentLoading, template, hasContentError } = this.state;
|
history,
|
||||||
|
i18n,
|
||||||
|
location,
|
||||||
|
match,
|
||||||
|
} = this.props;
|
||||||
|
const {
|
||||||
|
hasContentError,
|
||||||
|
hasContentLoading,
|
||||||
|
template
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
|
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
|
||||||
@@ -51,7 +81,8 @@ class Template extends Component {
|
|||||||
{ name: i18n._(t`Completed Jobs`), link: '/home', id: 4 },
|
{ name: i18n._(t`Completed Jobs`), link: '/home', id: 4 },
|
||||||
{ name: i18n._(t`Survey`), link: '/home', id: 5 }
|
{ name: i18n._(t`Survey`), link: '/home', id: 5 }
|
||||||
];
|
];
|
||||||
const cardHeader = (hasContentLoading ? null
|
|
||||||
|
let cardHeader = (hasContentLoading ? null
|
||||||
: (
|
: (
|
||||||
<CardHeader style={{ padding: 0 }}>
|
<CardHeader style={{ padding: 0 }}>
|
||||||
<RoutedTabs
|
<RoutedTabs
|
||||||
@@ -63,6 +94,10 @@ class Template extends Component {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (location.pathname.endsWith('edit')) {
|
||||||
|
cardHeader = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasContentLoading && hasContentError) {
|
if (!hasContentLoading && hasContentError) {
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
@@ -94,11 +129,22 @@ class Template extends Component {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{template && (
|
||||||
|
<Route
|
||||||
|
path="/templates/:templateType/:id/edit"
|
||||||
|
render={() => (
|
||||||
|
<TemplateEdit
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Switch>
|
</Switch>
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Template as _Template };
|
export { Template as _Template };
|
||||||
export default withI18n()(withRouter(Template));
|
export default withI18n()(withRouter(Template));
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ describe('<Template />', () => {
|
|||||||
mountWithContexts(<Template />);
|
mountWithContexts(<Template />);
|
||||||
});
|
});
|
||||||
test('When component mounts API is called and the response is put in state', async (done) => {
|
test('When component mounts API is called and the response is put in state', async (done) => {
|
||||||
const readTemplate = jest.spyOn(_Template.prototype, 'readTemplate');
|
const loadTemplate = jest.spyOn(_Template.prototype, 'loadTemplate');
|
||||||
const wrapper = mountWithContexts(<Template />);
|
const wrapper = mountWithContexts(<Template />);
|
||||||
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
|
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
|
||||||
expect(readTemplate).toHaveBeenCalled();
|
expect(loadTemplate).toHaveBeenCalled();
|
||||||
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
|
await waitForElement(wrapper, 'Template', (el) => el.state('hasContentLoading') === true);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|||||||
63
src/screens/Template/TemplateEdit/TemplateEdit.jsx
Normal file
63
src/screens/Template/TemplateEdit/TemplateEdit.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { withRouter, Redirect } from 'react-router-dom';
|
||||||
|
import { CardBody } from '@patternfly/react-core';
|
||||||
|
import TemplateForm from '../shared/TemplateForm';
|
||||||
|
import { JobTemplatesAPI } from '@api';
|
||||||
|
import { JobTemplate } from '@types';
|
||||||
|
|
||||||
|
class TemplateEdit extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
template: JobTemplate.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor (props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleCancel = this.handleCancel.bind(this);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit (values) {
|
||||||
|
const { template: { id, type }, history } = this.props;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await JobTemplatesAPI.update(id, { ...values });
|
||||||
|
history.push(`/templates/${type}/${id}/details`);
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({ error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancel () {
|
||||||
|
const { template: { id, type }, history } = this.props;
|
||||||
|
history.push(`/templates/${type}/${id}/details`);
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { template } = this.props;
|
||||||
|
const { error } = this.state;
|
||||||
|
const canEdit = template.summary_fields.user_capabilities.edit;
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
const { template: { id, type } } = this.props;
|
||||||
|
return <Redirect to={`/templates/${type}/${id}/details`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<TemplateForm
|
||||||
|
template={template}
|
||||||
|
handleCancel={this.handleCancel}
|
||||||
|
handleSubmit={this.handleSubmit}
|
||||||
|
/>
|
||||||
|
{error ? <div> error </div> : null}
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(TemplateEdit);
|
||||||
64
src/screens/Template/TemplateEdit/TemplateEdit.test.jsx
Normal file
64
src/screens/Template/TemplateEdit/TemplateEdit.test.jsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { JobTemplatesAPI } from '@api';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import TemplateEdit from './TemplateEdit';
|
||||||
|
|
||||||
|
jest.mock('@api');
|
||||||
|
|
||||||
|
describe('<TemplateEdit />', () => {
|
||||||
|
const mockData = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Foo',
|
||||||
|
description: 'Bar',
|
||||||
|
job_type: 'run',
|
||||||
|
inventory: 2,
|
||||||
|
project: 3,
|
||||||
|
playbook: 'Baz',
|
||||||
|
type: 'job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('initially renders successfully', () => {
|
||||||
|
mountWithContexts(
|
||||||
|
<TemplateEdit
|
||||||
|
template={mockData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleSubmit should call api update', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<TemplateEdit
|
||||||
|
template={mockData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const updatedTemplateData = {
|
||||||
|
name: 'new name',
|
||||||
|
description: 'new description',
|
||||||
|
job_type: 'check',
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapper.find('TemplateForm').prop('handleSubmit')(updatedTemplateData);
|
||||||
|
expect(JobTemplatesAPI.update).toHaveBeenCalledWith(1, updatedTemplateData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to job template detail when cancel is clicked', () => {
|
||||||
|
const history = {
|
||||||
|
push: jest.fn(),
|
||||||
|
};
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<TemplateEdit
|
||||||
|
template={mockData}
|
||||||
|
/>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(history.push).not.toHaveBeenCalled();
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||||
|
expect(history.push).toHaveBeenCalledWith('/templates/job_template/1/details');
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/screens/Template/TemplateEdit/index.js
Normal file
1
src/screens/Template/TemplateEdit/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './TemplateEdit';
|
||||||
@@ -80,7 +80,7 @@ describe('<TemplatesList />', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders successfully', () => {
|
||||||
mountWithContexts(
|
mountWithContexts(
|
||||||
<TemplatesList
|
<TemplatesList
|
||||||
match={{ path: '/templates', url: '/templates' }}
|
match={{ path: '/templates', url: '/templates' }}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class Templates extends Component {
|
|||||||
}
|
}
|
||||||
const breadcrumbConfig = {
|
const breadcrumbConfig = {
|
||||||
'/templates': i18n._(t`Templates`),
|
'/templates': i18n._(t`Templates`),
|
||||||
[`/templates/${template.type}/${template.id}/details`]: i18n._(t`${template.name} Details`)
|
[`/templates/${template.type}/${template.id}/details`]: i18n._(t`${template.name} Details`),
|
||||||
|
[`/templates/${template.type}/${template.id}/edit`]: i18n._(t`${template.name} Edit`)
|
||||||
};
|
};
|
||||||
this.setState({ breadcrumbConfig });
|
this.setState({ breadcrumbConfig });
|
||||||
}
|
}
|
||||||
|
|||||||
141
src/screens/Template/shared/TemplateForm.jsx
Normal file
141
src/screens/Template/shared/TemplateForm.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Formik, Field } from 'formik';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormGroup,
|
||||||
|
Tooltip,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||||
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
|
import FormActionGroup from '@components/FormActionGroup';
|
||||||
|
import FormField from '@components/FormField';
|
||||||
|
import FormRow from '@components/FormRow';
|
||||||
|
import { required } from '@util/validators';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { JobTemplate } from '@types';
|
||||||
|
|
||||||
|
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||||
|
margin-left: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
class TemplateForm extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
template: JobTemplate.isRequired,
|
||||||
|
handleCancel: PropTypes.func.isRequired,
|
||||||
|
handleSubmit: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
handleCancel,
|
||||||
|
handleSubmit,
|
||||||
|
i18n,
|
||||||
|
template
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const jobTypeOptions = [
|
||||||
|
{ value: '', key: '', label: i18n._(t`Choose a job type`), isDisabled: true },
|
||||||
|
{ value: 'run', key: 'run', label: i18n._(t`Run`), isDisabled: false },
|
||||||
|
{ value: 'check', key: 'check', label: i18n._(t`Check`), isDisabled: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
job_type: template.job_type,
|
||||||
|
inventory: template.inventory,
|
||||||
|
project: template.project,
|
||||||
|
playbook: template.playbook
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
render={formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormRow>
|
||||||
|
<FormField
|
||||||
|
id="template-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Name`)}
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="template-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Description`)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="job_type"
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormGroup
|
||||||
|
fieldId="template-job-type"
|
||||||
|
isRequired
|
||||||
|
label={i18n._(t`Job Type`)}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
position="right"
|
||||||
|
content={i18n._(t`For job templates, select run to execute
|
||||||
|
the playbook. Select check to only check playbook syntax,
|
||||||
|
test environment setup, and report problems without
|
||||||
|
executing the playbook.`)}
|
||||||
|
>
|
||||||
|
<QuestionCircleIcon />
|
||||||
|
</Tooltip>
|
||||||
|
<AnsibleSelect
|
||||||
|
data={jobTypeOptions}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="template-inventory"
|
||||||
|
name="inventory"
|
||||||
|
type="number"
|
||||||
|
label={i18n._(t`Inventory`)}
|
||||||
|
tooltip={i18n._(t`Select the inventory containing the hosts
|
||||||
|
you want this job to manage.`)}
|
||||||
|
isRequired
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="template-project"
|
||||||
|
name="project"
|
||||||
|
type="number"
|
||||||
|
label={i18n._(t`Project`)}
|
||||||
|
tooltip={i18n._(t`Select the project containing the playbook
|
||||||
|
you want this job to execute.`)}
|
||||||
|
isRequired
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="template-playbook"
|
||||||
|
name="playbook"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Playbook`)}
|
||||||
|
tooltip={i18n._(t`Select the playbook to be executed by this job.`)}
|
||||||
|
isRequired
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(withRouter(TemplateForm));
|
||||||
|
|
||||||
99
src/screens/Template/shared/TemplateForm.test.jsx
Normal file
99
src/screens/Template/shared/TemplateForm.test.jsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import { sleep } from '@testUtils/testUtils';
|
||||||
|
import TemplateForm from './TemplateForm';
|
||||||
|
|
||||||
|
jest.mock('@api');
|
||||||
|
|
||||||
|
describe('<TemplateForm />', () => {
|
||||||
|
const mockData = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Foo',
|
||||||
|
description: 'Bar',
|
||||||
|
job_type: 'run',
|
||||||
|
inventory: 2,
|
||||||
|
project: 3,
|
||||||
|
playbook: 'Baz',
|
||||||
|
type: 'job_template'
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders successfully', () => {
|
||||||
|
mountWithContexts(
|
||||||
|
<TemplateForm
|
||||||
|
template={mockData}
|
||||||
|
handleSubmit={jest.fn()}
|
||||||
|
handleCancel={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update form values on input changes', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<TemplateForm
|
||||||
|
template={mockData}
|
||||||
|
handleSubmit={jest.fn()}
|
||||||
|
handleCancel={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = wrapper.find('Formik');
|
||||||
|
wrapper.find('input#template-name').simulate('change', {
|
||||||
|
target: { value: 'new foo', name: 'name' }
|
||||||
|
});
|
||||||
|
expect(form.state('values').name).toEqual('new foo');
|
||||||
|
wrapper.find('input#template-description').simulate('change', {
|
||||||
|
target: { value: 'new bar', name: 'description' }
|
||||||
|
});
|
||||||
|
expect(form.state('values').description).toEqual('new bar');
|
||||||
|
wrapper.find('AnsibleSelect[name="job_type"]').simulate('change', {
|
||||||
|
target: { value: 'new job type', name: 'job_type' }
|
||||||
|
});
|
||||||
|
expect(form.state('values').job_type).toEqual('new job type');
|
||||||
|
wrapper.find('input#template-inventory').simulate('change', {
|
||||||
|
target: { value: 3, name: 'inventory' }
|
||||||
|
});
|
||||||
|
expect(form.state('values').inventory).toEqual(3);
|
||||||
|
wrapper.find('input#template-project').simulate('change', {
|
||||||
|
target: { value: 4, name: 'project' }
|
||||||
|
});
|
||||||
|
expect(form.state('values').project).toEqual(4);
|
||||||
|
wrapper.find('input#template-playbook').simulate('change', {
|
||||||
|
target: { value: 'new baz type', name: 'playbook' }
|
||||||
|
});
|
||||||
|
expect(form.state('values').playbook).toEqual('new baz type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call handleSubmit when Submit button is clicked', async () => {
|
||||||
|
const handleSubmit = jest.fn();
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<TemplateForm
|
||||||
|
template={mockData}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
handleCancel={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
|
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||||
|
await sleep(1);
|
||||||
|
expect(handleSubmit).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call handleCancel when Cancel button is clicked', () => {
|
||||||
|
const handleCancel = jest.fn();
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<TemplateForm
|
||||||
|
template={mockData}
|
||||||
|
handleSubmit={jest.fn()}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(handleCancel).not.toHaveBeenCalled();
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||||
|
expect(handleCancel).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/screens/Template/shared/index.js
Normal file
1
src/screens/Template/shared/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './TemplateForm';
|
||||||
14
src/types.js
14
src/types.js
@@ -1,4 +1,4 @@
|
|||||||
import { shape, arrayOf, number, string, bool } from 'prop-types';
|
import { shape, arrayOf, number, string, bool, oneOf } from 'prop-types';
|
||||||
|
|
||||||
export const Role = shape({
|
export const Role = shape({
|
||||||
descendent_roles: arrayOf(string),
|
descendent_roles: arrayOf(string),
|
||||||
@@ -53,3 +53,15 @@ export const QSConfig = shape({
|
|||||||
namespace: string,
|
namespace: string,
|
||||||
integerFields: arrayOf(string).isRequired,
|
integerFields: arrayOf(string).isRequired,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const JobTemplate = shape({
|
||||||
|
name: string.isRequired,
|
||||||
|
description: string,
|
||||||
|
inventory: number.isRequired,
|
||||||
|
job_type: oneOf([
|
||||||
|
'run',
|
||||||
|
'check'
|
||||||
|
]),
|
||||||
|
playbook: string.isRequired,
|
||||||
|
project: number.isRequired,
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { t } from '@lingui/macro';
|
|||||||
|
|
||||||
export function required (message, i18n) {
|
export function required (message, i18n) {
|
||||||
return value => {
|
return value => {
|
||||||
if (!value.trim()) {
|
if (typeof value === 'string' && !value.trim()) {
|
||||||
return message || i18n._(t`This field must not be blank`);
|
return message || i18n._(t`This field must not be blank`);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user