From 80bdb1a67a9c48b9b2d729585af2dc862e53f481 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 20 Aug 2019 14:05:20 -0700 Subject: [PATCH 01/16] add CollapsibleSection & ExpandingContainer components --- .../CollapsibleSection/CollapsibleSection.jsx | 54 +++++++++++++++++++ .../CollapsibleSection/ExpandingContainer.jsx | 36 +++++++++++++ .../components/CollapsibleSection/index.js | 1 + .../Template/shared/JobTemplateForm.jsx | 4 ++ 4 files changed, 95 insertions(+) create mode 100644 awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx create mode 100644 awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx create mode 100644 awx/ui_next/src/components/CollapsibleSection/index.js diff --git a/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx new file mode 100644 index 0000000000..4bae5b2c9d --- /dev/null +++ b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { bool, string } from 'prop-types'; +import styled from 'styled-components'; +import { Button } from '@patternfly/react-core'; +import { AngleRightIcon } from '@patternfly/react-icons'; +import ExpandingContainer from './ExpandingContainer'; + +const Toggle = styled.div` + display: flex; + + hr { + margin-left: 20px; + flex: 1 1 auto; + align-self: center; + border: 0; + border-bottom: 1px solid var(--pf-global--BorderColor--300); + } +`; + +const Arrow = styled(AngleRightIcon)` + margin-right: -5px; + margin-left: 5px; + transition: transform 0.1s ease-out; + transform-origin: 50% 50%; + ${(props) => props.isExpanded && `transform: rotate(90deg);`} +`; + +function CollapsibleSection({ label, startExpanded, children }) { + const [isExpanded, setIsExpanded] = useState(startExpanded); + const toggle = () => setIsExpanded(!isExpanded); + + return ( +
+ + +
+
+ + {children} + +
+ ); +} +CollapsibleSection.propTypes = { + label: string.isRequired, + startExpanded: bool, +}; +CollapsibleSection.defaultProps = { + startExpanded: false, +}; + +export default CollapsibleSection; diff --git a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx new file mode 100644 index 0000000000..9ee27c52ff --- /dev/null +++ b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx @@ -0,0 +1,36 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { bool } from 'prop-types'; +import styled from 'styled-components'; + +const Container = styled.div` + margin: 15px 0; + transition: all 0.2s ease-out; + overflow: hidden; +`; + +function ExpandingContainer({ isExpanded, children }) { + const [contentHeight, setContentHeight] = useState(0); + const ref = useRef(null); + useEffect(() => { + setContentHeight(ref.current.scrollHeight); + }); + const height = isExpanded ? contentHeight : '0'; + return ( + + {children} + + ); +} +ExpandingContainer.propTypes = { + isExpanded: bool, +}; +ExpandingContainer.defaultProps = { + isExpanded: false, +}; + +export default ExpandingContainer; diff --git a/awx/ui_next/src/components/CollapsibleSection/index.js b/awx/ui_next/src/components/CollapsibleSection/index.js new file mode 100644 index 0000000000..a4623e90ed --- /dev/null +++ b/awx/ui_next/src/components/CollapsibleSection/index.js @@ -0,0 +1 @@ +export { default } from './CollapsibleSection'; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index d7a699c387..a0bd23e30c 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -13,6 +13,7 @@ import MultiSelect from '@components/MultiSelect'; import FormActionGroup from '@components/FormActionGroup'; import FormField from '@components/FormField'; import FormRow from '@components/FormRow'; +import CollapsibleSection from '@components/CollapsibleSection'; import { required } from '@util/validators'; import styled from 'styled-components'; import { JobTemplate } from '@types'; @@ -415,6 +416,9 @@ class JobTemplateForm extends Component { /> + + Advanced inputs here + ); From 6fd86fed65f8d29399c3479977e3e7d11667b564 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 21 Aug 2019 16:10:29 -0700 Subject: [PATCH 02/16] add jt advanced fields --- .../Template/shared/InventoriesLookup.jsx | 1 + .../Template/shared/JobTemplateForm.jsx | 121 +++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx b/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx index 3bef9f782a..f4ef84771d 100644 --- a/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx +++ b/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx @@ -27,6 +27,7 @@ class InventoriesLookup extends React.Component { )} } + isRequired={required} fieldId="inventories-lookup" > @@ -271,7 +279,6 @@ class JobTemplateForm extends Component { ); } - return (
@@ -417,7 +424,101 @@ class JobTemplateForm extends Component { - Advanced inputs here + + + {i18n._(t`The number of parallel or simultaneous + processes to use while executing the playbook. An empty value, + or a value less than 1 will use the Ansible default which is + usually 5. The default number of forks can be overwritten + with a change to`)}{' '} + ansible.cfg.{' '} + {i18n._(t`Refer to the Ansible documentation for details + about the configuration file.`)} + + } + /> + + ( + + + + + + + )} + /> + + + ( + + + + +
+ + form.setFieldValue(field.name, checked) + } + /> +
+
+ )} + /> +
@@ -433,8 +534,14 @@ const FormikApp = withFormik({ description = '', job_type = 'run', inventory = '', - playbook = '', project = '', + playbook = '', + forks, + limit, + verbosity, + job_slicing, + timeout, + diff_mode summary_fields = { labels: { results: [] } }, } = { ...template }; @@ -446,6 +553,12 @@ const FormikApp = withFormik({ project: project || '', playbook: playbook || '', labels: summary_fields.labels.results, + forks: forks || '', + limit: limit || '', + verbosity: verbosity || '0', + job_slice_count: job_slicing || '', + timout: timeout || '', + diff_mode: diff_mode || false, }; }, handleSubmit: (values, bag) => bag.props.handleSubmit(values), From 8a31be6ffef5ff15014b500d5f7f2a743afc54e9 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 22 Aug 2019 14:48:25 -0700 Subject: [PATCH 03/16] refactor lookup components --- .../Lookup}/InstanceGroupsLookup.jsx | 0 .../Lookup}/InventoriesLookup.jsx | 0 awx/ui_next/src/components/Lookup/index.js | 2 + .../Organization/shared/OrganizationForm.jsx | 3 +- .../src/screens/Organization/shared/index.js | 2 +- .../Template/shared/JobTemplateForm.jsx | 244 ++++++++++-------- 6 files changed, 137 insertions(+), 114 deletions(-) rename awx/ui_next/src/{screens/Organization/shared => components/Lookup}/InstanceGroupsLookup.jsx (100%) rename awx/ui_next/src/{screens/Template/shared => components/Lookup}/InventoriesLookup.jsx (100%) diff --git a/awx/ui_next/src/screens/Organization/shared/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx similarity index 100% rename from awx/ui_next/src/screens/Organization/shared/InstanceGroupsLookup.jsx rename to awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx diff --git a/awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx b/awx/ui_next/src/components/Lookup/InventoriesLookup.jsx similarity index 100% rename from awx/ui_next/src/screens/Template/shared/InventoriesLookup.jsx rename to awx/ui_next/src/components/Lookup/InventoriesLookup.jsx diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index 5aeea8d028..abd530b8eb 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -1 +1,3 @@ export { default } from './Lookup'; +export { default as InstanceGroupsLookup } from './InstanceGroupsLookup'; +export { default as InventoriesLookup } from './InventoriesLookup'; diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx index 360a195048..2bc0dcdc4d 100644 --- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx +++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx @@ -15,10 +15,9 @@ import FormRow from '@components/FormRow'; import FormField from '@components/FormField'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import AnsibleSelect from '@components/AnsibleSelect'; +import { InstanceGroupsLookup } from '@components/Lookup/'; import { required, minMaxValue } from '@util/validators'; -import InstanceGroupsLookup from './InstanceGroupsLookup'; - class OrganizationForm extends Component { constructor(props) { super(props); diff --git a/awx/ui_next/src/screens/Organization/shared/index.js b/awx/ui_next/src/screens/Organization/shared/index.js index 7f931cff31..2ddcf675b7 100644 --- a/awx/ui_next/src/screens/Organization/shared/index.js +++ b/awx/ui_next/src/screens/Organization/shared/index.js @@ -1,2 +1,2 @@ -export { default as InstanceGroupsLookup } from './InstanceGroupsLookup'; +/* eslint-disable-next-line import/prefer-default-export */ export { default as OrganizationForm } from './OrganizationForm'; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 4713fe8b06..13218a68f5 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -17,18 +17,13 @@ import CollapsibleSection from '@components/CollapsibleSection'; import { required } from '@util/validators'; import styled from 'styled-components'; import { JobTemplate } from '@types'; -import InventoriesLookup from './InventoriesLookup'; +import { InventoriesLookup, InstanceGroupsLookup } from '@components/Lookup'; import ProjectLookup from './ProjectLookup'; -import { LabelsAPI, ProjectsAPI } from '@api'; +import { JobTemplatesAPI, LabelsAPI, ProjectsAPI } 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 = { @@ -64,6 +59,7 @@ class JobTemplateForm extends Component { project: props.template.summary_fields.project, inventory: props.template.summary_fields.inventory, relatedProjectPlaybooks: props.relatedProjectPlaybooks, + relatedInstanceGroups: [], }; this.handleNewLabel = this.handleNewLabel.bind(this); this.loadLabels = this.loadLabels.bind(this); @@ -74,21 +70,29 @@ class JobTemplateForm extends Component { ); } - async componentDidMount() { + componentDidMount() { const { validateField } = this.props; - await this.loadLabels(QSConfig); validateField('project'); + this.setState({ contentError: null, hasContentLoading: true }); + Promise.all([this.loadLabels(), this.loadRelatedInstanceGroups()]).then( + () => { + this.setState({ hasContentLoading: false }); + } + ); } - async loadLabels(QueryConfig) { + 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 + // enough. This can be updated to allow more than 400 labels if we // decide it is necessary. - this.setState({ contentError: null, hasContentLoading: true }); let loadedLabels; try { - const { data } = await LabelsAPI.read(QueryConfig); + 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 { @@ -103,8 +107,19 @@ class JobTemplateForm extends Component { this.setState({ loadedLabels }); } catch (err) { this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); + } + } + + async loadRelatedInstanceGroups() { + const { template } = this.props; + if (!template.id) { + return; + } + try { + const { data } = await JobTemplatesAPI.readInstanceGroups(template.id); + console.log(data.results); + } catch (err) { + this.setState({ contentError: err }); } } @@ -424,101 +439,108 @@ class JobTemplateForm extends Component { - - - {i18n._(t`The number of parallel or simultaneous - processes to use while executing the playbook. An empty value, - or a value less than 1 will use the Ansible default which is - usually 5. The default number of forks can be overwritten - with a change to`)}{' '} - ansible.cfg.{' '} - {i18n._(t`Refer to the Ansible documentation for details - about the configuration file.`)} - - } - /> - - ( - - - - - - - )} - /> - - - ( - - - - -
- - form.setFieldValue(field.name, checked) - } - /> -
-
- )} - /> -
+ + + {i18n._(t`The number of parallel or simultaneous + processes to use while executing the playbook. An empty value, + or a value less than 1 will use the Ansible default which is + usually 5. The default number of forks can be overwritten + with a change to`)}{' '} + ansible.cfg.{' '} + {i18n._(t`Refer to the Ansible documentation for details + about the configuration file.`)} + + } + /> + + ( + + + + + + + )} + /> + + + ( + + + + +
+ + form.setFieldValue(field.name, checked) + } + /> +
+
+ )} + /> +
+ {/* */}
@@ -541,7 +563,7 @@ const FormikApp = withFormik({ verbosity, job_slicing, timeout, - diff_mode + diff_mode, summary_fields = { labels: { results: [] } }, } = { ...template }; From 3a9a884bbc1c22c9f31a05d0b660ae4e085be916 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 27 Aug 2019 08:41:30 -0700 Subject: [PATCH 04/16] add omitProps helper --- .../CollapsibleSection/CollapsibleSection.jsx | 5 +++-- awx/ui_next/src/util/omitProps.jsx | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 awx/ui_next/src/util/omitProps.jsx diff --git a/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx index 4bae5b2c9d..184106a177 100644 --- a/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx +++ b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.jsx @@ -3,6 +3,7 @@ import { bool, string } from 'prop-types'; import styled from 'styled-components'; import { Button } from '@patternfly/react-core'; import { AngleRightIcon } from '@patternfly/react-icons'; +import omitProps from '@util/omitProps'; import ExpandingContainer from './ExpandingContainer'; const Toggle = styled.div` @@ -17,12 +18,12 @@ const Toggle = styled.div` } `; -const Arrow = styled(AngleRightIcon)` +const Arrow = styled(omitProps(AngleRightIcon, 'isExpanded'))` margin-right: -5px; margin-left: 5px; transition: transform 0.1s ease-out; transform-origin: 50% 50%; - ${(props) => props.isExpanded && `transform: rotate(90deg);`} + ${props => props.isExpanded && `transform: rotate(90deg);`} `; function CollapsibleSection({ label, startExpanded, children }) { diff --git a/awx/ui_next/src/util/omitProps.jsx b/awx/ui_next/src/util/omitProps.jsx new file mode 100644 index 0000000000..e9c1f8f975 --- /dev/null +++ b/awx/ui_next/src/util/omitProps.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export default function omitProps(Component, ...omit) { + return function Omit(props) { + const clean = { ...props }; + omit.forEach(key => { + delete clean[key]; + }) + return ; + } +} From 4d31d83e1e4819e73feaa2bfc439e2c7b4c4f3c7 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 27 Aug 2019 10:52:02 -0700 Subject: [PATCH 05/16] add instance groups to JT form --- .../Template/shared/JobTemplateForm.jsx | 198 +++++++++--------- awx/ui_next/src/util/omitProps.jsx | 5 + awx/ui_next/src/util/omitProps.test.jsx | 35 ++++ 3 files changed, 144 insertions(+), 94 deletions(-) create mode 100644 awx/ui_next/src/util/omitProps.test.jsx diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 13218a68f5..c49a973976 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -68,6 +68,9 @@ class JobTemplateForm extends Component { this.loadRelatedProjectPlaybooks = this.loadRelatedProjectPlaybooks.bind( this ); + this.handleInstanceGroupsChange = this.handleInstanceGroupsChange.bind( + this + ); } componentDidMount() { @@ -116,6 +119,7 @@ class JobTemplateForm extends Component { return; } try { + console.log('loading...'); const { data } = await JobTemplatesAPI.readInstanceGroups(template.id); console.log(data.results); } catch (err) { @@ -217,6 +221,10 @@ class JobTemplateForm extends Component { }; } + handleInstanceGroupsChange(relatedInstanceGroups) { + this.setState({ relatedInstanceGroups }); + } + render() { const { loadedLabels, @@ -225,6 +233,9 @@ class JobTemplateForm extends Component { inventory, project, relatedProjectPlaybooks = [], + newLabels, + removedLabels, + relatedInstanceGroups, } = this.state; const { handleCancel, @@ -271,13 +282,13 @@ class JobTemplateForm extends Component { ] ); - const verbosityOptions = [ - { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, - { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, - { value: '2', key: '2', label: i18n._(t`2 (More Verbose)`) }, - { value: '3', key: '3', label: i18n._(t`3 (Debug)`) }, - { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) }, - ]; + const verbosityOptions = [ + { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, + { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, + { value: '2', key: '2', label: i18n._(t`2 (More Verbose)`) }, + { value: '3', key: '3', label: i18n._(t`3 (Debug)`) }, + { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) }, + ]; if (hasContentLoading) { return ( @@ -439,108 +450,107 @@ class JobTemplateForm extends Component { - - - {i18n._(t`The number of parallel or simultaneous + + + {i18n._(t`The number of parallel or simultaneous processes to use while executing the playbook. An empty value, or a value less than 1 will use the Ansible default which is usually 5. The default number of forks can be overwritten with a change to`)}{' '} - ansible.cfg.{' '} - {i18n._(t`Refer to the Ansible documentation for details + ansible.cfg.{' '} + {i18n._(t`Refer to the Ansible documentation for details about the configuration file.`)} - - } - /> - + } + /> + - ( - - + ( + + - - - - - )} - /> - + + + + + )} + /> + - + - ( - - + ( + + - - -
- - form.setFieldValue(field.name, checked) - } - /> -
-
- )} - /> -
- {/* */} + > + + +
+ + form.setFieldValue(field.name, checked) + } + /> +
+ + )} + /> +
+
diff --git a/awx/ui_next/src/util/omitProps.jsx b/awx/ui_next/src/util/omitProps.jsx index e9c1f8f975..bb6b92f150 100644 --- a/awx/ui_next/src/util/omitProps.jsx +++ b/awx/ui_next/src/util/omitProps.jsx @@ -1,5 +1,10 @@ import React from 'react'; +/* + * Prevents styled-components from passing down an unsupported + * props to children, resulting in console warnings. + * https://github.com/styled-components/styled-components/issues/439 + */ export default function omitProps(Component, ...omit) { return function Omit(props) { const clean = { ...props }; diff --git a/awx/ui_next/src/util/omitProps.test.jsx b/awx/ui_next/src/util/omitProps.test.jsx new file mode 100644 index 0000000000..d2aef5eac8 --- /dev/null +++ b/awx/ui_next/src/util/omitProps.test.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import omitProps from './omitProps'; + +describe('omitProps', () => { + test('should render child component', () => { + const Omit = omitProps('div'); + const wrapper = mount(); + + const div = wrapper.find('div'); + expect(div).toHaveLength(1); + expect(div.prop('foo')).toEqual('one'); + expect(div.prop('bar')).toEqual('two'); + }); + + test('should not pass omitted props to child component', () => { + const Omit = omitProps('div', 'foo', 'bar'); + const wrapper = mount(); + + const div = wrapper.find('div'); + expect(div).toHaveLength(1); + expect(div.prop('foo')).toEqual(undefined); + expect(div.prop('bar')).toEqual(undefined); + }); + + test('should support mix of omitted and non-omitted props', () => { + const Omit = omitProps('div', 'foo'); + const wrapper = mount(); + + const div = wrapper.find('div'); + expect(div).toHaveLength(1); + expect(div.prop('foo')).toEqual(undefined); + expect(div.prop('bar')).toEqual('two'); + }); +}) From 9edc686ab5b4954177c3f8293a4707c728c86464 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 27 Aug 2019 16:28:54 -0700 Subject: [PATCH 06/16] add generic onChange prop to MultiSelect --- .../CollapsibleSection/ExpandingContainer.jsx | 5 ++- .../components/MultiSelect/MultiSelect.jsx | 26 +++++++++---- .../MultiSelect/MultiSelect.test.jsx | 9 +++++ .../Template/shared/JobTemplateForm.jsx | 37 +++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx index 9ee27c52ff..7046a29f85 100644 --- a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx +++ b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx @@ -5,7 +5,9 @@ import styled from 'styled-components'; const Container = styled.div` margin: 15px 0; transition: all 0.2s ease-out; - overflow: hidden; + ${props => !props.isExpanded && ` + overflow: hidden; + `} `; function ExpandingContainer({ isExpanded, children }) { @@ -21,6 +23,7 @@ function ExpandingContainer({ isExpanded, children }) { css={` height: ${height}px; `} + isExpanded={isExpanded} > {children} diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx index cb386b67b6..62352b0036 100644 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx @@ -45,10 +45,17 @@ class MultiSelect extends Component { name: PropTypes.string.isRequired, }) ).isRequired, - onAddNewItem: PropTypes.func.isRequired, - onRemoveItem: PropTypes.func.isRequired, + onAddNewItem: PropTypes.func, + onRemoveItem: PropTypes.func, + onChange: PropTypes.func, }; + static defaultProps = { + onAddNewItem: () => {}, + onRemoveItem: () => {}, + onChange: () => {}, + } + constructor(props) { super(props); this.state = { @@ -92,19 +99,21 @@ class MultiSelect extends Component { handleSelection(e, item) { const { chipItems } = this.state; - const { onAddNewItem } = this.props; + const { onAddNewItem, onChange } = this.props; e.preventDefault(); + const items = chipItems.concat({ name: item.name, id: item.id }); this.setState({ - chipItems: chipItems.concat({ name: item.name, id: item.id }), + chipItems: items, isExpanded: false, }); onAddNewItem(item); + onChange(items); } handleAddItem(event) { const { input, chipItems } = this.state; - const { onAddNewItem } = this.props; + const { onAddNewItem, onChange } = this.props; const isIncluded = chipItems.some(chipItem => chipItem.name === input); if (!input) { @@ -120,12 +129,14 @@ class MultiSelect extends Component { } if (event.key === 'Enter') { event.preventDefault(); + const items = chipItems.concat({ name: input, id: input }); this.setState({ - chipItems: chipItems.concat({ name: input, id: input }), + chipItems: items, isExpanded: false, input: '', }); onAddNewItem(input); + onChange(items); } else if (event.key === 'Tab') { this.setState({ input: '' }); } @@ -136,12 +147,13 @@ class MultiSelect extends Component { } removeChip(e, item) { - const { onRemoveItem } = this.props; + const { onRemoveItem, onChange } = this.props; const { chipItems } = this.state; const chips = chipItems.filter(chip => chip.id !== item.id); this.setState({ chipItems: chips }); onRemoveItem(item); + onChange(chips); e.preventDefault(); } diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx index 66996fcdb7..3832cea6fe 100644 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx @@ -28,6 +28,7 @@ describe('', () => { expect(getInitialChipItems).toBeCalled(); expect(component.state().chipItems.length).toBe(2); }); + test('handleSelection add item to chipItems', async () => { const wrapper = mountWithContexts( ', () => { 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 onChange = jest.fn(); const wrapper = mountWithContexts( @@ -68,14 +72,18 @@ describe('', () => { expect(component.state().input.length).toBe(0); expect(component.state().isExpanded).toBe(false); expect(onAddNewItem).toBeCalled(); + expect(onChange).toBeCalled(); }); + test('removeChip removes chip properly', () => { const onRemoveItem = jest.fn(); + const onChange = jest.fn(); const wrapper = mountWithContexts( @@ -89,5 +97,6 @@ describe('', () => { .removeChip(event, { name: 'Foo', id: 1, organization: 1 }); expect(component.state().chipItems.length).toBe(1); expect(onRemoveItem).toBeCalled(); + expect(onChange).toBeCalled(); }); }); diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index c49a973976..518f0ebc72 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -501,6 +501,7 @@ class JobTemplateForm extends Component { id="template-job-slicing" name="job_slice_count" type="number" + min="1" label={i18n._(t`Job Slicing`)} tooltip={i18n._(t`Divide the work done by this job template into the specified number of job slices, each running the @@ -551,6 +552,42 @@ class JobTemplateForm extends Component { t`Select the Instance Groups for this Organization to run on.` )} /> + + + + + + + + + + + + From 218348412b2962aaf3148e5f6ea82bba55fab88d Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 28 Aug 2019 15:45:35 -0700 Subject: [PATCH 07/16] creat TagMultiSelect component; cleanup MultiSelect --- .../components/MultiSelect/MultiSelect.jsx | 67 ++++++++++++------- .../MultiSelect/MultiSelect.test.jsx | 17 ++--- .../components/MultiSelect/TagMultiSelect.jsx | 39 +++++++++++ .../src/components/MultiSelect/index.js | 1 + .../Template/shared/JobTemplateForm.jsx | 50 ++++++++------ 5 files changed, 118 insertions(+), 56 deletions(-) create mode 100644 awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx index 62352b0036..930664334d 100644 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.jsx @@ -1,7 +1,5 @@ import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { withI18n } from '@lingui/react'; -import { withRouter } from 'react-router-dom'; +import { shape, number, string, func, arrayOf, oneOfType } from 'prop-types'; import { Chip, ChipGroup } from '@components/Chip'; import { Dropdown as PFDropdown, @@ -15,11 +13,13 @@ 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 { @@ -38,23 +38,28 @@ const Dropdown = styled(PFDropdown)` } `; +const Item = shape({ + id: oneOfType([number, string]).isRequired, + name: string.isRequired, +}); + class MultiSelect extends Component { static propTypes = { - associatedItems: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string.isRequired, - }) - ).isRequired, - onAddNewItem: PropTypes.func, - onRemoveItem: PropTypes.func, - onChange: PropTypes.func, + associatedItems: arrayOf(Item).isRequired, + options: arrayOf(Item), + onAddNewItem: func, + onRemoveItem: func, + onChange: func, + createNewItem: func, }; static defaultProps = { onAddNewItem: () => {}, onRemoveItem: () => {}, onChange: () => {}, - } + options: [], + createNewItem: null, + }; constructor(props) { super(props); @@ -68,6 +73,7 @@ class MultiSelect extends Component { this.handleSelection = this.handleSelection.bind(this); this.removeChip = this.removeChip.bind(this); this.handleClick = this.handleClick.bind(this); + this.createNewItem = this.createNewItem.bind(this); } componentDidMount() { @@ -80,11 +86,7 @@ class MultiSelect extends Component { getInitialChipItems() { const { associatedItems } = this.props; - return associatedItems.map(item => ({ - name: item.name, - id: item.id, - organization: item.organization, - })); + return associatedItems.map(item => ({ ...item })); } handleClick(e, option) { @@ -111,9 +113,21 @@ class MultiSelect extends Component { onChange(items); } + createNewItem(name) { + const { createNewItem } = this.props; + if (createNewItem) { + return createNewItem(name); + } + return { + id: Math.random(), + name, + }; + } + handleAddItem(event) { const { input, chipItems } = this.state; - const { onAddNewItem, onChange } = this.props; + const { options, onAddNewItem, onChange } = this.props; + const match = options.find(item => item.name === input); const isIncluded = chipItems.some(chipItem => chipItem.name === input); if (!input) { @@ -127,23 +141,25 @@ class MultiSelect extends Component { this.setState({ input: '', isExpanded: false }); return; } - if (event.key === 'Enter') { + 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(input); + onAddNewItem(newItem); onChange(items); - } else if (event.key === 'Tab') { - this.setState({ input: '' }); + } else if (!isNewItem || event.key === 'Tab') { + this.setState({ isExpanded: false, input: '' }); } } - handleInputChange(e) { - this.setState({ input: e, isExpanded: true }); + handleInputChange(value) { + this.setState({ input: value, isExpanded: true }); } removeChip(e, item) { @@ -226,5 +242,4 @@ class MultiSelect extends Component { ); } } -export { MultiSelect as _MultiSelect }; -export default withI18n()(withRouter(MultiSelect)); +export default MultiSelect; diff --git a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx index 3832cea6fe..e52e56b3c1 100644 --- a/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx +++ b/awx/ui_next/src/components/MultiSelect/MultiSelect.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; +import { mount } from 'enzyme'; import { sleep } from '@testUtils/testUtils'; -import MultiSelect, { _MultiSelect } from './MultiSelect'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import MultiSelect from './MultiSelect'; describe('', () => { const associatedItems = [ @@ -11,11 +11,7 @@ describe('', () => { 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 wrapper = mount( ', () => { ); 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 wrapper = mount( ', () => { test('handleAddItem adds a chip only when Tab is pressed', () => { const onAddNewItem = jest.fn(); const onChange = jest.fn(); - const wrapper = mountWithContexts( + const wrapper = mount( ', () => { const onRemoveItem = jest.fn(); const onChange = jest.fn(); - const wrapper = mountWithContexts( + const wrapper = mount( v.name).join(','); +} + +function stringToArray(value) { + return value.split(',').map(v => ({ + id: v, + name: v, + })); +} + +function TagMultiSelect ({ onChange, value }) { + const [options, setOptions] = useState(stringToArray(value)); + + return ( + { onChange(arrayToString(val)) }} + onAddNewItem={(newItem) => { + if (!options.find(o => o.name === newItem.name)) { + setOptions(options.concat(newItem)); + } + }} + associatedItems={stringToArray(value)} + options={options} + createNewItem={(name) => ({ id: name, name })} + /> + ) +} + +TagMultiSelect.propTypes = { + onChange: func.isRequired, + value: string.isRequired, +} + +export default TagMultiSelect; diff --git a/awx/ui_next/src/components/MultiSelect/index.js b/awx/ui_next/src/components/MultiSelect/index.js index 8cda42c7cb..d983765272 100644 --- a/awx/ui_next/src/components/MultiSelect/index.js +++ b/awx/ui_next/src/components/MultiSelect/index.js @@ -1 +1,2 @@ export { default } from './MultiSelect'; +export { default as TagMultiSelect } from './TagMultiSelect'; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 518f0ebc72..bd686bf3cf 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -9,7 +9,7 @@ import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-ic import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import AnsibleSelect from '@components/AnsibleSelect'; -import MultiSelect from '@components/MultiSelect'; +import MultiSelect, { TagMultiSelect } from '@components/MultiSelect'; import FormActionGroup from '@components/FormActionGroup'; import FormField from '@components/FormField'; import FormRow from '@components/FormRow'; @@ -552,24 +552,32 @@ class JobTemplateForm extends Component { t`Select the Instance Groups for this Organization to run on.` )} /> - - - - - - + { + return ( + + + + + form.setFieldValue(field.name, value)} + value={field.value} + /> + + ); + }} + /> bag.props.handleSubmit(values), From e6475f21f6d4dfb25cbf4f73aa48600a992341f8 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 30 Aug 2019 16:31:03 -0700 Subject: [PATCH 08/16] flush out JT form; upgrade enzyme; add CheckboxField --- awx/ui_next/package-lock.json | 221 ++++++++++++------ awx/ui_next/package.json | 4 +- .../AnsibleSelect/AnsibleSelect.jsx | 22 +- .../components/FormField/CheckboxField.jsx | 54 +++++ awx/ui_next/src/components/FormField/index.js | 1 + .../components/MultiSelect/TagMultiSelect.jsx | 10 +- .../MultiSelect/TagMultiSelect.test.jsx | 34 +++ .../Template/shared/JobTemplateForm.jsx | 162 ++++++++++--- 8 files changed, 388 insertions(+), 120 deletions(-) create mode 100644 awx/ui_next/src/components/FormField/CheckboxField.jsx create mode 100644 awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index 79dae38600..db64b4ef90 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -2072,9 +2072,9 @@ "dev": true }, "@types/node": { - "version": "11.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-11.13.4.tgz", - "integrity": "sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ==", + "version": "12.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.2.tgz", + "integrity": "sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg==", "dev": true }, "@types/stack-utils": { @@ -2338,13 +2338,13 @@ "integrity": "sha512-ugTb7Lq7u4GfWSqqpwE0bGyoBZNMTok/zDBXxfEG0QM50jNlGhIWjRC1pPN7bvV1anhF+bs+/gNcRw+o55Evbg==" }, "airbnb-prop-types": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.13.2.tgz", - "integrity": "sha512-2FN6DlHr6JCSxPPi25EnqGaXC4OC3/B3k1lCd6MMYrZ51/Gf/1qDfaR+JElzWa+Tl7cY2aYOlsYJGFeQyVHIeQ==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.15.0.tgz", + "integrity": "sha512-jUh2/hfKsRjNFC4XONQrxo/n/3GG4Tn6Hl0WlFQN5PY9OMC9loSCoAYKnZsWaP8wEfd5xcrPloK0Zg6iS1xwVA==", "dev": true, "requires": { - "array.prototype.find": "^2.0.4", - "function.prototype.name": "^1.1.0", + "array.prototype.find": "^2.1.0", + "function.prototype.name": "^1.1.1", "has": "^1.0.3", "is-regex": "^1.0.4", "object-is": "^1.0.1", @@ -2352,9 +2352,21 @@ "object.entries": "^1.1.0", "prop-types": "^15.7.2", "prop-types-exact": "^1.2.0", - "react-is": "^16.8.6" + "react-is": "^16.9.0" }, "dependencies": { + "function.prototype.name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.1.tgz", + "integrity": "sha512-e1NzkiJuw6xqVH7YSdiW/qDHebcmMhPNe6w+4ZYYEg0VA+LaLzx37RimbPLuonHhYGFGPx1ME2nSi74JiaCr/Q==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1", + "functions-have-names": "^1.1.1", + "is-callable": "^1.1.4" + } + }, "object.entries": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.0.tgz", @@ -2379,9 +2391,9 @@ } }, "react-is": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", + "integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==", "dev": true } } @@ -2949,13 +2961,35 @@ "dev": true }, "array.prototype.find": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.0.4.tgz", - "integrity": "sha1-VWpcU2LAhkgyPdrrnenRS8GGTJA=", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.1.0.tgz", + "integrity": "sha512-Wn41+K1yuO5p7wRZDl7890c3xvv5UBrfVXTVIe28rSQb6LS0fZMDrQB6PAcxQFRFy6vJTLDc3A2+3CjQdzVKRg==", "dev": true, "requires": { - "define-properties": "^1.1.2", - "es-abstract": "^1.7.0" + "define-properties": "^1.1.3", + "es-abstract": "^1.13.0" + }, + "dependencies": { + "es-abstract": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz", + "integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.0", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "is-callable": "^1.1.4", + "is-regex": "^1.0.4", + "object-keys": "^1.0.12" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + } } }, "array.prototype.flat": { @@ -5183,7 +5217,7 @@ }, "css-select": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", "dev": true, "requires": { @@ -5886,9 +5920,9 @@ "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" }, "enzyme": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.9.0.tgz", - "integrity": "sha512-JqxI2BRFHbmiP7/UFqvsjxTirWoM1HfeaJrmVSZ9a1EADKkZgdPcAuISPMpoUiHlac9J4dYt81MC5BBIrbJGMg==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.10.0.tgz", + "integrity": "sha512-p2yy9Y7t/PFbPoTvrWde7JIYB2ZyGC+NgTNbVEGvZ5/EyoYSr9aG/2rSbVvyNvMHEhw9/dmGUJHWtfQIEiX9pg==", "dev": true, "requires": { "array.prototype.flat": "^1.2.1", @@ -5915,18 +5949,19 @@ } }, "enzyme-adapter-react-16": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.12.1.tgz", - "integrity": "sha512-GB61gvY97XvrA6qljExGY+lgI6BBwz+ASLaRKct9VQ3ozu0EraqcNn3CcrUckSGIqFGa1+CxO5gj5is5t3lwrw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.14.0.tgz", + "integrity": "sha512-7PcOF7pb4hJUvjY7oAuPGpq3BmlCig3kxXGi2kFx0YzJHppqX1K8IIV9skT1IirxXlu8W7bneKi+oQ10QRnhcA==", "dev": true, "requires": { - "enzyme-adapter-utils": "^1.11.0", + "enzyme-adapter-utils": "^1.12.0", + "has": "^1.0.3", "object.assign": "^4.1.0", "object.values": "^1.1.0", "prop-types": "^15.7.2", "react-is": "^16.8.6", "react-test-renderer": "^16.0.0-0", - "semver": "^5.6.0" + "semver": "^5.7.0" }, "dependencies": { "prop-types": { @@ -5941,20 +5976,26 @@ } }, "react-is": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", + "integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "dev": true } } }, "enzyme-adapter-utils": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.11.0.tgz", - "integrity": "sha512-0VZeoE9MNx+QjTfsjmO1Mo+lMfunucYB4wt5ficU85WB/LoetTJrbuujmHP3PJx6pSoaAuLA+Mq877x4LoxdNg==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.12.0.tgz", + "integrity": "sha512-wkZvE0VxcFx/8ZsBw0iAbk3gR1d9hK447ebnSYBf95+r32ezBq+XDSAvRErkc4LZosgH8J7et7H7/7CtUuQfBA==", "dev": true, "requires": { - "airbnb-prop-types": "^2.12.0", + "airbnb-prop-types": "^2.13.2", "function.prototype.name": "^1.1.0", "object.assign": "^4.1.0", "object.fromentries": "^2.0.0", @@ -5974,9 +6015,9 @@ } }, "react-is": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", + "integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==", "dev": true } } @@ -7940,6 +7981,12 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "functions-have-names": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.1.1.tgz", + "integrity": "sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw==", + "dev": true + }, "fuzzaldrin": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fuzzaldrin/-/fuzzaldrin-2.1.0.tgz", @@ -8348,9 +8395,9 @@ } }, "html-element-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.0.1.tgz", - "integrity": "sha512-BZSfdEm6n706/lBfXKWa4frZRZcT5k1cOusw95ijZsHlI+GdgY0v95h6IzO3iIDf2ROwq570YTwqNPqHcNMozw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.1.0.tgz", + "integrity": "sha512-iqiG3dTZmy+uUaTmHarTL+3/A2VW9ox/9uasKEZC+R/wAtUrTcRlXPSaPqsnWPfIu8wqn09jQNwMRqzL54jSYA==", "dev": true, "requires": { "array-filter": "^1.0.0" @@ -8396,9 +8443,9 @@ }, "dependencies": { "readable-stream": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.3.0.tgz", - "integrity": "sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -8406,13 +8453,19 @@ "util-deprecate": "^1.0.1" } }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", + "dev": true + }, "string_decoder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.2.0.tgz", - "integrity": "sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "requires": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } } } @@ -11809,9 +11862,9 @@ "dev": true }, "nearley": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.16.0.tgz", - "integrity": "sha512-Tr9XD3Vt/EujXbZBv6UAHYoLUSMQAxSsTnm9K3koXzjzNWY195NqALeyrzLZBKzAkL3gl92BcSogqrHjD8QuUg==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.18.0.tgz", + "integrity": "sha512-/zQOMCeJcioI0xJtd5RpBiWw2WP7wLe6vq8/3Yu0rEwgus/G/+pViX80oA87JdVgjRt2895mZSv2VfZmy4W1uw==", "dev": true, "requires": { "commander": "^2.19.0", @@ -13376,32 +13429,22 @@ } }, "react-test-renderer": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.6.tgz", - "integrity": "sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==", + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.9.0.tgz", + "integrity": "sha512-R62stB73qZyhrJo7wmCW9jgl/07ai+YzvouvCXIJLBkRlRqLx4j9RqcLEAfNfU3OxTGucqR2Whmn3/Aad6L3hQ==", "dev": true, "requires": { "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "react-is": "^16.8.6", - "scheduler": "^0.13.6" + "react-is": "^16.9.0", + "scheduler": "^0.15.0" }, "dependencies": { "react-is": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", + "integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==", "dev": true - }, - "scheduler": { - "version": "0.13.6", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", - "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", - "dev": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } } } }, @@ -14364,6 +14407,16 @@ "object-assign": "^4.1.1" } }, + "scheduler": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz", + "integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "schema-utils": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", @@ -15250,14 +15303,36 @@ } }, "string.prototype.trim": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz", - "integrity": "sha1-0E3iyJ4Tf019IG8Ia17S+ua+jOo=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.0.tgz", + "integrity": "sha512-9EIjYD/WdlvLpn987+ctkLf0FfvBefOCuiEr2henD8X+7jfwPnyvTdmW8OJhj5p+M0/96mBdynLWkxUr+rHlpg==", "dev": true, "requires": { - "define-properties": "^1.1.2", - "es-abstract": "^1.5.0", - "function-bind": "^1.0.2" + "define-properties": "^1.1.3", + "es-abstract": "^1.13.0", + "function-bind": "^1.1.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz", + "integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.0", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "is-callable": "^1.1.4", + "is-regex": "^1.0.4", + "object-keys": "^1.0.12" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + } } }, "string_decoder": { diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json index 18236a002d..44f3e2926d 100644 --- a/awx/ui_next/package.json +++ b/awx/ui_next/package.json @@ -33,8 +33,8 @@ "babel-plugin-macros": "^2.4.2", "babel-plugin-styled-components": "^1.10.0", "css-loader": "^1.0.0", - "enzyme": "^3.9.0", - "enzyme-adapter-react-16": "^1.12.1", + "enzyme": "^3.10.0", + "enzyme-adapter-react-16": "^1.14.0", "enzyme-to-json": "^3.3.5", "eslint": "^5.6.0", "eslint-config-airbnb": "^17.1.0", diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index d5ea358915..1de791ab58 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -1,5 +1,13 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import { + arrayOf, + oneOfType, + func, + number, + string, + shape, + bool, +} from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormSelect, FormSelectOption } from '@patternfly/react-core'; @@ -48,12 +56,12 @@ AnsibleSelect.defaultProps = { }; AnsibleSelect.propTypes = { - data: PropTypes.arrayOf(PropTypes.object), - id: PropTypes.string.isRequired, - isValid: PropTypes.bool, - onBlur: PropTypes.func, - onChange: PropTypes.func.isRequired, - value: PropTypes.string.isRequired, + data: arrayOf(shape()), + id: string.isRequired, + isValid: bool, + onBlur: func, + onChange: func.isRequired, + value: oneOfType([string, number]).isRequired, }; export { AnsibleSelect as _AnsibleSelect }; diff --git a/awx/ui_next/src/components/FormField/CheckboxField.jsx b/awx/ui_next/src/components/FormField/CheckboxField.jsx new file mode 100644 index 0000000000..5d702a38f9 --- /dev/null +++ b/awx/ui_next/src/components/FormField/CheckboxField.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { string, func } from 'prop-types'; +import { Field } from 'formik'; +import { Checkbox, 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 CheckboxField({ id, name, label, tooltip, validate, ...rest }) { + return ( + ( + + {label} +   + {tooltip && ( + + + + )} + + } + id={id} + {...rest} + checked={field.value} + {...field} + onChange={(value, event) => { + field.onChange(event); + }} + /> + )} + /> + ); +} +CheckboxField.propTypes = { + id: string.isRequired, + name: string.isRequired, + label: string.isRequired, + validate: func, + tooltip: string, +}; +CheckboxField.defaultProps = { + validate: () => {}, + tooltip: '', +}; + +export default CheckboxField; diff --git a/awx/ui_next/src/components/FormField/index.js b/awx/ui_next/src/components/FormField/index.js index 06dabb2d71..c592b7c800 100644 --- a/awx/ui_next/src/components/FormField/index.js +++ b/awx/ui_next/src/components/FormField/index.js @@ -1 +1,2 @@ export { default } from './FormField'; +export { default as CheckboxField } from './CheckboxField'; diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx index d6d69d46e0..5ebd17e2dc 100644 --- a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx +++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx @@ -7,12 +7,16 @@ function arrayToString(tags) { } function stringToArray(value) { - return value.split(',').map(v => ({ - id: v, - name: v, + return value.split(',').filter(val => !!val).map(val => ({ + id: val, + name: val, })); } +/* + * Adapter providing a simplified API to a MultiSelect. The value + * is a comma-separated string. + */ function TagMultiSelect ({ onChange, value }) { const [options, setOptions] = useState(stringToArray(value)); diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx new file mode 100644 index 0000000000..3002319283 --- /dev/null +++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import TagMultiSelect from './TagMultiSelect'; + +describe('', () => { + it('should render MultiSelect', () => { + const wrapper = mount( + + ); + expect(wrapper.find('MultiSelect').prop('options')).toEqual([ + { id: 'foo', name: 'foo' }, + { id: 'bar', name: 'bar' }, + ]); + }); + + it('should not treat empty string as an option', () => { + const wrapper = mount(); + expect(wrapper.find('MultiSelect').prop('options')).toEqual([]); + }); + + // NOTE: this test throws a warning which *should* be go away once we upgrade + // to React 16.8 (https://github.com/airbnb/enzyme/blob/master/docs/api/ReactWrapper/invoke.md) + it('should trigger onChange', () => { + const onChange = jest.fn(); + const wrapper = mount( + + ); + + const input = wrapper.find('TextInput'); + input.invoke('onChange')('baz'); + input.invoke('onKeyDown')({ key: 'Tab' }); + expect(onChange).toHaveBeenCalledWith('foo,bar,baz'); + }); +}); diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index bd686bf3cf..b9e9fa8711 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -4,14 +4,21 @@ import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { withFormik, Field } from 'formik'; -import { Form, FormGroup, Tooltip, Card, Switch } from '@patternfly/react-core'; +import { + Form, + FormGroup, + Tooltip, + Card, + Switch, + Checkbox, +} from '@patternfly/react-core'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import AnsibleSelect from '@components/AnsibleSelect'; import MultiSelect, { TagMultiSelect } from '@components/MultiSelect'; import FormActionGroup from '@components/FormActionGroup'; -import FormField from '@components/FormField'; +import FormField, { CheckboxField } from '@components/FormField'; import FormRow from '@components/FormRow'; import CollapsibleSection from '@components/CollapsibleSection'; import { required } from '@util/validators'; @@ -25,6 +32,17 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)` margin-left: 10px; `; +const GridFormGroup = styled(FormGroup)` + & > label { + grid-column: 1 / -1; + } + + && { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +`; + class JobTemplateForm extends Component { static propTypes = { template: JobTemplate, @@ -60,6 +78,7 @@ class JobTemplateForm extends Component { inventory: props.template.summary_fields.inventory, relatedProjectPlaybooks: props.relatedProjectPlaybooks, relatedInstanceGroups: [], + allowCallbacks: !!props.template.host_config_key, }; this.handleNewLabel = this.handleNewLabel.bind(this); this.loadLabels = this.loadLabels.bind(this); @@ -119,9 +138,10 @@ class JobTemplateForm extends Component { return; } try { - console.log('loading...'); const { data } = await JobTemplatesAPI.readInstanceGroups(template.id); - console.log(data.results); + this.setState({ + relatedInstanceGroups: data.results, + }); } catch (err) { this.setState({ contentError: err }); } @@ -236,6 +256,7 @@ class JobTemplateForm extends Component { newLabels, removedLabels, relatedInstanceGroups, + allowCallbacks, } = this.state; const { handleCancel, @@ -554,47 +575,110 @@ class JobTemplateForm extends Component { /> { - return ( - - ( + + - - - form.setFieldValue(field.name, value)} - value={field.value} - /> - - ); - }} + > + + + form.setFieldValue(field.name, value)} + /> + + )} /> - - ( + + - - - + + + form.setFieldValue(field.name, value)} + /> + + )} + /> + + + + {i18n._(t`Provisioning Callbacks`)} +   + + + + + } + id="option-callbacks" + checked={allowCallbacks} + onChange={checked => { + this.setState({ allowCallbacks: checked }); + }} + /> + + + + + HERE @@ -621,6 +705,10 @@ const FormikApp = withFormik({ diff_mode, job_tags, skip_tags, + become_enabled, + allow_callbacks, + allow_simultaneous, + use_fact_cache, summary_fields = { labels: { results: [] } }, } = { ...template }; @@ -640,6 +728,10 @@ const FormikApp = withFormik({ diff_mode: diff_mode || false, job_tags: job_tags || '', skip_tags: skip_tags || '', + become_enabled: become_enabled || false, + allow_callbacks: allow_callbacks || false, + allow_simultaneous: allow_simultaneous || false, + use_fact_cache: use_fact_cache || false, }; }, handleSubmit: (values, bag) => bag.props.handleSubmit(values), From 4f546be87aa4de1f0582fbca3072eff8a80086ef Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 3 Sep 2019 10:27:11 -0700 Subject: [PATCH 09/16] add JT form callback fields --- .../Template/shared/JobTemplateForm.jsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index b9e9fa8711..2254e95b83 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -11,6 +11,7 @@ import { Card, Switch, Checkbox, + TextInput, } from '@patternfly/react-core'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import ContentError from '@components/ContentError'; @@ -673,13 +674,30 @@ class JobTemplateForm extends Component { )} /> - - HERE - + + + + + + + From 8b1ca12d8f932cc9bc4e48457031a3dbf3d13b11 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 3 Sep 2019 14:35:04 -0700 Subject: [PATCH 10/16] update JobTemplateForm tests --- .../components/FormField/CheckboxField.jsx | 1 + .../src/components/FormField/FormField.jsx | 2 +- .../Lookup/InstanceGroupsLookup.jsx | 3 +- .../JobTemplateEdit/JobTemplateEdit.test.jsx | 43 ++++++++- .../Template/shared/JobTemplateForm.jsx | 66 +++++++------- .../Template/shared/JobTemplateForm.test.jsx | 87 ++++++++++++------- 6 files changed, 135 insertions(+), 67 deletions(-) diff --git a/awx/ui_next/src/components/FormField/CheckboxField.jsx b/awx/ui_next/src/components/FormField/CheckboxField.jsx index 5d702a38f9..2f97e0dff5 100644 --- a/awx/ui_next/src/components/FormField/CheckboxField.jsx +++ b/awx/ui_next/src/components/FormField/CheckboxField.jsx @@ -16,6 +16,7 @@ function CheckboxField({ id, name, label, tooltip, validate, ...rest }) { validate={validate} render={({ field }) => ( {label} diff --git a/awx/ui_next/src/components/FormField/FormField.jsx b/awx/ui_next/src/components/FormField/FormField.jsx index 999ccd531a..3bd6370c3c 100644 --- a/awx/ui_next/src/components/FormField/FormField.jsx +++ b/awx/ui_next/src/components/FormField/FormField.jsx @@ -57,7 +57,7 @@ FormField.propTypes = { type: PropTypes.string, validate: PropTypes.func, isRequired: PropTypes.bool, - tooltip: PropTypes.string, + tooltip: PropTypes.node, }; FormField.defaultProps = { diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index a85e184f76..0cce199bc2 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -12,10 +12,11 @@ const getInstanceGroups = async params => InstanceGroupsAPI.read(params); class InstanceGroupsLookup extends React.Component { render() { - const { value, tooltip, onChange, i18n } = this.props; + const { value, tooltip, onChange, className, i18n } = this.props; return ( {i18n._(t`Instance Groups`)}{' '} 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 682d29b01a..1020dc6901 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -92,6 +92,32 @@ const mockRelatedProjectPlaybooks = [ 'vault.yml', ]; +const mockInstanceGroups = [ + { + id: 1, + type: 'instance_group', + url: '/api/v2/instance_groups/1/', + related: { + jobs: '/api/v2/instance_groups/1/jobs/', + instances: '/api/v2/instance_groups/1/instances/', + }, + name: 'tower', + capacity: 59, + committed_capacity: 0, + consumed_capacity: 0, + percent_capacity_remaining: 100.0, + jobs_running: 0, + jobs_total: 3, + instances: 1, + controller: null, + is_controller: false, + is_isolated: false, + policy_instance_percentage: 100, + policy_instance_minimum: 0, + policy_instance_list: [], + }, +]; + JobTemplatesAPI.readCredentials.mockResolvedValue({ data: mockRelatedCredentials, }); @@ -101,12 +127,25 @@ ProjectsAPI.readPlaybooks.mockResolvedValue({ LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); describe('', () => { - test('initially renders successfully', async done => { + beforeEach(() => { + LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); + JobTemplatesAPI.readCredentials.mockResolvedValue({ + data: mockRelatedCredentials, + }); + JobTemplatesAPI.readInstanceGroups.mockReturnValue({ + data: { results: mockInstanceGroups }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', async () => { const wrapper = mountWithContexts( ); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - done(); }); test('handleSubmit should call api update', async done => { diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 2254e95b83..4b078e4308 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -157,23 +157,6 @@ class JobTemplateForm extends Component { newLabel => newLabel.name !== label ); this.setState({ newLabels: filteredLabels }); - } else if (typeof label === 'string') { - setFieldValue('newLabels', [ - ...newLabels, - { - name: label, - organization: template.summary_fields.inventory.organization_id, - }, - ]); - this.setState({ - newLabels: [ - ...newLabels, - { - name: label, - organization: template.summary_fields.inventory.organization_id, - }, - ], - }); } else { setFieldValue('newLabels', [ ...newLabels, @@ -182,7 +165,12 @@ class JobTemplateForm extends Component { this.setState({ newLabels: [ ...newLabels, - { name: label.name, associate: true, id: label.id }, + { + name: label.name, + associate: true, + id: label.id, + organization: template.summary_fields.inventory.organization_id, + }, ], }); } @@ -311,6 +299,12 @@ class JobTemplateForm extends Component { { value: '3', key: '3', label: i18n._(t`3 (Debug)`) }, { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) }, ]; + let callbackUrl; + if (template && template.related) { + const { origin } = document.location; + const path = template.related.callback || `${template.url}callback`; + callbackUrl = `${origin}${path}`; + } if (hasContentLoading) { return ( @@ -477,6 +471,7 @@ class JobTemplateForm extends Component { id="template-forks" name="forks" type="number" + min="0" label={i18n._(t`Forks`)} tooltip={ @@ -568,6 +563,7 @@ class JobTemplateForm extends Component { /> ( ( )} /> - + @@ -677,19 +680,22 @@ class JobTemplateForm extends Component {
- - - + {callbackUrl && ( + + + + )} ', () => { labels: { results: [{ name: 'Sushi', id: 1 }, { name: 'Major', id: 2 }] }, }, }; + const mockInstanceGroups = [ + { + id: 1, + type: 'instance_group', + url: '/api/v2/instance_groups/1/', + related: { + jobs: '/api/v2/instance_groups/1/jobs/', + instances: '/api/v2/instance_groups/1/instances/', + }, + name: 'tower', + capacity: 59, + committed_capacity: 0, + consumed_capacity: 0, + percent_capacity_remaining: 100.0, + jobs_running: 0, + jobs_total: 3, + instances: 1, + controller: null, + is_controller: false, + is_isolated: false, + policy_instance_percentage: 100, + policy_instance_minimum: 0, + policy_instance_list: [], + }, + ]; beforeEach(() => { LabelsAPI.read.mockReturnValue({ data: mockData.summary_fields.labels, }); + JobTemplatesAPI.readInstanceGroups.mockReturnValue({ + data: { results: mockInstanceGroups }, + }); }); afterEach(() => { jest.clearAllMocks(); }); - test('initially renders successfully', async done => { + test('should render labels MultiSelect', async () => { const wrapper = mountWithContexts( ', () => { handleCancel={jest.fn()} /> ); - - await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + await waitForElement(wrapper, 'Form', el => el.length === 0); expect(LabelsAPI.read).toHaveBeenCalled(); + expect(JobTemplatesAPI.readInstanceGroups).toHaveBeenCalled(); + wrapper.update(); expect( wrapper - .find('FormGroup[fieldId="template-labels"] MultiSelect Chip') - .first() - .text() - ).toEqual('Sushi'); - done(); + .find('FormGroup[fieldId="template-labels"] MultiSelect') + .prop('associatedItems') + ).toEqual(mockData.summary_fields.labels.results); }); - test('should update form values on input changes', async done => { + test('should update form values on input changes', async () => { const wrapper = mountWithContexts( ', () => { target: { value: 'new baz type', name: 'playbook' }, }); expect(form.state('values').playbook).toEqual('new baz type'); - done(); }); - test('should call handleSubmit when Submit button is clicked', async done => { + test('should call handleSubmit when Submit button is clicked', async () => { const handleSubmit = jest.fn(); const wrapper = mountWithContexts( ', () => { wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(1); expect(handleSubmit).toBeCalled(); - done(); }); - test('should call handleCancel when Cancel button is clicked', async done => { + test('should call handleCancel when Cancel button is clicked', async () => { const handleCancel = jest.fn(); const wrapper = mountWithContexts( ', () => { expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); expect(handleCancel).toBeCalled(); - done(); }); - test('should call loadRelatedProjectPlaybooks when project value changes', async done => { + test('should call loadRelatedProjectPlaybooks when project value changes', async () => { const loadRelatedProjectPlaybooks = jest.spyOn( _JobTemplateForm.prototype, 'loadRelatedProjectPlaybooks' @@ -150,15 +174,10 @@ describe('', () => { name: 'project', }); expect(loadRelatedProjectPlaybooks).toHaveBeenCalledWith(10); - done(); }); - test('handleNewLabel should arrange new labels properly', async done => { - const handleNewLabel = jest.spyOn( - _JobTemplateForm.prototype, - 'handleNewLabel' - ); - const event = { key: 'Enter', preventDefault: () => {} }; + test('handleNewLabel should arrange new labels properly', async () => { + const event = { key: 'Enter' }; const wrapper = mountWithContexts( ', () => { /> ); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - const multiSelect = wrapper.find('MultiSelect'); + 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('input[aria-label="labels"]').prop('onKeyDown')(event); - expect(handleNewLabel).toHaveBeenCalledWith('Foo'); + component + .find('FormGroup[fieldId="template-labels"] input[aria-label="labels"]') + .prop('onKeyDown')(event); component.instance().handleNewLabel({ name: 'Bar', id: 2 }); - expect(component.state().newLabels).toEqual([ - { name: 'Foo', organization: 1 }, - { associate: true, id: 2, name: 'Bar' }, - ]); - done(); + 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 done => { + + test('disassociateLabel should arrange new labels properly', async () => { const wrapper = mountWithContexts( ', () => { component.instance().removeLabel({ name: 'Sushi', id: 1 }); expect(component.state().newLabels.length).toBe(0); expect(component.state().removedLabels.length).toBe(1); - done(); }); }); From 93b794eaa7c2eab098eac6fb7e6a8a4392b647f1 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 5 Sep 2019 13:42:57 -0700 Subject: [PATCH 11/16] JT Form fixes after rebase --- .../CollapsibleSection/ExpandingContainer.jsx | 4 +- .../components/FormField/CheckboxField.jsx | 2 +- .../Lookup/InstanceGroupsLookup.jsx | 95 ++++++++++--------- .../components/MultiSelect/TagMultiSelect.jsx | 25 +++-- .../Template/shared/JobTemplateForm.jsx | 68 ++++++------- awx/ui_next/src/util/omitProps.jsx | 4 +- awx/ui_next/src/util/omitProps.test.jsx | 2 +- 7 files changed, 104 insertions(+), 96 deletions(-) diff --git a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx index 7046a29f85..07fd6d35b5 100644 --- a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx +++ b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx @@ -5,9 +5,7 @@ import styled from 'styled-components'; const Container = styled.div` margin: 15px 0; transition: all 0.2s ease-out; - ${props => !props.isExpanded && ` - overflow: hidden; - `} + ${props => !props.isExpanded && `overflow: hidden;`} `; function ExpandingContainer({ isExpanded, children }) { diff --git a/awx/ui_next/src/components/FormField/CheckboxField.jsx b/awx/ui_next/src/components/FormField/CheckboxField.jsx index 2f97e0dff5..185a40347f 100644 --- a/awx/ui_next/src/components/FormField/CheckboxField.jsx +++ b/awx/ui_next/src/components/FormField/CheckboxField.jsx @@ -30,7 +30,7 @@ function CheckboxField({ id, name, label, tooltip, validate, ...rest }) { } id={id} {...rest} - checked={field.value} + isChecked={field.value} {...field} onChange={(value, event) => { field.onChange(event); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 0cce199bc2..b348356db1 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -14,52 +14,57 @@ class InstanceGroupsLookup extends React.Component { render() { const { value, tooltip, onChange, className, i18n } = this.props; + /* + Wrapping
added to workaround PF bug: + https://github.com/patternfly/patternfly-react/issues/2855 + */ return ( - - {i18n._(t`Instance Groups`)}{' '} - {tooltip && ( - - - - )} - - } - fieldId="org-instance-groups" - > - - +
+ + {i18n._(t`Instance Groups`)}{' '} + {tooltip && ( + + + + )} + + } + fieldId="org-instance-groups" + > + + +
); } } diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx index 5ebd17e2dc..472327aaad 100644 --- a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx +++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx @@ -7,37 +7,42 @@ function arrayToString(tags) { } function stringToArray(value) { - return value.split(',').filter(val => !!val).map(val => ({ - id: val, - name: val, - })); + return value + .split(',') + .filter(val => !!val) + .map(val => ({ + id: val, + name: val, + })); } /* * Adapter providing a simplified API to a MultiSelect. The value * is a comma-separated string. */ -function TagMultiSelect ({ onChange, value }) { +function TagMultiSelect({ onChange, value }) { const [options, setOptions] = useState(stringToArray(value)); return ( { onChange(arrayToString(val)) }} - onAddNewItem={(newItem) => { + onChange={val => { + onChange(arrayToString(val)); + }} + onAddNewItem={newItem => { if (!options.find(o => o.name === newItem.name)) { setOptions(options.concat(newItem)); } }} associatedItems={stringToArray(value)} options={options} - createNewItem={(name) => ({ id: name, name })} + createNewItem={name => ({ id: name, name })} /> - ) + ); } TagMultiSelect.propTypes = { onChange: func.isRequired, value: string.isRequired, -} +}; export default TagMultiSelect; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 4b078e4308..10c6d83257 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -85,6 +85,7 @@ class JobTemplateForm extends Component { this.loadLabels = this.loadLabels.bind(this); this.removeLabel = this.removeLabel.bind(this); this.handleProjectValidation = this.handleProjectValidation.bind(this); + this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this); this.loadRelatedProjectPlaybooks = this.loadRelatedProjectPlaybooks.bind( this ); @@ -95,11 +96,11 @@ class JobTemplateForm extends Component { componentDidMount() { const { validateField } = this.props; - validateField('project'); this.setState({ contentError: null, hasContentLoading: true }); Promise.all([this.loadLabels(), this.loadRelatedInstanceGroups()]).then( () => { this.setState({ hasContentLoading: false }); + validateField('project'); } ); } @@ -242,8 +243,6 @@ class JobTemplateForm extends Component { inventory, project, relatedProjectPlaybooks = [], - newLabels, - removedLabels, relatedInstanceGroups, allowCallbacks, } = this.state; @@ -343,8 +342,7 @@ class JobTemplateForm extends Component { validate={required(null, i18n)} onBlur={handleBlur} render={({ form, field }) => { - const isValid = - form && (!form.touched[field.name] || !form.errors[field.name]); + const isValid = !form.touched.job_type || !form.errors.job_type; return ( @@ -391,34 +389,29 @@ class JobTemplateForm extends Component { { - const isValid = form && !form.errors.project; - return ( - ( + { - this.loadRelatedProjectPlaybooks(value.id); - form.setFieldValue('project', value.id); - form.setFieldTouched('project'); - this.setState({ project: value }); - }} - required - /> - ); - }} + onChange={value => { + this.loadRelatedProjectPlaybooks(value.id); + form.setFieldValue('project', value.id); + this.setState({ project: value }); + }} + required + /> + )} /> { - const isValid = - form && (!form.touched[field.name] || !form.errors[field.name]); + const isValid = !form.touched.playbook || !form.errors.playbook; return ( - + )} /> @@ -528,6 +525,7 @@ class JobTemplateForm extends Component { id="template-timeout" name="timeout" type="number" + min="0" label={i18n._(t`Timeout`)} tooltip={i18n._(t`The amount of time (in seconds) to run before the task is canceled. Defaults to 0 for no job @@ -653,7 +651,7 @@ class JobTemplateForm extends Component { } id="option-callbacks" - checked={allowCallbacks} + isChecked={allowCallbacks} onChange={checked => { this.setState({ allowCallbacks: checked }); }} @@ -724,7 +722,7 @@ const FormikApp = withFormik({ forks, limit, verbosity, - job_slicing, + job_slice_count, timeout, diff_mode, job_tags, @@ -733,6 +731,7 @@ const FormikApp = withFormik({ allow_callbacks, allow_simultaneous, use_fact_cache, + host_config_key, summary_fields = { labels: { results: [] } }, } = { ...template }; @@ -744,11 +743,11 @@ const FormikApp = withFormik({ project: project || '', playbook: playbook || '', labels: summary_fields.labels.results, - forks: forks || '', + forks: forks || 0, limit: limit || '', verbosity: verbosity || '0', - job_slice_count: job_slicing || '', - timout: timeout || '', + job_slice_count: job_slice_count || 1, + timeout: timeout || 0, diff_mode: diff_mode || false, job_tags: job_tags || '', skip_tags: skip_tags || '', @@ -756,6 +755,7 @@ const FormikApp = withFormik({ allow_callbacks: allow_callbacks || false, allow_simultaneous: allow_simultaneous || false, use_fact_cache: use_fact_cache || false, + host_config_key: host_config_key || '', }; }, handleSubmit: (values, bag) => bag.props.handleSubmit(values), diff --git a/awx/ui_next/src/util/omitProps.jsx b/awx/ui_next/src/util/omitProps.jsx index bb6b92f150..0184706d23 100644 --- a/awx/ui_next/src/util/omitProps.jsx +++ b/awx/ui_next/src/util/omitProps.jsx @@ -10,7 +10,7 @@ export default function omitProps(Component, ...omit) { const clean = { ...props }; omit.forEach(key => { delete clean[key]; - }) + }); return ; - } + }; } diff --git a/awx/ui_next/src/util/omitProps.test.jsx b/awx/ui_next/src/util/omitProps.test.jsx index d2aef5eac8..03fedcfcb1 100644 --- a/awx/ui_next/src/util/omitProps.test.jsx +++ b/awx/ui_next/src/util/omitProps.test.jsx @@ -32,4 +32,4 @@ describe('omitProps', () => { expect(div.prop('foo')).toEqual(undefined); expect(div.prop('bar')).toEqual('two'); }); -}) +}); From 0254cf3567cd77d152bc368f48b5330692490ba7 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 5 Sep 2019 16:47:34 -0700 Subject: [PATCH 12/16] wire-in instance groups to JT form --- .../JobTemplateAdd/JobTemplateAdd.jsx | 29 +++++++++---- .../JobTemplateEdit/JobTemplateEdit.jsx | 41 ++++++++++++------- .../Template/shared/JobTemplateForm.jsx | 23 ++++++++++- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx index b3b4d12f92..c8f1a6894d 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx @@ -17,23 +17,30 @@ function JobTemplateAdd({ history, i18n }) { const [formSubmitError, setFormSubmitError] = useState(null); async function handleSubmit(values) { - const { newLabels, removedLabels } = values; - delete values.newLabels; - delete values.removedLabels; + const { + newLabels, + removedLabels, + addedInstanceGroups, + removedInstanceGroups, + ...remainingValues + } = values; setFormSubmitError(null); try { const { data: { id, type }, - } = await JobTemplatesAPI.create(values); - await Promise.all([submitLabels(id, newLabels, removedLabels)]); + } = await JobTemplatesAPI.create(remainingValues); + await Promise.all([ + submitLabels(id, newLabels, removedLabels), + submitInstanceGroups(id, addedInstanceGroups, removedInstanceGroups), + ]); history.push(`/templates/${type}/${id}/details`); } catch (error) { setFormSubmitError(error); } } - async function submitLabels(id, newLabels = [], removedLabels = []) { + function submitLabels(id, newLabels = [], removedLabels = []) { const disassociationPromises = removedLabels.map(label => JobTemplatesAPI.disassociateLabel(id, label) ); @@ -44,12 +51,18 @@ function JobTemplateAdd({ history, i18n }) { .filter(label => label.organization) .map(label => JobTemplatesAPI.generateLabel(id, label)); - const results = await Promise.all([ + return Promise.all([ ...disassociationPromises, ...associationPromises, ...creationPromises, ]); - return results; + } + + function submitInstanceGroups(templateId, addedGroups) { + const associatePromises = addedGroups.map(group => + JobTemplatesAPI.associateInstanceGroup(templateId, group.id) + ); + return Promise.all(associatePromises); } function handleCancel() { diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index 51e5a3ff65..8771b7c3bf 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -102,18 +102,22 @@ class JobTemplateEdit extends Component { } async handleSubmit(values) { + const { template, history } = this.props; const { - template: { id }, - history, - } = this.props; - const { newLabels, removedLabels } = values; - delete values.newLabels; - delete values.removedLabels; + newLabels, + removedLabels, + addedInstanceGroups, + removedInstanceGroups, + ...remainingValues + } = values; this.setState({ formSubmitError: null }); try { - await JobTemplatesAPI.update(id, values); - await Promise.all([this.submitLabels(newLabels, removedLabels)]); + await JobTemplatesAPI.update(template.id, remainingValues); + await Promise.all([ + this.submitLabels(newLabels, removedLabels), + this.submitInstanceGroups(addedInstanceGroups, removedInstanceGroups), + ]); history.push(this.detailsUrl); } catch (formSubmitError) { this.setState({ formSubmitError }); @@ -121,18 +125,16 @@ class JobTemplateEdit extends Component { } async submitLabels(newLabels = [], removedLabels = []) { - const { - template: { id }, - } = this.props; + const { template } = this.props; const disassociationPromises = removedLabels.map(label => - JobTemplatesAPI.disassociateLabel(id, label) + JobTemplatesAPI.disassociateLabel(template.id, label) ); const associationPromises = newLabels .filter(label => !label.organization) - .map(label => JobTemplatesAPI.associateLabel(id, label)); + .map(label => JobTemplatesAPI.associateLabel(template.id, label)); const creationPromises = newLabels .filter(label => label.organization) - .map(label => JobTemplatesAPI.generateLabel(id, label)); + .map(label => JobTemplatesAPI.generateLabel(template.id, label)); const results = await Promise.all([ ...disassociationPromises, @@ -142,6 +144,17 @@ class JobTemplateEdit extends Component { return results; } + async submitInstanceGroups(addedGroups, removedGroups) { + const { template } = this.props; + const associatePromises = addedGroups.map(group => + JobTemplatesAPI.associateInstanceGroup(template.id, group.id) + ); + const disassociatePromises = removedGroups.map(group => + JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id) + ); + return Promise.all([...associatePromises, ...disassociatePromises]); + } + handleCancel() { const { history } = this.props; history.push(this.detailsUrl); diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 10c6d83257..ff377380a2 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -142,7 +142,8 @@ class JobTemplateForm extends Component { try { const { data } = await JobTemplatesAPI.readInstanceGroups(template.id); this.setState({ - relatedInstanceGroups: data.results, + initialInstanceGroups: data.results, + relatedInstanceGroups: [...data.results], }); } catch (err) { this.setState({ contentError: err }); @@ -232,6 +233,26 @@ 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 }); } From 4e73f4b778d561d69c15ae209065a706a67d59bb Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 6 Sep 2019 09:10:03 -0700 Subject: [PATCH 13/16] show all advanced JT fields on edit form --- awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index ff377380a2..26fdd4c6e1 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -64,6 +64,7 @@ class JobTemplateForm extends Component { labels: { results: [] }, project: null, }, + isNew: true, }, }; @@ -341,6 +342,7 @@ class JobTemplateForm extends Component { ); } + const AdvancedFieldsWrapper = template.isNew ? CollapsibleSection : 'div'; return (
@@ -479,7 +481,7 @@ class JobTemplateForm extends Component { /> - +
- + ); From 9777b79818938ed8710adc0481d60a74e0c85f37 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 6 Sep 2019 09:59:58 -0700 Subject: [PATCH 14/16] hide overflow in ExpandingContainer while opening --- .../CollapsibleSection/ExpandingContainer.jsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx index 07fd6d35b5..8f8d982ac7 100644 --- a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx +++ b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx @@ -4,13 +4,19 @@ import styled from 'styled-components'; const Container = styled.div` margin: 15px 0; - transition: all 0.2s ease-out; - ${props => !props.isExpanded && `overflow: hidden;`} + transition: height 0.2s ease-out; + ${props => props.hideOverflow && `overflow: hidden;`} `; function ExpandingContainer({ isExpanded, children }) { const [contentHeight, setContentHeight] = useState(0); + const [hideOverflow, setHideOverflow] = useState(!isExpanded); const ref = useRef(null); + useEffect(() => { + ref.current.addEventListener('transitionend', () => { + setHideOverflow(!isExpanded); + }); + }) useEffect(() => { setContentHeight(ref.current.scrollHeight); }); @@ -21,7 +27,7 @@ function ExpandingContainer({ isExpanded, children }) { css={` height: ${height}px; `} - isExpanded={isExpanded} + hideOverflow={!isExpanded || hideOverflow} > {children} From be6f5e18ae52c5119128bce554b54f7b17fb4969 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 6 Sep 2019 11:35:24 -0700 Subject: [PATCH 15/16] fixing JT Form tests post-rebase --- .../CollapsibleSection.test.jsx | 26 +++++++++++++++++++ .../ExpandCollapse/ExpandCollapse.jsx | 2 ++ .../JobTemplateAdd/JobTemplateAdd.jsx | 2 +- .../JobTemplateAdd/JobTemplateAdd.test.jsx | 12 ++++++--- .../JobTemplateEdit/JobTemplateEdit.test.jsx | 2 ++ .../Template/shared/JobTemplateForm.test.jsx | 2 +- 6 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.test.jsx diff --git a/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.test.jsx b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.test.jsx new file mode 100644 index 0000000000..d3b5d09930 --- /dev/null +++ b/awx/ui_next/src/components/CollapsibleSection/CollapsibleSection.test.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import CollapsibleSection from './CollapsibleSection'; + +describe('', () => { + it('should render contents', () => { + const wrapper = shallow( + foo + ); + expect(wrapper.find('Button > *').prop('isExpanded')).toEqual(false); + expect(wrapper.find('ExpandingContainer').prop('isExpanded')).toEqual( + false + ); + expect(wrapper.find('ExpandingContainer').prop('children')).toEqual('foo'); + }); + + it('should toggle when clicked', () => { + const wrapper = shallow( + foo + ); + expect(wrapper.find('Button > *').prop('isExpanded')).toEqual(false); + wrapper.find('Button').simulate('click'); + expect(wrapper.find('Button > *').prop('isExpanded')).toEqual(true); + expect(wrapper.find('ExpandingContainer').prop('isExpanded')).toEqual(true); + }); +}); diff --git a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx index 6d06c8acd6..7ffce947d8 100644 --- a/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx +++ b/awx/ui_next/src/components/ExpandCollapse/ExpandCollapse.jsx @@ -29,6 +29,8 @@ const ToolbarItem = styled(PFToolbarItem)` } `; +// TODO: Recommend renaming this component to avoid confusion +// with ExpandingContainer class ExpandCollapse extends React.Component { render() { const { isCompact, onCompact, onExpand, i18n } = this.props; diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx index c8f1a6894d..85bec5dfff 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx @@ -58,7 +58,7 @@ function JobTemplateAdd({ history, i18n }) { ]); } - function submitInstanceGroups(templateId, addedGroups) { + function submitInstanceGroups(templateId, addedGroups = []) { const associatePromises = addedGroups.map(group => JobTemplatesAPI.associateInstanceGroup(templateId, group.id) ); 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 f5c68c7cf5..e9a3572ffd 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx @@ -62,7 +62,7 @@ describe('', () => { done(); }); - test('handleSubmit should post to api', async done => { + test.only('handleSubmit should post to api', async done => { const jobTemplateData = { description: 'Baz', inventory: 1, @@ -70,6 +70,9 @@ describe('', () => { name: 'Foo', playbook: 'Bar', project: 2, + verbosity: '0', + job_tags: '', + skip_tags: '', }; JobTemplatesAPI.create.mockResolvedValueOnce({ data: { @@ -106,6 +109,9 @@ describe('', () => { name: 'Foo', playbook: 'Bar', project: 2, + verbosity: '0', + job_tags: '', + skip_tags: '', }; JobTemplatesAPI.create.mockResolvedValueOnce({ data: { @@ -118,7 +124,7 @@ describe('', () => { context: { router: { history } }, }); - await wrapper.find('JobTemplateForm').prop('handleSubmit')(jobTemplateData); + await wrapper.find('JobTemplateForm').invoke('handleSubmit')(jobTemplateData); await sleep(0); expect(history.push).toHaveBeenCalledWith( '/templates/job_template/1/details' @@ -134,7 +140,7 @@ describe('', () => { context: { router: { history } }, }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(history.push).toHaveBeenCalledWith('/templates'); done(); }); 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 1020dc6901..8be8a1eb74 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -15,6 +15,8 @@ const mockJobTemplate = { project: 3, playbook: 'Baz', type: 'job_template', + job_tags: '', + skip_tags: '', summary_fields: { user_capabilities: { edit: true, diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx index 938e2035bb..cc079ef9c1 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx @@ -177,7 +177,7 @@ describe('', () => { }); test('handleNewLabel should arrange new labels properly', async () => { - const event = { key: 'Enter' }; + const event = { key: 'Enter', preventDefault: () => {} }; const wrapper = mountWithContexts( Date: Fri, 6 Sep 2019 14:27:02 -0700 Subject: [PATCH 16/16] fix JT form tests --- .../CollapsibleSection/ExpandingContainer.jsx | 2 +- .../MultiSelect/TagMultiSelect.test.jsx | 11 +++-- .../JobTemplateAdd/JobTemplateAdd.test.jsx | 49 ++++++++++--------- .../JobTemplateEdit/JobTemplateEdit.test.jsx | 10 ++++ 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx index 8f8d982ac7..5d8c91ef0d 100644 --- a/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx +++ b/awx/ui_next/src/components/CollapsibleSection/ExpandingContainer.jsx @@ -16,7 +16,7 @@ function ExpandingContainer({ isExpanded, children }) { ref.current.addEventListener('transitionend', () => { setHideOverflow(!isExpanded); }); - }) + }); useEffect(() => { setContentHeight(ref.current.scrollHeight); }); diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx index 3002319283..41f4aa5540 100644 --- a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx +++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.test.jsx @@ -18,17 +18,18 @@ describe('', () => { expect(wrapper.find('MultiSelect').prop('options')).toEqual([]); }); - // NOTE: this test throws a warning which *should* be go away once we upgrade - // to React 16.8 (https://github.com/airbnb/enzyme/blob/master/docs/api/ReactWrapper/invoke.md) it('should trigger onChange', () => { const onChange = jest.fn(); const wrapper = mount( ); - const input = wrapper.find('TextInput'); - input.invoke('onChange')('baz'); - input.invoke('onKeyDown')({ key: 'Tab' }); + const select = wrapper.find('MultiSelect'); + select.invoke('onChange')([ + { name: 'foo' }, + { name: 'bar' }, + { name: 'baz' }, + ]); expect(onChange).toHaveBeenCalledWith('foo,bar,baz'); }); }); 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 e9a3572ffd..506b97958e 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx @@ -6,6 +6,27 @@ import { JobTemplatesAPI, LabelsAPI } from '@api'; jest.mock('@api'); +const jobTemplateData = { + name: 'Foo', + description: 'Baz', + job_type: 'run', + inventory: 1, + project: 2, + playbook: 'Bar', + forks: 0, + limit: '', + verbosity: '0', + job_slice_count: 1, + timeout: 0, + job_tags: '', + skip_tags: '', + diff_mode: false, + allow_callbacks: false, + allow_simultaneous: false, + use_fact_cache: false, + host_config_key: '', +}; + describe('', () => { const defaultProps = { description: '', @@ -62,18 +83,7 @@ describe('', () => { done(); }); - test.only('handleSubmit should post to api', async done => { - const jobTemplateData = { - description: 'Baz', - inventory: 1, - job_type: 'run', - name: 'Foo', - playbook: 'Bar', - project: 2, - verbosity: '0', - job_tags: '', - skip_tags: '', - }; + test('handleSubmit should post to api', async done => { JobTemplatesAPI.create.mockResolvedValueOnce({ data: { id: 1, @@ -102,17 +112,6 @@ describe('', () => { const history = { push: jest.fn(), }; - const jobTemplateData = { - description: 'Baz', - inventory: 1, - job_type: 'run', - name: 'Foo', - playbook: 'Bar', - project: 2, - verbosity: '0', - job_tags: '', - skip_tags: '', - }; JobTemplatesAPI.create.mockResolvedValueOnce({ data: { id: 1, @@ -124,7 +123,9 @@ describe('', () => { context: { router: { history } }, }); - await wrapper.find('JobTemplateForm').invoke('handleSubmit')(jobTemplateData); + await wrapper.find('JobTemplateForm').invoke('handleSubmit')( + jobTemplateData + ); await sleep(0); expect(history.push).toHaveBeenCalledWith( '/templates/job_template/1/details' 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 8be8a1eb74..3f43faf279 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -15,8 +15,18 @@ const mockJobTemplate = { project: 3, playbook: 'Baz', type: 'job_template', + forks: 0, + limit: '', + verbosity: '0', + job_slice_count: 1, + timeout: 0, job_tags: '', skip_tags: '', + diff_mode: false, + allow_callbacks: false, + allow_simultaneous: false, + use_fact_cache: false, + host_config_key: '', summary_fields: { user_capabilities: { edit: true,