diff --git a/examples/controls_example/kibana.json b/examples/controls_example/kibana.json new file mode 100644 index 0000000000000..88dd37f41dcfa --- /dev/null +++ b/examples/controls_example/kibana.json @@ -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"] +} diff --git a/examples/controls_example/public/app.tsx b/examples/controls_example/public/app.tsx new file mode 100644 index 0000000000000..831635b8af4da --- /dev/null +++ b/examples/controls_example/public/app.tsx @@ -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 ( + + + + + + + ); +}; + +export const renderApp = async ( + { data }: ControlsExampleStartDeps, + { element }: AppMountParameters +) => { + const dataViews = await data.dataViews.find('kibana_sample_data_ecommerce'); + if (dataViews.length > 0) { + ReactDOM.render(, element); + } + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/controls_example/public/basic_redux_example.tsx b/examples/controls_example/public/basic_redux_example.tsx new file mode 100644 index 0000000000000..bca34e61042f6 --- /dev/null +++ b/examples/controls_example/public/basic_redux_example.tsx @@ -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(); + const [currentControlStyle, setCurrentControlStyle] = useState('oneLine'); + + const ControlGroupReduxWrapper = useMemo(() => { + if (myControlGroup) return myControlGroup.getReduxEmbeddableTools().Wrapper; + }, [myControlGroup]); + + const ButtonControls = () => { + const { + useEmbeddableDispatch, + actions: { setControlStyle }, + } = useControlGroupContainerContext(); + const dispatch = useEmbeddableDispatch(); + + return ( + <> + + + +

Choose a style for your control group:

+
+
+ + { + setCurrentControlStyle(value); + dispatch(setControlStyle(value)); + }} + type="single" + /> + +
+ + + ); + }; + + return ( + <> + +

Basic Redux Example

+
+ +

+ This example uses the redux context from the control group container in order to + dynamically change the style of the control group. +

+
+ + + {ControlGroupReduxWrapper && ( + + + + )} + + { + setControlGroup(controlGroup); + }} + getCreationOptions={async (controlGroupInputBuilder) => { + const initialInput: Partial = { + ...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; + }} + /> + + + ); +}; diff --git a/examples/controls_example/public/control_group_image.png b/examples/controls_example/public/control_group_image.png new file mode 100644 index 0000000000000..82e02d0b4078b Binary files /dev/null and b/examples/controls_example/public/control_group_image.png differ diff --git a/examples/controls_example/public/index.ts b/examples/controls_example/public/index.ts new file mode 100644 index 0000000000000..adb10a93fcec1 --- /dev/null +++ b/examples/controls_example/public/index.ts @@ -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(); +} diff --git a/examples/controls_example/public/plugin.tsx b/examples/controls_example/public/plugin.tsx new file mode 100644 index 0000000000000..6001e9779b526 --- /dev/null +++ b/examples/controls_example/public/plugin.tsx @@ -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 +{ + public setup(core: CoreSetup, { 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() {} +} diff --git a/examples/controls_example/tsconfig.json b/examples/controls_example/tsconfig.json new file mode 100644 index 0000000000000..1e8a32f62734e --- /dev/null +++ b/examples/controls_example/tsconfig.json @@ -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" } + ] +} diff --git a/src/plugins/controls/public/control_group/control_group_renderer.tsx b/src/plugins/controls/public/control_group/control_group_renderer.tsx index a1560a02568c0..57245e23b2a1a 100644 --- a/src/plugins/controls/public/control_group/control_group_renderer.tsx +++ b/src/plugins/controls/public/control_group/control_group_renderer.tsx @@ -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, + newPanelInput: { + title?: string; + panelId?: string; + fieldName: string; + dataViewId: string; + } & Partial + ) => { + 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, + }; + }, +}; export interface ControlGroupRendererProps { - input?: Partial>; onEmbeddableLoad: (controlGroupContainer: ControlGroupContainer) => void; + getCreationOptions: ( + builder: typeof ControlGroupInputBuilder + ) => Promise>; } -export const ControlGroupRenderer = ({ input, onEmbeddableLoad }: ControlGroupRendererProps) => { +export const ControlGroupRenderer = ({ + onEmbeddableLoad, + getCreationOptions, +}: ControlGroupRendererProps) => { const controlsRoot = useRef(null); const [controlGroupContainer, setControlGroupContainer] = useState(); - 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 - >(CONTROL_GROUP_TYPE) - ?.create({ id, ...getDefaultControlGroupInput(), ...input })) as ControlGroupContainer; + const factory = embeddable.getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + IEmbeddable + >(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
; }; +export const useControlGroupContainerContext = () => + useReduxContainerContext(); + // required for dynamic import using React.lazy() // eslint-disable-next-line import/no-default-export export default ControlGroupRenderer; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index dbcf5e7adc1fb..3e95100d95cfe 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -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 = {} ): ControlPanelState { 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: diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts b/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts new file mode 100644 index 0000000000000..817cf9c280155 --- /dev/null +++ b/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts @@ -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) => { + 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]; +}; diff --git a/src/plugins/controls/public/control_group/index.ts b/src/plugins/controls/public/control_group/index.ts index ded1c29934d6e..b55d63134439b 100644 --- a/src/plugins/controls/public/control_group/index.ts +++ b/src/plugins/controls/public/control_group/index.ts @@ -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')); diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts index 41eebbfd22ccf..2b6732335472a 100644 --- a/src/plugins/controls/public/index.ts +++ b/src/plugins/controls/public/index.ts @@ -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(); diff --git a/tsconfig.base.json b/tsconfig.base.json index 7b0a8e6781baf..8e27004935ea4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -724,6 +724,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"],