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:
commit
71bd257191
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
26
awx/ui_next/src/components/FormField/FieldTooltip.jsx
Normal file
26
awx/ui_next/src/components/FormField/FieldTooltip.jsx
Normal 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;
|
@ -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';
|
||||||
|
@ -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}
|
||||||
|
@ -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`)}
|
||||||
|
@ -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`)}
|
@ -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';
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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]]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 })}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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 = []) {
|
||||||
|
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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]);
|
||||||
|
@ -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()
|
||||||
|
@ -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’s --diff mode.`)}
|
to Ansible’s --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`)}
|
||||||
|
|
||||||
<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 };
|
||||||
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
61
awx/ui_next/src/screens/Template/shared/LabelSelect.jsx
Normal file
61
awx/ui_next/src/screens/Template/shared/LabelSelect.jsx
Normal 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;
|
50
awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx
Normal file
50
awx/ui_next/src/screens/Template/shared/LabelSelect.test.jsx
Normal 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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
54
awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx
Normal file
54
awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx
Normal 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);
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
18
awx/ui_next/src/util/lists.js
Normal file
18
awx/ui_next/src/util/lists.js
Normal 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 };
|
||||||
|
}
|
51
awx/ui_next/src/util/lists.test.js
Normal file
51
awx/ui_next/src/util/lists.test.js
Normal 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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user