diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 3e16a78083092..29f1c2eec03f0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -5,23 +5,18 @@ */ import { I18nProvider } from '@kbn/i18n/react'; -import React, { useCallback } from 'react'; +import React from 'react'; import { EditorFrameSetup } from '../types'; +import { NativeRenderer } from '../native_renderer'; export function App({ editorFrame }: { editorFrame: EditorFrameSetup }) { - const renderFrame = useCallback(node => { - if (node !== null) { - editorFrame.render(node); - } - }, []); - return (

Lens

-
+
); diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx index 40b9ba40cd430..64bc73cd0447a 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame.tsx @@ -6,6 +6,7 @@ import React, { useReducer, useEffect } from 'react'; import { Datasource, Visualization } from '../types'; +import { NativeRenderer } from '../native_renderer'; interface EditorFrameProps { datasources: { [key: string]: Datasource }; @@ -81,41 +82,35 @@ export function EditorFrame(props: EditorFrameProps) {

Editor Frame

-
{ - if (domElement) { - props.datasources[state.datasourceName].renderDataPanel(domElement, { - state: state.datasourceState, - setState: newState => - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }), - }); - } + + dispatch({ + type: 'UPDATE_DATASOURCE', + payload: newState, + }), }} /> -
{ - if (domElement) { - props.visualizations[state.visualizationName].renderConfigPanel(domElement, { - datasource: props.datasources[state.datasourceName].getPublicAPI( - state.datasourceState, - newState => - dispatch({ - type: 'UPDATE_DATASOURCE', - payload: newState, - }) - ), - state: state.visualizationState, - setState: newState => - dispatch({ - type: 'UPDATE_VISUALIZATION', - payload: newState, - }), - }); - } + + dispatch({ + type: 'UPDATE_DATASOURCE', + payload: newState, + }) + ), + state: state.visualizationState, + setState: (newState: any) => + dispatch({ + type: 'UPDATE_VISUALIZATION', + payload: newState, + }), }} />
diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 874249ff75d85..f8f074cdb99bf 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -6,8 +6,8 @@ export * from './types'; -import { IScope } from 'angular'; import { render, unmountComponentAtNode } from 'react-dom'; +import { IScope } from 'angular'; import chrome from 'ui/chrome'; import { appSetup, appStop } from './app_plugin'; diff --git a/x-pack/plugins/lens/public/native_renderer/index.ts b/x-pack/plugins/lens/public/native_renderer/index.ts new file mode 100644 index 0000000000000..0ef9bd8807bc5 --- /dev/null +++ b/x-pack/plugins/lens/public/native_renderer/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './native_renderer'; diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx new file mode 100644 index 0000000000000..af5196165f9a5 --- /dev/null +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import { NativeRenderer } from './native_renderer'; +import { act } from 'react-dom/test-utils'; + +function renderAndTriggerHooks(element: JSX.Element, mountpoint: Element) { + // act takes care of triggering state hooks + act(() => { + render(element, mountpoint); + }); +} + +describe('native_renderer', () => { + let mountpoint: Element; + + beforeEach(() => { + mountpoint = document.createElement('div'); + }); + + afterEach(() => { + mountpoint.remove(); + }); + + it('should render element in container', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).toHaveBeenCalledWith(containerElement, testProps); + }); + + it('should not render again if props do not change', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('should not render again if props do not change shallowly', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('should not render again for unchanged callback functions', () => { + const renderSpy = jest.fn(); + const testCallback = () => {}; + const testState = { a: 'abc' }; + + render( + , + mountpoint + ); + render( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('should render again once if props change', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(2); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, { a: 'def' }); + }); + + it('should render again once if props is just a string', () => { + const renderSpy = jest.fn(); + const testProps = 'abc'; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks(, mountpoint); + renderAndTriggerHooks(, mountpoint); + expect(renderSpy).toHaveBeenCalledTimes(2); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, 'def'); + }); + + it('should render again if props are extended', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(2); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, { a: 'abc', b: 'def' }); + }); + + it('should render again if props are limited', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc', b: 'def' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(2); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, { a: 'abc' }); + }); + + it('should render a div as container', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + const containerElement: Element = mountpoint.firstElementChild!; + expect(containerElement.nodeName).toBe('DIV'); + }); + + it('should render a specified element as container', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + const containerElement: Element = mountpoint.firstElementChild!; + expect(containerElement.nodeName).toBe('SPAN'); + }); +}); diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx new file mode 100644 index 0000000000000..f0eb4b829c153 --- /dev/null +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useRef } from 'react'; + +export interface NativeRendererProps { + render: (domElement: Element, props: T) => void; + nativeProps: T; + tag?: string; + children?: never; +} + +function is(x: unknown, y: unknown) { + return (x === y && (x !== 0 || 1 / (x as number) === 1 / (y as number))) || (x !== x && y !== y); +} + +function isShallowDifferent(objA: T, objB: T): boolean { + if (is(objA, objB)) { + return false; + } + + if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { + return true; + } + + const keysA = Object.keys(objA) as Array; + const keysB = Object.keys(objB) as Array; + + if (keysA.length !== keysB.length) { + return true; + } + + for (let i = 0; i < keysA.length; i++) { + if (!window.hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { + return true; + } + } + + return false; +} + +/** + * A component which takes care of providing a mountpoint for a generic render + * function which takes an html element and an optional props object. + * It also takes care of calling render again if the props object changes. + * By default the mountpoint element will be a div, this can be changed with the + * `tag` prop. + * + * @param props + */ +export function NativeRenderer({ render, nativeProps, tag }: NativeRendererProps) { + const elementRef = useRef(null); + const propsRef = useRef(null); + + function renderAndUpdate(element: Element) { + elementRef.current = element; + propsRef.current = nativeProps; + render(element, nativeProps); + } + + useEffect( + () => { + if (elementRef.current && isShallowDifferent(propsRef.current, nativeProps)) { + renderAndUpdate(elementRef.current); + } + }, + [nativeProps] + ); + + return React.createElement(tag || 'div', { + ref: element => { + if (element && element !== elementRef.current) { + renderAndUpdate(element); + } + }, + }); +}