Merge pull request #7081 from keithjgrant/6640-jt-form-loading

JT form loading UX

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-06-02 16:45:05 +00:00
committed by GitHub
9 changed files with 163 additions and 87 deletions

View File

@@ -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 { arrayOf, string, func, object, bool } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
@@ -8,6 +8,7 @@ import { InstanceGroupsAPI } from '../../api';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import { FieldTooltip } from '../FormField'; import { FieldTooltip } from '../FormField';
import OptionsList from '../OptionsList'; import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import Lookup from './Lookup'; import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
@@ -27,22 +28,27 @@ function InstanceGroupsLookup(props) {
history, history,
i18n, i18n,
} = props; } = props;
const [instanceGroups, setInstanceGroups] = useState([]);
const [count, setCount] = useState(0); const {
const [error, setError] = useState(null); 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(() => { useEffect(() => {
(async () => { fetchInstanceGroups();
const params = parseQueryString(QS_CONFIG, history.location.search); }, [fetchInstanceGroups]);
try {
const { data } = await InstanceGroupsAPI.read(params);
setInstanceGroups(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
return ( return (
<FormGroup <FormGroup
@@ -59,6 +65,7 @@ function InstanceGroupsLookup(props) {
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
multiple multiple
required={required} required={required}
isLoading={isLoading}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList
value={state.selectedItems} value={state.selectedItems}

View File

@@ -19,22 +19,20 @@ const QS_CONFIG = getQSConfig('inventory', {
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
const { const {
result: { count, inventories }, result: { inventories, count },
error,
request: fetchInventories, request: fetchInventories,
error,
isLoading,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search); const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await InventoriesAPI.read(params); const { data } = await InventoriesAPI.read(params);
return { return {
count: data.count,
inventories: data.results, inventories: data.results,
count: data.count,
}; };
}, [history.location.search]), }, [history.location]),
{ { inventories: [], count: 0 }
count: 0,
inventories: [],
}
); );
useEffect(() => { useEffect(() => {
@@ -50,6 +48,7 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
required={required} required={required}
isLoading={isLoading}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList

View File

@@ -56,6 +56,7 @@ function Lookup(props) {
header, header,
onChange, onChange,
onBlur, onBlur,
isLoading,
value, value,
multiple, multiple,
required, required,
@@ -124,6 +125,7 @@ function Lookup(props) {
id={id} id={id}
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })} onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
variant={ButtonVariant.tertiary} variant={ButtonVariant.tertiary}
isDisabled={isLoading}
> >
<SearchIcon /> <SearchIcon />
</SearchButton> </SearchButton>

View File

@@ -159,4 +159,30 @@ describe('<Lookup />', () => {
const list = wrapper.find('TestList'); const list = wrapper.find('TestList');
expect(list.prop('canDelete')).toEqual(false); 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(
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
isLoading
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
);
checkRootElementNotPresent('body div[role="dialog"]');
const button = wrapper.find('button[aria-label="Search"]');
expect(button.prop('disabled')).toEqual(true);
});
}); });

View File

@@ -1,5 +1,5 @@
import 'styled-components/macro'; 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 { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
@@ -9,6 +9,7 @@ import { CredentialsAPI, CredentialTypesAPI } from '../../api';
import AnsibleSelect from '../AnsibleSelect'; import AnsibleSelect from '../AnsibleSelect';
import CredentialChip from '../CredentialChip'; import CredentialChip from '../CredentialChip';
import OptionsList from '../OptionsList'; import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import Lookup from './Lookup'; import Lookup from './Lookup';
@@ -26,42 +27,62 @@ async function loadCredentials(params, selectedCredentialTypeId) {
function MultiCredentialsLookup(props) { function MultiCredentialsLookup(props) {
const { value, onChange, onError, history, i18n } = props; const { value, onChange, onError, history, i18n } = props;
const [credentialTypes, setCredentialTypes] = useState([]);
const [selectedType, setSelectedType] = useState(null); 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(() => { useEffect(() => {
(async () => { fetchTypes();
try { }, [fetchTypes]);
const types = await CredentialTypesAPI.loadAllTypes();
setCredentialTypes(types);
const match = types.find(type => type.kind === 'ssh') || types[0];
setSelectedType(match);
} catch (err) {
onError(err);
}
})();
}, [onError]);
useEffect(() => { const {
(async () => { result: { credentials, credentialsCount },
request: fetchCredentials,
error: credentialsError,
isLoading: isCredentialsLoading,
} = useRequest(
useCallback(async () => {
if (!selectedType) { if (!selectedType) {
return; return {
credentials: [],
count: 0,
};
} }
try { const params = parseQueryString(QS_CONFIG, history.location.search);
const params = parseQueryString(QS_CONFIG, history.location.search); const { results, count } = await loadCredentials(params, selectedType.id);
const { results, count } = await loadCredentials( return {
params, credentials: results,
selectedType.id credentialsCount: count,
); };
setCredentials(results); }, [selectedType, history.location]),
setCredentialsCount(count); {
} catch (err) { credentials: [],
onError(err); credentialsCount: 0,
} }
})(); );
}, [selectedType, history.location.search, onError]);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
useEffect(() => {
if (typesError || credentialsError) {
onError(typesError || credentialsError);
}
}, [typesError, credentialsError, onError]);
const renderChip = ({ item, removeItem, canDelete }) => ( const renderChip = ({ item, removeItem, canDelete }) => (
<CredentialChip <CredentialChip
@@ -82,6 +103,7 @@ function MultiCredentialsLookup(props) {
multiple multiple
onChange={onChange} onChange={onChange}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isLoading={isTypesLoading || isCredentialsLoading}
renderItemChip={renderChip} renderItemChip={renderChip}
renderOptionsList={({ state, dispatch, canDelete }) => { renderOptionsList={({ state, dispatch, canDelete }) => {
return ( return (

View File

@@ -156,7 +156,7 @@ describe('<MultiCredentialsLookup />', () => {
}); });
}); });
wrapper.update(); wrapper.update();
act(() => { await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')(); wrapper.find('Button[variant="primary"]').invoke('onClick')();
}); });
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([
@@ -201,7 +201,7 @@ describe('<MultiCredentialsLookup />', () => {
}); });
}); });
wrapper.update(); wrapper.update();
act(() => { await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')(); wrapper.find('Button[variant="primary"]').invoke('onClick')();
}); });
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([
@@ -248,7 +248,7 @@ describe('<MultiCredentialsLookup />', () => {
}); });
}); });
wrapper.update(); wrapper.update();
act(() => { await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')(); wrapper.find('Button[variant="primary"]').invoke('onClick')();
}); });
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([
@@ -301,7 +301,7 @@ describe('<MultiCredentialsLookup />', () => {
}); });
}); });
wrapper.update(); wrapper.update();
act(() => { await act(async () => {
wrapper.find('Button[variant="primary"]').invoke('onClick')(); wrapper.find('Button[variant="primary"]').invoke('onClick')();
}); });
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([

View File

@@ -32,9 +32,10 @@ function ProjectLookup({
history, history,
}) { }) {
const { const {
result: { count, projects }, result: { projects, count },
error,
request: fetchProjects, request: fetchProjects,
error,
isLoading,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search); const params = parseQueryString(QS_CONFIG, history.location.search);
@@ -74,6 +75,7 @@ function ProjectLookup({
onBlur={onBlur} onBlur={onBlur}
onChange={onChange} onChange={onChange}
required={required} required={required}
isLoading={isLoading}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList

View File

@@ -30,6 +30,7 @@ async function loadLabelOptions(setLabels, onError) {
} }
function LabelSelect({ value, placeholder, onChange, onError, createText }) { function LabelSelect({ value, placeholder, onChange, onError, createText }) {
const [isLoading, setIsLoading] = useState(true);
const { selections, onSelect, options, setOptions } = useSyncedSelectValue( const { selections, onSelect, options, setOptions } = useSyncedSelectValue(
value, value,
onChange onChange
@@ -41,7 +42,10 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
}; };
useEffect(() => { useEffect(() => {
loadLabelOptions(setOptions, onError); (async () => {
await loadLabelOptions(setOptions, onError);
setIsLoading(false);
})();
/* eslint-disable-next-line react-hooks/exhaustive-deps */ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []); }, []);
@@ -77,6 +81,7 @@ function LabelSelect({ value, placeholder, onChange, onError, createText }) {
} }
return label; return label;
}} }}
isDisabled={isLoading}
selections={selections} selections={selections}
isExpanded={isExpanded} isExpanded={isExpanded}
ariaLabelledBy="label-select" ariaLabelledBy="label-select"

View File

@@ -1,39 +1,51 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { number, string, oneOfType } from 'prop-types'; import { number, string, oneOfType } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import AnsibleSelect from '../../../components/AnsibleSelect'; import AnsibleSelect from '../../../components/AnsibleSelect';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
import useRequest from '../../../util/useRequest';
function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { 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(() => { useEffect(() => {
if (!projectId) { fetchOptions();
return; }, [fetchOptions]);
}
(async () => { useEffect(() => {
try { if (error) {
const { data } = await ProjectsAPI.readPlaybooks(projectId); onError(error);
const opts = (data || []).map(playbook => ({ }
value: playbook, }, [error, onError]);
key: playbook,
label: playbook,
isDisabled: false,
}));
opts.unshift({
value: '',
key: '',
label: i18n._(t`Choose a playbook`),
isDisabled: false,
});
setOptions(opts);
} catch (contentError) {
onError(contentError);
}
})();
}, [projectId, i18n, onError]);
return ( return (
<AnsibleSelect <AnsibleSelect
id="template-playbook" id="template-playbook"
@@ -41,6 +53,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
isValid={isValid} isValid={isValid}
{...field} {...field}
onBlur={onBlur} onBlur={onBlur}
isDisabled={isLoading}
/> />
); );
} }