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:
parent
bb2474f56f
commit
a577be906e
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
10
awx/ui_next/src/api/models/Labels.js
Normal file
10
awx/ui_next/src/api/models/Labels.js
Normal 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;
|
202
awx/ui_next/src/components/MultiSelect/MultiSelect.jsx
Normal file
202
awx/ui_next/src/components/MultiSelect/MultiSelect.jsx
Normal 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;
|
4
awx/ui_next/src/components/MultiSelect/index.js
Normal file
4
awx/ui_next/src/components/MultiSelect/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
export {
|
||||
default
|
||||
}
|
||||
from './MultiSelect';
|
@ -13,6 +13,11 @@ describe('<JobTemplateAdd />', () => {
|
||||
name: '',
|
||||
playbook: '',
|
||||
project: '',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -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 });
|
||||
|
@ -19,6 +19,9 @@ describe('<JobTemplateEdit />', () => {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
labels: {
|
||||
results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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));
|
||||
|
@ -20,6 +20,7 @@ describe('<JobTemplateForm />', () => {
|
||||
id: 2,
|
||||
name: 'foo',
|
||||
},
|
||||
labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] },
|
||||
},
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user