feat: kibana-react initial setup (#37206)

* 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.
This commit is contained in:
Vadim Dalecky 2019-06-03 16:27:13 -07:00 committed by GitHub
parent 983f3a25a8
commit a53e480881
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 507 additions and 0 deletions

View file

@ -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",

View file

@ -22,6 +22,7 @@ import { RESERVED_DIR_JEST_INTEGRATION_TESTS } from '../constants';
export default {
rootDir: '../../..',
roots: [
'<rootDir>/src/plugins',
'<rootDir>/src/legacy/ui',
'<rootDir>/src/core',
'<rootDir>/src/legacy/core_plugins',

View file

@ -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 <Provider> without crashing', () => {
const core = createMock();
ReactDOM.render(
<context.Provider value={{ core }}>
<div>Hello world</div>
</context.Provider>,
container
);
});
const TestConsumer = () => {
const { core } = useKibana();
return <div>{(core as any).foo}</div>;
};
test('useKibana() hook retrieves Kibana context', () => {
const core = createMock();
(core as any).foo = 'bar';
ReactDOM.render(
<context.Provider value={{ core }}>
<TestConsumer />
</context.Provider>,
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(
<Provider>
<TestConsumer />
</Provider>,
container
);
const div = container!.querySelector('div');
expect(div!.textContent).toBe('baz');
});

View file

@ -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<KibanaReactContextValue>({
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);

View file

@ -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';

View file

@ -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<any> = {
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<Core> = {
uiSettings,
};
return core as Core;
};

View file

@ -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<CoreSetup> & Partial<CoreStart>;
export interface KibanaReactContextValue {
core: Core;
}

View file

@ -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<any>] => {
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 (
<div>
{setting}: <strong>{value}</strong>
<button onClick={() => set(newValue)}>Set new value!</button>
</div>
);
};
test('synchronously renders setting value', async () => {
const [core] = mock();
const { Provider } = createContext(core);
ReactDOM.render(
<Provider>
<TestConsumer setting="foo" />
</Provider>,
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(
<Provider>
<TestConsumer setting="non_existing" />
</Provider>,
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(
<Provider>
<TestConsumer setting="theme:darkMode" />
</Provider>,
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(
<Provider>
<TestConsumer setting="a" newValue="c" />
</Provider>,
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');
});

View file

@ -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<T> = (newValue: T) => Promise<boolean>;
/**
* 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 = <T>(key: string, defaultValue: T): [T, Setter<T>] => {
const { core } = useKibana();
const observable$ = useMemo(() => core.uiSettings!.get$(key, defaultValue), [key, defaultValue]);
const value = useObservable<T>(observable$, core.uiSettings!.get(key, defaultValue));
const set = useCallback((newValue: T) => core.uiSettings!.set(key, newValue), [key]);
return [value, set];
};

View file

@ -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', () => {});

View file

@ -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<T>(observable$: Observable<T>): T | undefined;
export function useObservable<T>(observable$: Observable<T>, initialValue: T): T;
export function useObservable<T>(observable$: Observable<T>, initialValue?: T): T | undefined {
const [value, update] = useState<T | undefined>(initialValue);
useEffect(
() => {
const s = observable$.subscribe(update);
return () => s.unsubscribe();
},
[observable$]
);
return value;
}

View file

@ -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"