[Controls] [Portable Dashboards] Add control group renderer example plugin (#146189)

Closes https://github.com/elastic/kibana/issues/145429

## Summary

This PR expands on the control group building block by 
1. Replacing the old `input` declarative API and replacing it with a
`getCreationOptions` callback
2. Exposing the redux embeddable tools to the consumer

As part of this, I created an example plugin to demonstrate some of the
new functionality 👍

![Nov-23-2022
10-27-08](https://user-images.githubusercontent.com/8698078/203611035-a9633929-2a49-4d06-b38f-fcf90d8fa885.gif)


Also, to avoid some code duplication, I had to move some code to the
generic `control_group_helpers.tsx` so that both the controls plugin and
the new example plugin could use it.

### Checklist

- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Hannah Mudge 2022-11-24 14:00:49 -07:00 committed by GitHub
parent dd86c7f25c
commit bb91749f0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 393 additions and 50 deletions

View file

@ -0,0 +1,11 @@
{
"id": "controlsExample",
"owner": {
"name": "Kibana Presentation",
"githubTeam": "kibana-presentation"
},
"version": "1.0.0",
"kibanaVersion": "kibana",
"ui": true,
"requiredPlugins": ["data", "developerExamples", "presentationUtil", "controls"]
}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import type { DataView } from '@kbn/data-views-plugin/public';
import { AppMountParameters } from '@kbn/core/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { ControlsExampleStartDeps } from './plugin';
import { BasicReduxExample } from './basic_redux_example';
interface Props {
dataView: DataView;
}
const ControlsExamples = ({ dataView }: Props) => {
return (
<KibanaPageTemplate>
<KibanaPageTemplate.Header pageTitle="Controls as a Building Block" />
<KibanaPageTemplate.Section>
<BasicReduxExample dataView={dataView} />
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};
export const renderApp = async (
{ data }: ControlsExampleStartDeps,
{ element }: AppMountParameters
) => {
const dataViews = await data.dataViews.find('kibana_sample_data_ecommerce');
if (dataViews.length > 0) {
ReactDOM.render(<ControlsExamples dataView={dataViews[0]} />, element);
}
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useMemo, useState } from 'react';
import {
LazyControlGroupRenderer,
ControlGroupContainer,
ControlGroupInput,
useControlGroupContainerContext,
ControlStyle,
} from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import {
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common';
interface Props {
dataView: DataView;
}
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
export const BasicReduxExample = ({ dataView }: Props) => {
const [myControlGroup, setControlGroup] = useState<ControlGroupContainer>();
const [currentControlStyle, setCurrentControlStyle] = useState<ControlStyle>('oneLine');
const ControlGroupReduxWrapper = useMemo(() => {
if (myControlGroup) return myControlGroup.getReduxEmbeddableTools().Wrapper;
}, [myControlGroup]);
const ButtonControls = () => {
const {
useEmbeddableDispatch,
actions: { setControlStyle },
} = useControlGroupContainerContext();
const dispatch = useEmbeddableDispatch();
return (
<>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiText>
<p>Choose a style for your control group:</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonGroup
legend="Text style"
options={[
{
id: `oneLine`,
label: 'One line',
value: 'oneLine' as ControlStyle,
},
{
id: `twoLine`,
label: 'Two lines',
value: 'twoLine' as ControlStyle,
},
]}
idSelected={currentControlStyle}
onChange={(id, value) => {
setCurrentControlStyle(value);
dispatch(setControlStyle(value));
}}
type="single"
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
);
};
return (
<>
<EuiTitle>
<h2>Basic Redux Example</h2>
</EuiTitle>
<EuiText>
<p>
This example uses the redux context from the control group container in order to
dynamically change the style of the control group.
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
{ControlGroupReduxWrapper && (
<ControlGroupReduxWrapper>
<ButtonControls />
</ControlGroupReduxWrapper>
)}
<ControlGroupRenderer
onEmbeddableLoad={async (controlGroup) => {
setControlGroup(controlGroup);
}}
getCreationOptions={async (controlGroupInputBuilder) => {
const initialInput: Partial<ControlGroupInput> = {
...getDefaultControlGroupInput(),
defaultControlWidth: 'small',
};
await controlGroupInputBuilder.addDataControlFromField(initialInput, {
dataViewId: dataView.id ?? 'kibana_sample_data_ecommerce',
fieldName: 'customer_first_name.keyword',
});
await controlGroupInputBuilder.addDataControlFromField(initialInput, {
dataViewId: dataView.id ?? 'kibana_sample_data_ecommerce',
fieldName: 'customer_last_name.keyword',
width: 'medium',
grow: false,
title: 'Last Name',
});
return initialInput;
}}
/>
</EuiPanel>
</>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ControlsExamplePlugin } from './plugin';
export function plugin() {
return new ControlsExamplePlugin();
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
AppMountParameters,
AppNavLinkStatus,
CoreSetup,
CoreStart,
Plugin,
} from '@kbn/core/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
import img from './control_group_image.png';
interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
}
export interface ControlsExampleStartDeps {
data: DataPublicPluginStart;
}
export class ControlsExamplePlugin
implements Plugin<void, void, SetupDeps, ControlsExampleStartDeps>
{
public setup(core: CoreSetup<ControlsExampleStartDeps>, { developerExamples }: SetupDeps) {
core.application.register({
id: 'controlsExamples',
title: 'Controls examples',
navLinkStatus: AppNavLinkStatus.hidden,
async mount(params: AppMountParameters) {
const [, depsStart] = await core.getStartServices();
const { renderApp } = await import('./app');
return renderApp(depsStart, params);
},
});
developerExamples.register({
appId: 'controlsExamples',
title: 'Controls as a Building Block',
description: `Showcases different ways to embed a control group into your app`,
image: img,
});
}
public start(core: CoreStart) {}
public stop() {}
}

View file

@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types"
},
"include": [
"index.ts",
"common/**/*.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": [],
"kbn_references": [
{ "path": "../../src/core/tsconfig.json" },
{ "path": "../developer_examples/tsconfig.json" },
{ "path": "../../src/plugins/data/tsconfig.json" },
{ "path": "../../src/plugins/controls/tsconfig.json" },
{ "path": "../../src/plugins/presentation_util/tsconfig.json" }
]
}

View file

@ -8,41 +8,87 @@
import uuid from 'uuid';
import useLifecycles from 'react-use/lib/useLifecycles';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { IEmbeddable } from '@kbn/embeddable-plugin/public';
import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public';
import { pluginServices } from '../services';
import { getDefaultControlGroupInput } from '../../common';
import { ControlGroupInput, ControlGroupOutput, CONTROL_GROUP_TYPE } from './types';
import { ControlPanelState, getDefaultControlGroupInput } from '../../common';
import {
ControlGroupInput,
ControlGroupOutput,
ControlGroupReduxState,
CONTROL_GROUP_TYPE,
} from './types';
import { ControlGroupContainer } from './embeddable/control_group_container';
import { DataControlInput } from '../types';
import { getCompatibleControlType, getNextPanelOrder } from './embeddable/control_group_helpers';
import { controlGroupReducers } from './state/control_group_reducers';
const ControlGroupInputBuilder = {
addDataControlFromField: async (
initialInput: Partial<ControlGroupInput>,
newPanelInput: {
title?: string;
panelId?: string;
fieldName: string;
dataViewId: string;
} & Partial<ControlPanelState>
) => {
const { defaultControlGrow, defaultControlWidth } = getDefaultControlGroupInput();
const controlGrow = initialInput.defaultControlGrow ?? defaultControlGrow;
const controlWidth = initialInput.defaultControlWidth ?? defaultControlWidth;
const { panelId, dataViewId, fieldName, title, grow, width } = newPanelInput;
const newPanelId = panelId || uuid.v4();
const nextOrder = getNextPanelOrder(initialInput);
const controlType = await getCompatibleControlType({ dataViewId, fieldName });
initialInput.panels = {
...initialInput.panels,
[newPanelId]: {
order: nextOrder,
type: controlType,
grow: grow ?? controlGrow,
width: width ?? controlWidth,
explicitInput: { id: newPanelId, dataViewId, fieldName, title: title ?? fieldName },
} as ControlPanelState<DataControlInput>,
};
},
};
export interface ControlGroupRendererProps {
input?: Partial<Pick<ControlGroupInput, 'viewMode' | 'executionContext'>>;
onEmbeddableLoad: (controlGroupContainer: ControlGroupContainer) => void;
getCreationOptions: (
builder: typeof ControlGroupInputBuilder
) => Promise<Partial<ControlGroupInput>>;
}
export const ControlGroupRenderer = ({ input, onEmbeddableLoad }: ControlGroupRendererProps) => {
export const ControlGroupRenderer = ({
onEmbeddableLoad,
getCreationOptions,
}: ControlGroupRendererProps) => {
const controlsRoot = useRef(null);
const [controlGroupContainer, setControlGroupContainer] = useState<ControlGroupContainer>();
const id = useMemo(() => uuid.v4(), []);
/**
* Use Lifecycles to load initial control group container
*/
useLifecycles(
() => {
const { embeddable } = pluginServices.getServices();
(async () => {
const container = (await embeddable
.getEmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
IEmbeddable<ControlGroupInput, ControlGroupOutput>
>(CONTROL_GROUP_TYPE)
?.create({ id, ...getDefaultControlGroupInput(), ...input })) as ControlGroupContainer;
const factory = embeddable.getEmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
IEmbeddable<ControlGroupInput, ControlGroupOutput>
>(CONTROL_GROUP_TYPE);
const container = (await factory?.create({
id,
...getDefaultControlGroupInput(),
...(await getCreationOptions(ControlGroupInputBuilder)),
})) as ControlGroupContainer;
if (controlsRoot.current) {
container.render(controlsRoot.current);
@ -56,29 +102,12 @@ export const ControlGroupRenderer = ({ input, onEmbeddableLoad }: ControlGroupRe
}
);
/**
* Update embeddable input when props input changes
*/
useEffect(() => {
let updateCanceled = false;
(async () => {
// check if applying input from props would result in any changes to the embeddable input
const isInputEqual = await controlGroupContainer?.getExplicitInputIsEqual({
...controlGroupContainer?.getInput(),
...input,
});
if (!controlGroupContainer || isInputEqual || updateCanceled) return;
controlGroupContainer.updateInput({ ...input });
})();
return () => {
updateCanceled = true;
};
}, [controlGroupContainer, input]);
return <div ref={controlsRoot} />;
};
export const useControlGroupContainerContext = () =>
useReduxContainerContext<ControlGroupReduxState, typeof controlGroupReducers>();
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default ControlGroupRenderer;

View file

@ -44,7 +44,7 @@ import { ControlEmbeddable, ControlInput, ControlOutput, DataControlInput } from
import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control';
import { CreateTimeSliderControlButton } from '../editor/create_time_slider_control';
import { TIME_SLIDER_CONTROL } from '../../time_slider';
import { getDataControlFieldRegistry } from '../editor/data_control_editor_tools';
import { getCompatibleControlType, getNextPanelOrder } from './control_group_helpers';
let flyoutRef: OverlayRef | undefined;
export const setFlyoutRef = (newRef: OverlayRef | undefined) => {
@ -87,6 +87,10 @@ export class ControlGroupContainer extends Container<
return this.lastUsedDataViewId ?? this.relevantDataViewId;
};
public getReduxEmbeddableTools = () => {
return this.reduxEmbeddableTools;
};
public closeAllFlyouts() {
flyoutRef?.close();
flyoutRef = undefined;
@ -103,10 +107,7 @@ export class ControlGroupContainer extends Container<
fieldName: string;
title?: string;
}) {
const dataView = await pluginServices.getServices().dataViews.get(dataViewId);
const fieldRegistry = await getDataControlFieldRegistry(dataView);
const field = fieldRegistry[fieldName];
return this.addNewEmbeddable(field.compatibleControlTypes[0], {
return this.addNewEmbeddable(await getCompatibleControlType({ dataViewId, fieldName }), {
id: uuid,
dataViewId,
fieldName,
@ -316,14 +317,7 @@ export class ControlGroupContainer extends Container<
partial: Partial<TEmbeddableInput> = {}
): ControlPanelState<TEmbeddableInput> {
const panelState = super.createNewPanelState(factory, partial);
let nextOrder = 0;
if (Object.keys(this.getInput().panels).length > 0) {
nextOrder =
Object.values(this.getInput().panels).reduce((highestSoFar, panel) => {
if (panel.order > highestSoFar) highestSoFar = panel.order;
return highestSoFar;
}, 0) + 1;
}
const nextOrder = getNextPanelOrder(this.getInput());
return {
order: nextOrder,
width:

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../services';
import { getDataControlFieldRegistry } from '../editor/data_control_editor_tools';
export const getNextPanelOrder = (initialInput: Partial<ControlGroupInput>) => {
let nextOrder = 0;
if (Object.keys(initialInput.panels ?? {}).length > 0) {
nextOrder =
Object.values(initialInput.panels ?? {}).reduce((highestSoFar, panel) => {
if (panel.order > highestSoFar) highestSoFar = panel.order;
return highestSoFar;
}, 0) + 1;
}
return nextOrder;
};
export const getCompatibleControlType = async ({
dataViewId,
fieldName,
}: {
dataViewId: string;
fieldName: string;
}) => {
const dataView = await pluginServices.getServices().dataViews.get(dataViewId);
const fieldRegistry = await getDataControlFieldRegistry(dataView);
const field = fieldRegistry[fieldName];
return field.compatibleControlTypes[0];
};

View file

@ -14,5 +14,8 @@ export type { ControlGroupInput, ControlGroupOutput } from './types';
export { CONTROL_GROUP_TYPE } from './types';
export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory';
export type { ControlGroupRendererProps } from './control_group_renderer';
export {
type ControlGroupRendererProps,
useControlGroupContainerContext,
} from './control_group_renderer';
export const LazyControlGroupRenderer = React.lazy(() => import('./control_group_renderer'));

View file

@ -51,7 +51,11 @@ export {
} from './range_slider';
export { LazyControlsCallout, type CalloutProps } from './controls_callout';
export { LazyControlGroupRenderer, type ControlGroupRendererProps } from './control_group';
export {
LazyControlGroupRenderer,
useControlGroupContainerContext,
type ControlGroupRendererProps,
} from './control_group';
export function plugin() {
return new ControlsPlugin();

View file

@ -734,6 +734,8 @@
"@kbn/ml-string-hash/*": ["x-pack/packages/ml/string_hash/*"],
"@kbn/bfetch-explorer-plugin": ["examples/bfetch_explorer"],
"@kbn/bfetch-explorer-plugin/*": ["examples/bfetch_explorer/*"],
"@kbn/controls-example-plugin": ["examples/controls_example"],
"@kbn/controls-example-plugin/*": ["examples/controls_example/*"],
"@kbn/dashboard-embeddable-examples-plugin": ["examples/dashboard_embeddable_examples"],
"@kbn/dashboard-embeddable-examples-plugin/*": ["examples/dashboard_embeddable_examples/*"],
"@kbn/data-view-field-editor-example-plugin": ["examples/data_view_field_editor_example"],