1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 06:51:10 +03:00

Adds Multiselect functionality to labels on JTs

This commit is contained in:
Alex Corey 2019-07-30 13:00:22 -04:00
parent bb2474f56f
commit a577be906e
11 changed files with 396 additions and 11 deletions

View File

@ -1,6 +1,8 @@
import axios from 'axios';
import { encodeQueryString } from '@util/qs';
import {
encodeQueryString
} from '@util/qs';
const defaultHttp = axios.create({
xsrfCookieName: 'csrftoken',
@ -25,7 +27,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,7 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
this.launch = this.launch.bind(this);
this.readLaunch = this.readLaunch.bind(this);
this.updateLabels = this.updateLabels.bind(this);
}
launch(id, data) {
@ -17,6 +18,10 @@ class JobTemplates extends InstanceGroupsMixin(Base) {
readLaunch(id) {
return this.http.get(`${this.baseUrl}${id}/launch/`);
}
updateLabels(id, data) {
return this.http.post(`${this.baseUrl}${id}/labels/`, data)
}
}
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,202 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
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.myRef = React.createRef();
this.state = {
input: '',
chipItems: [],
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() {
this.renderChips();
document.addEventListener('mousedown', this.handleClick, false);
}
handleClick(e, option) {
if (this.node && this.node.contains(e.target)) {
if (option) {
this.handleSelection(e, option);
}
this.setState({ isExpanded: true });
} else {
this.setState({ isExpanded: false });
}
}
renderChips() {
const { associatedItems } = this.props;
const items = associatedItems.map(item => ({
name: item.name,
id: item.id,
organization: item.organization,
}));
this.setState({
chipItems: items,
});
}
handleSelection(e, item) {
const { chipItems } = this.state;
const { onAddNewItem } = this.props;
this.setState({
chipItems: chipItems.concat({ name: item.name, id: item.id }),
});
onAddNewItem(item);
e.preventDefault();
}
handleAddItem(event) {
const { input, chipItems } = this.state;
const { onAddNewItem } = this.props;
const newChip = { name: input, id: Math.random() };
if (event.key === 'Tab') {
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.name !== item.name);
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 default MultiSelect;

View File

@ -0,0 +1,4 @@
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,29 @@ 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
? removedLabels.forEach(removedLabel =>
JobTemplatesAPI.updateLabels(id, removedLabel)
)
: null;
const associatedLabels = newLabels
? newLabels.forEach(newLabel =>
JobTemplatesAPI.updateLabels(id, newLabel)
)
: null;
try {
await JobTemplatesAPI.update(id, { ...values });
await Promise.all([
JobTemplatesAPI.update(id, { ...values }),
disassociatedLabels,
associatedLabels,
]);
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 }],
},
},
};

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,107 @@ 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.disassociateLabel = this.disassociateLabel.bind(this);
}
componentDidMount() {
this.loadLabels(QSConfig);
}
async loadLabels(QueryConfig) {
const { loadedLabels } = this.state;
this.setState({ contentError: null, hasContentLoading: true });
try {
const { data } = await LabelsAPI.read(QueryConfig);
const labels = [...data.results];
this.setState({ loadedLabels: loadedLabels.concat(labels) });
if (data.next && data.next.includes('page=2')) {
this.loadLabels({
page: 2,
page_size: 200,
order_by: 'name',
});
}
} 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, { associate: true, id: label.id }],
});
}
}
disassociateLabel(label) {
const { removedLabels, newLabels } = this.state;
const isNewCreatedLabel = newLabels.some(
newLabel => newLabel === label.name
);
if (isNewCreatedLabel) {
const filteredLabels = newLabels.filter(
newLabel => newLabel !== label.name
);
this.setState({ newLabels: filteredLabels });
} else {
this.setState({
removedLabels: removedLabels.concat({
disassociate: true,
id: label.id,
}),
});
}
}
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 +167,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 +185,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,9 +267,31 @@ 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>
<Field
render={() => (
<MultiSelect
onAddNewItem={this.handleNewLabel}
onRemoveItem={this.disassociateLabel}
associatedItems={template.summary_fields.labels.results}
options={loadedLabels}
/>
)}
/>
</FormGroup>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onSubmit={values => handleSubmit(values)}
/>
</Form>
)}
@ -166,5 +299,5 @@ class JobTemplateForm extends Component {
);
}
}
export { JobTemplateForm as _JobTemplateForm };
export default withI18n()(withRouter(JobTemplateForm));

View File

@ -20,6 +20,7 @@ describe('<JobTemplateForm />', () => {
id: 2,
name: 'foo',
},
labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] },
},
};