diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index 6c87032341..c6e427309d 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -38,7 +38,7 @@ class AnsibleSelect extends React.Component { > {data.map(option => ( - CredentialsAPI.read( - mergeParams(params, { credential_type: credentialTypeId }) - ); + const [credentials, setCredentials] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await CredentialsAPI.read( + mergeParams(params, { credential_type: credentialTypeId }) + ); + setCredentials(data.results); + setCount(data.count); + } catch (err) { + if (setError) { + setError(err); + } + } + })(); + }); return ( ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} /> + ); } @@ -65,4 +101,4 @@ CredentialLookup.defaultProps = { }; export { CredentialLookup as _CredentialLookup }; -export default withI18n()(CredentialLookup); +export default withI18n()(withRouter(CredentialLookup)); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 8071c8b26f..1c551e14d7 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -7,8 +7,9 @@ import { FormGroup } from '@patternfly/react-core'; import { InstanceGroupsAPI } from '@api'; import { getQSConfig, parseQueryString } from '@util/qs'; import { FieldTooltip } from '@components/FormField'; -import Lookup from './NewLookup'; -import SelectList from './shared/SelectList'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; const QS_CONFIG = getQSConfig('instance_groups', { page: 1, @@ -45,17 +46,12 @@ function InstanceGroupsLookup(props) { ( - ( + )} /> - {error ?
error {error.message}
: ''} + ); } diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 494b4d3069..cd687b18c5 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; @@ -7,61 +8,93 @@ import { InventoriesAPI } from '@api'; import { Inventory } from '@types'; import Lookup from '@components/Lookup'; import { FieldTooltip } from '@components/FormField'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -const getInventories = async params => InventoriesAPI.read(params); +const QS_CONFIG = getQSConfig('inventory', { + page: 1, + page_size: 5, + order_by: 'name', +}); -class InventoryLookup extends React.Component { - render() { - const { - value, - tooltip, - onChange, - onBlur, - required, - isValid, - helperTextInvalid, - i18n, - } = this.props; +function InventoryLookup({ + value, + tooltip, + onChange, + onBlur, + required, + isValid, + helperTextInvalid, + i18n, + history, +}) { + const [inventories, setInventories] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); - return ( - - {tooltip && } - - - ); - } + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await InventoriesAPI.read(params); + setInventories(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + + return ( + + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); } InventoryLookup.propTypes = { @@ -77,4 +110,4 @@ InventoryLookup.defaultProps = { required: false, }; -export default withI18n()(InventoryLookup); +export default withI18n()(withRouter(InventoryLookup)); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index 0a9b7c473b..afb67b54c4 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useReducer, useEffect } from 'react'; import { string, bool, @@ -20,7 +20,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import SelectList from './shared/SelectList'; +import reducer, { initReducer } from './shared/reducer'; import { ChipGroup, Chip } from '../Chip'; import { QSConfig } from '@types'; @@ -48,198 +48,118 @@ const ChipHolder = styled.div` border-bottom-right-radius: 3px; `; -class Lookup extends React.Component { - constructor(props) { - super(props); +function Lookup(props) { + const { + id, + header, + onChange, + onBlur, + value, + multiple, + required, + qsConfig, + renderItemChip, + renderOptionsList, + history, + i18n, + } = props; - this.assertCorrectValueType(); - let selectedItems = []; - if (props.value) { - selectedItems = props.multiple ? [...props.value] : [props.value]; - } - this.state = { - isModalOpen: false, - selectedItems, - }; - this.handleModalToggle = this.handleModalToggle.bind(this); - this.addItem = this.addItem.bind(this); - this.removeItem = this.removeItem.bind(this); - this.saveModal = this.saveModal.bind(this); - this.clearQSParams = this.clearQSParams.bind(this); - } + const [state, dispatch] = useReducer( + reducer, + { value, multiple, required }, + initReducer + ); - 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'); - } - } + useEffect(() => { + dispatch({ type: 'SET_MULTIPLE', value: multiple }); + }, [multiple]); - removeItem(row) { - const { selectedItems } = this.state; - const { onToggleItem } = this.props; - if (onToggleItem) { - this.setState({ selectedItems: onToggleItem(selectedItems, row) }); - return; - } - this.setState({ - selectedItems: selectedItems.filter(item => item.id !== row.id), - }); - } + useEffect(() => { + dispatch({ type: 'SET_VALUE', value }); + }, [value]); - addItem(row) { - const { selectedItems } = this.state; - const { multiple, onToggleItem } = this.props; - if (onToggleItem) { - this.setState({ selectedItems: onToggleItem(selectedItems, row) }); - return; - } - const index = selectedItems.findIndex(item => item.id === row.id); - - if (!multiple) { - this.setState({ selectedItems: [row] }); - return; - } - if (index > -1) { - return; - } - this.setState({ selectedItems: [...selectedItems, row] }); - } - - // TODO: cleanup - handleModalToggle() { - const { isModalOpen } = this.state; - 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) { - let selectedItems = []; - if (value) { - selectedItems = multiple ? [...value] : [value]; - } - this.setState({ selectedItems }); - } else { - this.clearQSParams(); - } - this.setState(prevState => ({ - isModalOpen: !prevState.isModalOpen, - })); - } - - removeItemAndSave(item) { - const { value, onChange, multiple } = this.props; - if (multiple) { - onChange(value.filter(i => i.id !== item.id)); - } else if (value.id === item.id) { - onChange(null); - } - } - - saveModal() { - const { onChange, multiple } = this.props; - const { selectedItems } = this.state; - const value = multiple ? selectedItems : selectedItems[0] || null; - - this.handleModalToggle(); - onChange(value); - } - - clearQSParams() { - const { qsConfig, history } = this.props; + const clearQSParams = () => { const parts = history.location.search.replace(/^\?/, '').split('&'); const ns = qsConfig.namespace; const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); history.push(`${history.location.pathname}?${otherParts.join('&')}`); - } + }; - render() { - const { isModalOpen, selectedItems } = this.state; - const { - id, - lookupHeader, - value, - items, - count, - columns, - multiple, - name, - onBlur, - required, - qsConfig, - i18n, - } = this.props; - const header = lookupHeader || i18n._(t`Items`); - const canDelete = !required || (multiple && value.length > 1); - return ( - - - - - - - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - > - {chip.name} - - ))} - - - - - {i18n._(t`Select`)} - , - , - ]} + const save = () => { + const { selectedItems } = state; + const val = multiple ? selectedItems : selectedItems[0] || null; + onChange(val); + clearQSParams(); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const removeItem = item => { + if (multiple) { + onChange(value.filter(i => i.id !== item.id)); + } else { + onChange(null); + } + }; + + const closeModal = () => { + clearQSParams(); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const { isModalOpen, selectedItems } = state; + const canDelete = !required || (multiple && value.length > 1); + return ( + + + dispatch({ type: 'TOGGLE_MODAL' })} + variant={ButtonVariant.tertiary} > - this.setState({ selectedItems: newVal })} - options={items} - optionCount={count} - columns={columns} - multiple={multiple} - header={lookupHeader} - name={name} - qsConfig={qsConfig} - readOnly={!canDelete} - /> - - - ); - } + + + + + {(multiple ? value : [value]).map(item => + renderItemChip({ + item, + removeItem, + canDelete, + }) + )} + + + + + {i18n._(t`Select`)} + , + , + ]} + > + {renderOptionsList({ + state, + dispatch, + canDelete, + })} + + + ); } const Item = shape({ @@ -248,25 +168,33 @@ const Item = shape({ Lookup.propTypes = { id: string, - items: arrayOf(shape({})).isRequired, - count: number.isRequired, - // TODO: change to `header` - lookupHeader: string, - name: string, + header: string, onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), multiple: bool, required: bool, + onBlur: func, qsConfig: QSConfig.isRequired, + renderItemChip: func, + renderOptionsList: func.isRequired, }; Lookup.defaultProps = { id: 'lookup-search', - lookupHeader: null, - name: null, + header: null, value: null, multiple: false, required: false, + onBlur: () => {}, + renderItemChip: ({ item, removeItem, canDelete }) => ( + removeItem(item)} + isReadOnly={!canDelete} + > + {item.name} + + ), }; export { Lookup as _Lookup }; diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 20d5180e7c..db00354305 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -10,8 +10,8 @@ import { FieldTooltip } from '@components/FormField'; import { CredentialChip } from '@components/Chip'; import VerticalSeperator from '@components/VerticalSeparator'; import { getQSConfig, parseQueryString } from '@util/qs'; -import Lookup from './NewLookup'; -import SelectList from './shared/SelectList'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; const QS_CONFIG = getQSConfig('credentials', { page: 1, @@ -37,7 +37,6 @@ function MultiCredentialsLookup(props) { const [selectedType, setSelectedType] = useState(null); const [credentials, setCredentials] = useState([]); const [credentialsCount, setCredentialsCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); useEffect(() => { (async () => { @@ -60,12 +59,10 @@ function MultiCredentialsLookup(props) { } try { const params = parseQueryString(QS_CONFIG, history.location.search); - setIsLoading(true); const { results, count } = await loadCredentials( params, selectedType.id ); - setIsLoading(false); setCredentials(results); setCredentialsCount(count); } catch (err) { @@ -74,7 +71,7 @@ function MultiCredentialsLookup(props) { })(); }, [selectedType]); - const isMultiple = selectedType && selectedType.value === 'Vault'; + const isMultiple = selectedType && selectedType.name === 'Vault'; const renderChip = ({ item, removeItem, canDelete }) => ( { + renderOptionsList={({ state, dispatch, canDelete }) => { return ( {credentialTypes && credentialTypes.length > 0 && ( @@ -107,7 +104,7 @@ function MultiCredentialsLookup(props) { id="multiCredentialsLookUp-select" label={i18n._(t`Selected Category`)} data={credentialTypes.map(type => ({ - id: type.id, + key: type.id, value: type.id, label: type.name, isDisabled: false, @@ -121,7 +118,7 @@ function MultiCredentialsLookup(props) { /> )} - - props.multiple && - ` - --pf-c-form-control--Height: 90px; - overflow-y: auto; - `} -`; - -const ChipHolder = styled.div` - --pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200); - --pf-c-form-control--BorderRightColor: var(--pf-global--BorderColor--200); - border-top-right-radius: 3px; - border-bottom-right-radius: 3px; -`; - -function Lookup(props) { - const { - id, - header, - onChange, - onBlur, - value, - multiple, - required, - qsConfig, - renderItemChip, - renderSelectList, - history, - i18n, - } = props; - - const [state, dispatch] = useReducer( - reducer, - { value, multiple, required }, - initReducer - ); - - useEffect(() => { - dispatch({ type: 'SET_MULTIPLE', value: multiple }); - }, [multiple]); - - useEffect(() => { - dispatch({ type: 'SET_VALUE', value }); - }, [value]); - - const clearQSParams = () => { - const parts = history.location.search.replace(/^\?/, '').split('&'); - const ns = qsConfig.namespace; - const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); - history.push(`${history.location.pathname}?${otherParts.join('&')}`); - }; - - const save = () => { - const { selectedItems } = state; - const val = multiple ? selectedItems : selectedItems[0] || null; - onChange(val); - clearQSParams(); - dispatch({ type: 'CLOSE_MODAL' }); - }; - - const removeItem = item => { - if (multiple) { - onChange(value.filter(i => i.id !== item.id)); - } else { - onChange(null); - } - }; - - const closeModal = () => { - clearQSParams(); - dispatch({ type: 'CLOSE_MODAL' }); - }; - - const { isModalOpen, selectedItems } = state; - const canDelete = !required || (multiple && value.length > 1); - return ( - - - dispatch({ type: 'TOGGLE_MODAL' })} - variant={ButtonVariant.tertiary} - > - - - - - {(multiple ? value : [value]).map(item => - renderItemChip({ - item, - removeItem, - canDelete, - }) - )} - - - - - {i18n._(t`Select`)} - , - , - ]} - > - {renderSelectList({ - state, - dispatch, - canDelete, - })} - - - ); -} - -const Item = shape({ - id: number.isRequired, -}); - -Lookup.propTypes = { - id: string, - header: string, - onChange: func.isRequired, - value: oneOfType([Item, arrayOf(Item)]), - multiple: bool, - required: bool, - onBlur: func, - qsConfig: QSConfig.isRequired, - renderItemChip: func, - renderSelectList: func.isRequired, -}; - -Lookup.defaultProps = { - id: 'lookup-search', - header: null, - value: null, - multiple: false, - required: false, - onBlur: () => {}, - renderItemChip: ({ item, removeItem, canDelete }) => ( - removeItem(item)} - isReadOnly={!canDelete} - > - {item.name} - - ), -}; - -export { Lookup as _Lookup }; -export default withI18n()(withRouter(Lookup)); diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 32c93f2588..17d98acd9e 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -1,13 +1,17 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { string, func, bool } from 'prop-types'; import { OrganizationsAPI } from '@api'; import { Organization } from '@types'; import { FormGroup } from '@patternfly/react-core'; -import Lookup from '@components/Lookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -const getOrganizations = async params => OrganizationsAPI.read(params); +const QS_CONFIG = getQSConfig('organizations', {}); function OrganizationLookup({ helperTextInvalid, @@ -17,7 +21,25 @@ function OrganizationLookup({ onChange, required, value, + history, }) { + const [organizations, setOrganizations] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await OrganizationsAPI.read(params); + setOrganizations(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + return ( ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} /> + ); } @@ -58,5 +94,5 @@ OrganizationLookup.defaultProps = { value: null, }; -export default withI18n()(OrganizationLookup); export { OrganizationLookup as _OrganizationLookup }; +export default withI18n()(withRouter(OrganizationLookup)); diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index f83d30eb02..3203cadb30 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -1,59 +1,87 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; import { ProjectsAPI } from '@api'; import { Project } from '@types'; -import Lookup from '@components/Lookup'; import { FieldTooltip } from '@components/FormField'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -class ProjectLookup extends React.Component { - render() { - const { - helperTextInvalid, - i18n, - isValid, - onChange, - required, - tooltip, - value, - onBlur, - } = this.props; +const QS_CONFIG = getQSConfig('project', { + page: 1, + page_size: 5, + order_by: 'name', +}); - const loadProjects = async params => { - const response = await ProjectsAPI.read(params); - const { results, count } = response.data; - if (count === 1) { - onChange(results[0], 'project'); +function ProjectLookup({ + helperTextInvalid, + i18n, + isValid, + onChange, + required, + tooltip, + value, + onBlur, + history, +}) { + const [projects, setProjects] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await ProjectsAPI.read(params); + setProjects(data.results); + setCount(data.count); + } catch (err) { + setError(err); } - return response; - }; + })(); + }, [history.location]); - return ( - - {tooltip && } - - - ); - } + return ( + + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); } ProjectLookup.propTypes = { @@ -75,4 +103,5 @@ ProjectLookup.defaultProps = { onBlur: () => {}, }; -export default withI18n()(ProjectLookup); +export { ProjectLookup as _ProjectLookup }; +export default withI18n()(withRouter(ProjectLookup)); diff --git a/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx b/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx new file mode 100644 index 0000000000..588fe18719 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; + +function LookupErrorMessage({ error }) { + if (!error) { + return null; + } + + return ( +
+ {error.message || 'An error occured'} +
+ ); +} + +export default LookupErrorMessage; diff --git a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx similarity index 94% rename from awx/ui_next/src/components/Lookup/shared/SelectList.jsx rename to awx/ui_next/src/components/Lookup/shared/OptionsList.jsx index b9a2ec1f84..73e7b6b5ca 100644 --- a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx @@ -16,7 +16,7 @@ import CheckboxListItem from '../../CheckboxListItem'; import DataListToolbar from '../../DataListToolbar'; import { QSConfig } from '@types'; -function SelectList({ +function OptionsList({ value, options, optionCount, @@ -71,7 +71,7 @@ function SelectList({ const Item = shape({ id: oneOfType([number, string]).isRequired, }); -SelectList.propTypes = { +OptionsList.propTypes = { value: arrayOf(Item).isRequired, options: arrayOf(Item).isRequired, optionCount: number.isRequired, @@ -82,9 +82,9 @@ SelectList.propTypes = { deselectItem: func.isRequired, renderItemChip: func, }; -SelectList.defaultProps = { +OptionsList.defaultProps = { multiple: false, renderItemChip: null, }; -export default withI18n()(SelectList); +export default withI18n()(OptionsList);