mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge pull request #4413 from AlexSCorey/multiSelectPatternFly
Multi-Select AWX-PF Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
commit
9ab920cdb9
@ -25,7 +25,9 @@ class Base {
|
||||
}
|
||||
|
||||
read(params) {
|
||||
return this.http.get(this.baseUrl, { params });
|
||||
return this.http.get(this.baseUrl, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
readDetail(id) {
|
||||
|
||||
@ -3,6 +3,7 @@ import InstanceGroups from './models/InstanceGroups';
|
||||
import Inventories from './models/Inventories';
|
||||
import JobTemplates from './models/JobTemplates';
|
||||
import Jobs from './models/Jobs';
|
||||
import Labels from './models/Labels';
|
||||
import Me from './models/Me';
|
||||
import Organizations from './models/Organizations';
|
||||
import Root from './models/Root';
|
||||
@ -17,6 +18,7 @@ const InstanceGroupsAPI = new InstanceGroups();
|
||||
const InventoriesAPI = new Inventories();
|
||||
const JobTemplatesAPI = new JobTemplates();
|
||||
const JobsAPI = new Jobs();
|
||||
const LabelsAPI = new Labels();
|
||||
const MeAPI = new Me();
|
||||
const OrganizationsAPI = new Organizations();
|
||||
const RootAPI = new Root();
|
||||
@ -32,6 +34,7 @@ export {
|
||||
InventoriesAPI,
|
||||
JobTemplatesAPI,
|
||||
JobsAPI,
|
||||
LabelsAPI,
|
||||
MeAPI,
|
||||
OrganizationsAPI,
|
||||
RootAPI,
|
||||
|
||||
@ -8,6 +8,9 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
|
||||
|
||||
this.launch = this.launch.bind(this);
|
||||
this.readLaunch = this.readLaunch.bind(this);
|
||||
this.associateLabel = this.associateLabel.bind(this);
|
||||
this.disassociateLabel = this.disassociateLabel.bind(this);
|
||||
this.generateLabel = this.generateLabel.bind(this);
|
||||
}
|
||||
|
||||
launch(id, data) {
|
||||
@ -17,6 +20,18 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
|
||||
readLaunch(id) {
|
||||
return this.http.get(`${this.baseUrl}${id}/launch/`);
|
||||
}
|
||||
|
||||
associateLabel(id, label) {
|
||||
return this.http.post(`${this.baseUrl}${id}/labels/`, label);
|
||||
}
|
||||
|
||||
disassociateLabel(id, label) {
|
||||
return this.http.post(`${this.baseUrl}${id}/labels/`, label);
|
||||
}
|
||||
|
||||
generateLabel(orgId, label) {
|
||||
return this.http.post(`${this.baseUrl}${orgId}/labels/`, label);
|
||||
}
|
||||
}
|
||||
|
||||
export default JobTemplates;
|
||||
|
||||
10
awx/ui_next/src/api/models/Labels.js
Normal file
10
awx/ui_next/src/api/models/Labels.js
Normal file
@ -0,0 +1,10 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class Labels extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/labels/';
|
||||
}
|
||||
}
|
||||
|
||||
export default Labels;
|
||||
205
awx/ui_next/src/components/MultiSelect/MultiSelect.jsx
Normal file
205
awx/ui_next/src/components/MultiSelect/MultiSelect.jsx
Normal file
@ -0,0 +1,205 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Chip, ChipGroup } from '@components/Chip';
|
||||
import {
|
||||
Dropdown as PFDropdown,
|
||||
DropdownItem,
|
||||
TextInput as PFTextInput,
|
||||
DropdownToggle,
|
||||
} from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const InputGroup = styled.div`
|
||||
border: 1px solid black;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
const TextInput = styled(PFTextInput)`
|
||||
border: none;
|
||||
width: 100%;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
const Dropdown = styled(PFDropdown)`
|
||||
width: 100%;
|
||||
.pf-c-dropdown__toggle.pf-m-plain {
|
||||
display: none;
|
||||
}
|
||||
display: block;
|
||||
.pf-c-dropdown__menu {
|
||||
max-height: 200px;
|
||||
overflow: scroll;
|
||||
}
|
||||
&& button[disabled] {
|
||||
color: var(--pf-c-button--m-plain--Color);
|
||||
pointer-events: initial;
|
||||
cursor: not-allowed;
|
||||
color: var(--pf-global--disabled-color--200);
|
||||
}
|
||||
`;
|
||||
|
||||
class MultiSelect extends Component {
|
||||
static propTypes = {
|
||||
associatedItems: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
onAddNewItem: PropTypes.func.isRequired,
|
||||
onRemoveItem: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
input: '',
|
||||
chipItems: this.getInitialChipItems(),
|
||||
isExpanded: false,
|
||||
};
|
||||
this.handleAddItem = this.handleAddItem.bind(this);
|
||||
this.handleInputChange = this.handleInputChange.bind(this);
|
||||
this.handleSelection = this.handleSelection.bind(this);
|
||||
this.removeChip = this.removeChip.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', this.handleClick, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', this.handleClick, false);
|
||||
}
|
||||
|
||||
getInitialChipItems() {
|
||||
const { associatedItems } = this.props;
|
||||
return associatedItems.map(item => ({
|
||||
name: item.name,
|
||||
id: item.id,
|
||||
organization: item.organization,
|
||||
}));
|
||||
}
|
||||
|
||||
handleClick(e, option) {
|
||||
if (this.node && this.node.contains(e.target)) {
|
||||
if (option) {
|
||||
this.handleSelection(e, option);
|
||||
}
|
||||
} else {
|
||||
this.setState({ isExpanded: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleSelection(e, item) {
|
||||
const { chipItems } = this.state;
|
||||
const { onAddNewItem } = this.props;
|
||||
e.preventDefault();
|
||||
|
||||
this.setState({
|
||||
chipItems: chipItems.concat({ name: item.name, id: item.id }),
|
||||
isExpanded: false,
|
||||
});
|
||||
onAddNewItem(item);
|
||||
}
|
||||
|
||||
handleAddItem(event) {
|
||||
const { input, chipItems } = this.state;
|
||||
const { onAddNewItem } = this.props;
|
||||
const newChip = { name: input, id: Math.random() };
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
chipItems: chipItems.concat(newChip),
|
||||
isExpanded: false,
|
||||
input: '',
|
||||
});
|
||||
|
||||
onAddNewItem(input);
|
||||
}
|
||||
|
||||
handleInputChange(e) {
|
||||
this.setState({ input: e, isExpanded: true });
|
||||
}
|
||||
|
||||
removeChip(e, item) {
|
||||
const { onRemoveItem } = this.props;
|
||||
const { chipItems } = this.state;
|
||||
const chips = chipItems.filter(chip => chip.id !== item.id);
|
||||
|
||||
this.setState({ chipItems: chips });
|
||||
onRemoveItem(item);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { options } = this.props;
|
||||
const { chipItems, input, isExpanded } = this.state;
|
||||
|
||||
const list = options.map(option => (
|
||||
<Fragment key={option.id}>
|
||||
{option.name.includes(input) ? (
|
||||
<DropdownItem
|
||||
component="button"
|
||||
isDisabled={chipItems.some(item => item.id === option.id)}
|
||||
value={option.name}
|
||||
onClick={e => {
|
||||
this.handleClick(e, option);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</DropdownItem>
|
||||
) : null}
|
||||
</Fragment>
|
||||
));
|
||||
|
||||
const chips = (
|
||||
<ChipGroup>
|
||||
{chipItems &&
|
||||
chipItems.map(item => (
|
||||
<Chip
|
||||
key={item.id}
|
||||
onClick={e => {
|
||||
this.removeChip(e, item);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
);
|
||||
return (
|
||||
<Fragment>
|
||||
<InputGroup>
|
||||
<div
|
||||
ref={node => {
|
||||
this.node = node;
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
type="text"
|
||||
aria-label="labels"
|
||||
value={input}
|
||||
onClick={() => this.setState({ isExpanded: true })}
|
||||
onChange={this.handleInputChange}
|
||||
onKeyDown={this.handleAddItem}
|
||||
/>
|
||||
<Dropdown
|
||||
type="button"
|
||||
isPlain
|
||||
value={chipItems}
|
||||
toggle={<DropdownToggle isPlain>Labels</DropdownToggle>}
|
||||
// Above is not rendered but is a required prop from Patternfly
|
||||
isOpen={isExpanded}
|
||||
dropdownItems={list}
|
||||
/>
|
||||
</div>
|
||||
<div css="margin: 10px">{chips}</div>
|
||||
</InputGroup>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
export { MultiSelect as _MultiSelect };
|
||||
export default withI18n()(withRouter(MultiSelect));
|
||||
93
awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx
Normal file
93
awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { sleep } from '@testUtils/testUtils';
|
||||
import MultiSelect, { _MultiSelect } from './MultiSelect';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
describe('<MultiSelect />', () => {
|
||||
const associatedItems = [
|
||||
{ name: 'Foo', id: 1, organization: 1 },
|
||||
{ name: 'Bar', id: 2, organization: 1 },
|
||||
];
|
||||
const options = [{ name: 'Angry', id: 3 }, { name: 'Potato', id: 4 }];
|
||||
|
||||
test('Initially render successfully', () => {
|
||||
const getInitialChipItems = jest.spyOn(
|
||||
_MultiSelect.prototype,
|
||||
'getInitialChipItems'
|
||||
);
|
||||
const wrapper = mountWithContexts(
|
||||
<MultiSelect
|
||||
onAddNewItem={jest.fn()}
|
||||
onRemoveItem={jest.fn()}
|
||||
associatedItems={associatedItems}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
const component = wrapper.find('MultiSelect');
|
||||
|
||||
expect(getInitialChipItems).toBeCalled();
|
||||
expect(component.state().chipItems.length).toBe(2);
|
||||
});
|
||||
test('handleSelection add item to chipItems', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<MultiSelect
|
||||
onAddNewItem={jest.fn()}
|
||||
onRemoveItem={jest.fn()}
|
||||
associatedItems={associatedItems}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
const component = wrapper.find('MultiSelect');
|
||||
component
|
||||
.find('input[aria-label="labels"]')
|
||||
.simulate('keydown', { key: 'Tab' });
|
||||
component.update();
|
||||
await sleep(1);
|
||||
expect(component.state().chipItems.length).toBe(3);
|
||||
});
|
||||
test('handleAddItem adds a chip only when Tab is pressed', () => {
|
||||
const onAddNewItem = jest.fn();
|
||||
const wrapper = mountWithContexts(
|
||||
<MultiSelect
|
||||
onAddNewItem={onAddNewItem}
|
||||
onRemoveItem={jest.fn()}
|
||||
associatedItems={associatedItems}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
const event = {
|
||||
preventDefault: () => {},
|
||||
key: 'Tab',
|
||||
};
|
||||
const component = wrapper.find('MultiSelect');
|
||||
|
||||
component.setState({ input: 'newLabel' });
|
||||
component.update();
|
||||
component.instance().handleAddItem(event);
|
||||
expect(component.state().chipItems.length).toBe(3);
|
||||
expect(component.state().input.length).toBe(0);
|
||||
expect(component.state().isExpanded).toBe(false);
|
||||
expect(onAddNewItem).toBeCalled();
|
||||
});
|
||||
test('removeChip removes chip properly', () => {
|
||||
const onRemoveItem = jest.fn();
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<MultiSelect
|
||||
onAddNewItem={jest.fn()}
|
||||
onRemoveItem={onRemoveItem}
|
||||
associatedItems={associatedItems}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
const event = {
|
||||
preventDefault: () => {},
|
||||
};
|
||||
const component = wrapper.find('MultiSelect');
|
||||
component
|
||||
.instance()
|
||||
.removeChip(event, { name: 'Foo', id: 1, organization: 1 });
|
||||
expect(component.state().chipItems.length).toBe(1);
|
||||
expect(onRemoveItem).toBeCalled();
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/MultiSelect/index.js
Normal file
1
awx/ui_next/src/components/MultiSelect/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './MultiSelect';
|
||||
@ -13,6 +13,11 @@ describe('<JobTemplateAdd />', () => {
|
||||
name: '',
|
||||
playbook: '',
|
||||
project: '',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@ -21,14 +21,28 @@ class JobTemplateEdit extends Component {
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
async handleSubmit(values) {
|
||||
async handleSubmit(values, newLabels = [], removedLabels = []) {
|
||||
const {
|
||||
template: { id, type },
|
||||
history,
|
||||
} = this.props;
|
||||
|
||||
const disassociatedLabels = removedLabels.forEach(removedLabel =>
|
||||
JobTemplatesAPI.disassociateLabel(id, removedLabel)
|
||||
);
|
||||
const associatedLabels = newLabels
|
||||
.filter(newLabel => !newLabel.organization)
|
||||
.forEach(newLabel => JobTemplatesAPI.associateLabel(id, newLabel));
|
||||
const generatedLabels = newLabels
|
||||
.filter(newLabel => newLabel.organization)
|
||||
.forEach(newLabel => JobTemplatesAPI.generateLabel(id, newLabel));
|
||||
try {
|
||||
await JobTemplatesAPI.update(id, { ...values });
|
||||
await Promise.all([
|
||||
JobTemplatesAPI.update(id, { ...values }),
|
||||
disassociatedLabels,
|
||||
associatedLabels,
|
||||
generatedLabels,
|
||||
]);
|
||||
history.push(`/templates/${type}/${id}/details`);
|
||||
} catch (error) {
|
||||
this.setState({ error });
|
||||
|
||||
@ -19,6 +19,9 @@ describe('<JobTemplateEdit />', () => {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
labels: {
|
||||
results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -33,9 +36,26 @@ describe('<JobTemplateEdit />', () => {
|
||||
description: 'new description',
|
||||
job_type: 'check',
|
||||
};
|
||||
const newLabels = [
|
||||
{ associate: true, id: 3 },
|
||||
{ associate: true, id: 3 },
|
||||
{ name: 'Mapel', organization: 1 },
|
||||
{ name: 'Tree', organization: 1 },
|
||||
];
|
||||
const removedLabels = [
|
||||
{ disassociate: true, id: 1 },
|
||||
{ disassociate: true, id: 2 },
|
||||
];
|
||||
|
||||
wrapper.find('JobTemplateForm').prop('handleSubmit')(updatedTemplateData);
|
||||
wrapper.find('JobTemplateForm').prop('handleSubmit')(
|
||||
updatedTemplateData,
|
||||
newLabels,
|
||||
removedLabels
|
||||
);
|
||||
expect(JobTemplatesAPI.update).toHaveBeenCalledWith(1, updatedTemplateData);
|
||||
expect(JobTemplatesAPI.disassociateLabel).toHaveBeenCalledTimes(2);
|
||||
expect(JobTemplatesAPI.associateLabel).toHaveBeenCalledTimes(2);
|
||||
expect(JobTemplatesAPI.generateLabel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should navigate to job template detail when cancel is clicked', () => {
|
||||
|
||||
@ -4,9 +4,17 @@ 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 {
|
||||
Form,
|
||||
FormGroup,
|
||||
Tooltip,
|
||||
PageSection,
|
||||
Card,
|
||||
} from '@patternfly/react-core';
|
||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import ContentError from '@components/ContentError';
|
||||
import AnsibleSelect from '@components/AnsibleSelect';
|
||||
import MultiSelect from '@components/MultiSelect';
|
||||
import FormActionGroup from '@components/FormActionGroup';
|
||||
import FormField from '@components/FormField';
|
||||
import FormRow from '@components/FormRow';
|
||||
@ -14,10 +22,16 @@ import { required } from '@util/validators';
|
||||
import styled from 'styled-components';
|
||||
import { JobTemplate } from '@types';
|
||||
import InventoriesLookup from './InventoriesLookup';
|
||||
import { LabelsAPI } from '@api';
|
||||
|
||||
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
||||
margin-left: 10px;
|
||||
`;
|
||||
const QSConfig = {
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
order_by: 'name',
|
||||
};
|
||||
|
||||
class JobTemplateForm extends Component {
|
||||
static propTypes = {
|
||||
@ -36,22 +50,121 @@ class JobTemplateForm extends Component {
|
||||
playbook: '',
|
||||
summary_fields: {
|
||||
inventory: null,
|
||||
labels: { results: [] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hasContentLoading: true,
|
||||
contentError: false,
|
||||
loadedLabels: [],
|
||||
newLabels: [],
|
||||
removedLabels: [],
|
||||
inventory: props.template.summary_fields.inventory,
|
||||
};
|
||||
this.handleNewLabel = this.handleNewLabel.bind(this);
|
||||
this.loadLabels = this.loadLabels.bind(this);
|
||||
this.removeLabel = this.removeLabel.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadLabels(QSConfig);
|
||||
}
|
||||
|
||||
// The function below assumes that the user has no more than 400
|
||||
// labels. For the vast majority of users this will be more thans
|
||||
// enough.This can be updated to allow more than 400 labels if we
|
||||
// decide it is necessary.
|
||||
|
||||
async loadLabels(QueryConfig) {
|
||||
this.setState({ contentError: null, hasContentLoading: true });
|
||||
let loadedLabels;
|
||||
try {
|
||||
const { data } = await LabelsAPI.read(QueryConfig);
|
||||
loadedLabels = [...data.results];
|
||||
if (data.next && data.next.includes('page=2')) {
|
||||
const {
|
||||
data: { results },
|
||||
} = await LabelsAPI.read({
|
||||
page: 2,
|
||||
page_size: 200,
|
||||
order_by: 'name',
|
||||
});
|
||||
loadedLabels = loadedLabels.concat(results);
|
||||
}
|
||||
this.setState({ loadedLabels });
|
||||
} catch (err) {
|
||||
this.setState({ contentError: err });
|
||||
} finally {
|
||||
this.setState({ hasContentLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleNewLabel(label) {
|
||||
const { newLabels } = this.state;
|
||||
const { template } = this.props;
|
||||
const isIncluded = newLabels.some(newLabel => newLabel.name === label.name);
|
||||
if (isIncluded) {
|
||||
const filteredLabels = newLabels.filter(
|
||||
newLabel => newLabel.name !== label
|
||||
);
|
||||
this.setState({ newLabels: filteredLabels });
|
||||
} else if (typeof label === 'string') {
|
||||
this.setState({
|
||||
newLabels: [
|
||||
...newLabels,
|
||||
{
|
||||
name: label,
|
||||
organization: template.summary_fields.inventory.organization_id,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
newLabels: [
|
||||
...newLabels,
|
||||
{ name: label.name, associate: true, id: label.id },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeLabel(label) {
|
||||
const { removedLabels, newLabels } = this.state;
|
||||
const { template } = this.props;
|
||||
|
||||
const isAssociatedLabel = template.summary_fields.labels.results.some(
|
||||
tempLabel => tempLabel.id === label.id
|
||||
);
|
||||
|
||||
if (isAssociatedLabel) {
|
||||
this.setState({
|
||||
removedLabels: removedLabels.concat({
|
||||
disassociate: true,
|
||||
id: label.id,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const filteredLabels = newLabels.filter(
|
||||
newLabel => newLabel.name !== label.name
|
||||
);
|
||||
this.setState({ newLabels: filteredLabels });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
loadedLabels,
|
||||
contentError,
|
||||
hasContentLoading,
|
||||
inventory,
|
||||
newLabels,
|
||||
removedLabels,
|
||||
} = this.state;
|
||||
const { handleCancel, handleSubmit, i18n, template } = this.props;
|
||||
const { inventory } = this.state;
|
||||
|
||||
const jobTypeOptions = [
|
||||
{
|
||||
value: '',
|
||||
@ -68,6 +181,15 @@ class JobTemplateForm extends Component {
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasContentLoading && contentError) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card className="awx-c-card">
|
||||
<ContentError error={contentError} />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@ -77,8 +199,11 @@ class JobTemplateForm extends Component {
|
||||
inventory: template.inventory,
|
||||
project: template.project,
|
||||
playbook: template.playbook,
|
||||
labels: template.summary_fields.labels.results,
|
||||
}}
|
||||
onSubmit={values => {
|
||||
handleSubmit(values, newLabels, removedLabels);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
render={formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow>
|
||||
@ -156,6 +281,24 @@ class JobTemplateForm extends Component {
|
||||
validate={required(null, i18n)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
|
||||
<Tooltip
|
||||
position="right"
|
||||
content={i18n._(
|
||||
t`Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs.`
|
||||
)}
|
||||
>
|
||||
<QuestionCircleIcon />
|
||||
</Tooltip>
|
||||
<MultiSelect
|
||||
onAddNewItem={this.handleNewLabel}
|
||||
onRemoveItem={this.removeLabel}
|
||||
associatedItems={template.summary_fields.labels.results}
|
||||
options={loadedLabels}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormRow>
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
@ -166,5 +309,5 @@ class JobTemplateForm extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { JobTemplateForm as _JobTemplateForm };
|
||||
export default withI18n()(withRouter(JobTemplateForm));
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { sleep } from '@testUtils/testUtils';
|
||||
import JobTemplateForm from './JobTemplateForm';
|
||||
import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm';
|
||||
import { LabelsAPI } from '@api';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
@ -19,22 +20,32 @@ describe('<JobTemplateForm />', () => {
|
||||
inventory: {
|
||||
id: 2,
|
||||
name: 'foo',
|
||||
organization_id: 1,
|
||||
},
|
||||
labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] },
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
LabelsAPI.read.mockReturnValue({
|
||||
data: mockData.summary_fields.labels,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
mountWithContexts(
|
||||
const wrapper = mountWithContexts(
|
||||
<JobTemplateForm
|
||||
template={mockData}
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
/>
|
||||
);
|
||||
const component = wrapper.find('ChipGroup');
|
||||
expect(LabelsAPI.read).toHaveBeenCalled();
|
||||
expect(component.find('span#pf-random-id-1').text()).toEqual('Sushi');
|
||||
});
|
||||
|
||||
test('should update form values on input changes', async () => {
|
||||
@ -103,4 +114,53 @@ describe('<JobTemplateForm />', () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||
expect(handleCancel).toBeCalled();
|
||||
});
|
||||
|
||||
test('handleNewLabel should arrange new labels properly', async () => {
|
||||
const handleNewLabel = jest.spyOn(
|
||||
_JobTemplateForm.prototype,
|
||||
'handleNewLabel'
|
||||
);
|
||||
const event = { key: 'Tab' };
|
||||
const wrapper = mountWithContexts(
|
||||
<JobTemplateForm
|
||||
template={mockData}
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
/>
|
||||
);
|
||||
const multiSelect = wrapper.find('MultiSelect');
|
||||
const component = wrapper.find('JobTemplateForm');
|
||||
|
||||
wrapper.setState({ newLabels: [], loadedLabels: [], removedLabels: [] });
|
||||
multiSelect.setState({ input: 'Foo' });
|
||||
component.find('input[aria-label="labels"]').prop('onKeyDown')(event);
|
||||
expect(handleNewLabel).toHaveBeenCalledWith('Foo');
|
||||
|
||||
component.instance().handleNewLabel({ name: 'Bar', id: 2 });
|
||||
expect(component.state().newLabels).toEqual([
|
||||
{ name: 'Foo', organization: 1 },
|
||||
{ associate: true, id: 2, name: 'Bar' },
|
||||
]);
|
||||
});
|
||||
test('disassociateLabel should arrange new labels properly', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<JobTemplateForm
|
||||
template={mockData}
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
/>
|
||||
);
|
||||
const component = wrapper.find('JobTemplateForm');
|
||||
// This asserts that the user generated a label or clicked
|
||||
// on a label option, and then changed their mind and
|
||||
// removed the label.
|
||||
component.instance().removeLabel({ name: 'Alex', id: 17 });
|
||||
expect(component.state().newLabels.length).toBe(0);
|
||||
expect(component.state().removedLabels.length).toBe(0);
|
||||
// This asserts that the user removed a label that was associated
|
||||
// with the template when the template loaded.
|
||||
component.instance().removeLabel({ name: 'Sushi', id: 1 });
|
||||
expect(component.state().newLabels.length).toBe(0);
|
||||
expect(component.state().removedLabels.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user