[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>
```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> | |
| [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> | |
| [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> | |

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)
//
// @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)
//
@ -1050,6 +1050,8 @@ export interface ReactExpressionRendererProps extends IExpressionLoaderParams {
// (undocumented)
dataAttrs?: string[];
// (undocumented)
debounce?: number;
// (undocumented)
expression: string | ExpressionAstExpression;
// (undocumented)
onEvent?: (event: ExpressionRendererEvent) => void;

View file

@ -113,6 +113,39 @@ describe('ExpressionRenderer', () => {
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', () => {
const dataSubject = new Subject();
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
*/
reload$?: Observable<unknown>;
debounce?: number;
}
export type ReactExpressionRendererType = React.ComponentType<ReactExpressionRendererProps>;
@ -71,6 +72,7 @@ export const ReactExpressionRenderer = ({
expression,
onEvent,
reload$,
debounce,
...expressionLoaderOptions
}: ReactExpressionRendererProps) => {
const mountpoint: React.MutableRefObject<null | HTMLDivElement> = useRef(null);
@ -85,12 +87,28 @@ export const ReactExpressionRenderer = ({
const errorRenderHandlerRef: React.MutableRefObject<null | IInterpreterRenderHandlers> = useRef(
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 */
// OK to ignore react-hooks/exhaustive-deps because options update is handled by calling .update()
useEffect(() => {
const subs: Subscription[] = [];
expressionLoaderRef.current = new ExpressionLoader(mountpoint.current!, expression, {
expressionLoaderRef.current = new ExpressionLoader(mountpoint.current!, activeExpression, {
...expressionLoaderOptions,
// react component wrapper provides different
// error handling api which is easier to work with from react
@ -146,21 +164,21 @@ export const ReactExpressionRenderer = ({
useEffect(() => {
const subscription = reload$?.subscribe(() => {
if (expressionLoaderRef.current) {
expressionLoaderRef.current.update(expression, expressionLoaderOptions);
expressionLoaderRef.current.update(activeExpression, expressionLoaderOptions);
}
});
return () => subscription?.unsubscribe();
}, [reload$, expression, ...Object.values(expressionLoaderOptions)]);
}, [reload$, activeExpression, ...Object.values(expressionLoaderOptions)]);
// Re-fetch data automatically when the inputs change
useShallowCompareEffect(
() => {
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
[{ expression, ...expressionLoaderOptions }]
[{ activeExpression, ...expressionLoaderOptions }]
);
/* eslint-enable react-hooks/exhaustive-deps */
@ -188,7 +206,9 @@ export const ReactExpressionRenderer = ({
return (
<div {...dataAttrs} className={classes}>
{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.error &&
renderError &&

View file

@ -32,16 +32,11 @@ import {
ReactExpressionRendererType,
} from '../../../../../../src/plugins/expressions/public';
import { prependDatasourceExpression } from './expression_helpers';
import { debouncedComponent } from '../../debounced_component';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public';
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 {
activeDatasourceId: string | null;
datasourceMap: Record<string, Datasource>;
@ -82,6 +77,7 @@ const PreviewRenderer = ({
className="lnsSuggestionPanel__expressionRenderer"
padding="s"
expression={expression}
debounce={2000}
renderError={() => {
return (
<div className="lnsSuggestionPanel__suggestionIcon">
@ -104,8 +100,6 @@ const PreviewRenderer = ({
);
};
const DebouncedPreviewRenderer = debouncedComponent(PreviewRenderer, 2000);
const SuggestionPreview = ({
preview,
ExpressionRenderer: ExpressionRendererComponent,
@ -126,7 +120,7 @@ const SuggestionPreview = ({
return (
<EuiToolTip content={preview.title}>
<div data-test-subj={`lnsSuggestion-${camelCase(preview.title)}`}>
<EuiPanelFixed
<EuiPanel
className={classNames('lnsSuggestionPanel__button', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'lnsSuggestionPanel__button-isSelected': selected,
@ -136,7 +130,7 @@ const SuggestionPreview = ({
onClick={onSelect}
>
{preview.expression ? (
<DebouncedPreviewRenderer
<PreviewRenderer
ExpressionRendererComponent={ExpressionRendererComponent}
expression={toExpression(preview.expression)}
withLabel={Boolean(showTitleAsLabel)}
@ -149,7 +143,7 @@ const SuggestionPreview = ({
{showTitleAsLabel && (
<span className="lnsSuggestionPanel__buttonLabel">{preview.title}</span>
)}
</EuiPanelFixed>
</EuiPanel>
</div>
</EuiToolTip>
);