Embeddables React (#43272) (#43872)

* docs: ✏️ add demo command

* feat: 🎸 add openFlyout method to kibana-react

* chore: 🤖 revert master changes

* refactor: 💡 update core types and use mocks provided by NP

* chore: 🤖 remove old mock

* feat: 🎸 export react-use context, use useUiSetting in infra

* feat: 🎸 create React wrapper for Overlays service

* chore: 🤖 App Architecture team as kibana-react code owners

* feat: 🎸 improve kibana-react context interface

* feat: 🎸 check for uiSettings service in useSetting hook

* feat: 🎸 improve interface of KibanaReactOverlays

* feat: 🎸 improve Dashboard container options

* test: 💍 adapt tests to use context

* fix: 🐛 fix TypeScript types

* feat: 🎸 add notifications service wrapper

* feat: 🎸 add withKibana HOC

* feat: 🎸 add <UseKibana> render prop

* refactor: 💡 use React context in DashboardGrid

* test: 💍 use context in dashboard_grid tests

* test: 💍 add tests for createReactOverlays()

* test: 💍 add tests for React notification service wrapper

* docs: ✏️ add kibana-react documentation

* docs: ✏️ fixes to README

* docs: ✏️ add overlays and notifications context examples

* refactor: 💡 rename useUiSetting to useUiSetting$

* feat: 🎸 add useUiSetting hook

* docs: ✏️ remove un-necessary HOC usage

* feat: 🎸 add <KibanaContextProvider> component

* refactor: 💡 rename createContext to createKibanaReactContext

* feat: 🎸 make notifications and overlays always available

* refactor: 💡 use <KibanaContextProvider> component in infra

* test: 💍 fix dashboard embeddable tests

* refactor: 💡 remove context creation from Dashboard factory

* fix: 🐛 improve error messages

* fix: 🐛 fix TypeScript type check
This commit is contained in:
Vadim Dalecky 2019-08-23 20:00:53 +02:00 committed by GitHub
parent 2b2ed4c686
commit 9d786da965
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1598 additions and 471 deletions

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { isErrorEmbeddable, EmbeddableFactory, GetEmbeddableFactory } from '../embeddable_api';
import { isErrorEmbeddable, EmbeddableFactory } from '../embeddable_api';
import { ExpandPanelAction } from './expand_panel_action';
import { DashboardContainer } from '../embeddable';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers';
@ -30,29 +30,39 @@ import {
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
} from '../../../../../../embeddable_api/public/np_ready/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable';
import { DashboardOptions } from '../embeddable/dashboard_container_factory';
const __embeddableFactories = new Map<string, EmbeddableFactory>();
__embeddableFactories.set(
const embeddableFactories = new Map<string, EmbeddableFactory>();
embeddableFactories.set(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any)
);
const getEmbeddableFactory: GetEmbeddableFactory = (id: string) => __embeddableFactories.get(id);
let container: DashboardContainer;
let embeddable: ContactCardEmbeddable;
beforeEach(async () => {
container = new DashboardContainer(
getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
}),
{ getEmbeddableFactory } as any
);
const options: DashboardOptions = {
ExitFullScreenButton: () => null,
SavedObjectFinder: () => null,
application: {} as any,
embeddable: {
getEmbeddableFactory: (id: string) => embeddableFactories.get(id)!,
} as any,
inspector: {} as any,
notifications: {} as any,
overlays: {} as any,
savedObjectMetaData: {} as any,
};
const input = getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
});
container = new DashboardContainer(input, options);
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -21,7 +21,7 @@
import { findTestSubject } from '@elastic/eui/lib/test';
import { nextTick } from 'test_utils/enzyme_helpers';
import { isErrorEmbeddable, ViewMode, EmbeddableFactory } from '../embeddable_api';
import { DashboardContainer, ViewportProps } from './dashboard_container';
import { DashboardContainer, DashboardContainerOptions } from './dashboard_container';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers';
import {
CONTACT_CARD_EMBEDDABLE,
@ -33,10 +33,13 @@ import {
ContactCardEmbeddableOutput,
} from '../../../../../../embeddable_api/public/np_ready/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable';
const viewportProps: ViewportProps = {
getActions: (() => []) as any,
getAllEmbeddableFactories: (() => []) as any,
getEmbeddableFactory: undefined as any,
const options: DashboardContainerOptions = {
application: {} as any,
embeddable: {
getTriggerCompatibleActions: (() => []) as any,
getEmbeddableFactories: (() => []) as any,
getEmbeddableFactory: undefined as any,
} as any,
notifications: {} as any,
overlays: {} as any,
inspector: {} as any,
@ -45,26 +48,24 @@ const viewportProps: ViewportProps = {
};
beforeEach(() => {
const __embeddableFactories = new Map<string, EmbeddableFactory>();
__embeddableFactories.set(
const embeddableFactories = new Map<string, EmbeddableFactory>();
embeddableFactories.set(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any)
);
viewportProps.getEmbeddableFactory = (id: string) => __embeddableFactories.get(id);
options.embeddable.getEmbeddableFactory = (id: string) => embeddableFactories.get(id) as any;
});
test('DashboardContainer initializes embeddables', async done => {
const container = new DashboardContainer(
getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
}),
viewportProps
);
const initialInput = getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
});
const container = new DashboardContainer(initialInput, options);
const subscription = container.getOutput$().subscribe(output => {
if (container.getOutput().embeddableLoaded['123']) {
@ -85,7 +86,7 @@ test('DashboardContainer initializes embeddables', async done => {
});
test('DashboardContainer.addNewEmbeddable', async () => {
const container = new DashboardContainer(getSampleDashboardInput(), viewportProps);
const container = new DashboardContainer(getSampleDashboardInput(), options);
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
{
@ -106,17 +107,15 @@ test('DashboardContainer.addNewEmbeddable', async () => {
});
test('Container view mode change propagates to existing children', async () => {
const container = new DashboardContainer(
getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
}),
viewportProps
);
const initialInput = getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
}),
},
});
const container = new DashboardContainer(initialInput, options);
await nextTick();
const embeddable = await container.getChild('123');
@ -126,7 +125,7 @@ test('Container view mode change propagates to existing children', async () => {
});
test('Container view mode change propagates to new children', async () => {
const container = new DashboardContainer(getSampleDashboardInput(), viewportProps);
const container = new DashboardContainer(getSampleDashboardInput(), options);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,

View file

@ -21,9 +21,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { Filter } from '@kbn/es-query';
import { CoreStart } from '../../../../../../../../core/public';
import { RefreshInterval, TimeRange } from '../../../../../../../../plugins/data/public';
import {
Container,
ContainerInput,
@ -31,16 +29,20 @@ import {
ViewMode,
EmbeddableFactory,
IEmbeddable,
GetEmbeddableFactory,
GetActionsCompatibleWithTrigger,
GetEmbeddableFactories,
} from '../../../../../../embeddable_api/public/np_ready/public';
import { DASHBOARD_CONTAINER_TYPE } from './dashboard_container_factory';
import { createPanelState } from './panel';
import { DashboardPanelState } from './types';
import { DashboardViewport } from './viewport/dashboard_viewport';
import { Query } from '../../../../../../data/public';
import { CoreStart } from '../../../../../../../../core/public';
import { Start as InspectorStartContract } from '../../../../../../../../plugins/inspector/public';
import { Start as EmbeddableStartContract } from '../../../../../../embeddable_api/public/np_ready/public';
import {
KibanaReactContext,
KibanaReactContextValue,
KibanaContextProvider,
} from '../../../../../../../../plugins/kibana_react/public';
export interface DashboardContainerInput extends ContainerInput {
viewMode: ViewMode;
@ -72,23 +74,25 @@ export interface InheritedChildInput extends IndexSignature {
id: string;
}
export interface ViewportProps {
getActions: GetActionsCompatibleWithTrigger;
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
export interface DashboardContainerOptions {
application: CoreStart['application'];
overlays: CoreStart['overlays'];
notifications: CoreStart['notifications'];
embeddable: EmbeddableStartContract;
inspector: InspectorStartContract;
SavedObjectFinder: React.ComponentType<any>;
ExitFullScreenButton: React.ComponentType<any>;
}
export type DashboardReactContextValue = KibanaReactContextValue<DashboardContainerOptions>;
export type DashboardReactContext = KibanaReactContext<DashboardContainerOptions>;
export class DashboardContainer extends Container<InheritedChildInput, DashboardContainerInput> {
public readonly type = DASHBOARD_CONTAINER_TYPE;
constructor(
initialInput: DashboardContainerInput,
private readonly viewportProps: ViewportProps,
private readonly options: DashboardContainerOptions,
parent?: Container
) {
super(
@ -100,7 +104,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
...initialInput,
},
{ embeddableLoaded: {} },
viewportProps.getEmbeddableFactory,
options.embeddable.getEmbeddableFactory,
parent
);
}
@ -119,7 +123,9 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
public render(dom: HTMLElement) {
ReactDOM.render(
<I18nProvider>
<DashboardViewport container={this} {...this.viewportProps} />
<KibanaContextProvider services={this.options}>
<DashboardViewport container={this} />
</KibanaContextProvider>
</I18nProvider>,
dom
);

View file

@ -20,25 +20,18 @@
import { i18n } from '@kbn/i18n';
import { SavedObjectAttributes } from '../../../../../../../../core/server';
import { SavedObjectMetaData } from '../types';
import { ContainerOutput, EmbeddableFactory, ErrorEmbeddable, Container } from '../embeddable_api';
import {
ContainerOutput,
EmbeddableFactory,
ErrorEmbeddable,
Container,
GetEmbeddableFactory,
} from '../embeddable_api';
import { DashboardContainer, DashboardContainerInput, ViewportProps } from './dashboard_container';
DashboardContainer,
DashboardContainerInput,
DashboardContainerOptions,
} from './dashboard_container';
import { DashboardCapabilities } from '../types';
export const DASHBOARD_CONTAINER_TYPE = 'dashboard';
export interface DashboardOptions {
export interface DashboardOptions extends DashboardContainerOptions {
savedObjectMetaData?: SavedObjectMetaData<SavedObjectAttributes>;
capabilities: {
showWriteControls: boolean;
createNew: boolean;
};
getFactory: GetEmbeddableFactory;
}
export class DashboardContainerFactory extends EmbeddableFactory<
@ -47,11 +40,20 @@ export class DashboardContainerFactory extends EmbeddableFactory<
> {
public readonly isContainerType = true;
public readonly type = DASHBOARD_CONTAINER_TYPE;
private allowEditing: boolean;
constructor(options: DashboardOptions, private readonly containerOptions: ViewportProps) {
private readonly allowEditing: boolean;
constructor(private readonly options: DashboardOptions) {
super({ savedObjectMetaData: options.savedObjectMetaData });
this.allowEditing = options.capabilities.createNew && options.capabilities.showWriteControls;
const capabilities = (options.application.capabilities
.dashboard as unknown) as DashboardCapabilities;
if (!capabilities || typeof capabilities !== 'object') {
throw new TypeError('Dashboard capabilities not found.');
}
this.allowEditing = !!capabilities.createNew && !!capabilities.showWriteControls;
}
public isEditable() {
@ -76,6 +78,6 @@ export class DashboardContainerFactory extends EmbeddableFactory<
initialInput: DashboardContainerInput,
parent?: Container
): Promise<DashboardContainer | ErrorEmbeddable> {
return new DashboardContainer(initialInput, this.containerOptions, parent);
return new DashboardContainer(initialInput, this.options, parent);
}
}

View file

@ -21,56 +21,65 @@
import sizeMe from 'react-sizeme';
import React from 'react';
import { shallowWithIntl, nextTick, mountWithIntl } from 'test_utils/enzyme_helpers';
import { nextTick, mountWithIntl } from 'test_utils/enzyme_helpers';
import { skip } from 'rxjs/operators';
import { EmbeddableFactory, GetEmbeddableFactory } from '../../embeddable_api';
import { DashboardGrid, DashboardGridProps } from './dashboard_grid';
import { DashboardContainer } from '../dashboard_container';
import { DashboardContainer, DashboardContainerOptions } from '../dashboard_container';
import { getSampleDashboardInput } from '../../test_helpers';
import {
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableFactory,
} from '../../../../../../../embeddable_api/public/np_ready/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { KibanaContextProvider } from '../../../../../../../../../plugins/kibana_react/public';
let dashboardContainer: DashboardContainer | undefined;
function getProps(props?: Partial<DashboardGridProps>): DashboardGridProps {
const __embeddableFactories = new Map<string, EmbeddableFactory>();
__embeddableFactories.set(
function prepare(props?: Partial<DashboardGridProps>) {
const embeddableFactories = new Map<string, EmbeddableFactory>();
embeddableFactories.set(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory({} as any, (() => {}) as any, {} as any)
);
const getEmbeddableFactory: GetEmbeddableFactory = (id: string) => __embeddableFactories.get(id);
dashboardContainer = new DashboardContainer(
getSampleDashboardInput({
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '1' },
},
'2': {
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '2' },
},
const getEmbeddableFactory: GetEmbeddableFactory = (id: string) => embeddableFactories.get(id);
const initialInput = getSampleDashboardInput({
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '1' },
},
}),
{ getEmbeddableFactory } as any
);
const defaultTestProps: DashboardGridProps = {
container: dashboardContainer,
intl: null as any,
getActions: (() => []) as any,
getAllEmbeddableFactories: (() => []) as any,
getEmbeddableFactory: (() => {}) as any,
'2': {
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '2' },
},
},
});
const options: DashboardContainerOptions = {
application: {} as any,
embeddable: {
getTriggerCompatibleActions: (() => []) as any,
getEmbeddableFactories: (() => []) as any,
getEmbeddableFactory,
} as any,
notifications: {} as any,
overlays: {} as any,
inspector: {} as any,
SavedObjectFinder: () => null,
ExitFullScreenButton: () => null,
};
dashboardContainer = new DashboardContainer(initialInput, options);
const defaultTestProps: DashboardGridProps = {
container: dashboardContainer,
kibana: null as any,
intl: null as any,
};
return {
props: Object.assign(defaultTestProps, props),
options,
};
return Object.assign(defaultTestProps, props);
}
beforeAll(() => {
@ -84,14 +93,25 @@ afterAll(() => {
});
test('renders DashboardGrid', () => {
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...getProps()} />);
const panelElements = component.find('InjectIntl(EmbeddableChildPanelUi)');
const { props, options } = prepare();
const component = mountWithIntl(
<KibanaContextProvider services={options}>
<DashboardGrid {...props} />
</KibanaContextProvider>
);
const panelElements = component.find('EmbeddableChildPanelUi');
expect(panelElements.length).toBe(2);
});
test('renders DashboardGrid with no visualizations', async () => {
const props = getProps();
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...props} />);
const { props, options } = prepare();
const component = mountWithIntl(
<KibanaContextProvider services={options}>
<DashboardGrid {...props} />
</KibanaContextProvider>
);
props.container.updateInput({ panels: {} });
await nextTick();
component.update();
@ -99,8 +119,13 @@ test('renders DashboardGrid with no visualizations', async () => {
});
test('DashboardGrid removes panel when removed from container', async () => {
const props = getProps();
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...props} />);
const { props, options } = prepare();
const component = mountWithIntl(
<KibanaContextProvider services={options}>
<DashboardGrid {...props} />
</KibanaContextProvider>
);
const originalPanels = props.container.getInput().panels;
const filteredPanels = { ...originalPanels };
delete filteredPanels['1'];
@ -112,27 +137,41 @@ test('DashboardGrid removes panel when removed from container', async () => {
});
test('DashboardGrid renders expanded panel', async () => {
const props = getProps();
const component = shallowWithIntl(<DashboardGrid.WrappedComponent {...props} />);
const { props, options } = prepare();
const component = mountWithIntl(
<KibanaContextProvider services={options}>
<DashboardGrid {...props} />
</KibanaContextProvider>
);
props.container.updateInput({ expandedPanelId: '1' });
await nextTick();
component.update();
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
expect(component.find('InjectIntl(EmbeddableChildPanelUi)').length).toBe(2);
expect((component.state() as { expandedPanelId?: string }).expandedPanelId).toBe('1');
expect(
(component.find('DashboardGridUi').state() as { expandedPanelId?: string }).expandedPanelId
).toBe('1');
props.container.updateInput({ expandedPanelId: undefined });
await nextTick();
component.update();
expect(component.find('InjectIntl(EmbeddableChildPanelUi)').length).toBe(2);
expect((component.state() as { expandedPanelId?: string }).expandedPanelId).toBeUndefined();
expect(
(component.find('DashboardGridUi').state() as { expandedPanelId?: string }).expandedPanelId
).toBeUndefined();
});
test('DashboardGrid unmount unsubscribes', async done => {
const props = getProps();
const component = mountWithIntl(<DashboardGrid.WrappedComponent {...props} />);
const { props, options } = prepare();
const component = mountWithIntl(
<KibanaContextProvider services={options}>
<DashboardGrid {...props} />
</KibanaContextProvider>
);
component.unmount();
props.container

View file

@ -28,17 +28,11 @@ import { Subscription } from 'rxjs';
import ReactGridLayout, { Layout } from 'react-grid-layout';
// @ts-ignore
import sizeMe from 'react-sizeme';
import {
GetActionsCompatibleWithTrigger,
GetEmbeddableFactory,
GetEmbeddableFactories,
} from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public';
import { CoreStart } from 'src/core/public';
import { ViewMode, EmbeddableChildPanel } from '../../embeddable_api';
import { DASHBOARD_GRID_COLUMN_COUNT, DASHBOARD_GRID_HEIGHT } from '../dashboard_constants';
import { DashboardContainer } from '../dashboard_container';
import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container';
import { DashboardPanelState, GridData } from '../types';
import { Start as InspectorStartContract } from '../../../../../../../../../plugins/inspector/public';
import { withKibana } from '../../../../../../../../../plugins/kibana_react/public';
let lastValidGridSize = 0;
@ -117,14 +111,8 @@ const config = { monitorWidth: true };
const ResponsiveSizedGrid = sizeMe(config)(ResponsiveGrid);
export interface DashboardGridProps extends ReactIntl.InjectedIntlProps {
kibana: DashboardReactContextValue;
container: DashboardContainer;
getActions: GetActionsCompatibleWithTrigger;
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
notifications: CoreStart['notifications'];
inspector: InspectorStartContract;
SavedObjectFinder: React.ComponentType<any>;
}
interface State {
@ -172,12 +160,13 @@ class DashboardGridUi extends React.Component<DashboardGridProps, State> {
console.error(error); // eslint-disable-line no-console
isLayoutInvalid = true;
this.props.notifications.toasts.addDanger({
this.props.kibana.notifications.toasts.danger({
title: this.props.intl.formatMessage({
id: 'dashboardEmbeddableContainer.dashboardGrid.toast.unableToLoadDashboardDangerMessage',
defaultMessage: 'Unable to load dashboard.',
}),
text: error.message,
body: error.message,
toastLifeTimeMs: 5000,
});
}
this.setState({
@ -241,7 +230,7 @@ class DashboardGridUi extends React.Component<DashboardGridProps, State> {
}
};
public renderDOM() {
public renderPanels() {
const { focusedPanelIndex, panels, expandedPanelId } = this.state;
// Part of our unofficial API - need to render in a consistent order for plugins.
@ -277,13 +266,13 @@ class DashboardGridUi extends React.Component<DashboardGridProps, State> {
<EmbeddableChildPanel
embeddableId={panel.explicitInput.id}
container={this.props.container}
getActions={this.props.getActions}
getEmbeddableFactory={this.props.getEmbeddableFactory}
getAllEmbeddableFactories={this.props.getAllEmbeddableFactories}
overlays={this.props.overlays}
notifications={this.props.notifications}
inspector={this.props.inspector}
SavedObjectFinder={this.props.SavedObjectFinder}
getActions={this.props.kibana.services.embeddable.getTriggerCompatibleActions}
getEmbeddableFactory={this.props.kibana.services.embeddable.getEmbeddableFactory}
getAllEmbeddableFactories={this.props.kibana.services.embeddable.getEmbeddableFactories}
overlays={this.props.kibana.services.overlays}
notifications={this.props.kibana.services.notifications}
inspector={this.props.kibana.services.inspector}
SavedObjectFinder={this.props.kibana.services.SavedObjectFinder}
/>
</div>
);
@ -305,10 +294,10 @@ class DashboardGridUi extends React.Component<DashboardGridProps, State> {
maximizedPanelId={this.state.expandedPanelId}
useMargins={this.state.useMargins}
>
{this.renderDOM()}
{this.renderPanels()}
</ResponsiveSizedGrid>
);
}
}
export const DashboardGrid = injectI18n(DashboardGridUi);
export const DashboardGrid = injectI18n(withKibana(DashboardGridUi));

View file

@ -26,22 +26,34 @@ import { I18nProvider } from '@kbn/i18n/react';
import { nextTick } from 'test_utils/enzyme_helpers';
import { EmbeddableFactory } from '../../embeddable_api';
import { DashboardViewport, DashboardViewportProps } from './dashboard_viewport';
import { DashboardContainer, ViewportProps } from '../dashboard_container';
import { DashboardContainer, DashboardContainerOptions } from '../dashboard_container';
import { getSampleDashboardInput } from '../../test_helpers';
import {
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableFactory,
} from '../../../../../../../embeddable_api/public/np_ready/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { KibanaContextProvider } from '../../../../../../../../../plugins/kibana_react/public';
let dashboardContainer: DashboardContainer | undefined;
const ExitFullScreenButton = () => <div data-test-subj="exitFullScreenModeText">EXIT</div>;
function getProps(props?: Partial<DashboardViewportProps>): DashboardViewportProps {
const viewportProps: ViewportProps = {
getActions: (() => []) as any,
getAllEmbeddableFactories: (() => []) as any,
getEmbeddableFactory: undefined as any,
function getProps(
props?: Partial<DashboardViewportProps>
): { props: DashboardViewportProps; options: DashboardContainerOptions } {
const embeddableFactories = new Map<string, EmbeddableFactory>();
embeddableFactories.set(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory({}, (() => null) as any, {} as any)
);
const options: DashboardContainerOptions = {
application: {} as any,
embeddable: {
getTriggerCompatibleActions: (() => []) as any,
getEmbeddableFactories: (() => []) as any,
getEmbeddableFactory: (id: string) => embeddableFactories.get(id),
} as any,
notifications: {} as any,
overlays: {} as any,
inspector: {} as any,
@ -49,44 +61,39 @@ function getProps(props?: Partial<DashboardViewportProps>): DashboardViewportPro
ExitFullScreenButton,
};
const __embeddableFactories = new Map<string, EmbeddableFactory>();
__embeddableFactories.set(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory({}, (() => null) as any, {} as any)
);
viewportProps.getEmbeddableFactory = (id: string) => __embeddableFactories.get(id);
dashboardContainer = new DashboardContainer(
getSampleDashboardInput({
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '1' },
},
'2': {
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '2' },
},
const input = getSampleDashboardInput({
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '1' },
},
}),
viewportProps
);
'2': {
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
type: CONTACT_CARD_EMBEDDABLE,
explicitInput: { id: '2' },
},
},
});
dashboardContainer = new DashboardContainer(input, options);
const defaultTestProps: DashboardViewportProps = {
container: dashboardContainer,
...viewportProps,
inspector: {} as any,
ExitFullScreenButton: () => null,
};
return Object.assign(defaultTestProps, props);
return {
props: Object.assign(defaultTestProps, props),
options,
};
}
test('renders DashboardViewport', () => {
const props = getProps();
const { props, options } = getProps();
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
<KibanaContextProvider services={options}>
<DashboardViewport {...props} />
</KibanaContextProvider>
</I18nProvider>
);
const panels = findTestSubject(component, 'dashboardPanel');
@ -94,11 +101,13 @@ test('renders DashboardViewport', () => {
});
test('renders DashboardViewport with no visualizations', () => {
const props = getProps();
const { props, options } = getProps();
props.container.updateInput({ panels: {} });
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
<KibanaContextProvider services={options}>
<DashboardViewport {...props} />
</KibanaContextProvider>
</I18nProvider>
);
const panels = findTestSubject(component, 'dashboardPanel');
@ -108,11 +117,13 @@ test('renders DashboardViewport with no visualizations', () => {
});
test('renders exit full screen button when in full screen mode', async () => {
const props = getProps();
const { props, options } = getProps();
props.container.updateInput({ isFullScreenMode: true });
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
<KibanaContextProvider services={options}>
<DashboardViewport {...props} />
</KibanaContextProvider>
</I18nProvider>
);
@ -138,10 +149,12 @@ test('renders exit full screen button when in full screen mode', async () => {
});
test('DashboardViewport unmount unsubscribes', async done => {
const props = getProps();
const { props, options } = getProps();
const component = mount(
<I18nProvider>
<DashboardViewport {...props} />
<KibanaContextProvider services={options}>
<DashboardViewport {...props} />
</KibanaContextProvider>
</I18nProvider>
);
component.unmount();

View file

@ -19,27 +19,13 @@
import React from 'react';
import { Subscription } from 'rxjs';
import {
GetActionsCompatibleWithTrigger,
GetEmbeddableFactory,
GetEmbeddableFactories,
} from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public';
import { CoreStart } from 'src/core/public';
import { PanelState } from '../../embeddable_api';
import { DashboardContainer } from '../dashboard_container';
import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container';
import { DashboardGrid } from '../grid';
import { Start as InspectorStartContract } from '../../../../../../../../../plugins/inspector/public';
import { context } from '../../../../../../../../../plugins/kibana_react/public';
export interface DashboardViewportProps {
container: DashboardContainer;
getActions: GetActionsCompatibleWithTrigger;
getEmbeddableFactory: GetEmbeddableFactory;
getAllEmbeddableFactories: GetEmbeddableFactories;
overlays: CoreStart['overlays'];
notifications: CoreStart['notifications'];
inspector: InspectorStartContract;
SavedObjectFinder: React.ComponentType<any>;
ExitFullScreenButton: React.ComponentType<any>;
}
interface State {
@ -51,6 +37,9 @@ interface State {
}
export class DashboardViewport extends React.Component<DashboardViewportProps, State> {
static contextType = context;
public readonly context!: DashboardReactContextValue;
private subscription?: Subscription;
private mounted: boolean = false;
constructor(props: DashboardViewportProps) {
@ -106,18 +95,11 @@ export class DashboardViewport extends React.Component<DashboardViewportProps, S
}
>
{this.state.isFullScreenMode && (
<this.props.ExitFullScreenButton onExitFullScreenMode={this.onExitFullScreenMode} />
<this.context.services.ExitFullScreenButton
onExitFullScreenMode={this.onExitFullScreenMode}
/>
)}
<DashboardGrid
container={container}
getActions={this.props.getActions}
getAllEmbeddableFactories={this.props.getAllEmbeddableFactories}
getEmbeddableFactory={this.props.getEmbeddableFactory}
notifications={this.props.notifications}
overlays={this.props.overlays}
inspector={this.props.inspector}
SavedObjectFinder={this.props.SavedObjectFinder}
/>
<DashboardGrid container={container} />
</div>
);
}

View file

@ -19,7 +19,7 @@
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public';
import { CONTEXT_MENU_TRIGGER, Plugin as EmbeddablePlugin } from './lib/embeddable_api';
import { ExpandPanelAction, DashboardContainerFactory, DashboardCapabilities } from './lib';
import { ExpandPanelAction, DashboardContainerFactory } from './lib';
import { Start as InspectorStartContract } from '../../../../../../plugins/inspector/public';
interface SetupDependencies {
@ -52,16 +52,11 @@ export class DashboardEmbeddableContainerPublicPlugin
const { application, notifications, overlays } = core;
const { embeddable, inspector, __LEGACY } = plugins;
const dashboardOptions = {
capabilities: (application.capabilities.dashboard as unknown) as DashboardCapabilities,
getFactory: embeddable.getEmbeddableFactory,
};
const factory = new DashboardContainerFactory(dashboardOptions, {
getActions: embeddable.getTriggerCompatibleActions,
getAllEmbeddableFactories: embeddable.getEmbeddableFactories,
getEmbeddableFactory: embeddable.getEmbeddableFactory,
const factory = new DashboardContainerFactory({
application,
notifications,
overlays,
embeddable,
inspector,
SavedObjectFinder: __LEGACY.SavedObjectFinder,
ExitFullScreenButton: __LEGACY.ExitFullScreenButton,

View file

@ -24,7 +24,10 @@ import { mount } from 'enzyme';
import { nextTick } from 'test_utils/enzyme_helpers';
import { I18nProvider } from '@kbn/i18n/react';
import { ViewMode, CONTEXT_MENU_TRIGGER, EmbeddablePanel } from '../lib/embeddable_api';
import { DashboardContainer } from '../lib/embeddable/dashboard_container';
import {
DashboardContainer,
DashboardContainerOptions,
} from '../lib/embeddable/dashboard_container';
import { getSampleDashboardInput } from '../lib/test_helpers';
import {
CONTACT_CARD_EMBEDDABLE,
@ -39,6 +42,7 @@ import { embeddablePluginMock } from '../../../../../embeddable_api/public/np_re
import { EditModeAction } from '../../../../../embeddable_api/public/np_ready/public/lib/test_samples/actions/edit_mode_action';
// eslint-disable-next-line
import { inspectorPluginMock } from '../../../../../../../plugins/inspector/public/mocks';
import { KibanaContextProvider } from '../../../../../../../plugins/kibana_react/public';
test('DashboardContainer in edit mode shows edit mode actions', async () => {
const inspector = inspectorPluginMock.createStartContract();
@ -54,9 +58,17 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
const start = doStart();
const container = new DashboardContainer(getSampleDashboardInput({ viewMode: ViewMode.VIEW }), {
getEmbeddableFactory: start.getEmbeddableFactory,
} as any);
const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW });
const options: DashboardContainerOptions = {
application: {} as any,
embeddable: start,
notifications: {} as any,
overlays: {} as any,
inspector: {} as any,
SavedObjectFinder: () => null,
ExitFullScreenButton: () => null,
};
const container = new DashboardContainer(initialInput, options);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
@ -68,16 +80,18 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={(() => []) as any}
getEmbeddableFactory={(() => null) as any}
notifications={{} as any}
overlays={{} as any}
inspector={inspector}
SavedObjectFinder={() => null}
/>
<KibanaContextProvider services={options}>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={(() => []) as any}
getEmbeddableFactory={(() => null) as any}
notifications={{} as any}
overlays={{} as any}
inspector={inspector}
SavedObjectFinder={() => null}
/>
</KibanaContextProvider>
</I18nProvider>
);

View file

@ -70,3 +70,4 @@ export function plugin(initializerContext: PluginInitializerContext) {
}
export { EmbeddablePublicPlugin as Plugin };
export * from './plugin';

View file

@ -20,7 +20,8 @@ import { i18n } from '@kbn/i18n';
import { ViewMode, GetEmbeddableFactory, GetEmbeddableFactories } from '../../../../types';
import { Action, ActionContext } from '../../../../actions';
import { openAddPanelFlyout } from './open_add_panel_flyout';
import { OverlayStart, NotificationsStart } from '../../../../../../../../../../../core/public';
import { NotificationsStart } from '../../../../../../../../../../../core/public';
import { KibanaReactOverlays } from '../../../../../../../../../../../plugins/kibana_react/public';
export const ADD_PANEL_ACTION_ID = 'ADD_PANEL_ACTION_ID';
@ -30,7 +31,7 @@ export class AddPanelAction extends Action {
constructor(
private readonly getFactory: GetEmbeddableFactory,
private readonly getAllFactories: GetEmbeddableFactories,
private readonly overlays: OverlayStart,
private readonly overlays: KibanaReactOverlays,
private readonly notifications: NotificationsStart,
private readonly SavedObjectFinder: React.ComponentType<any>
) {

View file

@ -20,13 +20,14 @@ import React from 'react';
import { IContainer } from '../../../../containers';
import { AddPanelFlyout } from './add_panel_flyout';
import { GetEmbeddableFactory, GetEmbeddableFactories } from '../../../../types';
import { OverlayStart, NotificationsStart } from '../../../../../../../../../../../core/public';
import { NotificationsStart } from '../../../../../../../../../../../core/public';
import { KibanaReactOverlays } from '../../../../../../../../../../../plugins/kibana_react/public';
export async function openAddPanelFlyout(options: {
embeddable: IContainer;
getFactory: GetEmbeddableFactory;
getAllFactories: GetEmbeddableFactories;
overlays: OverlayStart;
overlays: KibanaReactOverlays;
notifications: NotificationsStart;
SavedObjectFinder: React.ComponentType<any>;
}) {

View file

@ -0,0 +1,236 @@
# `kibana-react`
Tools for building React applications in Kibana.
## Context
You can create React context that holds Core or plugin services that your plugin depends on.
```ts
import { createContext } from 'kibana-react';
class MyPlugin {
start(core, plugins) {
const context = createContext({ ...core, ...plugins });
}
}
```
You may also want to be explicit about services you depend on.
```ts
import { createContext } from 'kibana-react';
class MyPlugin {
start({ notifications, overlays }, { embeddable }) {
const context = createContext({ notifications, overlays, embeddable });
}
}
```
Wrap your React application in the created context.
```jsx
<context.Provider>
<KibanaApplication />
</context.Provider>
```
Or use already pre-created `<KibanaContextProvider>` component.
```jsx
import { KibanaContextProvider } from 'kibana-react';
<KibanaContextProvider services={{ ...core, ...plugins }}>
<KibanaApplication />
</KibanaContextProvider>
<KibanaContextProvider services={{ notifications, overlays, embeddable }}>
<KibanaApplication />
</KibanaContextProvider>
```
## Accessing context
Using `useKibana` hook.
```tsx
import { useKibana } from 'kibana-react';
const Demo = () => {
const kibana = useKibana();
return (
<div>
{kibana.services.uiSettings.get('theme:darkMode') ? 'dark' : 'light'}
</div>
);
};
```
Using `withKibana()` higher order component.
```tsx
import { withKibana } from 'kibana-react';
const Demo = ({ kibana }) => {
return (
<div>
{kibana.services.uiSettings.get('theme:darkMode') ? 'dark' : 'light'}
</div>
);
};
export default withKibana(Demo);
```
Using `<UseKibana>` render prop.
```tsx
import { UseKibana } from 'kibana-react';
const Demo = () => {
return (
<UseKibana>{kibana =>
<div>
{kibana.services.uiSettings.get('theme:darkMode') ? 'dark' : 'light'}
</div>
}</UseKibana>
);
};
```
## `uiSettings` service
Wrappers around Core's `uiSettings` service.
### `useUiSetting` hook
`useUiSetting` synchronously returns the latest setting from `CoreStart['uiSettings']` service.
```tsx
import { useUiSetting } from 'kibana-react';
const Demo = () => {
const darkMode = useUiSetting<boolean>('theme:darkMode');
return (
<div>
{darkMode ? 'dark' : 'light'}
</div>
);
};
```
#### Reference
```tsx
useUiSetting<T>(key: string, defaultValue: T): T;
```
### `useUiSetting$` hook
`useUiSetting$` synchronously returns the latest setting from `CoreStart['uiSettings']` service and
subscribes to changes, re-rendering your component with latest values.
```tsx
import { useUiSetting$ } from 'kibana-react';
const Demo = () => {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
return (
<div>
{darkMode ? 'dark' : 'light'}
</div>
);
};
```
#### Reference
```tsx
useUiSetting$<T>(key: string, defaultValue: T): [T, (newValue: T) => void];
```
## `overlays` service
Wrapper around Core's `overlays` service, allows you to display React modals and flyouts
directly without having to use `react-dom` library to mount to DOM nodes.
```tsx
import { createContext } from 'kibana-react';
class MyPlugin {
start(core) {
const { value: { overlays } } = createContext(core);
overlays.openModal(
<div>
Hello world!
</div>
);
}
}
```
- `overlays.openModal` &mdash; opens modal window.
- `overlays.openFlyout` &mdash; opens right side panel.
You can access `overlays` service through React context.
```tsx
const Demo = () => {
const { overlays } = useKibana();
useEffect(() => {
overlays.openModal(
<div>
Oooops! {errorMessage}
</div>
);
}, [errorMessage]);
};
```
## `notifications` service
Wrapper around Core's `notifications` service, allows you to render React elements
directly without having to use `react-dom` library to mount to DOM nodes.
```tsx
import { createContext } from 'kibana-react';
class MyPlugin {
start(core) {
const { value: { notifications } } = createContext(core);
notifications.toasts.show({
title: <div>Hello</div>,
body: <div>world!</div>
});
}
}
```
- `notifications.toasts.show()` &mdash; show generic toast message.
- `notifications.toasts.success()` &mdash; show positive toast message.
- `notifications.toasts.warning()` &mdash; show warning toast message.
- `notifications.toasts.danger()` &mdash; show error toast message.
You can access `notifications` service through React context.
```tsx
const Demo = () => {
const { notifications } = useKibana();
useEffect(() => {
notifications.toasts.danger({
title: 'Oooops!',
body: errorMessage,
});
}, [errorMessage]);
};
```

View file

@ -0,0 +1,271 @@
/*
* 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, createKibanaReactContext, useKibana, KibanaContextProvider } from './context';
import { coreMock } from '../../../../core/public/mocks';
import { CoreStart } from './types';
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 services = coreMock.createStart();
ReactDOM.render(
<context.Provider value={{ services } as any}>
<div>Hello world</div>
</context.Provider>,
container
);
});
const TestConsumer = () => {
const { services } = useKibana<{ foo: string }>();
return <div>{services.foo}</div>;
};
test('useKibana() hook retrieves Kibana context', () => {
const core = coreMock.createStart();
(core as any).foo = 'bar';
ReactDOM.render(
<context.Provider value={{ services: core } as any}>
<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 services = {
foo: 'baz',
} as Partial<CoreStart>;
const { Provider } = createKibanaReactContext(services);
ReactDOM.render(
<Provider>
<TestConsumer />
</Provider>,
container
);
const div = container!.querySelector('div');
expect(div!.textContent).toBe('baz');
});
test('services, notifications and overlays objects are always available', () => {
const { Provider } = createKibanaReactContext({});
const Test: React.FC = () => {
const kibana = useKibana();
expect(kibana).toMatchObject({
services: expect.any(Object),
notifications: expect.any(Object),
overlays: expect.any(Object),
});
return null;
};
ReactDOM.render(
<Provider>
<Test />
</Provider>,
container
);
});
test('<KibanaContextProvider> provider provides default kibana-react context', () => {
const Test: React.FC = () => {
const kibana = useKibana();
expect(kibana).toMatchObject({
services: expect.any(Object),
notifications: expect.any(Object),
overlays: expect.any(Object),
});
return null;
};
ReactDOM.render(
<KibanaContextProvider>
<Test />
</KibanaContextProvider>,
container
);
});
test('<KibanaContextProvider> can set custom services in context', () => {
const Test: React.FC = () => {
const { services } = useKibana();
expect(services.test).toBe('quux');
return null;
};
ReactDOM.render(
<KibanaContextProvider services={{ test: 'quux' }}>
<Test />
</KibanaContextProvider>,
container
);
});
test('nested <KibanaContextProvider> override and merge services', () => {
const Test: React.FC = () => {
const { services } = useKibana();
expect(services.foo).toBe('foo2');
expect(services.bar).toBe('bar');
expect(services.baz).toBe('baz3');
return null;
};
ReactDOM.render(
<KibanaContextProvider services={{ foo: 'foo', bar: 'bar', baz: 'baz' }}>
<KibanaContextProvider services={{ foo: 'foo2' }}>
<KibanaContextProvider services={{ baz: 'baz3' }}>
<Test />
</KibanaContextProvider>
</KibanaContextProvider>
</KibanaContextProvider>,
container
);
});
test('overlays wrapper uses the closest overlays service', () => {
const Test: React.FC = () => {
const { overlays } = useKibana();
overlays.openFlyout({} as any);
overlays.openModal({} as any);
return null;
};
const core1 = {
overlays: {
openFlyout: jest.fn(),
openModal: jest.fn(),
},
} as Partial<CoreStart>;
const core2 = {
overlays: {
openFlyout: jest.fn(),
openModal: jest.fn(),
},
} as Partial<CoreStart>;
ReactDOM.render(
<KibanaContextProvider services={core1}>
<KibanaContextProvider services={core2}>
<Test />
</KibanaContextProvider>
</KibanaContextProvider>,
container
);
expect(core1.overlays!.openFlyout).toHaveBeenCalledTimes(0);
expect(core1.overlays!.openModal).toHaveBeenCalledTimes(0);
expect(core2.overlays!.openFlyout).toHaveBeenCalledTimes(1);
expect(core2.overlays!.openModal).toHaveBeenCalledTimes(1);
});
test('notifications wrapper uses the closest notifications service', () => {
const Test: React.FC = () => {
const { notifications } = useKibana();
notifications.toasts.show({} as any);
return null;
};
const core1 = {
notifications: ({
toasts: {
add: jest.fn(),
},
} as unknown) as CoreStart['notifications'],
} as Partial<CoreStart>;
const core2 = {
notifications: ({
toasts: {
add: jest.fn(),
},
} as unknown) as CoreStart['notifications'],
} as Partial<CoreStart>;
ReactDOM.render(
<KibanaContextProvider services={core1}>
<KibanaContextProvider services={core2}>
<Test />
</KibanaContextProvider>
</KibanaContextProvider>,
container
);
expect(core1.notifications!.toasts.add).toHaveBeenCalledTimes(0);
expect(core2.notifications!.toasts.add).toHaveBeenCalledTimes(1);
});
test('overlays wrapper uses available overlays service, higher up in <KibanaContextProvider> tree', () => {
const Test: React.FC = () => {
const { overlays } = useKibana();
overlays.openFlyout({} as any);
return null;
};
const core1 = {
overlays: {
openFlyout: jest.fn(),
openModal: jest.fn(),
},
notifications: ({
toasts: {
add: jest.fn(),
},
} as unknown) as CoreStart['notifications'],
} as Partial<CoreStart>;
const core2 = {
notifications: ({
toasts: {
add: jest.fn(),
},
} as unknown) as CoreStart['notifications'],
} as Partial<CoreStart>;
expect(core1.overlays!.openFlyout).toHaveBeenCalledTimes(0);
ReactDOM.render(
<KibanaContextProvider services={core1}>
<KibanaContextProvider services={core2}>
<Test />
</KibanaContextProvider>
</KibanaContextProvider>,
container
);
expect(core1.overlays!.openFlyout).toHaveBeenCalledTimes(1);
});

View file

@ -0,0 +1,87 @@
/*
* 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 { KibanaReactContext, KibanaReactContextValue, KibanaServices } from './types';
import { createReactOverlays } from '../overlays';
import { createNotifications } from '../notifications';
const { useMemo, useContext, createElement, createContext } = React;
const defaultContextValue = {
services: {},
overlays: createReactOverlays({}),
notifications: createNotifications({}),
};
export const context = createContext<KibanaReactContextValue<KibanaServices>>(defaultContextValue);
export const useKibana = <Extra extends object = {}>(): KibanaReactContextValue<
KibanaServices & Extra
> =>
useContext((context as unknown) as React.Context<
KibanaReactContextValue<KibanaServices & Extra>
>);
export const withKibana = <Props extends { kibana: KibanaReactContextValue<any> }>(
type: React.ComponentType<Props>
): React.FC<Omit<Props, 'kibana'>> => {
const enhancedType: React.FC<Omit<Props, 'kibana'>> = (props: Omit<Props, 'kibana'>) => {
const kibana = useKibana();
return React.createElement(type, { ...props, kibana } as Props);
};
return enhancedType;
};
export const UseKibana: React.FC<{
children: (kibana: KibanaReactContextValue<any>) => React.ReactNode;
}> = ({ children }) => <>{children(useKibana())}</>;
export const createKibanaReactContext = <Services extends KibanaServices>(
services: Services
): KibanaReactContext<Services> => {
const value: KibanaReactContextValue<Services> = {
services,
overlays: createReactOverlays(services),
notifications: createNotifications(services),
};
const Provider: React.FC<{ services?: Services }> = ({
services: newServices = {},
children,
}) => {
const oldValue = useKibana();
const { value: newValue } = useMemo(
() => createKibanaReactContext({ ...services, ...oldValue.services, ...newServices }),
Object.keys(services)
);
return createElement(context.Provider as React.ComponentType<any>, {
value: newValue,
children,
});
};
return {
value,
Provider,
Consumer: (context.Consumer as unknown) as React.Consumer<KibanaReactContextValue<Services>>,
};
};
export const { Provider: KibanaContextProvider } = createKibanaReactContext({});

View file

@ -17,12 +17,12 @@
* under the License.
*/
import { CoreSetup, CoreStart } from '../../../../core/public';
export { CoreSetup, CoreStart };
export type Core = Partial<CoreSetup> & Partial<CoreStart>;
export interface KibanaReactContextValue {
core: Core;
}
export {
context,
createKibanaReactContext,
KibanaContextProvider,
useKibana,
withKibana,
UseKibana,
} from './context';
export { KibanaReactContext, KibanaReactContextValue } from './types';

View file

@ -17,34 +17,23 @@
* under the License.
*/
import { Core } from './types';
import * as React from 'react';
import { CoreStart } from '../../../../core/public';
import { KibanaReactOverlays } from '../overlays';
import { KibanaReactNotifications } from '../notifications';
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 { CoreStart };
export const createMock = (): Core => {
const uiSettings = createSetupContractMock();
export type KibanaServices = Partial<CoreStart>;
const core: Partial<Core> = {
uiSettings,
};
export interface KibanaReactContextValue<Services extends KibanaServices> {
readonly services: Services;
readonly overlays: KibanaReactOverlays;
readonly notifications: KibanaReactNotifications;
}
return core as Core;
};
export interface KibanaReactContext<T extends KibanaServices> {
value: KibanaReactContextValue<T>;
Provider: React.FC<{ services?: T }>;
Consumer: React.Consumer<KibanaReactContextValue<T>>;
}

View file

@ -1,80 +0,0 @@
/*
* 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

@ -17,5 +17,6 @@
* under the License.
*/
export { context, createContext, useKibana } from './context';
export { KibanaReactContextValue } from './types';
export * from './context';
export * from './overlays';
export * from './ui_settings';

View file

@ -0,0 +1,153 @@
/*
* 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 { createNotifications } from './create_notifications';
// eslint-disable-next-lien
import { notificationServiceMock } from '../../../../core/public/mocks';
test('throws if no overlays service provided', () => {
const notifications = createNotifications({});
expect(() => notifications.toasts.show({})).toThrowErrorMatchingInlineSnapshot(
`"Could not show notification as notifications service is not available."`
);
});
test('creates wrapped notifications service', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = createNotifications({ notifications });
expect(wrapper).toMatchObject({
toasts: {
show: expect.any(Function),
success: expect.any(Function),
warning: expect.any(Function),
danger: expect.any(Function),
},
});
});
test('can display string element as title', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = createNotifications({ notifications });
expect(notifications.toasts.add).toHaveBeenCalledTimes(0);
wrapper.toasts.show({ title: 'foo' });
expect(notifications.toasts.add).toHaveBeenCalledTimes(1);
expect(notifications.toasts.add.mock.calls[0][0]).toMatchObject({
title: 'foo',
});
});
test('can display React element as title', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = createNotifications({ notifications });
expect(notifications.toasts.add).toHaveBeenCalledTimes(0);
wrapper.toasts.show({ title: <div>bar</div> });
expect(notifications.toasts.add).toHaveBeenCalledTimes(1);
expect((notifications.toasts.add.mock.calls[0][0] as any).title).toMatchInlineSnapshot(`
<div>
bar
</div>
`);
});
test('can display React element as toast body', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = createNotifications({ notifications });
wrapper.toasts.show({ body: <div>baz</div> });
expect(notifications.toasts.add).toHaveBeenCalledTimes(1);
expect((notifications.toasts.add.mock.calls[0][0] as any).text).toMatchInlineSnapshot(`
<React.Fragment>
<div>
baz
</div>
</React.Fragment>
`);
});
test('can set toast properties', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = createNotifications({ notifications });
wrapper.toasts.show({
body: '1',
color: 'danger',
iconType: 'foo',
title: '2',
toastLifeTimeMs: 3,
});
expect(notifications.toasts.add.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"color": "danger",
"iconType": "foo",
"onClose": undefined,
"text": <React.Fragment>
1
</React.Fragment>,
"title": "2",
"toastLifeTimeMs": 3,
}
`);
});
test('can display success, warning and danger toasts', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = createNotifications({ notifications });
wrapper.toasts.success({ title: '1' });
wrapper.toasts.warning({ title: '2' });
wrapper.toasts.danger({ title: '3' });
expect(notifications.toasts.add).toHaveBeenCalledTimes(3);
expect(notifications.toasts.add.mock.calls[0][0]).toMatchObject({
title: '1',
color: 'success',
iconType: 'check',
});
expect(notifications.toasts.add.mock.calls[1][0]).toMatchObject({
title: '2',
color: 'warning',
iconType: 'help',
});
expect(notifications.toasts.add.mock.calls[2][0]).toMatchObject({
title: '3',
color: 'danger',
iconType: 'alert',
});
});
test('if body is not set, renders it empty', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = createNotifications({ notifications });
wrapper.toasts.success({ title: '1' });
expect((notifications.toasts.add.mock.calls[0][0] as any).text).toMatchInlineSnapshot(
`<React.Fragment />`
);
});

View file

@ -0,0 +1,65 @@
/*
* 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 { KibanaServices } from '../context/types';
import { KibanaReactNotifications } from './types';
export const createNotifications = (services: KibanaServices): KibanaReactNotifications => {
const show: KibanaReactNotifications['toasts']['show'] = ({
title,
body,
color,
iconType,
toastLifeTimeMs,
onClose,
}) => {
if (!services.notifications) {
throw new TypeError('Could not show notification as notifications service is not available.');
}
services.notifications!.toasts.add({
title,
text: <>{body || null}</>,
color,
iconType,
toastLifeTimeMs,
onClose,
});
};
const success: KibanaReactNotifications['toasts']['success'] = input =>
show({ color: 'success', iconType: 'check', ...input });
const warning: KibanaReactNotifications['toasts']['warning'] = input =>
show({ color: 'warning', iconType: 'help', ...input });
const danger: KibanaReactNotifications['toasts']['danger'] = input =>
show({ color: 'danger', iconType: 'alert', ...input });
const notifications: KibanaReactNotifications = {
toasts: {
show,
success,
warning,
danger,
},
};
return notifications;
};

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 * from './types';
export * from './create_notifications';

View file

@ -18,21 +18,22 @@
*/
import * as React from 'react';
import { KibanaReactContextValue, Core } from './types';
import { Toast } from '../../../../core/public';
export const context = React.createContext<KibanaReactContextValue>({
core: {},
});
export interface ToastInput {
title?: React.ReactNode;
body?: React.ReactNode;
color?: Toast['color'];
iconType?: Toast['iconType'];
toastLifeTimeMs?: Toast['toastLifeTimeMs'];
onClose?: Toast['onClose'];
}
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 interface KibanaReactNotifications {
toasts: {
show: (input: ToastInput) => void;
success: (input: ToastInput) => void;
warning: (input: ToastInput) => void;
danger: (input: ToastInput) => void;
};
};
export const useKibana = (): KibanaReactContextValue => React.useContext(context);
}

View file

@ -0,0 +1,127 @@
/*
* 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 { createReactOverlays } from './create_react_overlays';
test('throws if no overlays service provided', () => {
const overlays = createReactOverlays({});
expect(() => overlays.openFlyout(null)).toThrowErrorMatchingInlineSnapshot(
`"Could not show overlay as overlays service is not available."`
);
});
test('creates wrapped overlays service', () => {
const overlays = createReactOverlays({
overlays: {
openFlyout: jest.fn(),
openModal: jest.fn(),
},
});
expect(typeof overlays.openFlyout).toBe('function');
expect(typeof overlays.openModal).toBe('function');
});
test('can open flyout with React element', () => {
const openFlyout = jest.fn();
const overlays = createReactOverlays({
overlays: {
openFlyout,
openModal: jest.fn(),
},
});
expect(openFlyout).toHaveBeenCalledTimes(0);
overlays.openFlyout(<div>foo</div>);
expect(openFlyout).toHaveBeenCalledTimes(1);
expect(openFlyout.mock.calls[0][0]).toMatchInlineSnapshot(`
<React.Fragment>
<div>
foo
</div>
</React.Fragment>
`);
});
test('can open modal with React element', () => {
const openFlyout = jest.fn();
const openModal = jest.fn();
const overlays = createReactOverlays({
overlays: {
openFlyout,
openModal,
},
});
expect(openModal).toHaveBeenCalledTimes(0);
overlays.openModal(<div>bar</div>);
expect(openModal).toHaveBeenCalledTimes(1);
expect(openModal.mock.calls[0][0]).toMatchInlineSnapshot(`
<React.Fragment>
<div>
bar
</div>
</React.Fragment>
`);
});
test('passes through flyout options when opening flyout', () => {
const openFlyout = jest.fn();
const overlays = createReactOverlays({
overlays: {
openFlyout,
openModal: jest.fn(),
},
});
overlays.openFlyout(<>foo</>, {
'data-test-subj': 'foo',
closeButtonAriaLabel: 'bar',
});
expect(openFlyout.mock.calls[0][1]).toEqual({
'data-test-subj': 'foo',
closeButtonAriaLabel: 'bar',
});
});
test('passes through modal options when opening modal', () => {
const openModal = jest.fn();
const overlays = createReactOverlays({
overlays: {
openFlyout: jest.fn(),
openModal,
},
});
overlays.openModal(<>foo</>, {
'data-test-subj': 'foo2',
closeButtonAriaLabel: 'bar2',
});
expect(openModal.mock.calls[0][1]).toEqual({
'data-test-subj': 'foo2',
closeButtonAriaLabel: 'bar2',
});
});

View file

@ -0,0 +1,47 @@
/*
* 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 { KibanaServices } from '../context/types';
import { KibanaReactOverlays } from './types';
export const createReactOverlays = (services: KibanaServices): KibanaReactOverlays => {
const checkCoreService = () => {
if (!services.overlays) {
throw new TypeError('Could not show overlay as overlays service is not available.');
}
};
const openFlyout: KibanaReactOverlays['openFlyout'] = (node, options?) => {
checkCoreService();
return services.overlays!.openFlyout(<>{node}</>, options);
};
const openModal: KibanaReactOverlays['openModal'] = (node, options?) => {
checkCoreService();
return services.overlays!.openModal(<>{node}</>, options);
};
const overlays: KibanaReactOverlays = {
openFlyout,
openModal,
};
return overlays;
};

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 * from './types';
export * from './create_react_overlays';

View file

@ -0,0 +1,32 @@
/*
* 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 { CoreStart } from '../context/types';
export interface KibanaReactOverlays {
openFlyout: (
node: React.ReactNode,
options?: Parameters<CoreStart['overlays']['openFlyout']>['1']
) => ReturnType<CoreStart['overlays']['openFlyout']>;
openModal: (
node: React.ReactNode,
options?: Parameters<CoreStart['overlays']['openFlyout']>['1']
) => ReturnType<CoreStart['overlays']['openModal']>;
}

View file

@ -0,0 +1,20 @@
/*
* 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 * from './use_ui_setting';

View file

@ -20,21 +20,21 @@
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 { useUiSetting$ } from './use_ui_setting';
import { createKibanaReactContext } from '../context';
import { KibanaServices } from '../context/types';
import { Subject } from 'rxjs';
import { useObservable } from '../util/use_observable';
import { coreMock } from '../../../../core/public/mocks';
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 mock = (): [KibanaServices, Subject<any>] => {
const core = coreMock.createStart();
const get = core.uiSettings.get;
const get$ = core.uiSettings.get$;
const subject = new Subject();
get.mockImplementation(() => 'bar');
@ -56,85 +56,136 @@ afterEach(() => {
container = null;
});
const TestConsumer: React.FC<{
setting: string;
newValue?: string;
}> = ({ setting, newValue = '' }) => {
const [value, set] = useUiSetting(setting, 'DEFAULT');
describe('useUiSetting', () => {
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>
);
};
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);
test('returns setting value', async () => {
const [core] = mock();
const { Provider } = createKibanaReactContext(core);
ReactDOM.render(
<Provider>
<TestConsumer setting="foo" />
</Provider>,
container
);
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')!, {});
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');
});
expect(core.uiSettings!.set).toHaveBeenCalledTimes(1);
expect(core.uiSettings!.set).toHaveBeenCalledWith('a', 'c');
test('calls uiSettings.get() method with correct key and default value', async () => {
const [core] = mock();
const { Provider } = createKibanaReactContext(core);
ReactDOM.render(
<Provider>
<TestConsumer setting="foo" />
</Provider>,
container
);
expect(core.uiSettings!.get).toHaveBeenCalledTimes(1);
expect((core.uiSettings!.get as any).mock.calls[0][0]).toBe('foo');
expect((core.uiSettings!.get as any).mock.calls[0][1]).toBe('DEFAULT');
});
});
describe('useUiSetting$', () => {
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 } = createKibanaReactContext(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 = coreMock.createStart();
const { Provider } = createKibanaReactContext(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 } = createKibanaReactContext(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 } = createKibanaReactContext(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

@ -18,9 +18,28 @@
*/
import { useCallback, useMemo } from 'react';
import { useKibana } from '../core';
import { useKibana } from '../context';
import { useObservable } from '../util/use_observable';
/**
* Returns the current UI-settings value.
*
* Usage:
*
* ```js
* const darkMode = useUiSetting('theme:darkMode');
* ```
*/
export const useUiSetting = <T>(key: string, defaultValue?: T): T => {
const { services } = useKibana();
if (typeof services.uiSettings !== 'object') {
throw new TypeError('uiSettings service not available in kibana-react context.');
}
return services.uiSettings.get(key, defaultValue);
};
type Setter<T> = (newValue: T) => Promise<boolean>;
/**
@ -33,18 +52,22 @@ type Setter<T> = (newValue: T) => Promise<boolean>;
* Usage:
*
* ```js
* const [darkMode, setDarkMode] = useUiSetting('theme:darkMode');
* 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]);
export const useUiSetting$ = <T>(key: string, defaultValue?: T): [T, Setter<T>] => {
const { services } = useKibana();
if (typeof services.uiSettings !== 'object') {
throw new TypeError('uiSettings service not available in kibana-react context.');
}
const observable$ = useMemo(() => services.uiSettings!.get$(key, defaultValue), [
key,
defaultValue,
]);
const value = useObservable<T>(observable$, services.uiSettings!.get(key, defaultValue));
const set = useCallback((newValue: T) => services.uiSettings!.set(key, newValue), [key]);
return [value, set];
};

View file

@ -16,13 +16,19 @@ import { pluck } from 'rxjs/operators';
import { EuiErrorBoundary } from '@elastic/eui';
import { UICapabilitiesProvider } from 'ui/capabilities/react';
import { I18nContext } from 'ui/i18n';
import { npStart } from 'ui/new_platform';
import { EuiThemeProvider } from '../../../../common/eui_styled_components';
import { InfraFrontendLibs } from '../lib/lib';
import { PageRouter } from '../routes';
import { createStore } from '../store';
import { ApolloClientContext } from '../utils/apollo_context';
import { HistoryContext } from '../utils/history_context';
import { useKibanaUiSetting } from '../utils/use_kibana_ui_setting';
import {
useUiSetting$,
KibanaContextProvider,
} from '../../../../../../src/plugins/kibana_react/public';
const { uiSettings } = npStart.core;
export async function startApp(libs: InfraFrontendLibs) {
const history = createHashHistory();
@ -34,7 +40,7 @@ export async function startApp(libs: InfraFrontendLibs) {
});
const InfraPluginRoot: React.FunctionComponent = () => {
const [darkMode] = useKibanaUiSetting('theme:darkMode');
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
return (
<I18nContext>
@ -59,5 +65,9 @@ export async function startApp(libs: InfraFrontendLibs) {
);
};
libs.framework.render(<InfraPluginRoot />);
libs.framework.render(
<KibanaContextProvider services={{ uiSettings }}>
<InfraPluginRoot />
</KibanaContextProvider>
);
}