Merge pull request #4413 from AlexSCorey/multiSelectPatternFly

Multi-Select AWX-PF

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-08-02 22:37:27 +00:00 committed by GitHub
commit 9ab920cdb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 583 additions and 12 deletions

View File

@ -25,7 +25,9 @@ class Base {
}
read(params) {
return this.http.get(this.baseUrl, { params });
return this.http.get(this.baseUrl, {
params,
});
}
readDetail(id) {

View File

@ -3,6 +3,7 @@ import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories';
import JobTemplates from './models/JobTemplates';
import Jobs from './models/Jobs';
import Labels from './models/Labels';
import Me from './models/Me';
import Organizations from './models/Organizations';
import Root from './models/Root';
@ -17,6 +18,7 @@ const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories();
const JobTemplatesAPI = new JobTemplates();
const JobsAPI = new Jobs();
const LabelsAPI = new Labels();
const MeAPI = new Me();
const OrganizationsAPI = new Organizations();
const RootAPI = new Root();
@ -32,6 +34,7 @@ export {
InventoriesAPI,
JobTemplatesAPI,
JobsAPI,
LabelsAPI,
MeAPI,
OrganizationsAPI,
RootAPI,

View File

@ -8,6 +8,9 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
this.launch = this.launch.bind(this);
this.readLaunch = this.readLaunch.bind(this);
this.associateLabel = this.associateLabel.bind(this);
this.disassociateLabel = this.disassociateLabel.bind(this);
this.generateLabel = this.generateLabel.bind(this);
}
launch(id, data) {
@ -17,6 +20,18 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
readLaunch(id) {
return this.http.get(`${this.baseUrl}${id}/launch/`);
}
associateLabel(id, label) {
return this.http.post(`${this.baseUrl}${id}/labels/`, label);
}
disassociateLabel(id, label) {
return this.http.post(`${this.baseUrl}${id}/labels/`, label);
}
generateLabel(orgId, label) {
return this.http.post(`${this.baseUrl}${orgId}/labels/`, label);
}
}
export default JobTemplates;

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Labels extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/labels/';
}
}
export default Labels;

View File

@ -0,0 +1,205 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { Chip, ChipGroup } from '@components/Chip';
import {
Dropdown as PFDropdown,
DropdownItem,
TextInput as PFTextInput,
DropdownToggle,
} from '@patternfly/react-core';
import styled from 'styled-components';
const InputGroup = styled.div`
border: 1px solid black;
margin-top: 2px;
`;
const TextInput = styled(PFTextInput)`
border: none;
width: 100%;
padding-left: 8px;
`;
const Dropdown = styled(PFDropdown)`
width: 100%;
.pf-c-dropdown__toggle.pf-m-plain {
display: none;
}
display: block;
.pf-c-dropdown__menu {
max-height: 200px;
overflow: scroll;
}
&& button[disabled] {
color: var(--pf-c-button--m-plain--Color);
pointer-events: initial;
cursor: not-allowed;
color: var(--pf-global--disabled-color--200);
}
`;
class MultiSelect extends Component {
static propTypes = {
associatedItems: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
})
).isRequired,
onAddNewItem: PropTypes.func.isRequired,
onRemoveItem: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
input: '',
chipItems: this.getInitialChipItems(),
isExpanded: false,
};
this.handleAddItem = this.handleAddItem.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSelection = this.handleSelection.bind(this);
this.removeChip = this.removeChip.bind(this);
this.handleClick = this.handleClick.bind(this);
}
componentDidMount() {
document.addEventListener('mousedown', this.handleClick, false);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.handleClick, false);
}
getInitialChipItems() {
const { associatedItems } = this.props;
return associatedItems.map(item => ({
name: item.name,
id: item.id,
organization: item.organization,
}));
}
handleClick(e, option) {
if (this.node && this.node.contains(e.target)) {
if (option) {
this.handleSelection(e, option);
}
} else {
this.setState({ isExpanded: false });
}
}
handleSelection(e, item) {
const { chipItems } = this.state;
const { onAddNewItem } = this.props;
e.preventDefault();
this.setState({
chipItems: chipItems.concat({ name: item.name, id: item.id }),
isExpanded: false,
});
onAddNewItem(item);
}
handleAddItem(event) {
const { input, chipItems } = this.state;
const { onAddNewItem } = this.props;
const newChip = { name: input, id: Math.random() };
if (event.key !== 'Tab') {
return;
}
this.setState({
chipItems: chipItems.concat(newChip),
isExpanded: false,
input: '',
});
onAddNewItem(input);
}
handleInputChange(e) {
this.setState({ input: e, isExpanded: true });
}
removeChip(e, item) {
const { onRemoveItem } = this.props;
const { chipItems } = this.state;
const chips = chipItems.filter(chip => chip.id !== item.id);
this.setState({ chipItems: chips });
onRemoveItem(item);
e.preventDefault();
}
render() {
const { options } = this.props;
const { chipItems, input, isExpanded } = this.state;
const list = options.map(option => (
<Fragment key={option.id}>
{option.name.includes(input) ? (
<DropdownItem
component="button"
isDisabled={chipItems.some(item => item.id === option.id)}
value={option.name}
onClick={e => {
this.handleClick(e, option);
}}
>
{option.name}
</DropdownItem>
) : null}
</Fragment>
));
const chips = (
<ChipGroup>
{chipItems &&
chipItems.map(item => (
<Chip
key={item.id}
onClick={e => {
this.removeChip(e, item);
}}
>
{item.name}
</Chip>
))}
</ChipGroup>
);
return (
<Fragment>
<InputGroup>
<div
ref={node => {
this.node = node;
}}
>
<TextInput
type="text"
aria-label="labels"
value={input}
onClick={() => this.setState({ isExpanded: true })}
onChange={this.handleInputChange}
onKeyDown={this.handleAddItem}
/>
<Dropdown
type="button"
isPlain
value={chipItems}
toggle={<DropdownToggle isPlain>Labels</DropdownToggle>}
// Above is not rendered but is a required prop from Patternfly
isOpen={isExpanded}
dropdownItems={list}
/>
</div>
<div css="margin: 10px">{chips}</div>
</InputGroup>
</Fragment>
);
}
}
export { MultiSelect as _MultiSelect };
export default withI18n()(withRouter(MultiSelect));

View File

@ -0,0 +1,93 @@
import React from 'react';
import { sleep } from '@testUtils/testUtils';
import MultiSelect, { _MultiSelect } from './MultiSelect';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
describe('<MultiSelect />', () => {
const associatedItems = [
{ name: 'Foo', id: 1, organization: 1 },
{ name: 'Bar', id: 2, organization: 1 },
];
const options = [{ name: 'Angry', id: 3 }, { name: 'Potato', id: 4 }];
test('Initially render successfully', () => {
const getInitialChipItems = jest.spyOn(
_MultiSelect.prototype,
'getInitialChipItems'
);
const wrapper = mountWithContexts(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
associatedItems={associatedItems}
options={options}
/>
);
const component = wrapper.find('MultiSelect');
expect(getInitialChipItems).toBeCalled();
expect(component.state().chipItems.length).toBe(2);
});
test('handleSelection add item to chipItems', async () => {
const wrapper = mountWithContexts(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={jest.fn()}
associatedItems={associatedItems}
options={options}
/>
);
const component = wrapper.find('MultiSelect');
component
.find('input[aria-label="labels"]')
.simulate('keydown', { key: 'Tab' });
component.update();
await sleep(1);
expect(component.state().chipItems.length).toBe(3);
});
test('handleAddItem adds a chip only when Tab is pressed', () => {
const onAddNewItem = jest.fn();
const wrapper = mountWithContexts(
<MultiSelect
onAddNewItem={onAddNewItem}
onRemoveItem={jest.fn()}
associatedItems={associatedItems}
options={options}
/>
);
const event = {
preventDefault: () => {},
key: 'Tab',
};
const component = wrapper.find('MultiSelect');
component.setState({ input: 'newLabel' });
component.update();
component.instance().handleAddItem(event);
expect(component.state().chipItems.length).toBe(3);
expect(component.state().input.length).toBe(0);
expect(component.state().isExpanded).toBe(false);
expect(onAddNewItem).toBeCalled();
});
test('removeChip removes chip properly', () => {
const onRemoveItem = jest.fn();
const wrapper = mountWithContexts(
<MultiSelect
onAddNewItem={jest.fn()}
onRemoveItem={onRemoveItem}
associatedItems={associatedItems}
options={options}
/>
);
const event = {
preventDefault: () => {},
};
const component = wrapper.find('MultiSelect');
component
.instance()
.removeChip(event, { name: 'Foo', id: 1, organization: 1 });
expect(component.state().chipItems.length).toBe(1);
expect(onRemoveItem).toBeCalled();
});
});

View File

@ -0,0 +1 @@
export { default } from './MultiSelect';

View File

@ -13,6 +13,11 @@ describe('<JobTemplateAdd />', () => {
name: '',
playbook: '',
project: '',
summary_fields: {
user_capabilities: {
edit: true,
},
},
};
afterEach(() => {

View File

@ -21,14 +21,28 @@ class JobTemplateEdit extends Component {
this.handleSubmit = this.handleSubmit.bind(this);
}
async handleSubmit(values) {
async handleSubmit(values, newLabels = [], removedLabels = []) {
const {
template: { id, type },
history,
} = this.props;
const disassociatedLabels = removedLabels.forEach(removedLabel =>
JobTemplatesAPI.disassociateLabel(id, removedLabel)
);
const associatedLabels = newLabels
.filter(newLabel => !newLabel.organization)
.forEach(newLabel => JobTemplatesAPI.associateLabel(id, newLabel));
const generatedLabels = newLabels
.filter(newLabel => newLabel.organization)
.forEach(newLabel => JobTemplatesAPI.generateLabel(id, newLabel));
try {
await JobTemplatesAPI.update(id, { ...values });
await Promise.all([
JobTemplatesAPI.update(id, { ...values }),
disassociatedLabels,
associatedLabels,
generatedLabels,
]);
history.push(`/templates/${type}/${id}/details`);
} catch (error) {
this.setState({ error });

View File

@ -19,6 +19,9 @@ describe('<JobTemplateEdit />', () => {
user_capabilities: {
edit: true,
},
labels: {
results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
},
},
};
@ -33,9 +36,26 @@ describe('<JobTemplateEdit />', () => {
description: 'new description',
job_type: 'check',
};
const newLabels = [
{ associate: true, id: 3 },
{ associate: true, id: 3 },
{ name: 'Mapel', organization: 1 },
{ name: 'Tree', organization: 1 },
];
const removedLabels = [
{ disassociate: true, id: 1 },
{ disassociate: true, id: 2 },
];
wrapper.find('JobTemplateForm').prop('handleSubmit')(updatedTemplateData);
wrapper.find('JobTemplateForm').prop('handleSubmit')(
updatedTemplateData,
newLabels,
removedLabels
);
expect(JobTemplatesAPI.update).toHaveBeenCalledWith(1, updatedTemplateData);
expect(JobTemplatesAPI.disassociateLabel).toHaveBeenCalledTimes(2);
expect(JobTemplatesAPI.associateLabel).toHaveBeenCalledTimes(2);
expect(JobTemplatesAPI.generateLabel).toHaveBeenCalledTimes(2);
});
test('should navigate to job template detail when cancel is clicked', () => {

View File

@ -4,9 +4,17 @@ import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik, Field } from 'formik';
import { Form, FormGroup, Tooltip } from '@patternfly/react-core';
import {
Form,
FormGroup,
Tooltip,
PageSection,
Card,
} from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import ContentError from '@components/ContentError';
import AnsibleSelect from '@components/AnsibleSelect';
import MultiSelect from '@components/MultiSelect';
import FormActionGroup from '@components/FormActionGroup';
import FormField from '@components/FormField';
import FormRow from '@components/FormRow';
@ -14,10 +22,16 @@ import { required } from '@util/validators';
import styled from 'styled-components';
import { JobTemplate } from '@types';
import InventoriesLookup from './InventoriesLookup';
import { LabelsAPI } from '@api';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;
`;
const QSConfig = {
page: 1,
page_size: 200,
order_by: 'name',
};
class JobTemplateForm extends Component {
static propTypes = {
@ -36,22 +50,121 @@ class JobTemplateForm extends Component {
playbook: '',
summary_fields: {
inventory: null,
labels: { results: [] },
},
},
};
constructor(props) {
super(props);
this.state = {
hasContentLoading: true,
contentError: false,
loadedLabels: [],
newLabels: [],
removedLabels: [],
inventory: props.template.summary_fields.inventory,
};
this.handleNewLabel = this.handleNewLabel.bind(this);
this.loadLabels = this.loadLabels.bind(this);
this.removeLabel = this.removeLabel.bind(this);
}
componentDidMount() {
this.loadLabels(QSConfig);
}
// The function below assumes that the user has no more than 400
// labels. For the vast majority of users this will be more thans
// enough.This can be updated to allow more than 400 labels if we
// decide it is necessary.
async loadLabels(QueryConfig) {
this.setState({ contentError: null, hasContentLoading: true });
let loadedLabels;
try {
const { data } = await LabelsAPI.read(QueryConfig);
loadedLabels = [...data.results];
if (data.next && data.next.includes('page=2')) {
const {
data: { results },
} = await LabelsAPI.read({
page: 2,
page_size: 200,
order_by: 'name',
});
loadedLabels = loadedLabels.concat(results);
}
this.setState({ loadedLabels });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
handleNewLabel(label) {
const { newLabels } = this.state;
const { template } = this.props;
const isIncluded = newLabels.some(newLabel => newLabel.name === label.name);
if (isIncluded) {
const filteredLabels = newLabels.filter(
newLabel => newLabel.name !== label
);
this.setState({ newLabels: filteredLabels });
} else if (typeof label === 'string') {
this.setState({
newLabels: [
...newLabels,
{
name: label,
organization: template.summary_fields.inventory.organization_id,
},
],
});
} else {
this.setState({
newLabels: [
...newLabels,
{ name: label.name, associate: true, id: label.id },
],
});
}
}
removeLabel(label) {
const { removedLabels, newLabels } = this.state;
const { template } = this.props;
const isAssociatedLabel = template.summary_fields.labels.results.some(
tempLabel => tempLabel.id === label.id
);
if (isAssociatedLabel) {
this.setState({
removedLabels: removedLabels.concat({
disassociate: true,
id: label.id,
}),
});
} else {
const filteredLabels = newLabels.filter(
newLabel => newLabel.name !== label.name
);
this.setState({ newLabels: filteredLabels });
}
}
render() {
const {
loadedLabels,
contentError,
hasContentLoading,
inventory,
newLabels,
removedLabels,
} = this.state;
const { handleCancel, handleSubmit, i18n, template } = this.props;
const { inventory } = this.state;
const jobTypeOptions = [
{
value: '',
@ -68,6 +181,15 @@ class JobTemplateForm extends Component {
},
];
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError} />
</Card>
</PageSection>
);
}
return (
<Formik
initialValues={{
@ -77,8 +199,11 @@ class JobTemplateForm extends Component {
inventory: template.inventory,
project: template.project,
playbook: template.playbook,
labels: template.summary_fields.labels.results,
}}
onSubmit={values => {
handleSubmit(values, newLabels, removedLabels);
}}
onSubmit={handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow>
@ -156,6 +281,24 @@ class JobTemplateForm extends Component {
validate={required(null, i18n)}
/>
</FormRow>
<FormRow>
<FormGroup label={i18n._(t`Labels`)} fieldId="template-labels">
<Tooltip
position="right"
content={i18n._(
t`Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs.`
)}
>
<QuestionCircleIcon />
</Tooltip>
<MultiSelect
onAddNewItem={this.handleNewLabel}
onRemoveItem={this.removeLabel}
associatedItems={template.summary_fields.labels.results}
options={loadedLabels}
/>
</FormGroup>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
@ -166,5 +309,5 @@ class JobTemplateForm extends Component {
);
}
}
export { JobTemplateForm as _JobTemplateForm };
export default withI18n()(withRouter(JobTemplateForm));

View File

@ -1,7 +1,8 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import JobTemplateForm from './JobTemplateForm';
import JobTemplateForm, { _JobTemplateForm } from './JobTemplateForm';
import { LabelsAPI } from '@api';
jest.mock('@api');
@ -19,22 +20,32 @@ describe('<JobTemplateForm />', () => {
inventory: {
id: 2,
name: 'foo',
organization_id: 1,
},
labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] },
},
};
beforeEach(() => {
LabelsAPI.read.mockReturnValue({
data: mockData.summary_fields.labels,
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
mountWithContexts(
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
const component = wrapper.find('ChipGroup');
expect(LabelsAPI.read).toHaveBeenCalled();
expect(component.find('span#pf-random-id-1').text()).toEqual('Sushi');
});
test('should update form values on input changes', async () => {
@ -103,4 +114,53 @@ describe('<JobTemplateForm />', () => {
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled();
});
test('handleNewLabel should arrange new labels properly', async () => {
const handleNewLabel = jest.spyOn(
_JobTemplateForm.prototype,
'handleNewLabel'
);
const event = { key: 'Tab' };
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
const multiSelect = wrapper.find('MultiSelect');
const component = wrapper.find('JobTemplateForm');
wrapper.setState({ newLabels: [], loadedLabels: [], removedLabels: [] });
multiSelect.setState({ input: 'Foo' });
component.find('input[aria-label="labels"]').prop('onKeyDown')(event);
expect(handleNewLabel).toHaveBeenCalledWith('Foo');
component.instance().handleNewLabel({ name: 'Bar', id: 2 });
expect(component.state().newLabels).toEqual([
{ name: 'Foo', organization: 1 },
{ associate: true, id: 2, name: 'Bar' },
]);
});
test('disassociateLabel should arrange new labels properly', async () => {
const wrapper = mountWithContexts(
<JobTemplateForm
template={mockData}
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
/>
);
const component = wrapper.find('JobTemplateForm');
// This asserts that the user generated a label or clicked
// on a label option, and then changed their mind and
// removed the label.
component.instance().removeLabel({ name: 'Alex', id: 17 });
expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(0);
// This asserts that the user removed a label that was associated
// with the template when the template loaded.
component.instance().removeLabel({ name: 'Sushi', id: 1 });
expect(component.state().newLabels.length).toBe(0);
expect(component.state().removedLabels.length).toBe(1);
});
});