From a53e480881eecf3928b1533934e919eb4686e1ad Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 3 Jun 2019 16:27:13 -0700 Subject: [PATCH] feat: kibana-react initial setup (#37206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add NP TypeScript typings * feat: 🎸 add kibana-react context provider * feat: 🎸 add initial in-memory core implementation * test: 💍 simplify useKibana() tests * feat: 🎸 add useObservable() hook * test: 💍 implement initial mock for uiSettings for testing * feat: 🎸 add useUiSetting() hook * docs: ✏️ update function name * test: 💍 use core mock for testing useUiSetting() hook * chore: 🤖 remove unused types * chore: 🤖 fix linter error * test: 💍 move core mock inline Currently it fails on CI because of the relative import, so moving the mock inline util it is resolved. --- package.json | 1 + src/dev/jest/config.js | 1 + .../kibana_react/public/core/context.test.tsx | 80 ++++++++++ .../kibana_react/public/core/context.ts | 38 +++++ src/plugins/kibana_react/public/core/index.ts | 21 +++ src/plugins/kibana_react/public/core/mock.ts | 50 +++++++ src/plugins/kibana_react/public/core/types.ts | 28 ++++ .../ui_settings/use_ui_setting.test.tsx | 140 ++++++++++++++++++ .../public/ui_settings/use_ui_setting.ts | 50 +++++++ .../public/util/use_observable.test.tsx | 54 +++++++ .../public/util/use_observable.ts | 37 +++++ yarn.lock | 7 + 12 files changed, 507 insertions(+) create mode 100644 src/plugins/kibana_react/public/core/context.test.tsx create mode 100644 src/plugins/kibana_react/public/core/context.ts create mode 100644 src/plugins/kibana_react/public/core/index.ts create mode 100644 src/plugins/kibana_react/public/core/mock.ts create mode 100644 src/plugins/kibana_react/public/core/types.ts create mode 100644 src/plugins/kibana_react/public/ui_settings/use_ui_setting.test.tsx create mode 100644 src/plugins/kibana_react/public/ui_settings/use_ui_setting.ts create mode 100644 src/plugins/kibana_react/public/util/use_observable.test.tsx create mode 100644 src/plugins/kibana_react/public/util/use_observable.ts diff --git a/package.json b/package.json index 5a629c46f6e1..8689b602f6ec 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,7 @@ "react-color": "^2.13.8", "react-dom": "^16.8.0", "react-grid-layout": "^0.16.2", + "react-hooks-testing-library": "^0.5.0", "react-input-range": "^1.3.0", "react-markdown": "^3.4.1", "react-redux": "^5.0.7", diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index e1a0c4a87392..ee0b413ad40b 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -22,6 +22,7 @@ import { RESERVED_DIR_JEST_INTEGRATION_TESTS } from '../constants'; export default { rootDir: '../../..', roots: [ + '/src/plugins', '/src/legacy/ui', '/src/core', '/src/legacy/core_plugins', diff --git a/src/plugins/kibana_react/public/core/context.test.tsx b/src/plugins/kibana_react/public/core/context.test.tsx new file mode 100644 index 000000000000..a452fc72d855 --- /dev/null +++ b/src/plugins/kibana_react/public/core/context.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 React from 'react'; +import * as ReactDOM from 'react-dom'; +import { context, createContext, useKibana } from './context'; +import { createMock } from './mock'; + +let container: HTMLDivElement | null; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); +}); + +afterEach(() => { + document.body.removeChild(container!); + container = null; +}); + +test('can mount without crashing', () => { + const core = createMock(); + ReactDOM.render( + +
Hello world
+
, + container + ); +}); + +const TestConsumer = () => { + const { core } = useKibana(); + return
{(core as any).foo}
; +}; + +test('useKibana() hook retrieves Kibana context', () => { + const core = createMock(); + (core as any).foo = 'bar'; + ReactDOM.render( + + + , + container + ); + + const div = container!.querySelector('div'); + expect(div!.textContent).toBe('bar'); +}); + +test('createContext() creates context that can be consumed by useKibana() hook', () => { + const core = createMock(); + (core as any).foo = 'baz'; + const { Provider } = createContext(core, {}); + + ReactDOM.render( + + + , + container + ); + + const div = container!.querySelector('div'); + expect(div!.textContent).toBe('baz'); +}); diff --git a/src/plugins/kibana_react/public/core/context.ts b/src/plugins/kibana_react/public/core/context.ts new file mode 100644 index 000000000000..015be2a573fa --- /dev/null +++ b/src/plugins/kibana_react/public/core/context.ts @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import { KibanaReactContextValue, Core } from './types'; + +export const context = React.createContext({ + core: {}, +}); + +export const createContext = (core: Core, plugins?: any) => { + const value: KibanaReactContextValue = { core }; + const Provider: React.FC = ({ children }) => + React.createElement(context.Provider, { value, children }); + + return { + Provider, + Consumer: context.Consumer, + }; +}; + +export const useKibana = (): KibanaReactContextValue => React.useContext(context); diff --git a/src/plugins/kibana_react/public/core/index.ts b/src/plugins/kibana_react/public/core/index.ts new file mode 100644 index 000000000000..4b377f9c03de --- /dev/null +++ b/src/plugins/kibana_react/public/core/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export { context, createContext, useKibana } from './context'; +export { KibanaReactContextValue } from './types'; diff --git a/src/plugins/kibana_react/public/core/mock.ts b/src/plugins/kibana_react/public/core/mock.ts new file mode 100644 index 000000000000..69e859f4f712 --- /dev/null +++ b/src/plugins/kibana_react/public/core/mock.ts @@ -0,0 +1,50 @@ +/* + * 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 { Core } from './types'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + getAll: jest.fn(), + get: jest.fn(), + get$: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + isDeclared: jest.fn(), + isDefault: jest.fn(), + isCustom: jest.fn(), + isOverridden: jest.fn(), + overrideLocalDefault: jest.fn(), + getUpdate$: jest.fn(), + getSaved$: jest.fn(), + getUpdateErrors$: jest.fn(), + stop: jest.fn(), + }; + return setupContract as any; +}; + +export const createMock = (): Core => { + const uiSettings = createSetupContractMock(); + + const core: Partial = { + uiSettings, + }; + + return core as Core; +}; diff --git a/src/plugins/kibana_react/public/core/types.ts b/src/plugins/kibana_react/public/core/types.ts new file mode 100644 index 000000000000..6d195d8e2b6c --- /dev/null +++ b/src/plugins/kibana_react/public/core/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { CoreSetup, CoreStart } from '../../../../core/public'; + +export { CoreSetup, CoreStart }; + +export type Core = Partial & Partial; + +export interface KibanaReactContextValue { + core: Core; +} diff --git a/src/plugins/kibana_react/public/ui_settings/use_ui_setting.test.tsx b/src/plugins/kibana_react/public/ui_settings/use_ui_setting.test.tsx new file mode 100644 index 000000000000..bf53f57e5233 --- /dev/null +++ b/src/plugins/kibana_react/public/ui_settings/use_ui_setting.test.tsx @@ -0,0 +1,140 @@ +/* + * 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 React from 'react'; +import * as ReactDOM from 'react-dom'; +import { act, Simulate } from 'react-dom/test-utils'; +import { useUiSetting } from './use_ui_setting'; +import { createContext } from '../core'; +import { Core } from '../core/types'; +import { createMock } from '../core/mock'; +import { Subject } from 'rxjs'; +import { useObservable } from '../util/use_observable'; + +jest.mock('../util/use_observable'); +const useObservableSpy = (useObservable as any) as jest.SpyInstance; +useObservableSpy.mockImplementation((observable, def) => def); + +const mock = (): [Core, Subject] => { + const core = createMock() as Core; + const get = (core.uiSettings!.get as any) as jest.SpyInstance; + const get$ = (core.uiSettings!.get$ as any) as jest.SpyInstance; + const subject = new Subject(); + + get.mockImplementation(() => 'bar'); + get$.mockImplementation(() => subject); + + return [core, subject]; +}; + +let container: HTMLDivElement | null; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + useObservableSpy.mockClear(); +}); + +afterEach(() => { + document.body.removeChild(container!); + container = null; +}); + +const TestConsumer: React.FC<{ + setting: string; + newValue?: string; +}> = ({ setting, newValue = '' }) => { + const [value, set] = useUiSetting(setting, 'DEFAULT'); + + return ( +
+ {setting}: {value} + +
+ ); +}; + +test('synchronously renders setting value', async () => { + const [core] = mock(); + const { Provider } = createContext(core); + + ReactDOM.render( + + + , + container + ); + + const strong = container!.querySelector('strong'); + expect(strong!.textContent).toBe('bar'); + expect(core.uiSettings!.get).toHaveBeenCalledTimes(1); + expect((core.uiSettings!.get as any).mock.calls[0][0]).toBe('foo'); +}); + +test('calls Core with correct arguments', async () => { + const core = createMock(); + const { Provider } = createContext(core); + + ReactDOM.render( + + + , + container + ); + + expect(core.uiSettings!.get).toHaveBeenCalledWith('non_existing', 'DEFAULT'); +}); + +test('subscribes to observable using useObservable', async () => { + const [core, subject] = mock(); + const { Provider } = createContext(core); + + expect(useObservableSpy).toHaveBeenCalledTimes(0); + + ReactDOM.render( + + + , + container + ); + + expect(useObservableSpy).toHaveBeenCalledTimes(1); + expect(useObservableSpy.mock.calls[0][0]).toBe(subject); +}); + +test('can set new hook value', async () => { + const [core] = mock(); + const { Provider } = createContext(core); + + ReactDOM.render( + + + , + container + ); + + expect(core.uiSettings!.set).toHaveBeenCalledTimes(0); + + act(() => { + Simulate.click(container!.querySelector('button')!, {}); + }); + + expect(core.uiSettings!.set).toHaveBeenCalledTimes(1); + expect(core.uiSettings!.set).toHaveBeenCalledWith('a', 'c'); +}); diff --git a/src/plugins/kibana_react/public/ui_settings/use_ui_setting.ts b/src/plugins/kibana_react/public/ui_settings/use_ui_setting.ts new file mode 100644 index 000000000000..92d7a253ace5 --- /dev/null +++ b/src/plugins/kibana_react/public/ui_settings/use_ui_setting.ts @@ -0,0 +1,50 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import { useKibana } from '../core'; +import { useObservable } from '../util/use_observable'; + +type Setter = (newValue: T) => Promise; + +/** + * Returns a 2-tuple, where first entry is the setting value and second is a + * function to update the setting value. + * + * Synchronously returns the most current value of the setting and subscribes + * to all subsequent updates, which will re-render your component on new values. + * + * Usage: + * + * ```js + * const [darkMode, setDarkMode] = useUiSetting('theme:darkMode'); + * ``` + * + * @todo As of this writing `uiSettings` service exists only on *setup* `core` + * object, but I assume it will be available on *start* `core` object, too, + * thus postfix assertion is used `core.uiSetting!`. + */ +export const useUiSetting = (key: string, defaultValue: T): [T, Setter] => { + const { core } = useKibana(); + const observable$ = useMemo(() => core.uiSettings!.get$(key, defaultValue), [key, defaultValue]); + const value = useObservable(observable$, core.uiSettings!.get(key, defaultValue)); + const set = useCallback((newValue: T) => core.uiSettings!.set(key, newValue), [key]); + + return [value, set]; +}; diff --git a/src/plugins/kibana_react/public/util/use_observable.test.tsx b/src/plugins/kibana_react/public/util/use_observable.test.tsx new file mode 100644 index 000000000000..af7603ebf115 --- /dev/null +++ b/src/plugins/kibana_react/public/util/use_observable.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 { renderHook, act } from 'react-hooks-testing-library'; +import { Subject } from 'rxjs'; +import { useObservable } from './use_observable'; + +test('default initial value is undefined', () => { + const subject$ = new Subject(); + const { result } = renderHook(() => useObservable(subject$)); + + expect(result.current).toBe(undefined); +}); + +test('can specify initial value', () => { + const subject$ = new Subject(); + const { result } = renderHook(() => useObservable(subject$, 123)); + + expect(result.current).toBe(123); +}); + +test('returns the latest value of observables', () => { + const subject$ = new Subject(); + const { result } = renderHook(() => useObservable(subject$, 123)); + + act(() => { + subject$.next(125); + }); + expect(result.current).toBe(125); + + act(() => { + subject$.next(300); + subject$.next(400); + }); + expect(result.current).toBe(400); +}); + +xtest('subscribes to observable only once', () => {}); diff --git a/src/plugins/kibana_react/public/util/use_observable.ts b/src/plugins/kibana_react/public/util/use_observable.ts new file mode 100644 index 000000000000..2c698c317ab2 --- /dev/null +++ b/src/plugins/kibana_react/public/util/use_observable.ts @@ -0,0 +1,37 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { Observable } from 'rxjs'; + +export function useObservable(observable$: Observable): T | undefined; +export function useObservable(observable$: Observable, initialValue: T): T; +export function useObservable(observable$: Observable, initialValue?: T): T | undefined { + const [value, update] = useState(initialValue); + + useEffect( + () => { + const s = observable$.subscribe(update); + return () => s.unsubscribe(); + }, + [observable$] + ); + + return value; +} diff --git a/yarn.lock b/yarn.lock index 39880b1ee5f7..697a2ed12a27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21834,6 +21834,13 @@ react-hooks-testing-library@^0.3.8: "@babel/runtime" "^7.4.2" react-testing-library "^6.0.2" +react-hooks-testing-library@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/react-hooks-testing-library/-/react-hooks-testing-library-0.5.0.tgz#571af3522f88ea4ac23c634fb4deff84873f2bc2" + integrity sha512-qX4SA28pcCCf1Q23Gtl1VKqQk26pSPTEsdLtfJanDqm4oacT5wadL+e2Xypk/H+AOXN5kdZrWmXkt+hAaiNHgg== + dependencies: + "@babel/runtime" "^7.4.2" + react-hotkeys@2.0.0-pre4: version "2.0.0-pre4" resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0-pre4.tgz#a1c248a51bdba4282c36bf3204f80d58abc73333"