mirror of
https://github.com/ansible/awx.git
synced 2026-05-17 06:17:36 -02:30
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:
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user