diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 204985b31d..5e7b221c77 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,5 +1,6 @@ import Config from './models/Config'; import InstanceGroups from './models/InstanceGroups'; +import Inventories from './models/Inventories'; import JobTemplates from './models/JobTemplates'; import Jobs from './models/Jobs'; import Me from './models/Me'; @@ -13,6 +14,7 @@ import WorkflowJobTemplates from './models/WorkflowJobTemplates'; const ConfigAPI = new Config(); const InstanceGroupsAPI = new InstanceGroups(); +const InventoriesAPI = new Inventories(); const JobTemplatesAPI = new JobTemplates(); const JobsAPI = new Jobs(); const MeAPI = new Me(); @@ -27,6 +29,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); export { ConfigAPI, InstanceGroupsAPI, + InventoriesAPI, JobTemplatesAPI, JobsAPI, MeAPI, diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js new file mode 100644 index 0000000000..9c9f86754a --- /dev/null +++ b/awx/ui_next/src/api/models/Inventories.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Inventories extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/inventories/'; + } +} + +export default Inventories; diff --git a/awx/ui_next/src/app.scss b/awx/ui_next/src/app.scss index 22a874d349..cf8f2d2338 100644 --- a/awx/ui_next/src/app.scss +++ b/awx/ui_next/src/app.scss @@ -97,7 +97,7 @@ width: 600px; .pf-c-modal-box__body { - overflow: visible; + overflow: auto; } .pf-c-modal-box__footer > .pf-c-button:not(:last-child) { @@ -155,11 +155,6 @@ // and bem style, as well as moved into component-based scss files // -.awx-lookup .pf-c-form-control { - --pf-c-form-control--Height: 90px; - overflow-y: auto; -} - .at-c-listCardBody { --pf-c-card__footer--PaddingX: 0; --pf-c-card__footer--PaddingY: 0; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx index 77c06e99b5..8610bfa779 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx @@ -107,6 +107,7 @@ class SelectResourceStep extends React.Component { itemId={item.id} key={item.id} name={item[displayKey]} + label={item[displayKey]} onSelect={() => onRowClick(item)} /> )} diff --git a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx index 903ff83606..c8561f5982 100644 --- a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx +++ b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx @@ -7,42 +7,57 @@ import { DataListCheck, DataListCell, } from '@patternfly/react-core'; - +import DataListRadio from '@components/DataListRadio'; import VerticalSeparator from '../VerticalSeparator'; -const CheckboxListItem = ({ itemId, name, isSelected, onSelect }) => ( - - - - - - , - - - , - ]} - /> - - -); + + , + + + , + ]} + /> + + + ); +}; CheckboxListItem.propTypes = { itemId: PropTypes.number.isRequired, name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, isSelected: PropTypes.bool.isRequired, onSelect: PropTypes.func.isRequired, }; diff --git a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx index ad0603f5f1..a28003b71a 100644 --- a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx +++ b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx @@ -9,6 +9,7 @@ describe('CheckboxListItem', () => { {}} /> diff --git a/awx/ui_next/src/components/DataListRadio/DataListRadio.jsx b/awx/ui_next/src/components/DataListRadio/DataListRadio.jsx new file mode 100644 index 0000000000..bccb1a3efe --- /dev/null +++ b/awx/ui_next/src/components/DataListRadio/DataListRadio.jsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { string, bool, func } from 'prop-types'; + +function DataListRadio({ + className = '', + onChange, + isValid = true, + isDisabled = false, + isChecked = null, + checked = null, + ...props +}) { + return ( +
+
+ onChange(event.currentTarget.checked, event)} + aria-invalid={!isValid} + disabled={isDisabled} + checked={isChecked || checked} + /> +
+
+ ); +} +DataListRadio.propTypes = { + className: string, + isValid: bool, + isDisabled: bool, + isChecked: bool, + checked: bool, + onChange: func, + 'aria-labelledby': string, +}; +DataListRadio.defaultProps = { + className: '', + isValid: true, + isDisabled: false, + isChecked: false, + checked: false, + onChange: () => {}, + 'aria-labelledby': '', +}; + +export default DataListRadio; diff --git a/awx/ui_next/src/components/DataListRadio/DataListRadio.test.jsx b/awx/ui_next/src/components/DataListRadio/DataListRadio.test.jsx new file mode 100644 index 0000000000..b8fa2e4135 --- /dev/null +++ b/awx/ui_next/src/components/DataListRadio/DataListRadio.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import DataListRadio from './DataListRadio'; + +describe('DataListRadio', () => { + test('should call onChange', () => { + const onChange = jest.fn(); + const wrapper = mountWithContexts(); + wrapper.find('input[type="radio"]').prop('onChange')({ + currentTarget: { checked: true }, + }); + expect(onChange).toHaveBeenCalledWith(true, { + currentTarget: { checked: true }, + }); + }); + + test('should pass props to correct children', () => { + const onChange = jest.fn(); + const wrapper = mountWithContexts( + + ); + const div = wrapper.find('.pf-c-data-list__item-control'); + const input = wrapper.find('input[type="radio"]'); + + expect(div.prop('className')).toEqual('pf-c-data-list__item-control foo'); + expect(input.prop('disabled')).toBe(true); + expect(input.prop('checked')).toBe(true); + expect(input.prop('aria-invalid')).toBe(false); + }); +}); diff --git a/awx/ui_next/src/components/DataListRadio/index.js b/awx/ui_next/src/components/DataListRadio/index.js new file mode 100644 index 0000000000..c8f5b6d345 --- /dev/null +++ b/awx/ui_next/src/components/DataListRadio/index.js @@ -0,0 +1 @@ +export { default } from './DataListRadio'; diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index 92c72a8856..9eeecfe6af 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -1,15 +1,24 @@ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; +import { + string, + bool, + arrayOf, + func, + number, + oneOfType, + shape, +} from 'prop-types'; import { withRouter } from 'react-router-dom'; import { SearchIcon } from '@patternfly/react-icons'; import { Button, ButtonVariant, - InputGroup, - Modal, + InputGroup as PFInputGroup, + Modal as PFModal, } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import styled from 'styled-components'; import PaginatedDataList from '../PaginatedDataList'; import DataListToolbar from '../DataListToolbar'; @@ -18,13 +27,31 @@ import SelectedList from '../SelectedList'; import { ChipGroup, Chip } from '../Chip'; import { getQSConfig, parseNamespacedQueryString } from '../../util/qs'; +const InputGroup = styled(PFInputGroup)` + ${props => + props.multiple && + ` + --pf-c-form-control--Height: 90px; + overflow-y: auto; + `} +`; + +const Modal = styled(PFModal)` + --pf-c-modal-box--body--MinHeight: 460px; +`; + class Lookup extends React.Component { constructor(props) { super(props); + this.assertCorrectValueType(); + let lookupSelectedItems = []; + if (props.value) { + lookupSelectedItems = props.multiple ? [...props.value] : [props.value]; + } this.state = { isModalOpen: false, - lookupSelectedItems: [...props.value] || [], + lookupSelectedItems, results: [], count: 0, error: null, @@ -51,6 +78,18 @@ class Lookup extends React.Component { } } + assertCorrectValueType() { + const { multiple, value } = this.props; + if (!multiple && Array.isArray(value)) { + throw new Error( + 'Lookup value must not be an array unless `multiple` is set' + ); + } + if (multiple && !Array.isArray(value)) { + throw new Error('Lookup value must be an array if `multiple` is set'); + } + } + async getData() { const { getItems, @@ -73,7 +112,7 @@ class Lookup extends React.Component { } toggleSelected(row) { - const { name, onLookupSave } = this.props; + const { name, onLookupSave, multiple } = this.props; const { lookupSelectedItems: updatedSelectedItems, isModalOpen, @@ -83,13 +122,17 @@ class Lookup extends React.Component { selectedRow => selectedRow.id === row.id ); - if (selectedIndex > -1) { - updatedSelectedItems.splice(selectedIndex, 1); - this.setState({ lookupSelectedItems: updatedSelectedItems }); + if (multiple) { + if (selectedIndex > -1) { + updatedSelectedItems.splice(selectedIndex, 1); + this.setState({ lookupSelectedItems: updatedSelectedItems }); + } else { + this.setState(prevState => ({ + lookupSelectedItems: [...prevState.lookupSelectedItems, row], + })); + } } else { - this.setState(prevState => ({ - lookupSelectedItems: [...prevState.lookupSelectedItems, row], - })); + this.setState({ lookupSelectedItems: [row] }); } // Updates the selected items from parent state @@ -102,12 +145,16 @@ class Lookup extends React.Component { handleModalToggle() { const { isModalOpen } = this.state; - const { value } = this.props; + const { value, multiple } = this.props; // Resets the selected items from parent state whenever modal is opened // This handles the case where the user closes/cancels the modal and // opens it again if (!isModalOpen) { - this.setState({ lookupSelectedItems: [...value] }); + let lookupSelectedItems = []; + if (value) { + lookupSelectedItems = multiple ? [...value] : [value]; + } + this.setState({ lookupSelectedItems }); } this.setState(prevState => ({ isModalOpen: !prevState.isModalOpen, @@ -115,9 +162,12 @@ class Lookup extends React.Component { } saveModal() { - const { onLookupSave, name } = this.props; + const { onLookupSave, name, multiple } = this.props; const { lookupSelectedItems } = this.state; - onLookupSave(lookupSelectedItems, name); + const value = multiple + ? lookupSelectedItems + : lookupSelectedItems[0] || null; + onLookupSave(value, name); this.handleModalToggle(); } @@ -129,14 +179,28 @@ class Lookup extends React.Component { results, count, } = this.state; - const { id, lookupHeader, value, columns, i18n } = this.props; + const { + id, + lookupHeader, + value, + columns, + multiple, + name, + required, + i18n, + } = this.props; const header = lookupHeader || i18n._(t`items`); + const canDelete = !required || (multiple && value.length > 1); const chips = value ? ( - {value.map(chip => ( - this.toggleSelected(chip)}> + {(multiple ? value : [value]).map(chip => ( + this.toggleSelected(chip)} + isReadOnly={!canDelete} + > {chip.name} ))} @@ -145,7 +209,7 @@ class Lookup extends React.Component { return ( - +