diff --git a/awx/ui_next/src/api/Base.js b/awx/ui_next/src/api/Base.js index ef7c53460f..33e3626994 100644 --- a/awx/ui_next/src/api/Base.js +++ b/awx/ui_next/src/api/Base.js @@ -25,7 +25,9 @@ class Base { } read(params) { - return this.http.get(this.baseUrl, { params }); + return this.http.get(this.baseUrl, { + params, + }); } readDetail(id) { diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 5e7b221c77..d295502309 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -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, diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js index de24e298a7..44eef5529b 100644 --- a/awx/ui_next/src/api/models/JobTemplates.js +++ b/awx/ui_next/src/api/models/JobTemplates.js @@ -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; diff --git a/awx/ui_next/src/api/models/Labels.js b/awx/ui_next/src/api/models/Labels.js new file mode 100644 index 0000000000..0c0126b898 --- /dev/null +++ b/awx/ui_next/src/api/models/Labels.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Labels extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/labels/'; + } +} + +export default Labels; diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx new file mode 100644 index 0000000000..ea5a701ccd --- /dev/null +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx @@ -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 => ( + + {option.name.includes(input) ? ( + item.id === option.id)} + value={option.name} + onClick={e => { + this.handleClick(e, option); + }} + > + {option.name} + + ) : null} + + )); + + const chips = ( + + {chipItems && + chipItems.map(item => ( + { + this.removeChip(e, item); + }} + > + {item.name} + + ))} + + ); + return ( + + +
{ + this.node = node; + }} + > + this.setState({ isExpanded: true })} + onChange={this.handleInputChange} + onKeyDown={this.handleAddItem} + /> + Labels} + // Above is not rendered but is a required prop from Patternfly + isOpen={isExpanded} + dropdownItems={list} + /> +
+
{chips}
+
+
+ ); + } +} +export { MultiSelect as _MultiSelect }; +export default withI18n()(withRouter(MultiSelect)); diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx new file mode 100644 index 0000000000..422baacb84 --- /dev/null +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { sleep } from '@testUtils/testUtils'; +import MultiSelect, { _MultiSelect } from './MultiSelect'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +describe('', () => { + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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(); + }); +}); diff --git a/awx/ui_next/src/components/MultiSelect/index.js b/awx/ui_next/src/components/MultiSelect/index.js new file mode 100644 index 0000000000..8cda42c7cb --- /dev/null +++ b/awx/ui_next/src/components/MultiSelect/index.js @@ -0,0 +1 @@ +export { default } from './MultiSelect'; diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx index 859e327663..e9e579b673 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx @@ -13,6 +13,11 @@ describe('', () => { name: '', playbook: '', project: '', + summary_fields: { + user_capabilities: { + edit: true, + }, + }, }; afterEach(() => { diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index c488b3c8f6..655ececfe5 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -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 }); diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx index 35900e81a9..1448887e95 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -19,6 +19,9 @@ describe('', () => { user_capabilities: { edit: true, }, + labels: { + results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }], + }, }, }; @@ -33,9 +36,26 @@ describe('', () => { 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', () => { diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index daebb1770f..fc2ff53556 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -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 ( + + + + + + ); + } return ( { + handleSubmit(values, newLabels, removedLabels); }} - onSubmit={handleSubmit} render={formik => (
@@ -156,6 +281,24 @@ class JobTemplateForm extends Component { validate={required(null, i18n)} /> + + + + + + + + ', () => { 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( ); + 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('', () => { 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( + + ); + 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( + + ); + 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); + }); });