1
0
mirror of https://github.com/ansible/awx.git synced 2024-11-01 08:21:15 +03:00

Merge pull request #4875 from keithjgrant/4684-jt-form-cleanup

Job Template form cleanup

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-10-02 22:24:57 +00:00 committed by GitHub
commit 71bd257191
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 663 additions and 624 deletions

View File

@ -27,11 +27,17 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
}
disassociateLabel(id, label) {
return this.http.post(`${this.baseUrl}${id}/labels/`, label);
return this.http.post(`${this.baseUrl}${id}/labels/`, {
id: label.id,
disassociate: true,
});
}
generateLabel(orgId, label) {
return this.http.post(`${this.baseUrl}${orgId}/labels/`, label);
generateLabel(id, label, orgId) {
return this.http.post(`${this.baseUrl}${id}/labels/`, {
name: label.name,
organization: orgId,
});
}
readCredentials(id, params) {

View File

@ -1,6 +1,7 @@
import { Chip } from '@patternfly/react-core';
import styled from 'styled-components';
Chip.displayName = 'PFChip';
export default styled(Chip)`
--pf-c-chip--m-read-only--PaddingTop: 3px;
--pf-c-chip--m-read-only--PaddingRight: 8px;

View File

@ -0,0 +1,26 @@
import React from 'react';
import { node } from 'prop-types';
import { 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 FieldTooltip({ content }) {
return (
<Tooltip
position="right"
content={content}
trigger="click mouseenter focus"
>
<QuestionCircleIcon />
</Tooltip>
);
}
FieldTooltip.propTypes = {
content: node.isRequired,
};
export default FieldTooltip;

View File

@ -1,2 +1,3 @@
export { default } from './FormField';
export { default as CheckboxField } from './CheckboxField';
export { default as FieldTooltip } from './FieldTooltip';

View File

@ -113,6 +113,7 @@ class ListHeader extends React.Component {
columns,
onSearch: this.handleSearch,
onSort: this.handleSort,
qsConfig,
})}
<FilterTags
itemCount={itemCount}

View File

@ -2,17 +2,11 @@ import React from 'react';
import { string, func, bool } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { FormGroup } from '@patternfly/react-core';
import { InventoriesAPI } from '@api';
import { Inventory } from '@types';
import Lookup from '@components/Lookup';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
import { FieldTooltip } from '@components/FormField';
const getInventories = async params => InventoriesAPI.read(params);
@ -26,11 +20,7 @@ class InventoryLookup extends React.Component {
isRequired={required}
fieldId="inventory-lookup"
>
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="inventory-lookup"
lookupHeader={i18n._(t`Inventory`)}

View File

@ -2,16 +2,11 @@ import React from 'react';
import { string, func, bool } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import { FormGroup } from '@patternfly/react-core';
import { ProjectsAPI } from '@api';
import { Project } from '@types';
import Lookup from '@components/Lookup';
import styled from 'styled-components';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
import { FieldTooltip } from '@components/FormField';
const loadProjects = async params => ProjectsAPI.read(params);
@ -36,11 +31,7 @@ class ProjectLookup extends React.Component {
isValid={isValid}
label={i18n._(t`Project`)}
>
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="project"
lookupHeader={i18n._(t`Project`)}

View File

@ -1,3 +1,4 @@
export { default } from './Lookup';
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
export { default as InventoryLookup } from './InventoryLookup';
export { default as ProjectLookup } from './ProjectLookup';

View File

@ -45,7 +45,7 @@ const Item = shape({
class MultiSelect extends Component {
static propTypes = {
associatedItems: arrayOf(Item).isRequired,
value: arrayOf(Item).isRequired,
options: arrayOf(Item),
onAddNewItem: func,
onRemoveItem: func,
@ -65,13 +65,11 @@ class MultiSelect extends Component {
super(props);
this.state = {
input: '',
chipItems: this.getInitialChipItems(),
isExpanded: false,
};
this.handleAddItem = this.handleAddItem.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSelection = this.handleSelection.bind(this);
this.removeChip = this.removeChip.bind(this);
this.removeItem = this.removeItem.bind(this);
this.handleClick = this.handleClick.bind(this);
this.createNewItem = this.createNewItem.bind(this);
}
@ -84,33 +82,57 @@ class MultiSelect extends Component {
document.removeEventListener('mousedown', this.handleClick, false);
}
getInitialChipItems() {
const { associatedItems } = this.props;
return associatedItems.map(item => ({ ...item }));
}
handleClick(e, option) {
if (this.node && this.node.contains(e.target)) {
if (option) {
this.handleSelection(e, option);
e.preventDefault();
this.addItem(option);
}
} else {
this.setState({ input: '', isExpanded: false });
}
}
handleSelection(e, item) {
const { chipItems } = this.state;
const { onAddNewItem, onChange } = this.props;
e.preventDefault();
const items = chipItems.concat({ name: item.name, id: item.id });
this.setState({
chipItems: items,
isExpanded: false,
});
addItem(item) {
const { value, onAddNewItem, onChange } = this.props;
const items = value.concat(item);
onAddNewItem(item);
onChange(items);
this.close();
}
// TODO: UpArrow & DownArrow for menu navigation
handleKeyDown(event) {
const { value, options } = this.props;
const { input } = this.state;
if (event.key === 'Tab') {
this.close();
return;
}
if (!input || event.key !== 'Enter') {
return;
}
const isAlreadySelected = value.some(i => i.name === input);
if (isAlreadySelected) {
event.preventDefault();
this.close();
return;
}
const match = options.find(item => item.name === input);
const isNewItem = !match || !value.find(item => item.id === match.id);
if (isNewItem) {
event.preventDefault();
this.addItem(match || this.createNewItem(input));
}
}
close() {
this.setState({
isExpanded: false,
input: '',
});
}
createNewItem(name) {
@ -124,66 +146,28 @@ class MultiSelect extends Component {
};
}
handleAddItem(event) {
const { input, chipItems } = this.state;
const { options, onAddNewItem, onChange } = this.props;
const match = options.find(item => item.name === input);
const isIncluded = chipItems.some(chipItem => chipItem.name === input);
if (!input) {
return;
}
if (isIncluded) {
// This event.preventDefault prevents the form from submitting
// if the user tries to create 2 chips of the same name
event.preventDefault();
this.setState({ input: '', isExpanded: false });
return;
}
const isNewItem = !match || !chipItems.find(item => item.id === match.id);
if (event.key === 'Enter' && isNewItem) {
event.preventDefault();
const items = chipItems.concat({ name: input, id: input });
const newItem = match || this.createNewItem(input);
this.setState({
chipItems: items,
isExpanded: false,
input: '',
});
onAddNewItem(newItem);
onChange(items);
} else if (!isNewItem || event.key === 'Tab') {
this.setState({ isExpanded: false, input: '' });
}
}
handleInputChange(value) {
this.setState({ input: value, isExpanded: true });
}
removeChip(e, item) {
const { onRemoveItem, onChange } = this.props;
const { chipItems } = this.state;
const chips = chipItems.filter(chip => chip.id !== item.id);
removeItem(item) {
const { value, onRemoveItem, onChange } = this.props;
const remainingItems = value.filter(chip => chip.id !== item.id);
this.setState({ chipItems: chips });
onRemoveItem(item);
onChange(chips);
e.preventDefault();
onChange(remainingItems);
}
render() {
const { options } = this.props;
const { chipItems, input, isExpanded } = this.state;
const { value, options } = this.props;
const { input, isExpanded } = this.state;
const list = options.map(option => (
const dropdownOptions = options.map(option => (
<Fragment key={option.id}>
{option.name.includes(input) ? (
<DropdownItem
component="button"
isDisabled={chipItems.some(item => item.id === option.id)}
isDisabled={value.some(item => item.id === option.id)}
value={option.name}
onClick={e => {
this.handleClick(e, option);
@ -195,21 +179,6 @@ class MultiSelect extends Component {
</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>
@ -222,21 +191,34 @@ class MultiSelect extends Component {
type="text"
aria-label="labels"
value={input}
onClick={() => this.setState({ isExpanded: true })}
onFocus={() => this.setState({ isExpanded: true })}
onChange={this.handleInputChange}
onKeyDown={this.handleAddItem}
onKeyDown={this.handleKeyDown}
/>
<Dropdown
type="button"
isPlain
value={chipItems}
value={value}
toggle={<DropdownToggle isPlain>Labels</DropdownToggle>}
// Above is not rendered but is a required prop from Patternfly
// Above is not visible but is a required prop from Patternfly
isOpen={isExpanded}
dropdownItems={list}
dropdownItems={dropdownOptions}
/>
</div>
<div css="margin: 10px">{chips}</div>
<div css="margin: 10px">
<ChipGroup>
{value.map(item => (
<Chip
key={item.id}
onClick={() => {
this.removeItem(item);
}}
>
{item.name}
</Chip>
))}
</ChipGroup>
</div>
</InputGroup>
</Fragment>
);

View File

@ -1,48 +1,51 @@
import React from 'react';
import { mount } from 'enzyme';
import { sleep } from '@testUtils/testUtils';
import { mount, shallow } from 'enzyme';
import MultiSelect from './MultiSelect';
describe('<MultiSelect />', () => {
const associatedItems = [
const value = [
{ 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 wrapper = mount(
test('should render successfully', () => {
const wrapper = shallow(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
associatedItems={associatedItems}
value={value}
options={options}
/>
);
expect(wrapper.find('Chip')).toHaveLength(2);
});
test('should add item when typed', async () => {
const onChange = jest.fn();
const onAdd = jest.fn();
const wrapper = mount(
<MultiSelect
onAddNewItem={onAdd}
onRemoveItem={jest.fn()}
onChange={onChange}
value={value}
options={options}
/>
);
const component = wrapper.find('MultiSelect');
const input = component.find('TextInput');
input.invoke('onChange')('Flabadoo');
input.simulate('keydown', { key: 'Enter' });
expect(component.state().chipItems.length).toBe(2);
expect(onAdd.mock.calls[0][0].name).toEqual('Flabadoo');
const newVal = onChange.mock.calls[0][0];
expect(newVal).toHaveLength(3);
expect(newVal[2].name).toEqual('Flabadoo');
});
test('handleSelection add item to chipItems', async () => {
const wrapper = mount(
<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: 'Enter' });
component.update();
await sleep(1);
expect(component.state().chipItems.length).toBe(2);
});
test('handleAddItem adds a chip only when Tab is pressed', () => {
test('should add item when clicked from menu', () => {
const onAddNewItem = jest.fn();
const onChange = jest.fn();
const wrapper = mount(
@ -50,48 +53,53 @@ describe('<MultiSelect />', () => {
onAddNewItem={onAddNewItem}
onRemoveItem={jest.fn()}
onChange={onChange}
associatedItems={associatedItems}
value={value}
options={options}
/>
);
const input = wrapper.find('TextInput');
input.simulate('focus');
wrapper.update();
const event = {
preventDefault: () => {},
key: 'Enter',
target: wrapper
.find('DropdownItem')
.at(1)
.getDOMNode(),
};
const component = wrapper.find('MultiSelect');
wrapper
.find('DropdownItem')
.at(1)
.invoke('onClick')(event);
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();
expect(onChange).toBeCalled();
expect(onAddNewItem).toHaveBeenCalledWith(options[1]);
const newVal = onChange.mock.calls[0][0];
expect(newVal).toHaveLength(3);
expect(newVal[2]).toEqual(options[1]);
});
test('removeChip removes chip properly', () => {
test('should remove item', () => {
const onRemoveItem = jest.fn();
const onChange = jest.fn();
const wrapper = mount(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={onRemoveItem}
onChange={onChange}
associatedItems={associatedItems}
value={value}
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();
expect(onChange).toBeCalled();
wrapper
.find('Chip')
.at(1)
.invoke('onClick')();
expect(onRemoveItem).toHaveBeenCalledWith(value[1]);
const newVal = onChange.mock.calls[0][0];
expect(newVal).toHaveLength(1);
expect(newVal).toEqual([value[0]]);
});
});

View File

@ -33,7 +33,7 @@ function TagMultiSelect({ onChange, value }) {
setOptions(options.concat(newItem));
}
}}
associatedItems={stringToArray(value)}
value={stringToArray(value)}
options={options}
createNewItem={name => ({ id: name, name })}
/>

View File

@ -693,7 +693,7 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
isReadOnly={false}
onClick={[Function]}
>
<Chip
<PFChip
className="Chip-sc-1rzr8oo-0 dgUGLg"
closeBtnAriaLabel="close"
component="li"
@ -771,7 +771,7 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
</ChipButton>
</li>
</GenerateId>
</Chip>
</PFChip>
</StyledComponent>
</Chip>
</ul>

View File

@ -402,6 +402,20 @@ exports[`<OrganizationNotifications /> initially renders succesfully 1`] = `
}
onSearch={[Function]}
onSort={[Function]}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "notification",
}
}
sortOrder="ascending"
sortedColumnKey="name"
>
@ -442,6 +456,20 @@ exports[`<OrganizationNotifications /> initially renders succesfully 1`] = `
onSearch={[Function]}
onSelectAll={null}
onSort={[Function]}
qsConfig={
Object {
"defaultParams": Object {
"order_by": "name",
"page": 1,
"page_size": 5,
},
"integerFields": Array [
"page",
"page_size",
],
"namespace": "notification",
}
}
showSelectAll={false}
sortOrder="ascending"
sortedColumnKey="name"

View File

@ -18,10 +18,10 @@ function JobTemplateAdd({ history, i18n }) {
async function handleSubmit(values) {
const {
newLabels,
removedLabels,
addedInstanceGroups,
removedInstanceGroups,
labels,
organizationId,
instanceGroups,
initialInstanceGroups,
...remainingValues
} = values;
@ -31,8 +31,8 @@ function JobTemplateAdd({ history, i18n }) {
data: { id, type },
} = await JobTemplatesAPI.create(remainingValues);
await Promise.all([
submitLabels(id, newLabels, removedLabels),
submitInstanceGroups(id, addedInstanceGroups, removedInstanceGroups),
submitLabels(id, labels, organizationId),
submitInstanceGroups(id, instanceGroups),
]);
history.push(`/templates/${type}/${id}/details`);
} catch (error) {
@ -40,22 +40,17 @@ function JobTemplateAdd({ history, i18n }) {
}
}
function submitLabels(id, newLabels = [], removedLabels = []) {
const disassociationPromises = removedLabels.map(label =>
JobTemplatesAPI.disassociateLabel(id, label)
);
const associationPromises = newLabels
.filter(label => !label.organization)
.map(label => JobTemplatesAPI.associateLabel(id, label));
const creationPromises = newLabels
.filter(label => label.organization)
.map(label => JobTemplatesAPI.generateLabel(id, label));
function submitLabels(templateId, labels = [], organizationId) {
const associationPromises = labels
.filter(label => !label.isNew)
.map(label => JobTemplatesAPI.associateLabel(templateId, label));
const creationPromises = labels
.filter(label => label.isNew)
.map(label =>
JobTemplatesAPI.generateLabel(templateId, label, organizationId)
);
return Promise.all([
...disassociationPromises,
...associationPromises,
...creationPromises,
]);
return Promise.all([...associationPromises, ...creationPromises]);
}
function submitInstanceGroups(templateId, addedGroups = []) {

View File

@ -27,6 +27,8 @@ const jobTemplateData = {
host_config_key: '',
};
// TODO: Needs React/React-router upgrade to remove `act()` warnings
// See https://github.com/ansible/awx/issues/4817
describe('<JobTemplateAdd />', () => {
const defaultProps = {
description: '',
@ -55,7 +57,7 @@ describe('<JobTemplateAdd />', () => {
expect(wrapper.find('JobTemplateForm').length).toBe(1);
});
test('should render Job Template Form with default values', async done => {
test('should render Job Template Form with default values', async () => {
const wrapper = mountWithContexts(<JobTemplateAdd />);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
expect(wrapper.find('input#template-description').text()).toBe(
@ -76,14 +78,11 @@ describe('<JobTemplateAdd />', () => {
).toEqual(true);
expect(wrapper.find('input#template-name').text()).toBe(defaultProps.name);
expect(wrapper.find('AnsibleSelect[name="playbook"]').text()).toBe(
'Choose a playbook'
);
expect(wrapper.find('PlaybookSelect')).toHaveLength(1);
expect(wrapper.find('ProjectLookup').prop('value')).toBe(null);
done();
});
test('handleSubmit should post to api', async done => {
test('handleSubmit should post to api', async () => {
JobTemplatesAPI.create.mockResolvedValueOnce({
data: {
id: 1,
@ -96,7 +95,10 @@ describe('<JobTemplateAdd />', () => {
const changeState = new Promise(resolve => {
formik.setState(
{
values: jobTemplateData,
values: {
...jobTemplateData,
labels: [],
},
},
() => resolve()
);
@ -105,10 +107,9 @@ describe('<JobTemplateAdd />', () => {
wrapper.find('form').simulate('submit');
await sleep(1);
expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData);
done();
});
test('should navigate to job template detail after form submission', async done => {
test('should navigate to job template detail after form submission', async () => {
const history = {
push: jest.fn(),
};
@ -130,10 +131,9 @@ describe('<JobTemplateAdd />', () => {
expect(history.push).toHaveBeenCalledWith(
'/templates/job_template/1/details'
);
done();
});
test('should navigate to templates list when cancel is clicked', async done => {
test('should navigate to templates list when cancel is clicked', async () => {
const history = {
push: jest.fn(),
};
@ -143,6 +143,5 @@ describe('<JobTemplateAdd />', () => {
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(history.push).toHaveBeenCalledWith('/templates');
done();
});
});

View File

@ -6,6 +6,7 @@ import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { JobTemplatesAPI, ProjectsAPI } from '@api';
import { JobTemplate } from '@types';
import { getAddedAndRemoved } from '@util/lists';
import JobTemplateForm from '../shared/JobTemplateForm';
class JobTemplateEdit extends Component {
@ -104,10 +105,10 @@ class JobTemplateEdit extends Component {
async handleSubmit(values) {
const { template, history } = this.props;
const {
newLabels,
removedLabels,
addedInstanceGroups,
removedInstanceGroups,
labels,
organizationId,
instanceGroups,
initialInstanceGroups,
...remainingValues
} = values;
@ -115,8 +116,8 @@ class JobTemplateEdit extends Component {
try {
await JobTemplatesAPI.update(template.id, remainingValues);
await Promise.all([
this.submitLabels(newLabels, removedLabels),
this.submitInstanceGroups(addedInstanceGroups, removedInstanceGroups),
this.submitLabels(labels, organizationId),
this.submitInstanceGroups(instanceGroups, initialInstanceGroups),
]);
history.push(this.detailsUrl);
} catch (formSubmitError) {
@ -124,17 +125,23 @@ class JobTemplateEdit extends Component {
}
}
async submitLabels(newLabels = [], removedLabels = []) {
async submitLabels(labels = [], organizationId) {
const { template } = this.props;
const disassociationPromises = removedLabels.map(label =>
const { added, removed } = getAddedAndRemoved(
template.summary_fields.labels.results,
labels
);
const disassociationPromises = removed.map(label =>
JobTemplatesAPI.disassociateLabel(template.id, label)
);
const associationPromises = newLabels
.filter(label => !label.organization)
const associationPromises = added
.filter(label => !label.isNew)
.map(label => JobTemplatesAPI.associateLabel(template.id, label));
const creationPromises = newLabels
.filter(label => label.organization)
.map(label => JobTemplatesAPI.generateLabel(template.id, label));
const creationPromises = added
.filter(label => label.isNew)
.map(label =>
JobTemplatesAPI.generateLabel(template.id, label, organizationId)
);
const results = await Promise.all([
...disassociationPromises,
@ -144,12 +151,13 @@ class JobTemplateEdit extends Component {
return results;
}
async submitInstanceGroups(addedGroups, removedGroups) {
async submitInstanceGroups(groups, initialGroups) {
const { template } = this.props;
const associatePromises = addedGroups.map(group =>
const { added, removed } = getAddedAndRemoved(initialGroups, groups);
const associatePromises = added.map(group =>
JobTemplatesAPI.associateInstanceGroup(template.id, group.id)
);
const disassociatePromises = removedGroups.map(group =>
const disassociatePromises = removed.map(group =>
JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id)
);
return Promise.all([...associatePromises, ...disassociatePromises]);

View File

@ -34,6 +34,9 @@ const mockJobTemplate = {
labels: {
results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
},
inventory: {
organization_id: 1,
},
},
};
@ -170,15 +173,11 @@ describe('<JobTemplateEdit />', () => {
description: 'new description',
job_type: 'check',
};
const newLabels = [
{ associate: true, id: 3 },
{ associate: true, id: 3 },
{ name: 'Maple', organization: 1 },
{ name: 'Tree', organization: 1 },
];
const removedLabels = [
{ disassociate: true, id: 1 },
{ disassociate: true, id: 2 },
const labels = [
{ id: 3, name: 'Foo', isNew: true },
{ id: 4, name: 'Bar', isNew: true },
{ id: 5, name: 'Maple' },
{ id: 6, name: 'Tree' },
];
JobTemplatesAPI.update.mockResolvedValue({
data: { ...updatedTemplateData },
@ -190,8 +189,7 @@ describe('<JobTemplateEdit />', () => {
values: {
...mockJobTemplate,
...updatedTemplateData,
newLabels,
removedLabels,
labels,
},
},
() => resolve()

View File

@ -7,31 +7,30 @@ import { withFormik, Field } from 'formik';
import {
Form,
FormGroup,
Tooltip,
Card,
Switch,
Checkbox,
TextInput,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import AnsibleSelect from '@components/AnsibleSelect';
import MultiSelect, { TagMultiSelect } from '@components/MultiSelect';
import { TagMultiSelect } from '@components/MultiSelect';
import FormActionGroup from '@components/FormActionGroup';
import FormField, { CheckboxField } from '@components/FormField';
import FormField, { CheckboxField, FieldTooltip } from '@components/FormField';
import FormRow from '@components/FormRow';
import CollapsibleSection from '@components/CollapsibleSection';
import { required } from '@util/validators';
import styled from 'styled-components';
import { JobTemplate } from '@types';
import { InventoryLookup, InstanceGroupsLookup } from '@components/Lookup';
import ProjectLookup from './ProjectLookup';
import { JobTemplatesAPI, LabelsAPI, ProjectsAPI } from '@api';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
import {
InventoryLookup,
InstanceGroupsLookup,
ProjectLookup,
} from '@components/Lookup';
import { JobTemplatesAPI } from '@api';
import LabelSelect from './LabelSelect';
import PlaybookSelect from './PlaybookSelect';
const GridFormGroup = styled(FormGroup)`
& > label {
@ -73,152 +72,38 @@ class JobTemplateForm extends Component {
this.state = {
hasContentLoading: true,
contentError: false,
loadedLabels: [],
newLabels: [],
removedLabels: [],
project: props.template.summary_fields.project,
inventory: props.template.summary_fields.inventory,
relatedProjectPlaybooks: props.relatedProjectPlaybooks,
relatedInstanceGroups: [],
allowCallbacks: !!props.template.host_config_key,
};
this.handleNewLabel = this.handleNewLabel.bind(this);
this.loadLabels = this.loadLabels.bind(this);
this.removeLabel = this.removeLabel.bind(this);
this.handleProjectValidation = this.handleProjectValidation.bind(this);
this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this);
this.loadRelatedProjectPlaybooks = this.loadRelatedProjectPlaybooks.bind(
this
);
this.handleInstanceGroupsChange = this.handleInstanceGroupsChange.bind(
this
);
}
componentDidMount() {
const { validateField } = this.props;
this.setState({ contentError: null, hasContentLoading: true });
Promise.all([this.loadLabels(), this.loadRelatedInstanceGroups()]).then(
() => {
this.setState({ hasContentLoading: false });
validateField('project');
}
);
}
async loadLabels() {
// This function 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.
let loadedLabels;
try {
const { data } = await LabelsAPI.read({
page: 1,
page_size: 200,
order_by: 'name',
});
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 });
}
// TODO: determine when LabelSelect has finished loading labels
Promise.all([this.loadRelatedInstanceGroups()]).then(() => {
this.setState({ hasContentLoading: false });
validateField('project');
});
}
async loadRelatedInstanceGroups() {
const { template } = this.props;
const { setFieldValue, template } = this.props;
if (!template.id) {
return;
}
try {
const { data } = await JobTemplatesAPI.readInstanceGroups(template.id);
this.setState({
initialInstanceGroups: data.results,
relatedInstanceGroups: [...data.results],
});
setFieldValue('initialInstanceGroups', data.results);
setFieldValue('instanceGroups', [...data.results]);
} catch (err) {
this.setState({ contentError: err });
}
}
handleNewLabel(label) {
const { newLabels } = this.state;
const { template, setFieldValue } = 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 {
setFieldValue('newLabels', [
...newLabels,
{ name: label.name, associate: true, id: label.id },
]);
this.setState({
newLabels: [
...newLabels,
{
name: label.name,
associate: true,
id: label.id,
organization: template.summary_fields.inventory.organization_id,
},
],
});
}
}
removeLabel(label) {
const { removedLabels, newLabels } = this.state;
const { template, setFieldValue } = this.props;
const isAssociatedLabel = template.summary_fields.labels.results.some(
tempLabel => tempLabel.id === label.id
);
if (isAssociatedLabel) {
setFieldValue(
'removedLabels',
removedLabels.concat({
disassociate: true,
id: label.id,
})
);
this.setState({
removedLabels: removedLabels.concat({
disassociate: true,
id: label.id,
}),
});
} else {
const filteredLabels = newLabels.filter(
newLabel => newLabel.name !== label.name
);
setFieldValue('newLabels', filteredLabels);
this.setState({ newLabels: filteredLabels });
}
}
async loadRelatedProjectPlaybooks(project) {
try {
const { data: playbooks = [] } = await ProjectsAPI.readPlaybooks(project);
this.setState({ relatedProjectPlaybooks: playbooks });
} catch (contentError) {
this.setState({ contentError });
}
}
handleProjectValidation() {
const { i18n, touched } = this.props;
const { project } = this.state;
@ -233,45 +118,19 @@ class JobTemplateForm extends Component {
};
}
handleInstanceGroupsChange(relatedInstanceGroups) {
const { setFieldValue } = this.props;
const { initialInstanceGroups } = this.state;
let added = [];
const removed = [];
if (initialInstanceGroups) {
initialInstanceGroups.forEach(group => {
if (!relatedInstanceGroups.find(g => g.id === group.id)) {
removed.push(group);
}
});
relatedInstanceGroups.forEach(group => {
if (!initialInstanceGroups.find(g => g.id === group.id)) {
added.push(group);
}
});
} else {
added = relatedInstanceGroups;
}
setFieldValue('addedInstanceGroups', added);
setFieldValue('removedInstanceGroups', removed);
this.setState({ relatedInstanceGroups });
}
render() {
const {
loadedLabels,
contentError,
hasContentLoading,
inventory,
project,
relatedProjectPlaybooks = [],
relatedInstanceGroups,
allowCallbacks,
} = this.state;
const {
handleCancel,
handleSubmit,
handleBlur,
setFieldValue,
i18n,
template,
} = this.props;
@ -290,28 +149,6 @@ class JobTemplateForm extends Component {
isDisabled: false,
},
];
const playbookOptions = relatedProjectPlaybooks
.map(playbook => {
return {
value: playbook,
key: playbook,
label: playbook,
isDisabled: false,
};
})
.reduce(
(arr, playbook) => {
return arr.concat(playbook);
},
[
{
value: '',
key: '',
label: i18n._(t`Choose a playbook`),
isDisabled: false,
},
]
);
const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
@ -374,15 +211,12 @@ class JobTemplateForm extends Component {
isValid={isValid}
label={i18n._(t`Job Type`)}
>
<Tooltip
position="right"
<FieldTooltip
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>
the playbook. Select check to only check playbook syntax,
test environment setup, and report problems without
executing the playbook.`)}
/>
<AnsibleSelect
isValid={isValid}
id="template-job-type"
@ -403,6 +237,7 @@ class JobTemplateForm extends Component {
you want this job to manage.`)}
onChange={value => {
form.setFieldValue('inventory', value.id);
form.setFieldValue('organizationId', value.organization);
this.setState({ inventory: value });
}}
required
@ -421,7 +256,6 @@ class JobTemplateForm extends Component {
tooltip={i18n._(t`Select the project containing the playbook
you want this job to execute.`)}
onChange={value => {
this.loadRelatedProjectPlaybooks(value.id);
form.setFieldValue('project', value.id);
this.setState({ project: value });
}}
@ -443,20 +277,17 @@ class JobTemplateForm extends Component {
isValid={isValid}
label={i18n._(t`Playbook`)}
>
<Tooltip
position="right"
<FieldTooltip
content={i18n._(
t`Select the playbook to be executed by this job.`
)}
>
<QuestionCircleIcon />
</Tooltip>
<AnsibleSelect
id="template-playbook"
data={playbookOptions}
/>
<PlaybookSelect
projectId={form.values.project}
isValid={isValid}
form={form}
{...field}
field={field}
onError={err => this.setState({ contentError: err })}
/>
</FormGroup>
);
@ -464,22 +295,23 @@ class JobTemplateForm extends Component {
/>
</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>
<Field
name="labels"
render={({ field }) => (
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
<FieldTooltip
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.`)}
/>
<LabelSelect
value={field.value}
onChange={labels => setFieldValue('labels', labels)}
onError={err => this.setState({ contentError: err })}
/>
</FormGroup>
)}
/>
</FormRow>
<AdvancedFieldsWrapper label="Advanced">
<FormRow>
@ -519,13 +351,10 @@ class JobTemplateForm extends Component {
fieldId="template-verbosity"
label={i18n._(t`Verbosity`)}
>
<Tooltip
position="right"
<FieldTooltip
content={i18n._(t`Control the level of output ansible will
produce as the playbook executes.`)}
>
<QuestionCircleIcon />
</Tooltip>
produce as the playbook executes.`)}
/>
<AnsibleSelect
id="template-verbosity"
data={verbosityOptions}
@ -541,8 +370,8 @@ class JobTemplateForm extends Component {
min="1"
label={i18n._(t`Job Slicing`)}
tooltip={i18n._(t`Divide the work done by this job template
into the specified number of job slices, each running the
same tasks against a portion of the inventory.`)}
into the specified number of job slices, each running the
same tasks against a portion of the inventory.`)}
/>
<FormField
id="template-timeout"
@ -551,8 +380,8 @@ class JobTemplateForm extends Component {
min="0"
label={i18n._(t`Timeout`)}
tooltip={i18n._(t`The amount of time (in seconds) to run
before the task is canceled. Defaults to 0 for no job
timeout.`)}
before the task is canceled. Defaults to 0 for no job
timeout.`)}
/>
<Field
name="diff_mode"
@ -561,14 +390,11 @@ class JobTemplateForm extends Component {
fieldId="template-show-changes"
label={i18n._(t`Show Changes`)}
>
<Tooltip
position="right"
<FieldTooltip
content={i18n._(t`If enabled, show the changes made by
Ansible tasks, where supported. This is equivalent
to Ansible&#x2019s --diff mode.`)}
>
<QuestionCircleIcon />
</Tooltip>
Ansible tasks, where supported. This is equivalent
to Ansible&#x2019s --diff mode.`)}
/>
<div>
<Switch
id="template-show-changes"
@ -583,12 +409,16 @@ class JobTemplateForm extends Component {
)}
/>
</FormRow>
<InstanceGroupsLookup
css="margin-top: 20px"
value={relatedInstanceGroups}
onChange={this.handleInstanceGroupsChange}
tooltip={i18n._(
t`Select the Instance Groups for this Organization to run on.`
<Field
name="instanceGroups"
render={({ field, form }) => (
<InstanceGroupsLookup
css="margin-top: 20px"
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
tooltip={i18n._(t`Select the Instance Groups for this Organization
to run on.`)}
/>
)}
/>
<Field
@ -599,16 +429,13 @@ class JobTemplateForm extends Component {
css="margin-top: 20px"
fieldId="template-job-tags"
>
<Tooltip
position="right"
<FieldTooltip
content={i18n._(t`Tags are useful when you have a large
playbook, and you want to run a specific part of a
play or task. Use commas to separate multiple tags.
Refer to Ansible Tower documentation for details on
the usage of tags.`)}
>
<QuestionCircleIcon />
</Tooltip>
playbook, and you want to run a specific part of a
play or task. Use commas to separate multiple tags.
Refer to Ansible Tower documentation for details on
the usage of tags.`)}
/>
<TagMultiSelect
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
@ -624,16 +451,13 @@ class JobTemplateForm extends Component {
css="margin-top: 20px"
fieldId="template-skip-tags"
>
<Tooltip
position="right"
<FieldTooltip
content={i18n._(t`Skip tags are useful when you have a
large playbook, and you want to skip specific parts of a
play or task. Use commas to separate multiple tags. Refer
to Ansible Tower documentation for details on the usage
of tags.`)}
>
<QuestionCircleIcon />
</Tooltip>
/>
<TagMultiSelect
value={field.value}
onChange={value => form.setFieldValue(field.name, value)}
@ -651,9 +475,8 @@ class JobTemplateForm extends Component {
id="option-privilege-escalation"
name="become_enabled"
label={i18n._(t`Privilege Escalation`)}
tooltip={i18n._(
t`If enabled, run this playbook as an administrator.`
)}
tooltip={i18n._(t`If enabled, run this playbook as an
administrator.`)}
/>
<Checkbox
aria-label={i18n._(t`Provisioning Callbacks`)}
@ -661,16 +484,12 @@ class JobTemplateForm extends Component {
<span>
{i18n._(t`Provisioning Callbacks`)}
&nbsp;
<Tooltip
position="right"
content={i18n._(
t`Enables creation of a provisioning callback URL. Using
the URL a host can contact BRAND_NAME and request a
configuration update using this job template.`
)}
>
<QuestionCircleIcon />
</Tooltip>
<FieldTooltip
content={i18n._(t`Enables creation of a provisioning
callback URL. Using the URL a host can contact BRAND_NAME
and request a configuration update using this job
template.`)}
/>
</span>
}
id="option-callbacks"
@ -683,19 +502,15 @@ class JobTemplateForm extends Component {
id="option-concurrent"
name="allow_simultaneous"
label={i18n._(t`Concurrent Jobs`)}
tooltip={i18n._(
t`If enabled, simultaneous runs of this job template will
be allowed.`
)}
tooltip={i18n._(t`If enabled, simultaneous runs of this job
template will be allowed.`)}
/>
<CheckboxField
id="option-fact-cache"
name="use_fact_cache"
label={i18n._(t`Fact Cache`)}
tooltip={i18n._(
t`If enabled, use cached facts if available and store
discovered facts in the cache.`
)}
tooltip={i18n._(t`If enabled, use cached facts if available
and store discovered facts in the cache.`)}
/>
</GridFormGroup>
<div
@ -736,52 +551,39 @@ const FormikApp = withFormik({
mapPropsToValues(props) {
const { template = {} } = props;
const {
name = '',
description = '',
job_type = 'run',
inventory = '',
project = '',
playbook = '',
forks,
limit,
verbosity,
job_slice_count,
timeout,
diff_mode,
job_tags,
skip_tags,
become_enabled,
allow_callbacks,
allow_simultaneous,
use_fact_cache,
host_config_key,
summary_fields = { labels: { results: [] } },
} = { ...template };
summary_fields = {
labels: { results: [] },
inventory: { organization: null },
},
} = template;
return {
name: name || '',
description: description || '',
job_type: job_type || '',
inventory: inventory || '',
project: project || '',
playbook: playbook || '',
labels: summary_fields.labels.results,
forks: forks || 0,
limit: limit || '',
verbosity: verbosity || '0',
job_slice_count: job_slice_count || 1,
timeout: timeout || 0,
diff_mode: diff_mode || false,
job_tags: job_tags || '',
skip_tags: skip_tags || '',
become_enabled: become_enabled || false,
allow_callbacks: allow_callbacks || false,
allow_simultaneous: allow_simultaneous || false,
use_fact_cache: use_fact_cache || false,
host_config_key: host_config_key || '',
name: template.name || '',
description: template.description || '',
job_type: template.job_type || 'run',
inventory: template.inventory || '',
project: template.project || '',
playbook: template.playbook || '',
labels: summary_fields.labels.results || [],
forks: template.forks || 0,
limit: template.limit || '',
verbosity: template.verbosity || '0',
job_slice_count: template.job_slice_count || 1,
timeout: template.timeout || 0,
diff_mode: template.diff_mode || false,
job_tags: template.job_tags || '',
skip_tags: template.skip_tags || '',
become_enabled: template.become_enabled || false,
allow_callbacks: template.allow_callbacks || false,
allow_simultaneous: template.allow_simultaneous || false,
use_fact_cache: template.use_fact_cache || false,
host_config_key: template.host_config_key || '',
organizationId: summary_fields.inventory.organization_id || null,
initialInstanceGroups: [],
instanceGroups: [],
};
},
handleSubmit: (values, bag) => bag.props.handleSubmit(values),
handleSubmit: (values, { props }) => props.handleSubmit(values),
})(JobTemplateForm);
export { JobTemplateForm as _JobTemplateForm };

View File

@ -1,8 +1,8 @@
import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm';
import { LabelsAPI, JobTemplatesAPI } from '@api';
import JobTemplateForm from './JobTemplateForm';
import { LabelsAPI, JobTemplatesAPI, ProjectsAPI } from '@api';
jest.mock('@api');
@ -61,13 +61,16 @@ describe('<JobTemplateForm />', () => {
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups },
});
ProjectsAPI.readPlaybooks.mockReturnValue({
data: ['debug.yml'],
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render labels MultiSelect', async () => {
test('should render LabelsSelect', async () => {
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
@ -79,11 +82,11 @@ describe('<JobTemplateForm />', () => {
expect(LabelsAPI.read).toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalled();
wrapper.update();
expect(
wrapper
.find('FormGroup[fieldId="template-labels"] MultiSelect')
.prop('associatedItems')
).toEqual(mockData.summary_fields.labels.results);
const select = wrapper.find('LabelSelect');
expect(select).toHaveLength(1);
expect(select.prop('value')).toEqual(
mockData.summary_fields.labels.results
);
});
test('should update form values on input changes', async () => {
@ -155,75 +158,4 @@ describe('<JobTemplateForm />', () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
});
test('should call loadRelatedProjectPlaybooks when project value changes', async () => {
const loadRelatedProjectPlaybooks = jest.spyOn(
_JobTemplateForm.prototype,
'loadRelatedProjectPlaybooks'
);
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('ProjectLookup').prop('onChange')({
id: 10,
name: 'project',
});
expect(loadRelatedProjectPlaybooks).toHaveBeenCalledWith(10);
});
test('handleNewLabel should arrange new labels properly', async () => {
const event = { key: 'Enter', preventDefault: () => {} };
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const multiSelect = wrapper.find(
'FormGroup[fieldId="template-labels"] MultiSelect'
);
const component = wrapper.find('JobTemplateForm');
wrapper.setState({ newLabels: [], loadedLabels: [], removedLabels: [] });
multiSelect.setState({ input: 'Foo' });
component
.find('FormGroup[fieldId="template-labels"] input[aria-label="labels"]')
.prop('onKeyDown')(event);
component.instance().handleNewLabel({ name: 'Bar', id: 2 });
const newLabels = component.state('newLabels');
expect(newLabels).toHaveLength(2);
expect(newLabels[0].name).toEqual('Foo');
expect(newLabels[0].organization).toEqual(1);
});
test('disassociateLabel should arrange new labels properly', async () => {
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
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);
});
});

View File

@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react';
import { func, arrayOf, number, shape, string, oneOfType } from 'prop-types';
import MultiSelect from '@components/MultiSelect';
import { LabelsAPI } from '@api';
async function loadLabelOptions(setLabels, onError) {
let labels;
try {
const { data } = await LabelsAPI.read({
page: 1,
page_size: 200,
order_by: 'name',
});
labels = data.results;
setLabels(labels);
if (data.next && data.next.includes('page=2')) {
const {
data: { results },
} = await LabelsAPI.read({
page: 2,
page_size: 200,
order_by: 'name',
});
labels = labels.concat(results);
}
setLabels(labels);
} catch (err) {
onError(err);
}
}
function LabelSelect({ value, onChange, onError }) {
const [options, setOptions] = useState([]);
useEffect(() => {
loadLabelOptions(setOptions, onError);
}, []);
return (
<MultiSelect
onChange={onChange}
value={value}
options={options}
createNewItem={name => ({
id: name,
name,
isNew: true,
})}
/>
);
}
LabelSelect.propTypes = {
value: arrayOf(
shape({
id: oneOfType([number, string]).isRequired,
name: string.isRequired,
})
).isRequired,
onError: func.isRequired,
};
export default LabelSelect;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { mount } from 'enzyme';
import { LabelsAPI } from '@api';
import { sleep } from '@testUtils/testUtils';
import LabelSelect from './LabelSelect';
jest.mock('@api');
const options = [{ id: 1, name: 'one' }, { id: 2, name: 'two' }];
describe('<LabelSelect />', () => {
afterEach(() => {
jest.resetAllMocks();
});
test('should fetch labels', async () => {
LabelsAPI.read.mockReturnValue({
data: { results: options },
});
const wrapper = mount(<LabelSelect value={[]} />);
await sleep(1);
wrapper.update();
expect(LabelsAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('MultiSelect').prop('options')).toEqual(options);
});
test('should fetch two pages labels if present', async () => {
LabelsAPI.read.mockReturnValueOnce({
data: {
results: options,
next: '/foo?page=2',
},
});
LabelsAPI.read.mockReturnValueOnce({
data: {
results: options,
},
});
const wrapper = mount(<LabelSelect value={[]} />);
await sleep(1);
wrapper.update();
expect(LabelsAPI.read).toHaveBeenCalledTimes(2);
expect(wrapper.find('MultiSelect').prop('options')).toEqual([
...options,
...options,
]);
});
});

View File

@ -0,0 +1,54 @@
import React, { useState, useEffect } from 'react';
import { number, string, oneOfType } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AnsibleSelect from '@components/AnsibleSelect';
import { ProjectsAPI } from '@api';
function PlaybookSelect({ projectId, isValid, form, field, onError, i18n }) {
const [options, setOptions] = useState([]);
useEffect(() => {
if (!projectId) {
return;
}
(async () => {
try {
const { data } = await ProjectsAPI.readPlaybooks(projectId);
const opts = (data || []).map(playbook => ({
value: playbook,
key: playbook,
label: playbook,
isDisabled: false,
}));
opts.unshift({
value: '',
key: '',
label: i18n._(t`Choose a playbook`),
isDisabled: false,
});
setOptions(opts);
} catch (contentError) {
onError(contentError);
}
})();
}, [projectId]);
return (
<AnsibleSelect
id="template-playbook"
data={options}
isValid={isValid}
form={form}
{...field}
/>
);
}
PlaybookSelect.propTypes = {
projectId: oneOfType([number, string]),
};
PlaybookSelect.defaultProps = {
projectId: null,
};
export { PlaybookSelect as _PlaybookSelect };
export default withI18n()(PlaybookSelect);

View File

@ -0,0 +1,36 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import PlaybookSelect from './PlaybookSelect';
import { ProjectsAPI } from '@api';
jest.mock('@api');
describe('<PlaybookSelect />', () => {
beforeEach(() => {
ProjectsAPI.readPlaybooks.mockReturnValue({
data: ['debug.yml'],
});
});
afterEach(() => {
jest.resetAllMocks();
});
test('should reload playbooks when project value changes', () => {
const wrapper = mountWithContexts(
<PlaybookSelect
projectId={1}
isValid
form={{}}
field={{}}
onError={() => {}}
/>
);
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
wrapper.setProps({ projectId: 15 });
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15);
});
});

View File

@ -0,0 +1,18 @@
/* eslint-disable import/prefer-default-export */
export function getAddedAndRemoved(original, current) {
original = original || [];
current = current || [];
const added = [];
const removed = [];
original.forEach(orig => {
if (!current.find(cur => cur.id === orig.id)) {
removed.push(orig);
}
});
current.forEach(cur => {
if (!original.find(orig => orig.id === cur.id)) {
added.push(cur);
}
});
return { added, removed };
}

View File

@ -0,0 +1,51 @@
import { getAddedAndRemoved } from './lists';
const one = { id: 1 };
const two = { id: 2 };
const three = { id: 3 };
describe('getAddedAndRemoved', () => {
test('should handle no original list', () => {
const items = [one, two, three];
expect(getAddedAndRemoved(null, items)).toEqual({
added: items,
removed: [],
});
});
test('should list added item', () => {
const original = [one, two];
const current = [one, two, three];
expect(getAddedAndRemoved(original, current)).toEqual({
added: [three],
removed: [],
});
});
test('should list removed item', () => {
const original = [one, two, three];
const current = [one, three];
expect(getAddedAndRemoved(original, current)).toEqual({
added: [],
removed: [two],
});
});
test('should handle both added and removed together', () => {
const original = [two];
const current = [one, three];
expect(getAddedAndRemoved(original, current)).toEqual({
added: [one, three],
removed: [two],
});
});
test('should handle different list order', () => {
const original = [three, two];
const current = [one, two, three];
expect(getAddedAndRemoved(original, current)).toEqual({
added: [one],
removed: [],
});
});
});