Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expressions service fixes: better error and loading states handling #51183

Merged
Merged
7 changes: 7 additions & 0 deletions src/plugins/expressions/public/execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ jest.mock('./services', () => ({
},
};
},
getNotifications: jest.fn(() => {
return {
toasts: {
addError: jest.fn(() => {}),
},
};
}),
}));

describe('execute helper function', () => {
Expand Down
83 changes: 40 additions & 43 deletions src/plugins/expressions/public/expression_renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
*/

import React from 'react';
import { act } from 'react-dom/test-utils';
import { Subject } from 'rxjs';
import { share } from 'rxjs/operators';
import { ExpressionRendererImplementation } from './expression_renderer';
import { ExpressionLoader } from './loader';
import { mount } from 'enzyme';
import { EuiProgress } from '@elastic/eui';
import { RenderErrorHandlerFnType } from './types';

jest.mock('./loader', () => {
return {
Expand Down Expand Up @@ -54,68 +56,49 @@ describe('ExpressionRenderer', () => {

const instance = mount(<ExpressionRendererImplementation expression="" />);

loadingSubject.next();
act(() => {
loadingSubject.next();
});

instance.update();

expect(instance.find(EuiProgress)).toHaveLength(1);

renderSubject.next(1);
act(() => {
renderSubject.next(1);
});

instance.update();

expect(instance.find(EuiProgress)).toHaveLength(0);

instance.setProps({ expression: 'something new' });
loadingSubject.next();
act(() => {
loadingSubject.next();
});
instance.update();

expect(instance.find(EuiProgress)).toHaveLength(1);

renderSubject.next(1);
instance.update();

expect(instance.find(EuiProgress)).toHaveLength(0);
});

it('should display an error message when the expression fails', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've changed the behavior this test was testing- why?

Copy link
Contributor Author

@Dosant Dosant Nov 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default error handling of expression_renderer.tsx now fallbacks to default error handling of render.ts. This was done primarily to reduce number of code paths to support.

Pls see: https://github.com/elastic/kibana/pull/51183/files#r349611820

And that is why test was deleted as we have less code paths in this component.

This looks like it's changing the default behavior of the error handler when the expression fails to render. Try going to Lens and configuring only a Y axis.

I guess I indeed missing the change, where I had to explicitly override error handling, where previous default of expression_renderer.tsx was expected.
I found one missing place here: x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/expression_wrapper.tsx and added error override, but as I understand this is for dashboard page?
9a3282f
Any other places I am missing?

Try going to Lens and configuring only a Y axis.

I am likely doing it wrong, but I think I am getting the same behaviour as in master. Blank screen. Any suggestions?

const dataSubject = new Subject();
const data$ = dataSubject.asObservable().pipe(share());
const renderSubject = new Subject();
const render$ = renderSubject.asObservable().pipe(share());
const loadingSubject = new Subject();
const loading$ = loadingSubject.asObservable().pipe(share());

(ExpressionLoader as jest.Mock).mockImplementation(() => {
return {
render$,
data$,
loading$,
update: jest.fn(),
};
});

const instance = mount(<ExpressionRendererImplementation expression="" />);

dataSubject.next('good data');
renderSubject.next({
type: 'error',
error: { message: 'render error' },
act(() => {
renderSubject.next(1);
});
instance.update();

expect(instance.find(EuiProgress)).toHaveLength(0);
expect(instance.find('[data-test-subj="expression-renderer-error"]')).toHaveLength(1);
});

it('should display a custom error message if the user provides one', () => {
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());
const renderSubject = new Subject();
const render$ = renderSubject.asObservable().pipe(share());
const loadingSubject = new Subject();
const loading$ = loadingSubject.asObservable().pipe(share());

(ExpressionLoader as jest.Mock).mockImplementation(() => {
let onRenderError: RenderErrorHandlerFnType;
(ExpressionLoader as jest.Mock).mockImplementation((...args) => {
const params = args[2];
onRenderError = params.onRenderError;
return {
render$,
data$,
Expand All @@ -124,18 +107,32 @@ describe('ExpressionRenderer', () => {
};
});

const renderErrorFn = jest.fn().mockReturnValue(null);

const instance = mount(
<ExpressionRendererImplementation expression="" renderError={renderErrorFn} />
<ExpressionRendererImplementation
expression=""
renderError={message => <div data-test-subj={'custom-error'}>{message}</div>}
/>
);

renderSubject.next({
type: 'error',
error: { message: 'render error' },
act(() => {
onRenderError!(instance.getDOMNode(), new Error('render error'), {
done: () => {
renderSubject.next(1);
},
} as any);
});

instance.update();
expect(instance.find(EuiProgress)).toHaveLength(0);
expect(instance.find('[data-test-subj="custom-error"]')).toHaveLength(1);
expect(instance.find('[data-test-subj="custom-error"]').contains('render error')).toBeTruthy();

expect(renderErrorFn).toHaveBeenCalledWith('render error');
act(() => {
loadingSubject.next();
renderSubject.next(2);
});
instance.update();
expect(instance.find(EuiProgress)).toHaveLength(0);
expect(instance.find('[data-test-subj="custom-error"]')).toHaveLength(0);
});
});
144 changes: 81 additions & 63 deletions src/plugins/expressions/public/expression_renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@
* under the License.
*/

import { useRef, useEffect, useState } from 'react';
import { useRef, useEffect, useState, useLayoutEffect } from 'react';
import React from 'react';
import classNames from 'classnames';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { EuiLoadingChart, EuiProgress } from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { IExpressionLoaderParams } from './types';
import { useShallowCompareEffect } from '../../kibana_react/public';
import { IExpressionLoaderParams, IInterpreterRenderHandlers, RenderError } from './types';
import { ExpressionAST } from '../common/types';
import { ExpressionLoader } from './loader';

Expand All @@ -39,7 +42,7 @@ export interface ExpressionRendererProps extends IExpressionLoaderParams {
interface State {
isEmpty: boolean;
isLoading: boolean;
error: null | { message: string };
error: null | RenderError;
}

export type ExpressionRenderer = React.FC<ExpressionRendererProps>;
Expand All @@ -53,73 +56,94 @@ const defaultState: State = {
export const ExpressionRendererImplementation = ({
className,
dataAttrs,
expression,
renderError,
padding,
...options
renderError,
expression,
...expressionLoaderOptions
}: ExpressionRendererProps) => {
const mountpoint: React.MutableRefObject<null | HTMLDivElement> = useRef(null);
const handlerRef: React.MutableRefObject<null | ExpressionLoader> = useRef(null);
const [state, setState] = useState<State>({ ...defaultState });
const hasCustomRenderErrorHandler = !!renderError;
const expressionLoaderRef: React.MutableRefObject<null | ExpressionLoader> = useRef(null);
// flag to skip next render$ notification,
// because of just handled error
const hasHandledErrorRef = useRef(false);

// Re-fetch data automatically when the inputs change
/* eslint-disable react-hooks/exhaustive-deps */
useEffect(() => {
if (handlerRef.current) {
handlerRef.current.update(expression, options);
}
}, [
expression,
options.searchContext,
options.context,
options.variables,
options.disableCaching,
]);
/* eslint-enable react-hooks/exhaustive-deps */
// will call done() in LayoutEffect when done with rendering custom error state
const errorRenderHandlerRef: React.MutableRefObject<null | IInterpreterRenderHandlers> = useRef(
null
);

// Initialize the loader only once
/* eslint-disable react-hooks/exhaustive-deps */
// OK to ignore react-hooks/exhaustive-deps because options update is handled by calling .update()
useEffect(() => {
if (mountpoint.current && !handlerRef.current) {
handlerRef.current = new ExpressionLoader(mountpoint.current, expression, options);
const subs: Subscription[] = [];
expressionLoaderRef.current = new ExpressionLoader(mountpoint.current!, expression, {
...expressionLoaderOptions,
// react component wrapper provides different
// error handling api which is easier to work with from react
// if custom renderError is not provided then we fallback to default error handling from ExpressionLoader
onRenderError: hasCustomRenderErrorHandler
? (domNode, error, handlers) => {
errorRenderHandlerRef.current = handlers;
setState(() => ({
...defaultState,
isEmpty: false,
error,
}));

handlerRef.current.loading$.subscribe(() => {
if (!handlerRef.current) {
return;
}
if (expressionLoaderOptions.onRenderError) {
expressionLoaderOptions.onRenderError(domNode, error, handlers);
}
}
: expressionLoaderOptions.onRenderError,
});
subs.push(
expressionLoaderRef.current.loading$.subscribe(() => {
hasHandledErrorRef.current = false;
setState(prevState => ({ ...prevState, isLoading: true }));
});
handlerRef.current.render$.subscribe(item => {
if (!handlerRef.current) {
return;
}
if (typeof item !== 'number') {
}),
expressionLoaderRef.current.render$
.pipe(filter(() => !hasHandledErrorRef.current))
.subscribe(item => {
setState(() => ({
...defaultState,
isEmpty: false,
error: item.error,
}));
} else {
setState(() => ({
...defaultState,
isEmpty: false,
}));
}
});
}
/* eslint-disable */
// TODO: Replace mountpoint.current by something else.
}, [mountpoint.current]);
/* eslint-enable */
})
);

useEffect(() => {
// We only want a clean up to run when the entire component is unloaded, not on every render
return function cleanup() {
if (handlerRef.current) {
handlerRef.current.destroy();
handlerRef.current = null;
return () => {
subs.forEach(s => s.unsubscribe());
if (expressionLoaderRef.current) {
expressionLoaderRef.current.destroy();
expressionLoaderRef.current = null;
}

errorRenderHandlerRef.current = null;
};
}, []);
}, [hasCustomRenderErrorHandler]);

// Re-fetch data automatically when the inputs change
useShallowCompareEffect(
() => {
if (expressionLoaderRef.current) {
expressionLoaderRef.current.update(expression, expressionLoaderOptions);
}
},
// when expression is changed by reference and when any other loaderOption is changed by reference
[{ expression, ...expressionLoaderOptions }]
);

/* eslint-enable react-hooks/exhaustive-deps */
// call expression loader's done() handler when finished rendering custom error state
useLayoutEffect(() => {
if (state.error && errorRenderHandlerRef.current) {
hasHandledErrorRef.current = true;
errorRenderHandlerRef.current.done();
errorRenderHandlerRef.current = null;
}
}, [state.error]);

const classes = classNames('expExpressionRenderer', {
'expExpressionRenderer-isEmpty': state.isEmpty,
Expand All @@ -135,15 +159,9 @@ export const ExpressionRendererImplementation = ({

return (
<div {...dataAttrs} className={classes}>
{state.isEmpty ? <EuiLoadingChart mono size="l" /> : null}
{state.isLoading ? <EuiProgress size="xs" color="accent" position="absolute" /> : null}
{!state.isLoading && state.error ? (
renderError ? (
renderError(state.error.message)
) : (
<div data-test-subj="expression-renderer-error">{state.error.message}</div>
Copy link
Contributor Author

@Dosant Dosant Nov 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Default error state now fallbacks to default of render.ts which is toast from notification service
  2. If user of this react component provided renderErrorfunction, then the component itself will handle the error and will render the error component returned from renderError()

)
) : null}
{state.isEmpty && <EuiLoadingChart mono size="l" />}
{state.isLoading && <EuiProgress size="xs" color="accent" position="absolute" />}
{!state.isLoading && state.error && renderError && renderError(state.error.message)}
<div
className="expExpressionRenderer__expression"
style={expressionStyles}
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/expressions/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export { interpreterProvider, ExpressionInterpret } from './interpreter_provider
export { ExpressionRenderer, ExpressionRendererProps } from './expression_renderer';
export { ExpressionDataHandler } from './execute';

export { RenderResult, ExpressionRenderHandler } from './render';
export { ExpressionRenderHandler } from './render';

export function plugin(initializerContext: PluginInitializerContext) {
return new ExpressionsPublicPlugin(initializerContext);
Expand Down
Loading