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) { 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) { generateLabel(id, label, orgId) {
return this.http.post(`${this.baseUrl}${orgId}/labels/`, label); return this.http.post(`${this.baseUrl}${id}/labels/`, {
name: label.name,
organization: orgId,
});
} }
readCredentials(id, params) { readCredentials(id, params) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
export { default } from './Lookup'; export { default } from './Lookup';
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup'; export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
export { default as InventoryLookup } from './InventoryLookup'; 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 { class MultiSelect extends Component {
static propTypes = { static propTypes = {
associatedItems: arrayOf(Item).isRequired, value: arrayOf(Item).isRequired,
options: arrayOf(Item), options: arrayOf(Item),
onAddNewItem: func, onAddNewItem: func,
onRemoveItem: func, onRemoveItem: func,
@ -65,13 +65,11 @@ class MultiSelect extends Component {
super(props); super(props);
this.state = { this.state = {
input: '', input: '',
chipItems: this.getInitialChipItems(),
isExpanded: false, isExpanded: false,
}; };
this.handleAddItem = this.handleAddItem.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleInputChange = this.handleInputChange.bind(this); this.handleInputChange = this.handleInputChange.bind(this);
this.handleSelection = this.handleSelection.bind(this); this.removeItem = this.removeItem.bind(this);
this.removeChip = this.removeChip.bind(this);
this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this);
this.createNewItem = this.createNewItem.bind(this); this.createNewItem = this.createNewItem.bind(this);
} }
@ -84,33 +82,57 @@ class MultiSelect extends Component {
document.removeEventListener('mousedown', this.handleClick, false); document.removeEventListener('mousedown', this.handleClick, false);
} }
getInitialChipItems() {
const { associatedItems } = this.props;
return associatedItems.map(item => ({ ...item }));
}
handleClick(e, option) { handleClick(e, option) {
if (this.node && this.node.contains(e.target)) { if (this.node && this.node.contains(e.target)) {
if (option) { if (option) {
this.handleSelection(e, option); e.preventDefault();
this.addItem(option);
} }
} else { } else {
this.setState({ input: '', isExpanded: false }); this.setState({ input: '', isExpanded: false });
} }
} }
handleSelection(e, item) { addItem(item) {
const { chipItems } = this.state; const { value, onAddNewItem, onChange } = this.props;
const { onAddNewItem, onChange } = this.props; const items = value.concat(item);
e.preventDefault();
const items = chipItems.concat({ name: item.name, id: item.id });
this.setState({
chipItems: items,
isExpanded: false,
});
onAddNewItem(item); onAddNewItem(item);
onChange(items); 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) { 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) { handleInputChange(value) {
this.setState({ input: value, isExpanded: true }); this.setState({ input: value, isExpanded: true });
} }
removeChip(e, item) { removeItem(item) {
const { onRemoveItem, onChange } = this.props; const { value, onRemoveItem, onChange } = this.props;
const { chipItems } = this.state; const remainingItems = value.filter(chip => chip.id !== item.id);
const chips = chipItems.filter(chip => chip.id !== item.id);
this.setState({ chipItems: chips });
onRemoveItem(item); onRemoveItem(item);
onChange(chips); onChange(remainingItems);
e.preventDefault();
} }
render() { render() {
const { options } = this.props; const { value, options } = this.props;
const { chipItems, input, isExpanded } = this.state; const { input, isExpanded } = this.state;
const list = options.map(option => ( const dropdownOptions = options.map(option => (
<Fragment key={option.id}> <Fragment key={option.id}>
{option.name.includes(input) ? ( {option.name.includes(input) ? (
<DropdownItem <DropdownItem
component="button" component="button"
isDisabled={chipItems.some(item => item.id === option.id)} isDisabled={value.some(item => item.id === option.id)}
value={option.name} value={option.name}
onClick={e => { onClick={e => {
this.handleClick(e, option); this.handleClick(e, option);
@ -195,21 +179,6 @@ class MultiSelect extends Component {
</Fragment> </Fragment>
)); ));
const chips = (
<ChipGroup>
{chipItems &&
chipItems.map(item => (
<Chip
key={item.id}
onClick={e => {
this.removeChip(e, item);
}}
>
{item.name}
</Chip>
))}
</ChipGroup>
);
return ( return (
<Fragment> <Fragment>
<InputGroup> <InputGroup>
@ -222,21 +191,34 @@ class MultiSelect extends Component {
type="text" type="text"
aria-label="labels" aria-label="labels"
value={input} value={input}
onClick={() => this.setState({ isExpanded: true })} onFocus={() => this.setState({ isExpanded: true })}
onChange={this.handleInputChange} onChange={this.handleInputChange}
onKeyDown={this.handleAddItem} onKeyDown={this.handleKeyDown}
/> />
<Dropdown <Dropdown
type="button" type="button"
isPlain isPlain
value={chipItems} value={value}
toggle={<DropdownToggle isPlain>Labels</DropdownToggle>} 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} isOpen={isExpanded}
dropdownItems={list} dropdownItems={dropdownOptions}
/> />
</div> </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> </InputGroup>
</Fragment> </Fragment>
); );

View File

@ -1,48 +1,51 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { mount, shallow } from 'enzyme';
import { sleep } from '@testUtils/testUtils';
import MultiSelect from './MultiSelect'; import MultiSelect from './MultiSelect';
describe('<MultiSelect />', () => { describe('<MultiSelect />', () => {
const associatedItems = [ const value = [
{ name: 'Foo', id: 1, organization: 1 }, { name: 'Foo', id: 1, organization: 1 },
{ name: 'Bar', id: 2, organization: 1 }, { name: 'Bar', id: 2, organization: 1 },
]; ];
const options = [{ name: 'Angry', id: 3 }, { name: 'Potato', id: 4 }]; const options = [{ name: 'Angry', id: 3 }, { name: 'Potato', id: 4 }];
test('Initially render successfully', () => { test('should render successfully', () => {
const wrapper = mount( const wrapper = shallow(
<MultiSelect <MultiSelect
onAddNewItem={jest.fn()} onAddNewItem={jest.fn()}
onRemoveItem={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} options={options}
/> />
); );
const component = wrapper.find('MultiSelect'); 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 () => { test('should add item when clicked from menu', () => {
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', () => {
const onAddNewItem = jest.fn(); const onAddNewItem = jest.fn();
const onChange = jest.fn(); const onChange = jest.fn();
const wrapper = mount( const wrapper = mount(
@ -50,48 +53,53 @@ describe('<MultiSelect />', () => {
onAddNewItem={onAddNewItem} onAddNewItem={onAddNewItem}
onRemoveItem={jest.fn()} onRemoveItem={jest.fn()}
onChange={onChange} onChange={onChange}
associatedItems={associatedItems} value={value}
options={options} options={options}
/> />
); );
const input = wrapper.find('TextInput');
input.simulate('focus');
wrapper.update();
const event = { const event = {
preventDefault: () => {}, 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' }); expect(onAddNewItem).toHaveBeenCalledWith(options[1]);
component.update(); const newVal = onChange.mock.calls[0][0];
component.instance().handleAddItem(event); expect(newVal).toHaveLength(3);
expect(component.state().chipItems.length).toBe(3); expect(newVal[2]).toEqual(options[1]);
expect(component.state().input.length).toBe(0);
expect(component.state().isExpanded).toBe(false);
expect(onAddNewItem).toBeCalled();
expect(onChange).toBeCalled();
}); });
test('removeChip removes chip properly', () => { test('should remove item', () => {
const onRemoveItem = jest.fn(); const onRemoveItem = jest.fn();
const onChange = jest.fn(); const onChange = jest.fn();
const wrapper = mount( const wrapper = mount(
<MultiSelect <MultiSelect
onAddNewItem={jest.fn()} onAddNewItem={jest.fn()}
onRemoveItem={onRemoveItem} onRemoveItem={onRemoveItem}
onChange={onChange} onChange={onChange}
associatedItems={associatedItems} value={value}
options={options} options={options}
/> />
); );
const event = {
preventDefault: () => {}, wrapper
}; .find('Chip')
const component = wrapper.find('MultiSelect'); .at(1)
component .invoke('onClick')();
.instance()
.removeChip(event, { name: 'Foo', id: 1, organization: 1 }); expect(onRemoveItem).toHaveBeenCalledWith(value[1]);
expect(component.state().chipItems.length).toBe(1); const newVal = onChange.mock.calls[0][0];
expect(onRemoveItem).toBeCalled(); expect(newVal).toHaveLength(1);
expect(onChange).toBeCalled(); expect(newVal).toEqual([value[0]]);
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils'; import { sleep } from '@testUtils/testUtils';
import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm'; import JobTemplateForm from './JobTemplateForm';
import { LabelsAPI, JobTemplatesAPI } from '@api'; import { LabelsAPI, JobTemplatesAPI, ProjectsAPI } from '@api';
jest.mock('@api'); jest.mock('@api');
@ -61,13 +61,16 @@ describe('<JobTemplateForm />', () => {
JobTemplatesAPI.readInstanceGroups.mockReturnValue({ JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups }, data: { results: mockInstanceGroups },
}); });
ProjectsAPI.readPlaybooks.mockReturnValue({
data: ['debug.yml'],
});
}); });
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('should render labels MultiSelect', async () => { test('should render LabelsSelect', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
template={mockData} template={mockData}
@ -79,11 +82,11 @@ describe('<JobTemplateForm />', () => {
expect(LabelsAPI.read).toHaveBeenCalled(); expect(LabelsAPI.read).toHaveBeenCalled();
expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalled(); expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalled();
wrapper.update(); wrapper.update();
expect( const select = wrapper.find('LabelSelect');
wrapper expect(select).toHaveLength(1);
.find('FormGroup[fieldId="template-labels"] MultiSelect') expect(select.prop('value')).toEqual(
.prop('associatedItems') mockData.summary_fields.labels.results
).toEqual(mockData.summary_fields.labels.results); );
}); });
test('should update form values on input changes', async () => { test('should update form values on input changes', async () => {
@ -155,75 +158,4 @@ describe('<JobTemplateForm />', () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled(); 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: [],
});
});
});