[Lens] Add loading indicator during debounce time (#80158)

This commit is contained in:
Joe Reuter 2020-10-29 16:06:12 +01:00 committed by GitHub
parent 667ff6cd2c
commit 59662eefd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 79 additions and 18 deletions

View file

@ -7,5 +7,5 @@
<b>Signature:</b> <b>Signature:</b>
```typescript ```typescript
ReactExpressionRenderer: ({ className, dataAttrs, padding, renderError, expression, onEvent, reload$, ...expressionLoaderOptions }: ReactExpressionRendererProps) => JSX.Element ReactExpressionRenderer: ({ className, dataAttrs, padding, renderError, expression, onEvent, reload$, debounce, ...expressionLoaderOptions }: ReactExpressionRendererProps) => JSX.Element
``` ```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) &gt; [ReactExpressionRendererProps](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.md) &gt; [debounce](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.debounce.md)
## ReactExpressionRendererProps.debounce property
<b>Signature:</b>
```typescript
debounce?: number;
```

View file

@ -16,6 +16,7 @@ export interface ReactExpressionRendererProps extends IExpressionLoaderParams
| --- | --- | --- | | --- | --- | --- |
| [className](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.classname.md) | <code>string</code> | | | [className](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.classname.md) | <code>string</code> | |
| [dataAttrs](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.dataattrs.md) | <code>string[]</code> | | | [dataAttrs](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.dataattrs.md) | <code>string[]</code> | |
| [debounce](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.debounce.md) | <code>number</code> | |
| [expression](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.expression.md) | <code>string &#124; ExpressionAstExpression</code> | | | [expression](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.expression.md) | <code>string &#124; ExpressionAstExpression</code> | |
| [onEvent](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.onevent.md) | <code>(event: ExpressionRendererEvent) =&gt; void</code> | | | [onEvent](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.onevent.md) | <code>(event: ExpressionRendererEvent) =&gt; void</code> | |
| [padding](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.padding.md) | <code>'xs' &#124; 's' &#124; 'm' &#124; 'l' &#124; 'xl'</code> | | | [padding](./kibana-plugin-plugins-expressions-public.reactexpressionrendererprops.padding.md) | <code>'xs' &#124; 's' &#124; 'm' &#124; 'l' &#124; 'xl'</code> | |

View file

@ -1039,7 +1039,7 @@ export interface Range {
// Warning: (ae-missing-release-tag) "ReactExpressionRenderer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "ReactExpressionRenderer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
// //
// @public (undocumented) // @public (undocumented)
export const ReactExpressionRenderer: ({ className, dataAttrs, padding, renderError, expression, onEvent, reload$, ...expressionLoaderOptions }: ReactExpressionRendererProps) => JSX.Element; export const ReactExpressionRenderer: ({ className, dataAttrs, padding, renderError, expression, onEvent, reload$, debounce, ...expressionLoaderOptions }: ReactExpressionRendererProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "ReactExpressionRendererProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "ReactExpressionRendererProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
// //
@ -1050,6 +1050,8 @@ export interface ReactExpressionRendererProps extends IExpressionLoaderParams {
// (undocumented) // (undocumented)
dataAttrs?: string[]; dataAttrs?: string[];
// (undocumented) // (undocumented)
debounce?: number;
// (undocumented)
expression: string | ExpressionAstExpression; expression: string | ExpressionAstExpression;
// (undocumented) // (undocumented)
onEvent?: (event: ExpressionRendererEvent) => void; onEvent?: (event: ExpressionRendererEvent) => void;

View file

@ -113,6 +113,39 @@ describe('ExpressionRenderer', () => {
instance.unmount(); instance.unmount();
}); });
it('waits for debounce period if specified', () => {
jest.useFakeTimers();
const refreshSubject = new Subject();
const loaderUpdate = jest.fn();
(ExpressionLoader as jest.Mock).mockImplementation(() => {
return {
render$: new Subject(),
data$: new Subject(),
loading$: new Subject(),
update: loaderUpdate,
destroy: jest.fn(),
};
});
const instance = mount(
<ReactExpressionRenderer reload$={refreshSubject} expression="" debounce={1000} />
);
instance.setProps({ expression: 'abc' });
expect(loaderUpdate).toHaveBeenCalledTimes(1);
act(() => {
jest.runAllTimers();
});
expect(loaderUpdate).toHaveBeenCalledTimes(2);
instance.unmount();
});
it('should display a custom error message if the user provides one and then remove it after successful render', () => { it('should display a custom error message if the user provides one and then remove it after successful render', () => {
const dataSubject = new Subject(); const dataSubject = new Subject();
const data$ = dataSubject.asObservable().pipe(share()); const data$ = dataSubject.asObservable().pipe(share());

View file

@ -45,6 +45,7 @@ export interface ReactExpressionRendererProps extends IExpressionLoaderParams {
* An observable which can be used to re-run the expression without destroying the component * An observable which can be used to re-run the expression without destroying the component
*/ */
reload$?: Observable<unknown>; reload$?: Observable<unknown>;
debounce?: number;
} }
export type ReactExpressionRendererType = React.ComponentType<ReactExpressionRendererProps>; export type ReactExpressionRendererType = React.ComponentType<ReactExpressionRendererProps>;
@ -71,6 +72,7 @@ export const ReactExpressionRenderer = ({
expression, expression,
onEvent, onEvent,
reload$, reload$,
debounce,
...expressionLoaderOptions ...expressionLoaderOptions
}: ReactExpressionRendererProps) => { }: ReactExpressionRendererProps) => {
const mountpoint: React.MutableRefObject<null | HTMLDivElement> = useRef(null); const mountpoint: React.MutableRefObject<null | HTMLDivElement> = useRef(null);
@ -85,12 +87,28 @@ export const ReactExpressionRenderer = ({
const errorRenderHandlerRef: React.MutableRefObject<null | IInterpreterRenderHandlers> = useRef( const errorRenderHandlerRef: React.MutableRefObject<null | IInterpreterRenderHandlers> = useRef(
null null
); );
const [debouncedExpression, setDebouncedExpression] = useState(expression);
useEffect(() => {
if (debounce === undefined) {
return;
}
const handler = setTimeout(() => {
setDebouncedExpression(expression);
}, debounce);
return () => {
clearTimeout(handler);
};
}, [expression, debounce]);
const activeExpression = debounce !== undefined ? debouncedExpression : expression;
const waitingForDebounceToComplete = debounce !== undefined && expression !== debouncedExpression;
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
// OK to ignore react-hooks/exhaustive-deps because options update is handled by calling .update() // OK to ignore react-hooks/exhaustive-deps because options update is handled by calling .update()
useEffect(() => { useEffect(() => {
const subs: Subscription[] = []; const subs: Subscription[] = [];
expressionLoaderRef.current = new ExpressionLoader(mountpoint.current!, expression, { expressionLoaderRef.current = new ExpressionLoader(mountpoint.current!, activeExpression, {
...expressionLoaderOptions, ...expressionLoaderOptions,
// react component wrapper provides different // react component wrapper provides different
// error handling api which is easier to work with from react // error handling api which is easier to work with from react
@ -146,21 +164,21 @@ export const ReactExpressionRenderer = ({
useEffect(() => { useEffect(() => {
const subscription = reload$?.subscribe(() => { const subscription = reload$?.subscribe(() => {
if (expressionLoaderRef.current) { if (expressionLoaderRef.current) {
expressionLoaderRef.current.update(expression, expressionLoaderOptions); expressionLoaderRef.current.update(activeExpression, expressionLoaderOptions);
} }
}); });
return () => subscription?.unsubscribe(); return () => subscription?.unsubscribe();
}, [reload$, expression, ...Object.values(expressionLoaderOptions)]); }, [reload$, activeExpression, ...Object.values(expressionLoaderOptions)]);
// Re-fetch data automatically when the inputs change // Re-fetch data automatically when the inputs change
useShallowCompareEffect( useShallowCompareEffect(
() => { () => {
if (expressionLoaderRef.current) { if (expressionLoaderRef.current) {
expressionLoaderRef.current.update(expression, expressionLoaderOptions); expressionLoaderRef.current.update(activeExpression, expressionLoaderOptions);
} }
}, },
// when expression is changed by reference and when any other loaderOption is changed by reference // when expression is changed by reference and when any other loaderOption is changed by reference
[{ expression, ...expressionLoaderOptions }] [{ activeExpression, ...expressionLoaderOptions }]
); );
/* eslint-enable react-hooks/exhaustive-deps */ /* eslint-enable react-hooks/exhaustive-deps */
@ -188,7 +206,9 @@ export const ReactExpressionRenderer = ({
return ( return (
<div {...dataAttrs} className={classes}> <div {...dataAttrs} className={classes}>
{state.isEmpty && <EuiLoadingChart mono size="l" />} {state.isEmpty && <EuiLoadingChart mono size="l" />}
{state.isLoading && <EuiProgress size="xs" color="accent" position="absolute" />} {(state.isLoading || waitingForDebounceToComplete) && (
<EuiProgress size="xs" color="accent" position="absolute" />
)}
{!state.isLoading && {!state.isLoading &&
state.error && state.error &&
renderError && renderError &&

View file

@ -32,16 +32,11 @@ import {
ReactExpressionRendererType, ReactExpressionRendererType,
} from '../../../../../../src/plugins/expressions/public'; } from '../../../../../../src/plugins/expressions/public';
import { prependDatasourceExpression } from './expression_helpers'; import { prependDatasourceExpression } from './expression_helpers';
import { debouncedComponent } from '../../debounced_component';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry'; import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
const MAX_SUGGESTIONS_DISPLAYED = 5; const MAX_SUGGESTIONS_DISPLAYED = 5;
// TODO: Remove this <any> when upstream fix is merged https://github.com/elastic/eui/issues/2329
// eslint-disable-next-line
const EuiPanelFixed = EuiPanel as React.ComponentType<any>;
export interface SuggestionPanelProps { export interface SuggestionPanelProps {
activeDatasourceId: string | null; activeDatasourceId: string | null;
datasourceMap: Record<string, Datasource>; datasourceMap: Record<string, Datasource>;
@ -82,6 +77,7 @@ const PreviewRenderer = ({
className="lnsSuggestionPanel__expressionRenderer" className="lnsSuggestionPanel__expressionRenderer"
padding="s" padding="s"
expression={expression} expression={expression}
debounce={2000}
renderError={() => { renderError={() => {
return ( return (
<div className="lnsSuggestionPanel__suggestionIcon"> <div className="lnsSuggestionPanel__suggestionIcon">
@ -104,8 +100,6 @@ const PreviewRenderer = ({
); );
}; };
const DebouncedPreviewRenderer = debouncedComponent(PreviewRenderer, 2000);
const SuggestionPreview = ({ const SuggestionPreview = ({
preview, preview,
ExpressionRenderer: ExpressionRendererComponent, ExpressionRenderer: ExpressionRendererComponent,
@ -126,7 +120,7 @@ const SuggestionPreview = ({
return ( return (
<EuiToolTip content={preview.title}> <EuiToolTip content={preview.title}>
<div data-test-subj={`lnsSuggestion-${camelCase(preview.title)}`}> <div data-test-subj={`lnsSuggestion-${camelCase(preview.title)}`}>
<EuiPanelFixed <EuiPanel
className={classNames('lnsSuggestionPanel__button', { className={classNames('lnsSuggestionPanel__button', {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'lnsSuggestionPanel__button-isSelected': selected, 'lnsSuggestionPanel__button-isSelected': selected,
@ -136,7 +130,7 @@ const SuggestionPreview = ({
onClick={onSelect} onClick={onSelect}
> >
{preview.expression ? ( {preview.expression ? (
<DebouncedPreviewRenderer <PreviewRenderer
ExpressionRendererComponent={ExpressionRendererComponent} ExpressionRendererComponent={ExpressionRendererComponent}
expression={toExpression(preview.expression)} expression={toExpression(preview.expression)}
withLabel={Boolean(showTitleAsLabel)} withLabel={Boolean(showTitleAsLabel)}
@ -149,7 +143,7 @@ const SuggestionPreview = ({
{showTitleAsLabel && ( {showTitleAsLabel && (
<span className="lnsSuggestionPanel__buttonLabel">{preview.title}</span> <span className="lnsSuggestionPanel__buttonLabel">{preview.title}</span>
)} )}
</EuiPanelFixed> </EuiPanel>
</div> </div>
</EuiToolTip> </EuiToolTip>
); );