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

React component support fork 2 #64224

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
abd522e
refactor: 💡 remove render() utility
streamich Apr 22, 2020
bc1f903
test: 💍 remove render utility from Jest mocks
streamich Apr 22, 2020
5f72e14
test: 💍 make expression rendering tests work
streamich Apr 22, 2020
de684ec
chore: 🤖 fix OSS typecheck errors
streamich Apr 22, 2020
826b682
chore: 🤖 fix TypeScript typecheck errors
streamich Apr 22, 2020
c6f091a
refactor: 💡 improve interface of ExpressionRendering
streamich Apr 22, 2020
826bfef
test: 💍 rename test title
streamich Apr 22, 2020
48153b5
chore: 🤖 delete unused default error renderer error message
streamich Apr 23, 2020
8dc1ccb
Merge remote-tracking branch 'upstream/master' into react-component-s…
streamich Apr 23, 2020
cdc4e56
Merge remote-tracking branch 'upstream/master' into react-component-s…
streamich Apr 24, 2020
73493ce
feat: 🎸 set default error renderer in browser
streamich Apr 24, 2020
6cbbf74
docs: ✏️ add JSDoc for createRendering() function
streamich Apr 24, 2020
e7dc45d
docs: ✏️ add onRenderError JSDoc
streamich Apr 24, 2020
407143f
fix: 🐛 use correct types in ExpressionRendering
streamich Apr 24, 2020
3a4f001
fix: 🐛 improve type in ExpressionRendering
streamich Apr 24, 2020
f6ba240
docs: ✏️ add JSDoc about uiState handling
streamich Apr 24, 2020
0f3890f
Merge branch 'master' into react-component-support-fork-2
elasticmachine Apr 24, 2020
9cb56e9
Merge branch 'master' into react-component-support-fork-2
elasticmachine Apr 28, 2020
d1d65cf
Merge branch 'master' into react-component-support-fork-2
elasticmachine May 1, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export class ExpressionRendererRegistry implements IRegistry<ExpressionRenderer>
return this.renderers.get(id) || null;
}

public has(id: string): boolean {
return this.renderers.has(id);
}

public toJS(): Record<string, ExpressionRenderer> {
return this.toArray().reduce(
(acc, renderer) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { RenderError } from './expression_rendering';
import { Observable } from 'rxjs';
import { first, take, toArray } from 'rxjs/operators';
import { IInterpreterRenderHandlers } from './types';
import {
ExpressionsService,
ExpressionsCreateRenderingParams,
} from '../service/expressions_services';

const setup = (params: ExpressionsCreateRenderingParams) => {
const service = new ExpressionsService();
const rendering = service.createRendering(params);

return { service, rendering };
};

const element: HTMLElement = {} as HTMLElement;
const mockNotificationService = {
toasts: {
addError: jest.fn(() => {}),
},
};

const mockMockErrorRenderFunction = jest.fn(
(el: HTMLElement, error: RenderError, handlers: IInterpreterRenderHandlers) => handlers.done()
);
// extracts data from mockMockErrorRenderFunction call to assert in tests
const getHandledError = () => {
try {
return mockMockErrorRenderFunction.mock.calls[0][1];
} catch (e) {
return null;
}
};

describe('ExpressionRendering', () => {
it('constructor creates observers', () => {
const { rendering } = setup({ element });
expect(rendering.events$).toBeInstanceOf(Observable);
expect(rendering.render$).toBeInstanceOf(Observable);
expect(rendering.update$).toBeInstanceOf(Observable);
});

it('getElement returns the element', () => {
const { rendering } = setup({ element });
expect(rendering.getElement()).toBe(element);
});

describe('render()', () => {
beforeEach(() => {
mockMockErrorRenderFunction.mockClear();
mockNotificationService.toasts.addError.mockClear();
});

it('in case of error render$ should emit when error renderer is finished', async () => {
const { rendering } = setup({ element });
rendering.render(false);
const promise1 = rendering.render$.pipe(first()).toPromise();
await expect(promise1).resolves.toEqual(1);

rendering.render(false);
const promise2 = rendering.render$.pipe(first()).toPromise();
await expect(promise2).resolves.toEqual(2);
});

it('should use custom error handler if provided', async () => {
const { rendering } = setup({
element,
onRenderError: mockMockErrorRenderFunction,
});
await rendering.render(false);
expect(getHandledError()!.message).toEqual(
`invalid data provided to the expression renderer`
);
});

it('should throw error if the rendering function throws', async () => {
const { service, rendering } = setup({
element,
onRenderError: mockMockErrorRenderFunction,
});

service.registerRenderer({
displayName: 'something',
name: 'something',
reuseDomNode: false,
render: (el, config, handlers) => {
throw new Error('renderer error');
},
});

await rendering.render({ type: 'render', as: 'something' });
expect(getHandledError()!.message).toEqual('renderer error');
});

it('sends a next observable once rendering is complete', () => {
const { rendering } = setup({ element });

expect.assertions(1);

return new Promise(resolve => {
rendering.render$.subscribe(renderCount => {
expect(renderCount).toBe(1);
resolve();
});

rendering.render({ type: 'render', as: 'test' });
});
});

// in case render$ subscription happen after render() got called
// we still want to be notified about sync render$ updates
it("doesn't swallow sync render errors", async () => {
const { rendering } = setup({
element,
onRenderError: mockMockErrorRenderFunction,
});
rendering.render(false);
const renderPromiseAfterRender = rendering.render$.pipe(first()).toPromise();
await expect(renderPromiseAfterRender).resolves.toEqual(1);
expect(getHandledError()!.message).toEqual(
'invalid data provided to the expression renderer'
);

mockMockErrorRenderFunction.mockClear();

const { rendering: rendering2 } = setup({
element,
onRenderError: mockMockErrorRenderFunction,
});
const renderPromiseBeforeRender = rendering2.render$.pipe(first()).toPromise();
rendering2.render(false);
await expect(renderPromiseBeforeRender).resolves.toEqual(1);
expect(getHandledError()!.message).toEqual(
'invalid data provided to the expression renderer'
);
});

// it is expected side effect of using BehaviorSubject for render$,
// that observables will emit previous result if subscription happens after render
it('should emit previous render and error results', async () => {
const { rendering } = setup({ element });
rendering.render(false);
const renderPromise = rendering.render$
.pipe(take(2), toArray())
.toPromise()
.catch(() => {});
rendering.render(false);
await expect(renderPromise).resolves.toEqual([1, 2]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import * as Rx from 'rxjs';
import { Observable } from 'rxjs';
import { filter } from 'rxjs/operators';
import { IInterpreterRenderHandlers } from './types';
import { ExecutionContextSearch } from '../execution';
import {
ExpressionValue,
ExpressionAstExpression,
ExpressionRendererRegistry,
ExpressionValueRender,
ExpressionValueError,
} from '../../public';
import { Adapters } from '../../../inspector/public';

export const onRenderErrorDefault: RenderErrorHandlerFnType = (element, error, handlers) => {
// eslint-disable-next-line no-console
console.error(error);
handlers.done();
};

export type RenderError = ExpressionValueError['error'];

export type RenderErrorHandlerFnType = (
domNode: HTMLElement,
error: RenderError,
handlers: IInterpreterRenderHandlers
) => void;

export interface IExpressionLoaderParams {
searchContext?: ExecutionContextSearch;
context?: ExpressionValue;
variables?: Record<string, any>;
disableCaching?: boolean;
customFunctions?: [];
customRenderers?: [];
uiState?: unknown;
inspectorAdapters?: Adapters;
onRenderError?: RenderErrorHandlerFnType;
}

export type IExpressionRendererExtraHandlers = Record<string, any>;

interface Event {
name: string;
data: any;
}

interface UpdateValue {
newExpression?: string | ExpressionAstExpression;
newParams: IExpressionLoaderParams;
}

export interface ExpressionRenderingParams {
readonly renderers: ExpressionRendererRegistry;
readonly element: HTMLElement;
readonly onRenderError?: RenderErrorHandlerFnType;
}

/**
* Constructs expression renderer handlers and passes them to expression renderer.
*/
export class ExpressionRendering {
private destroyFn?: () => void;
private renderCount: number = 0;
private renderSubject: Rx.BehaviorSubject<number | null>;
private eventsSubject: Rx.Subject<unknown>;
private updateSubject: Rx.Subject<UpdateValue | null>;
private handlers: IInterpreterRenderHandlers;

constructor(private readonly params: ExpressionRenderingParams) {
this.eventsSubject = new Rx.Subject();
this.events$ = this.eventsSubject.asObservable() as Observable<Event>;

this.renderSubject = new Rx.BehaviorSubject(null as any | null);
this.render$ = this.renderSubject.asObservable().pipe(filter(_ => _ !== null)) as Observable<
any
>;

this.updateSubject = new Rx.Subject();
this.update$ = this.updateSubject.asObservable();

this.handlers = {
onDestroy: (fn: () => void) => {
this.destroyFn = fn;
},
done: () => {
this.renderCount++;
this.renderSubject.next(this.renderCount);
},
reload: () => {
this.updateSubject.next(null);
},
update: value => {
this.updateSubject.next(value);
},
event: event => {
this.eventsSubject.next(event);
},
};
}

private handleRenderError(error: RenderError) {
(this.params.onRenderError || onRenderErrorDefault)(this.params.element, error, this.handlers);
}

// Public API ----------------------------------------------------------------

public readonly render$: Observable<number>;
public readonly update$: Observable<UpdateValue | null>;
public readonly events$: Observable<Event>;

public readonly render = async (
value: ExpressionValueError | ExpressionValueRender<unknown>,
// TODO: Should we remove this? Why is there special treatment of uiState in Expressions?
// TODO: Expressions should know nothing about uiState; and extra data provided to renderer
// TODO: should have proper TypeScript typing.
// TODO: Tech debt issue created: https://github.com/elastic/kibana/issues/64420
uiState: any = {}
) => {
if (!value || typeof value !== 'object') {
return this.handleRenderError(new Error('invalid data provided to the expression renderer'));
}

if (value.type !== 'render' || !value.as) {
if (value.type === 'error') {
return this.handleRenderError(value.error);
} else {
return this.handleRenderError(
new Error('invalid data provided to the expression renderer')
);
}
}

if (!this.params.renderers.has(value.as)) {
return this.handleRenderError(new Error(`invalid renderer id '${value.as}'`));
}

try {
// Rendering is asynchronous, completed by handlers.done()
await this.params.renderers.get(value.as)!.render(this.params.element, value.value, {
...this.handlers,
uiState,
} as any);
} catch (e) {
return this.handleRenderError(e);
}
};

public readonly destroy = () => {
this.renderSubject.complete();
this.eventsSubject.complete();
this.updateSubject.complete();
if (this.destroyFn) {
this.destroyFn();
}
};

public readonly getElement = () => this.params.element;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@
export * from './types';
export * from './expression_renderer';
export * from './expression_renderer_registry';
export * from './expression_rendering';
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ const name = 'render';
/**
* Represents an object that is intended to be rendered.
*/
export type ExpressionValueRender<T> = ExpressionValueBoxed<
export type ExpressionValueRender<Value, As = string> = ExpressionValueBoxed<
typeof name,
{
as: string;
value: T;
as: As;
value: Value;
}
>;

Expand Down
Loading