diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 26d1760f57..ecc5aa142b 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { arrayOf, string, func, object, bool } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; @@ -8,6 +8,7 @@ import { InstanceGroupsAPI } from '../../api'; import { getQSConfig, parseQueryString } from '../../util/qs'; import { FieldTooltip } from '../FormField'; import OptionsList from '../OptionsList'; +import useRequest from '../../util/useRequest'; import Lookup from './Lookup'; import LookupErrorMessage from './shared/LookupErrorMessage'; @@ -27,22 +28,27 @@ function InstanceGroupsLookup(props) { history, i18n, } = props; - const [instanceGroups, setInstanceGroups] = useState([]); - const [count, setCount] = useState(0); - const [error, setError] = useState(null); + + const { + result: { instanceGroups, count }, + request: fetchInstanceGroups, + error, + isLoading, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { data } = await InstanceGroupsAPI.read(params); + return { + instanceGroups: data.results, + count: data.count, + }; + }, [history.location]), + { instanceGroups: [], count: 0 } + ); useEffect(() => { - (async () => { - const params = parseQueryString(QS_CONFIG, history.location.search); - try { - const { data } = await InstanceGroupsAPI.read(params); - setInstanceGroups(data.results); - setCount(data.count); - } catch (err) { - setError(err); - } - })(); - }, [history.location]); + fetchInstanceGroups(); + }, [fetchInstanceGroups]); return ( ( { const params = parseQueryString(QS_CONFIG, history.location.search); const { data } = await InventoriesAPI.read(params); return { - count: data.count, inventories: data.results, + count: data.count, }; - }, [history.location.search]), - { - count: 0, - inventories: [], - } + }, [history.location]), + { inventories: [], count: 0 } ); useEffect(() => { @@ -50,6 +48,7 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { onChange={onChange} onBlur={onBlur} required={required} + isLoading={isLoading} qsConfig={QS_CONFIG} renderOptionsList={({ state, dispatch, canDelete }) => ( dispatch({ type: 'TOGGLE_MODAL' })} variant={ButtonVariant.tertiary} + isDisabled={isLoading} > diff --git a/awx/ui_next/src/components/Lookup/Lookup.test.jsx b/awx/ui_next/src/components/Lookup/Lookup.test.jsx index 7fb1ed464c..bcb4d9fdc1 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.test.jsx @@ -159,4 +159,30 @@ describe('', () => { const list = wrapper.find('TestList'); expect(list.prop('canDelete')).toEqual(false); }); + + test('should be disabled while isLoading is true', async () => { + const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; + wrapper = mountWithContexts( + ( + + )} + /> + ); + checkRootElementNotPresent('body div[role="dialog"]'); + const button = wrapper.find('button[aria-label="Search"]'); + expect(button.prop('disabled')).toEqual(true); + }); }); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index c1dec4f9d7..d76bd905c8 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -1,5 +1,5 @@ import 'styled-components/macro'; -import React, { Fragment, useState, useEffect } from 'react'; +import React, { Fragment, useState, useCallback, useEffect } from 'react'; import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; @@ -9,6 +9,7 @@ import { CredentialsAPI, CredentialTypesAPI } from '../../api'; import AnsibleSelect from '../AnsibleSelect'; import CredentialChip from '../CredentialChip'; import OptionsList from '../OptionsList'; +import useRequest from '../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../util/qs'; import Lookup from './Lookup'; @@ -26,42 +27,62 @@ async function loadCredentials(params, selectedCredentialTypeId) { function MultiCredentialsLookup(props) { const { value, onChange, onError, history, i18n } = props; - const [credentialTypes, setCredentialTypes] = useState([]); const [selectedType, setSelectedType] = useState(null); - const [credentials, setCredentials] = useState([]); - const [credentialsCount, setCredentialsCount] = useState(0); + + const { + result: credentialTypes, + request: fetchTypes, + error: typesError, + isLoading: isTypesLoading, + } = useRequest( + useCallback(async () => { + const types = await CredentialTypesAPI.loadAllTypes(); + const match = types.find(type => type.kind === 'ssh') || types[0]; + setSelectedType(match); + return types; + }, []), + [] + ); useEffect(() => { - (async () => { - try { - const types = await CredentialTypesAPI.loadAllTypes(); - setCredentialTypes(types); - const match = types.find(type => type.kind === 'ssh') || types[0]; - setSelectedType(match); - } catch (err) { - onError(err); - } - })(); - }, [onError]); + fetchTypes(); + }, [fetchTypes]); - useEffect(() => { - (async () => { + const { + result: { credentials, credentialsCount }, + request: fetchCredentials, + error: credentialsError, + isLoading: isCredentialsLoading, + } = useRequest( + useCallback(async () => { if (!selectedType) { - return; + return { + credentials: [], + count: 0, + }; } - try { - const params = parseQueryString(QS_CONFIG, history.location.search); - const { results, count } = await loadCredentials( - params, - selectedType.id - ); - setCredentials(results); - setCredentialsCount(count); - } catch (err) { - onError(err); - } - })(); - }, [selectedType, history.location.search, onError]); + const params = parseQueryString(QS_CONFIG, history.location.search); + const { results, count } = await loadCredentials(params, selectedType.id); + return { + credentials: results, + credentialsCount: count, + }; + }, [selectedType, history.location]), + { + credentials: [], + credentialsCount: 0, + } + ); + + useEffect(() => { + fetchCredentials(); + }, [fetchCredentials]); + + useEffect(() => { + if (typesError || credentialsError) { + onError(typesError || credentialsError); + } + }, [typesError, credentialsError, onError]); const renderChip = ({ item, removeItem, canDelete }) => ( { return ( diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index a73a6b4025..dedd7bc7c7 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -156,7 +156,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ @@ -201,7 +201,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ @@ -248,7 +248,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ @@ -301,7 +301,7 @@ describe('', () => { }); }); wrapper.update(); - act(() => { + await act(async () => { wrapper.find('Button[variant="primary"]').invoke('onClick')(); }); expect(onChange).toBeCalledWith([ diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index 2b1ee265ac..03c6dbbbe3 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -32,9 +32,10 @@ function ProjectLookup({ history, }) { const { - result: { count, projects }, - error, + result: { projects, count }, request: fetchProjects, + error, + isLoading, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); @@ -74,6 +75,7 @@ function ProjectLookup({ onBlur={onBlur} onChange={onChange} required={required} + isLoading={isLoading} qsConfig={QS_CONFIG} renderOptionsList={({ state, dispatch, canDelete }) => ( { - loadLabelOptions(setOptions, onError); + (async () => { + await loadLabelOptions(setOptions, onError); + setIsLoading(false); + })(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []); @@ -77,6 +81,7 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) { } return label; }} + isDisabled={isLoading} selections={selections} isExpanded={isExpanded} ariaLabelledBy="label-select" diff --git a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx index cefd5bd126..78fd6e28f9 100644 --- a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx +++ b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx @@ -1,39 +1,51 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { number, string, oneOfType } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import AnsibleSelect from '../../../components/AnsibleSelect'; import { ProjectsAPI } from '../../../api'; +import useRequest from '../../../util/useRequest'; function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { - const [options, setOptions] = useState([]); + const { + result: options, + request: fetchOptions, + isLoading, + error, + } = useRequest( + useCallback(async () => { + if (!projectId) { + return []; + } + const { data } = await ProjectsAPI.readPlaybooks(projectId); + const opts = (data || []).map(playbook => ({ + value: playbook, + key: playbook, + label: playbook, + isDisabled: false, + })); + + opts.unshift({ + value: '', + key: '', + label: i18n._(t`Choose a playbook`), + isDisabled: false, + }); + return opts; + }, [projectId, i18n]), + [] + ); useEffect(() => { - if (!projectId) { - return; - } - (async () => { - try { - const { data } = await ProjectsAPI.readPlaybooks(projectId); - const opts = (data || []).map(playbook => ({ - value: playbook, - key: playbook, - label: playbook, - isDisabled: false, - })); + fetchOptions(); + }, [fetchOptions]); + + useEffect(() => { + if (error) { + onError(error); + } + }, [error, onError]); - opts.unshift({ - value: '', - key: '', - label: i18n._(t`Choose a playbook`), - isDisabled: false, - }); - setOptions(opts); - } catch (contentError) { - onError(contentError); - } - })(); - }, [projectId, i18n, onError]); return ( ); }