diff --git a/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts index 6104fcfdbe949..c04f2623d6d55 100644 --- a/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts +++ b/src/plugins/dashboard/common/embeddable/dashboard_container_persistable_state.ts @@ -12,10 +12,17 @@ import { EmbeddableStateWithType, } from '../../../embeddable/common'; import { SavedObjectReference } from '../../../../core/types'; -import { DashboardContainerStateWithType, DashboardPanelState } from '../types'; +import { + DashboardContainerControlGroupInput, + DashboardContainerStateWithType, + DashboardPanelState, +} from '../types'; +import { CONTROL_GROUP_TYPE } from '../../../presentation_util/common/lib'; const getPanelStatePrefix = (state: DashboardPanelState) => `${state.explicitInput.id}:`; +const controlGroupReferencePrefix = 'controlGroup_'; + export const createInject = ( persistableStateService: EmbeddablePersistableStateService ): EmbeddablePersistableStateService['inject'] => { @@ -69,6 +76,26 @@ export const createInject = ( } } + // since the controlGroup is not part of the panels array, its references need to be injected separately + if ('controlGroupInput' in workingState && workingState.controlGroupInput) { + const controlGroupReferences = references + .filter((reference) => reference.name.indexOf(controlGroupReferencePrefix) === 0) + .map((reference) => ({ + ...reference, + name: reference.name.replace(controlGroupReferencePrefix, ''), + })); + + const { type, ...injectedControlGroupState } = persistableStateService.inject( + { + ...workingState.controlGroupInput, + type: CONTROL_GROUP_TYPE, + }, + controlGroupReferences + ); + workingState.controlGroupInput = + injectedControlGroupState as DashboardContainerControlGroupInput; + } + return workingState as EmbeddableStateWithType; }; }; @@ -120,6 +147,22 @@ export const createExtract = ( } } + // since the controlGroup is not part of the panels array, its references need to be extracted separately + if ('controlGroupInput' in workingState && workingState.controlGroupInput) { + const { state: extractedControlGroupState, references: controlGroupReferences } = + persistableStateService.extract({ + ...workingState.controlGroupInput, + type: CONTROL_GROUP_TYPE, + }); + workingState.controlGroupInput = + extractedControlGroupState as DashboardContainerControlGroupInput; + const prefixedControlGroupReferences = controlGroupReferences.map((reference) => ({ + ...reference, + name: `${controlGroupReferencePrefix}${reference.name}`, + })); + references.push(...prefixedControlGroupReferences); + } + return { state: workingState as EmbeddableStateWithType, references }; }; }; diff --git a/src/plugins/dashboard/common/saved_dashboard_references.ts b/src/plugins/dashboard/common/saved_dashboard_references.ts index 4b3a379068c48..bc7358b49ceb4 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.ts @@ -7,13 +7,20 @@ */ import semverGt from 'semver/functions/gt'; import { SavedObjectAttributes, SavedObjectReference } from '../../../core/types'; -import { DashboardContainerStateWithType, DashboardPanelState } from './types'; +import { + DashboardContainerControlGroupInput, + DashboardContainerStateWithType, + DashboardPanelState, + RawControlGroupAttributes, +} from './types'; import { EmbeddablePersistableStateService } from '../../embeddable/common/types'; import { convertPanelStateToSavedDashboardPanel, convertSavedDashboardPanelToPanelState, } from './embeddable/embeddable_saved_object_converters'; import { SavedDashboardPanel } from './types'; +import { CONTROL_GROUP_TYPE } from '../../presentation_util/common/lib'; + export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; } @@ -35,10 +42,27 @@ function dashboardAttributesToState(attributes: SavedObjectAttributes): { inputPanels = JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[]; } + let controlGroupInput: DashboardContainerControlGroupInput | undefined; + if (attributes.controlGroupInput) { + const rawControlGroupInput = + attributes.controlGroupInput as unknown as RawControlGroupAttributes; + if (rawControlGroupInput.panelsJSON && typeof rawControlGroupInput.panelsJSON === 'string') { + const controlGroupPanels = JSON.parse(rawControlGroupInput.panelsJSON); + if (controlGroupPanels && typeof controlGroupPanels === 'object') { + controlGroupInput = { + ...rawControlGroupInput, + type: CONTROL_GROUP_TYPE, + panels: controlGroupPanels, + }; + } + } + } + return { panels: inputPanels, state: { id: attributes.id as string, + controlGroupInput, type: 'dashboard', panels: inputPanels.reduce>((current, panel, index) => { const panelIndex = panel.panelIndex || `${index}`; @@ -92,20 +116,27 @@ export function extractReferences( throw new Error(`"type" attribute is missing from panel "${missingTypeIndex}"`); } - const { state: extractedState, references: extractedReferences } = + const { references: extractedReferences, state: rawExtractedState } = deps.embeddablePersistableStateService.extract(state); + const extractedState = rawExtractedState as DashboardContainerStateWithType; + + const extractedPanels = panelStatesToPanels(extractedState.panels, panels); - const extractedPanels = panelStatesToPanels( - (extractedState as DashboardContainerStateWithType).panels, - panels - ); + const newAttributes = { + ...attributes, + panelsJSON: JSON.stringify(extractedPanels), + } as SavedObjectAttributes; + + if (extractedState.controlGroupInput) { + newAttributes.controlGroupInput = { + ...(attributes.controlGroupInput as SavedObjectAttributes), + panelsJSON: JSON.stringify(extractedState.controlGroupInput.panels), + }; + } return { references: [...references, ...extractedReferences], - attributes: { - ...attributes, - panelsJSON: JSON.stringify(extractedPanels), - }, + attributes: newAttributes, }; } @@ -131,16 +162,25 @@ export function injectReferences( const { panels, state } = dashboardAttributesToState(attributes); - const injectedState = deps.embeddablePersistableStateService.inject(state, references); - const injectedPanels = panelStatesToPanels( - (injectedState as DashboardContainerStateWithType).panels, - panels - ); + const injectedState = deps.embeddablePersistableStateService.inject( + state, + references + ) as DashboardContainerStateWithType; + const injectedPanels = panelStatesToPanels(injectedState.panels, panels); - return { + const newAttributes = { ...attributes, panelsJSON: JSON.stringify(injectedPanels), - }; + } as SavedObjectAttributes; + + if (injectedState.controlGroupInput) { + newAttributes.controlGroupInput = { + ...(attributes.controlGroupInput as SavedObjectAttributes), + panelsJSON: JSON.stringify(injectedState.controlGroupInput.panels), + }; + } + + return newAttributes; } function pre730ExtractReferences( diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 5851ffa045bc7..bfe53514969d7 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -22,6 +22,7 @@ import { } from './bwc/types'; import { GridData } from './embeddable/types'; +import { ControlGroupInput } from '../../presentation_util/common/controls/control_group/types'; export type PanelId = string; export type SavedObjectId = string; @@ -96,8 +97,22 @@ export type SavedDashboardPanel730ToLatest = Pick< // Making this interface because so much of the Container type from embeddable is tied up in public // Once that is all available from common, we should be able to move the dashboard_container type to our common as well + +export interface DashboardContainerControlGroupInput extends EmbeddableStateWithType { + panels: ControlGroupInput['panels']; + controlStyle: ControlGroupInput['controlStyle']; + id: string; +} + +export interface RawControlGroupAttributes { + controlStyle: ControlGroupInput['controlStyle']; + panelsJSON: string; + id: string; +} + export interface DashboardContainerStateWithType extends EmbeddableStateWithType { panels: { [panelId: string]: DashboardPanelState; }; + controlGroupInput?: DashboardContainerControlGroupInput; } diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx index 0f7acfbb3f5f6..fa484de2180b4 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -7,7 +7,7 @@ */ import { AddToLibraryAction } from '.'; -import { DashboardContainer } from '../embeddable'; +import { DashboardContainer } from '../embeddable/dashboard_container'; import { getSampleDashboardInput } from '../test_helpers'; import { CoreStart } from 'kibana/public'; diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx index 08e115ffca908..99665d312d32e 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { DashboardContainer, DashboardPanelState } from '../embeddable'; +import { DashboardPanelState } from '../embeddable'; +import { DashboardContainer } from '../embeddable/dashboard_container'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx index 48bb787116862..0635152332993 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx @@ -7,7 +7,7 @@ */ import { ExpandPanelAction } from './expand_panel_action'; -import { DashboardContainer } from '../embeddable'; +import { DashboardContainer } from '../embeddable/dashboard_container'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; diff --git a/src/plugins/dashboard/public/application/actions/export_csv_action.test.tsx b/src/plugins/dashboard/public/application/actions/export_csv_action.test.tsx index 3d68f720d1eb3..51c64f1875376 100644 --- a/src/plugins/dashboard/public/application/actions/export_csv_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/export_csv_action.test.tsx @@ -9,7 +9,7 @@ import { CoreStart } from 'kibana/public'; import { isErrorEmbeddable, IContainer, ErrorEmbeddable } from '../../services/embeddable'; -import { DashboardContainer } from '../../application/embeddable'; +import { DashboardContainer } from '../../application/embeddable/dashboard_container'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../../application/test_helpers'; import { ContactCardEmbeddable, diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx index 95e12918bb8e9..587f741461bb4 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput } from '../test_helpers'; +import { DashboardContainer } from '../embeddable/dashboard_container'; import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; import { CoreStart } from 'kibana/public'; diff --git a/src/plugins/dashboard/public/application/actions/library_notification_popover.test.tsx b/src/plugins/dashboard/public/application/actions/library_notification_popover.test.tsx index fab640694cb64..b5efa0447e651 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_popover.test.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_popover.test.tsx @@ -7,8 +7,9 @@ */ import React from 'react'; -import { DashboardContainer } from '..'; import { mountWithIntl } from '@kbn/test/jest'; + +import { DashboardContainer } from '../embeddable/dashboard_container'; import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; import { getSampleDashboardInput } from '../test_helpers'; import { diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx index c8fe39f63fa23..f8880ac5618fc 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx @@ -7,7 +7,7 @@ */ import { ReplacePanelAction } from './replace_panel_action'; -import { DashboardContainer } from '../embeddable'; +import { DashboardContainer } from '../embeddable/dashboard_container'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index daa21b034f7c2..7d87c49bda649 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -17,8 +17,8 @@ import { SavedObjectEmbeddableInput, } from '../../services/embeddable'; import { UnlinkFromLibraryAction } from '.'; -import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput } from '../test_helpers'; +import { DashboardContainer } from '../embeddable/dashboard_container'; import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 86f81aa1ee10d..f86307d71fb18 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -25,6 +25,8 @@ import { EmbeddableStart, EmbeddableOutput, EmbeddableFactory, + ErrorEmbeddable, + isErrorEmbeddable, } from '../../services/embeddable'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; import { createPanelState } from './panel'; @@ -39,6 +41,11 @@ import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { DashboardAppCapabilities, DashboardContainerInput } from '../../types'; import { PresentationUtilPluginStart } from '../../services/presentation_util'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; +import { + combineDashboardFiltersWithControlGroupFilters, + syncDashboardControlGroup, +} from '../lib/dashboard_control_group'; +import { ControlGroupContainer } from '../../../../presentation_util/public'; export interface DashboardContainerServices { ExitFullScreenButton: React.ComponentType; @@ -88,6 +95,9 @@ const defaultCapabilities: DashboardAppCapabilities = { export class DashboardContainer extends Container { public readonly type = DASHBOARD_CONTAINER_TYPE; + private onDestroyControlGroup?: () => void; + public controlGroup?: ControlGroupContainer; + public getPanelCount = () => { return Object.keys(this.getInput().panels).length; }; @@ -95,7 +105,8 @@ export class DashboardContainer extends Container { + if (!result) return; + const { onDestroyControlGroup } = result; + this.onDestroyControlGroup = onDestroyControlGroup; + } + ); + } } protected createNewPanelState< @@ -232,7 +258,7 @@ export class DashboardContainer extends Container - + , @@ -240,6 +266,11 @@ export class DashboardContainer extends Container => { const services = await this.getStartServices(); - return new DashboardContainer(initialInput, services, parent); + const controlsGroupFactory = services.embeddable.getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + ControlGroupContainer + >(CONTROL_GROUP_TYPE); + const controlGroup = await controlsGroupFactory?.create({ + ...getDefaultDashboardControlGroupInput(), + ...(initialInput.controlGroupInput ?? {}), + viewMode: initialInput.viewMode, + id: `control_group_${initialInput.id ?? 'new_dashboard'}`, + }); + const { DashboardContainer: DashboardContainerEmbeddable } = await import( + './dashboard_container' + ); + + return new DashboardContainerEmbeddable(initialInput, services, parent, controlGroup); }; public inject = createInject(this.persistableStateService); diff --git a/src/plugins/dashboard/public/application/embeddable/index.ts b/src/plugins/dashboard/public/application/embeddable/index.ts index a678dbea16a55..b3ee0f83ee852 100644 --- a/src/plugins/dashboard/public/application/embeddable/index.ts +++ b/src/plugins/dashboard/public/application/embeddable/index.ts @@ -10,7 +10,7 @@ export { DashboardContainerFactoryDefinition, DashboardContainerFactory, } from './dashboard_container_factory'; -export { DashboardContainer } from './dashboard_container'; +export type { DashboardContainer } from './dashboard_container'; export { createPanelState } from './panel'; export * from './types'; diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss b/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss index bb95840676969..f71868b059159 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss +++ b/src/plugins/dashboard/public/application/embeddable/viewport/_dashboard_viewport.scss @@ -5,3 +5,11 @@ .dshDashboardViewport-withMargins { width: 100%; } + +.dshDashboardViewport-controlGroup { + margin: 0 $euiSizeS 0 $euiSizeS; +} + +.dshDashboardEmptyScreen { + margin-top: $euiSizeS; +} diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx index e401721d48442..1f4cd3952e7a5 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx @@ -13,13 +13,16 @@ import { DashboardContainer, DashboardReactContextValue } from '../dashboard_con import { DashboardGrid } from '../grid'; import { context } from '../../../services/kibana_react'; import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; +import { ControlGroupContainer } from '../../../../../presentation_util/public'; export interface DashboardViewportProps { container: DashboardContainer; + controlGroup?: ControlGroupContainer; } interface State { isFullScreenMode: boolean; + controlGroupReady: boolean; useMargins: boolean; title: string; description?: string; @@ -29,8 +32,10 @@ interface State { export class DashboardViewport extends React.Component { static contextType = context; - public readonly context!: DashboardReactContextValue; + + private controlsRoot: React.RefObject; + private subscription?: Subscription; private mounted: boolean = false; constructor(props: DashboardViewportProps) { @@ -38,7 +43,10 @@ export class DashboardViewport extends React.Component this.setState({ controlGroupReady: true })); + } } public componentWillUnmount() { @@ -83,7 +97,8 @@ export class DashboardViewport extends React.Component + <> +
)} - + {this.state.controlGroupReady && }
- + ); } } diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx index 3237eb106e4ec..5561d1676e41c 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx @@ -12,13 +12,13 @@ import { Provider } from 'react-redux'; import { createBrowserHistory } from 'history'; import { renderHook, act, RenderHookResult } from '@testing-library/react-hooks'; -import { DashboardContainer } from '..'; import { DashboardSessionStorage } from '../lib'; import { coreMock } from '../../../../../core/public/mocks'; import { DashboardConstants } from '../../dashboard_constants'; import { dataPluginMock } from '../../../../data/public/mocks'; import { SavedObjectLoader } from '../../services/saved_objects'; import { DashboardAppServices, DashboardAppState } from '../../types'; +import { DashboardContainer } from '../embeddable/dashboard_container'; import { KibanaContextProvider } from '../../../../kibana_react/public'; import { EmbeddableFactory, ViewMode } from '../../services/embeddable'; import { dashboardStateStore, setDescription, setViewMode } from '../state'; diff --git a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts index 0bd49cccbe5ef..8d55af5808da6 100644 --- a/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts @@ -20,6 +20,8 @@ import { DashboardBuildContext, } from '../../types'; import { convertSavedPanelsToPanelMap } from './convert_dashboard_panels'; +import { deserializeControlGroupFromDashboardSavedObject } from './dashboard_control_group'; +import { ControlGroupInput } from '../../../../presentation_util/public'; interface SavedObjectToDashboardStateProps { version: string; @@ -73,6 +75,9 @@ export const savedObjectToDashboardState = ({ usageCollection ); + rawState.controlGroupInput = deserializeControlGroupFromDashboardSavedObject( + savedDashboard + ) as ControlGroupInput; return { ...rawState, panels: convertSavedPanelsToPanelMap(rawState.panels) }; }; @@ -91,8 +96,17 @@ export const stateToDashboardContainerInput = ({ const { filterManager, timefilter: timefilterService } = queryService; const { timefilter } = timefilterService; - const { expandedPanelId, fullScreenMode, description, options, viewMode, panels, query, title } = - dashboardState; + const { + controlGroupInput, + expandedPanelId, + fullScreenMode, + description, + options, + viewMode, + panels, + query, + title, + } = dashboardState; return { refreshConfig: timefilter.getRefreshInterval(), @@ -102,6 +116,7 @@ export const stateToDashboardContainerInput = ({ dashboardCapabilities, isEmbeddedExternally, ...(options || {}), + controlGroupInput, searchSessionId, expandedPanelId, description, diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts new file mode 100644 index 0000000000000..aaf6c5f0af4fc --- /dev/null +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -0,0 +1,214 @@ +/* + * 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 { Subscription } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; +import { compareFilters, COMPARE_ALL_OPTIONS, Filter } from '@kbn/es-query'; +import { distinctUntilChanged, distinctUntilKeyChanged } from 'rxjs/operators'; + +import { DashboardContainer } from '..'; +import { DashboardState } from '../../types'; +import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants'; +import { DashboardContainerInput, DashboardSavedObject } from '../..'; +import { ControlGroupContainer, ControlGroupInput } from '../../../../presentation_util/public'; + +// only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard. +export interface DashboardControlGroupInput { + panels: ControlGroupInput['panels']; + controlStyle: ControlGroupInput['controlStyle']; +} + +interface DiffChecks { + [key: string]: (a?: unknown, b?: unknown) => boolean; +} + +const distinctUntilDiffCheck = (a: T, b: T, diffChecks: DiffChecks) => + !(Object.keys(diffChecks) as Array) + .map((key) => deepEqual(a[key], b[key])) + .includes(false); + +type DashboardControlGroupCommonKeys = keyof Pick< + DashboardContainerInput | ControlGroupInput, + 'filters' | 'lastReloadRequestTime' | 'timeRange' | 'query' +>; + +export const syncDashboardControlGroup = async ({ + controlGroup, + dashboardContainer, +}: { + controlGroup: ControlGroupContainer; + dashboardContainer: DashboardContainer; +}) => { + const subscriptions = new Subscription(); + + const isControlGroupInputEqual = () => + controlGroupInputIsEqual( + controlGroup.getInput(), + dashboardContainer.getInput().controlGroupInput + ); + + // Because dashboard container stores control group state, certain control group changes need to be passed up dashboard container + const controlGroupDiff: DiffChecks = { + panels: deepEqual, + controlStyle: deepEqual, + }; + + subscriptions.add( + controlGroup + .getInput$() + .pipe( + distinctUntilChanged((a, b) => + distinctUntilDiffCheck(a, b, controlGroupDiff) + ) + ) + .subscribe(() => { + const { panels, controlStyle } = controlGroup.getInput(); + if (!isControlGroupInputEqual()) { + dashboardContainer.updateInput({ controlGroupInput: { panels, controlStyle } }); + } + }) + ); + + const dashboardRefetchDiff: DiffChecks = { + filters: (a, b) => + compareFilters((a as Filter[]) ?? [], (b as Filter[]) ?? [], COMPARE_ALL_OPTIONS), + lastReloadRequestTime: deepEqual, + timeRange: deepEqual, + query: deepEqual, + viewMode: deepEqual, + }; + + // pass down any pieces of input needed to refetch or force refetch data for the controls + subscriptions.add( + dashboardContainer + .getInput$() + .pipe( + distinctUntilChanged((a, b) => + distinctUntilDiffCheck(a, b, dashboardRefetchDiff) + ) + ) + .subscribe(() => { + const newInput: { [key: string]: unknown } = {}; + (Object.keys(dashboardRefetchDiff) as DashboardControlGroupCommonKeys[]).forEach((key) => { + if ( + !dashboardRefetchDiff[key]?.( + dashboardContainer.getInput()[key], + controlGroup.getInput()[key] + ) + ) { + newInput[key] = dashboardContainer.getInput()[key]; + } + }); + if (Object.keys(newInput).length > 0) { + controlGroup.updateInput(newInput); + } + }) + ); + + // dashboard may reset the control group input when discarding changes. Subscribe to these changes and update accordingly + subscriptions.add( + dashboardContainer + .getInput$() + .pipe(distinctUntilKeyChanged('controlGroupInput')) + .subscribe(() => { + if (!isControlGroupInputEqual()) { + if (!dashboardContainer.getInput().controlGroupInput) { + controlGroup.updateInput(getDefaultDashboardControlGroupInput()); + return; + } + controlGroup.updateInput({ ...dashboardContainer.getInput().controlGroupInput }); + } + }) + ); + + // when control group outputs filters, force a refresh! + subscriptions.add( + controlGroup + .getOutput$() + .subscribe(() => dashboardContainer.updateInput({ lastReloadRequestTime: Date.now() })) + ); + + return { + onDestroyControlGroup: () => { + subscriptions.unsubscribe(); + controlGroup.destroy(); + }, + }; +}; + +export const controlGroupInputIsEqual = ( + a: DashboardControlGroupInput | undefined, + b: DashboardControlGroupInput | undefined +) => { + const defaultInput = getDefaultDashboardControlGroupInput(); + const inputA = { + panels: a?.panels ?? defaultInput.panels, + controlStyle: a?.controlStyle ?? defaultInput.controlStyle, + }; + const inputB = { + panels: b?.panels ?? defaultInput.panels, + controlStyle: b?.controlStyle ?? defaultInput.controlStyle, + }; + if (deepEqual(inputA, inputB)) return true; + return false; +}; + +export const serializeControlGroupToDashboardSavedObject = ( + dashboardSavedObject: DashboardSavedObject, + dashboardState: DashboardState +) => { + // only save to saved object if control group is not default + if (controlGroupInputIsEqual(dashboardState.controlGroupInput, {} as ControlGroupInput)) { + dashboardSavedObject.controlGroupInput = undefined; + return; + } + if (dashboardState.controlGroupInput) { + dashboardSavedObject.controlGroupInput = { + controlStyle: dashboardState.controlGroupInput.controlStyle, + panelsJSON: JSON.stringify(dashboardState.controlGroupInput.panels), + }; + } +}; + +export const deserializeControlGroupFromDashboardSavedObject = ( + dashboardSavedObject: DashboardSavedObject +): Omit | undefined => { + if (!dashboardSavedObject.controlGroupInput) return; + + const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + return { + controlStyle: + dashboardSavedObject.controlGroupInput?.controlStyle ?? defaultControlGroupInput.controlStyle, + panels: dashboardSavedObject.controlGroupInput?.panelsJSON + ? JSON.parse(dashboardSavedObject.controlGroupInput?.panelsJSON) + : {}, + }; +}; + +export const combineDashboardFiltersWithControlGroupFilters = ( + dashboardFilters: Filter[], + controlGroup: ControlGroupContainer +) => { + const dashboardFiltersByKey = dashboardFilters.reduce( + (acc: { [key: string]: Filter }, current) => { + const key = current.meta.key; + if (key) acc[key] = current; + return acc; + }, + {} + ); + const controlGroupFiltersByKey = controlGroup + .getOutput() + .filters?.reduce((acc: { [key: string]: Filter }, current) => { + const key = current.meta.key; + if (key) acc[key] = current; + return acc; + }, {}); + const finalFilters = { ...dashboardFiltersByKey, ...(controlGroupFiltersByKey ?? {}) }; + return Object.values(finalFilters); +}; diff --git a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts index e718c98cb3626..2e89ee70d057d 100644 --- a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts @@ -15,6 +15,7 @@ import { DashboardPanelMap, DashboardState, } from '../../types'; +import { controlGroupInputIsEqual } from './dashboard_control_group'; interface DashboardDiffCommon { [key: string]: unknown; @@ -40,7 +41,7 @@ export const diffDashboardState = ( const common = commonDiffFilters( original as unknown as DashboardDiffCommonFilters, newState as unknown as DashboardDiffCommonFilters, - ['viewMode', 'panels', 'options', 'savedQuery', 'expandedPanelId'], + ['viewMode', 'panels', 'options', 'savedQuery', 'expandedPanelId', 'controlGroupInput'], true ); @@ -48,6 +49,9 @@ export const diffDashboardState = ( ...common, ...(panelsAreEqual(original.panels, newState.panels) ? {} : { panels: newState.panels }), ...(optionsAreEqual(original.options, newState.options) ? {} : { options: newState.options }), + ...(controlGroupInputIsEqual(original.controlGroupInput, newState.controlGroupInput) + ? {} + : { controlGroupInput: newState.controlGroupInput }), }; }; diff --git a/src/plugins/dashboard/public/application/lib/save_dashboard.ts b/src/plugins/dashboard/public/application/lib/save_dashboard.ts index 960d7d9cc8687..5a699eb116401 100644 --- a/src/plugins/dashboard/public/application/lib/save_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/save_dashboard.ts @@ -19,6 +19,7 @@ import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss import { RefreshInterval, TimefilterContract, esFilters } from '../../services/data'; import { convertPanelStateToSavedDashboardPanel } from '../../../common/embeddable/embeddable_saved_object_converters'; import { DashboardSessionStorage } from './dashboard_session_storage'; +import { serializeControlGroupToDashboardSavedObject } from './dashboard_control_group'; export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { stayInEditMode?: boolean }; @@ -60,6 +61,9 @@ export const saveDashboard = async ({ savedDashboard.optionsJSON = JSON.stringify(options); savedDashboard.panelsJSON = JSON.stringify(savedDashboardPanels); + // control group input + serializeControlGroupToDashboardSavedObject(savedDashboard, currentState); + if (hasTaggingCapabilities(savedDashboard)) { savedDashboard.setTags(tags); } diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts index 6d06863d02179..0fa7487390cd8 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_container_input.ts @@ -13,7 +13,13 @@ import { debounceTime, tap } from 'rxjs/operators'; import { DashboardContainer } from '../embeddable'; import { esFilters, Filter, Query } from '../../services/data'; import { DashboardConstants, DashboardSavedObject } from '../..'; -import { setExpandedPanelId, setFullScreenMode, setPanels, setQuery } from '../state'; +import { + setControlGroupState, + setExpandedPanelId, + setFullScreenMode, + setPanels, + setQuery, +} from '../state'; import { diffDashboardContainerInput } from './diff_dashboard_state'; import { replaceUrlHashQuery } from '../../../../kibana_utils/public'; import { DashboardBuildContext, DashboardContainerInput } from '../../types'; @@ -113,6 +119,10 @@ export const applyContainerChangesToState = ({ if (!_.isEqual(input.expandedPanelId, latestState.expandedPanelId)) { dispatchDashboardStateChange(setExpandedPanelId(input.expandedPanelId)); } + + if (!_.isEqual(input.controlGroupInput, latestState.controlGroupInput)) { + dispatchDashboardStateChange(setControlGroupState(input.controlGroupInput)); + } dispatchDashboardStateChange(setFullScreenMode(input.isFullScreenMode)); }; diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts index 3a1d60696331a..5460ef7b00037 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_index_patterns.ts @@ -8,7 +8,7 @@ import { uniqBy } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { Observable, pipe } from 'rxjs'; +import { Observable, pipe, combineLatest } from 'rxjs'; import { distinctUntilChanged, switchMap, filter, mapTo, map } from 'rxjs/operators'; import { DashboardContainer } from '..'; @@ -30,6 +30,7 @@ export const syncDashboardIndexPatterns = ({ filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), map((container: DashboardContainer): IndexPattern[] | undefined => { let panelIndexPatterns: IndexPattern[] = []; + Object.values(container.getChildIds()).forEach((id) => { const embeddableInstance = container.getChild(id); if (isErrorEmbeddable(embeddableInstance)) return; @@ -37,6 +38,9 @@ export const syncDashboardIndexPatterns = ({ if (!embeddableIndexPatterns) return; panelIndexPatterns.push(...embeddableIndexPatterns); }); + if (container.controlGroup) { + panelIndexPatterns.push(...(container.controlGroup.getOutput().dataViews ?? [])); + } panelIndexPatterns = uniqBy(panelIndexPatterns, 'id'); /** @@ -77,8 +81,11 @@ export const syncDashboardIndexPatterns = ({ }) ); - return dashboardContainer - .getOutput$() + const indexPatternSources = [dashboardContainer.getOutput$()]; + if (dashboardContainer.controlGroup) + indexPatternSources.push(dashboardContainer.controlGroup.getOutput$()); + + return combineLatest(indexPatternSources) .pipe(mapTo(dashboardContainer), updateIndexPatternsOperator) .subscribe(); }; diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts index 1acf806ae2f0d..5604dfaa875e1 100644 --- a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts +++ b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts @@ -10,6 +10,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Filter, Query } from '../../services/data'; import { ViewMode } from '../../services/embeddable'; +import type { DashboardControlGroupInput } from '../lib/dashboard_control_group'; import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types'; export const dashboardStateSlice = createSlice({ @@ -41,6 +42,12 @@ export const dashboardStateSlice = createSlice({ state.tags = action.payload.tags; } }, + setControlGroupState: ( + state, + action: PayloadAction + ) => { + state.controlGroupInput = action.payload; + }, setUseMargins: (state, action: PayloadAction) => { state.options.useMargins = action.payload; }, @@ -92,6 +99,7 @@ export const dashboardStateSlice = createSlice({ export const { setStateFromSaveModal, + setControlGroupState, setDashboardOptions, setExpandedPanelId, setHidePanelTitles, diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 7d5123ac27cb6..409d80e2ef066 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { ControlStyle } from '../../presentation_util/public'; + export const DASHBOARD_STATE_STORAGE_KEY = '_a'; export const DashboardConstants = { @@ -21,6 +23,11 @@ export const DashboardConstants = { CHANGE_APPLY_DEBOUNCE: 50, }; +export const getDefaultDashboardControlGroupInput = () => ({ + controlStyle: 'oneLine' as ControlStyle, + panels: {}, +}); + export function createDashboardEditUrl(id?: string, editMode?: boolean) { if (!id) { return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`; diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 4afb42aa841bb..d8e8b70fc1340 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -18,6 +18,8 @@ import { extractReferences, injectReferences } from '../../common/saved_dashboar import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; import { DashboardOptions } from '../types'; +import { ControlStyle } from '../../../presentation_util/public'; + export interface DashboardSavedObject extends SavedObject { id?: string; timeRestore: boolean; @@ -36,6 +38,8 @@ export interface DashboardSavedObject extends SavedObject { getFullEditPath: (editMode?: boolean) => string; outcome?: string; aliasId?: string; + + controlGroupInput?: { controlStyle?: ControlStyle; panelsJSON?: string }; } const defaults = { @@ -86,6 +90,13 @@ export function createSavedDashboardClass( value: { type: 'integer' }, }, }, + controlGroupInput: { + type: 'object', + properties: { + controlStyle: { type: 'keyword' }, + panelsJSON: { type: 'text' }, + }, + }, }; public static fieldOrder = ['title', 'description']; public static searchSource = true; diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 651a51834a794..c4cc00838a343 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -33,10 +33,11 @@ import { SavedObjectsTaggingApi } from './services/saved_objects_tagging_oss'; import { DataPublicPluginStart, IndexPatternsContract } from './services/data'; import { SavedObjectLoader, SavedObjectsStart } from './services/saved_objects'; import { IKbnUrlStateStorage } from './services/kibana_utils'; -import { DashboardContainer, DashboardSavedObject } from '.'; +import type { DashboardContainer, DashboardSavedObject } from '.'; import { VisualizationsStart } from '../../visualizations/public'; import { DashboardAppLocatorParams } from './locator'; import { SpacesPluginStart } from './services/spaces'; +import type { DashboardControlGroupInput } from './application/lib/dashboard_control_group'; export { SavedDashboardPanel }; @@ -65,6 +66,8 @@ export interface DashboardState { expandedPanelId?: string; options: DashboardOptions; panels: DashboardPanelMap; + + controlGroupInput?: DashboardControlGroupInput; } /** @@ -74,6 +77,7 @@ export type RawDashboardState = Omit & { panels: Saved export interface DashboardContainerInput extends ContainerInput { dashboardCapabilities?: DashboardAppCapabilities; + controlGroupInput?: DashboardControlGroupInput; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index 944ceda3b33b3..2ddbcfd9fdb74 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -52,6 +52,12 @@ export const createDashboardSavedObjectType = ({ value: { type: 'integer', index: false, doc_values: false }, }, }, + controlGroupInput: { + properties: { + controlStyle: { type: 'keyword', index: false, doc_values: false }, + panelsJSON: { type: 'text', index: false }, + }, + }, timeFrom: { type: 'keyword', index: false, doc_values: false }, timeRestore: { type: 'boolean', index: false, doc_values: false }, timeTo: { type: 'keyword', index: false, doc_values: false }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index d0e85d23d04cb..007d3a99cb1dd 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -452,6 +452,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'labs:dashboard:dashboardControls': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'discover:showFieldStatistics': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 34394602067bc..d35a05fe04780 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -125,4 +125,5 @@ export interface UsageStats { 'labs:presentation:timeToPresent': boolean; 'labs:dashboard:enable_ui': boolean; 'labs:dashboard:deferBelowFold': boolean; + 'labs:dashboard:dashboardControls': boolean; } diff --git a/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts b/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts new file mode 100644 index 0000000000000..2da488acdc436 --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_group/control_group_persistable_state.ts @@ -0,0 +1,85 @@ +/* + * 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 { + EmbeddableInput, + EmbeddablePersistableStateService, + EmbeddableStateWithType, +} from '../../../../embeddable/common/types'; +import { ControlGroupInput, ControlPanelState } from './types'; +import { SavedObjectReference } from '../../../../../core/types'; + +type ControlGroupInputWithType = Partial & { type: string }; + +const getPanelStatePrefix = (state: ControlPanelState) => `${state.explicitInput.id}:`; + +export const createControlGroupInject = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddablePersistableStateService['inject'] => { + return (state: EmbeddableStateWithType, references: SavedObjectReference[]) => { + const workingState = { ...state } as EmbeddableStateWithType | ControlGroupInputWithType; + + if ('panels' in workingState) { + workingState.panels = { ...workingState.panels }; + + for (const [key, panel] of Object.entries(workingState.panels)) { + workingState.panels[key] = { ...panel }; + // Find the references for this panel + const prefix = getPanelStatePrefix(panel); + + const filteredReferences = references + .filter((reference) => reference.name.indexOf(prefix) === 0) + .map((reference) => ({ ...reference, name: reference.name.replace(prefix, '') })); + + const panelReferences = filteredReferences.length === 0 ? references : filteredReferences; + + const { type, ...injectedState } = persistableStateService.inject( + { ...workingState.panels[key].explicitInput, type: workingState.panels[key].type }, + panelReferences + ); + workingState.panels[key].explicitInput = injectedState as EmbeddableInput; + } + } + return workingState as EmbeddableStateWithType; + }; +}; + +export const createControlGroupExtract = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddablePersistableStateService['extract'] => { + return (state: EmbeddableStateWithType) => { + const workingState = { ...state } as EmbeddableStateWithType | ControlGroupInputWithType; + const references: SavedObjectReference[] = []; + + if ('panels' in workingState) { + workingState.panels = { ...workingState.panels }; + + // Run every panel through the state service to get the nested references + for (const [key, panel] of Object.entries(workingState.panels)) { + const prefix = getPanelStatePrefix(panel); + + const { state: panelState, references: panelReferences } = persistableStateService.extract({ + ...panel.explicitInput, + type: panel.type, + }); + + // Map reference to its embeddable id for lookup in inject + const mappedReferences = panelReferences.map((reference) => ({ + ...reference, + name: `${prefix}${reference.name}`, + })); + + references.push(...mappedReferences); + + const { type, ...restOfState } = panelState; + workingState.panels[key].explicitInput = restOfState as EmbeddableInput; + } + } + return { state: workingState as EmbeddableStateWithType, references }; + }; +}; diff --git a/src/plugins/presentation_util/common/controls/control_group/types.ts b/src/plugins/presentation_util/common/controls/control_group/types.ts new file mode 100644 index 0000000000000..da1cec0391102 --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_group/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { EmbeddableInput, PanelState } from '../../../../embeddable/common/types'; +import { ControlInput, ControlStyle, ControlWidth } from '../types'; + +export const CONTROL_GROUP_TYPE = 'control_group'; + +export interface ControlPanelState + extends PanelState { + order: number; + width: ControlWidth; +} + +export interface ControlsPanels { + [panelId: string]: ControlPanelState; +} + +export interface ControlGroupInput extends EmbeddableInput, ControlInput { + defaultControlWidth?: ControlWidth; + controlStyle: ControlStyle; + panels: ControlsPanels; +} diff --git a/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts b/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts new file mode 100644 index 0000000000000..90390256325ae --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_types/options_list/options_list_persistable_state.ts @@ -0,0 +1,47 @@ +/* + * 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 { + EmbeddableStateWithType, + EmbeddablePersistableStateService, +} from '../../../../../embeddable/common/types'; +import { OptionsListEmbeddableInput } from './types'; +import { SavedObjectReference } from '../../../../../../core/types'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../../data_views/common'; + +type OptionsListInputWithType = Partial & { type: string }; +const dataViewReferenceName = 'optionsListDataView'; + +export const createOptionsListInject = (): EmbeddablePersistableStateService['inject'] => { + return (state: EmbeddableStateWithType, references: SavedObjectReference[]) => { + const workingState = { ...state } as EmbeddableStateWithType | OptionsListInputWithType; + references.forEach((reference) => { + if (reference.name === dataViewReferenceName) { + (workingState as OptionsListInputWithType).dataViewId = reference.id; + } + }); + return workingState as EmbeddableStateWithType; + }; +}; + +export const createOptionsListExtract = (): EmbeddablePersistableStateService['extract'] => { + return (state: EmbeddableStateWithType) => { + const workingState = { ...state } as EmbeddableStateWithType | OptionsListInputWithType; + const references: SavedObjectReference[] = []; + + if ('dataViewId' in workingState) { + references.push({ + name: dataViewReferenceName, + type: DATA_VIEW_SAVED_OBJECT_TYPE, + id: workingState.dataViewId!, + }); + delete workingState.dataViewId; + } + return { state: workingState as EmbeddableStateWithType, references }; + }; +}; diff --git a/src/plugins/presentation_util/common/controls/control_types/options_list/types.ts b/src/plugins/presentation_util/common/controls/control_types/options_list/types.ts new file mode 100644 index 0000000000000..9a6a96e861bed --- /dev/null +++ b/src/plugins/presentation_util/common/controls/control_types/options_list/types.ts @@ -0,0 +1,20 @@ +/* + * 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 { ControlInput } from '../../types'; + +export const OPTIONS_LIST_CONTROL = 'optionsListControl'; + +export interface OptionsListEmbeddableInput extends ControlInput { + fieldName: string; + dataViewId: string; + + selectedOptions?: string[]; + singleSelect?: boolean; + loading?: boolean; +} diff --git a/src/plugins/presentation_util/common/controls/index.ts b/src/plugins/presentation_util/common/controls/index.ts new file mode 100644 index 0000000000000..b01a242bdfa5f --- /dev/null +++ b/src/plugins/presentation_util/common/controls/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export * from './control_group/types'; +export * from './control_types/options_list/types'; diff --git a/src/plugins/presentation_util/common/controls/types.ts b/src/plugins/presentation_util/common/controls/types.ts new file mode 100644 index 0000000000000..288324e30b47c --- /dev/null +++ b/src/plugins/presentation_util/common/controls/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { Filter, Query } from '@kbn/es-query'; +import { TimeRange } from '../../../data/common'; +import { EmbeddableInput } from '../../../embeddable/common/types'; + +export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; +export type ControlStyle = 'twoLine' | 'oneLine'; + +export interface ParentIgnoreSettings { + ignoreFilters?: boolean; + ignoreQuery?: boolean; + ignoreTimerange?: boolean; +} + +export type ControlInput = EmbeddableInput & { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + controlStyle?: ControlStyle; + ignoreParentSettings?: ParentIgnoreSettings; +}; diff --git a/src/plugins/presentation_util/common/labs.ts b/src/plugins/presentation_util/common/labs.ts index 86c05810723bd..8eefbd6981280 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -10,9 +10,10 @@ import { i18n } from '@kbn/i18n'; export const LABS_PROJECT_PREFIX = 'labs:'; export const DEFER_BELOW_FOLD = `${LABS_PROJECT_PREFIX}dashboard:deferBelowFold` as const; +export const DASHBOARD_CONTROLS = `${LABS_PROJECT_PREFIX}dashboard:dashboardControls` as const; export const BY_VALUE_EMBEDDABLE = `${LABS_PROJECT_PREFIX}canvas:byValueEmbeddable` as const; -export const projectIDs = [DEFER_BELOW_FOLD, BY_VALUE_EMBEDDABLE] as const; +export const projectIDs = [DEFER_BELOW_FOLD, DASHBOARD_CONTROLS, BY_VALUE_EMBEDDABLE] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; @@ -35,6 +36,20 @@ export const projects: { [ID in ProjectID]: ProjectConfig & { id: ID } } = { }), solutions: ['dashboard'], }, + [DASHBOARD_CONTROLS]: { + id: DASHBOARD_CONTROLS, + isActive: false, + isDisplayed: true, + environments: ['kibana', 'browser', 'session'], + name: i18n.translate('presentationUtil.labs.enableDashboardControlsProjectName', { + defaultMessage: 'Enable dashboard controls', + }), + description: i18n.translate('presentationUtil.labs.enableDashboardControlsProjectDescription', { + defaultMessage: + 'Enables the controls system for dashboard, which allows dashboard authors to more easily build interactive elements for their users.', + }), + solutions: ['dashboard'], + }, [BY_VALUE_EMBEDDABLE]: { id: BY_VALUE_EMBEDDABLE, isActive: true, diff --git a/src/plugins/presentation_util/common/lib/index.ts b/src/plugins/presentation_util/common/lib/index.ts index 3fe90009ad8df..030780c130fa5 100644 --- a/src/plugins/presentation_util/common/lib/index.ts +++ b/src/plugins/presentation_util/common/lib/index.ts @@ -8,3 +8,4 @@ export * from './utils'; export * from './test_helpers'; +export * from '../controls'; diff --git a/src/plugins/presentation_util/kibana.json b/src/plugins/presentation_util/kibana.json index 71ac224d1976a..210937b335e50 100644 --- a/src/plugins/presentation_util/kibana.json +++ b/src/plugins/presentation_util/kibana.json @@ -10,6 +10,6 @@ "server": true, "ui": true, "extraPublicDirs": ["common/lib"], - "requiredPlugins": ["savedObjects", "kibanaReact"], + "requiredPlugins": ["savedObjects", "data", "dataViews", "embeddable", "kibanaReact"], "optionalPlugins": [] } diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/controls.stories.tsx similarity index 77% rename from src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx rename to src/plugins/presentation_util/public/components/controls/__stories__/controls.stories.tsx index ec1678c5faa96..1b1dada24b288 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/controls.stories.tsx @@ -6,21 +6,22 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo, useState, useCallback, FC } from 'react'; -import uuid from 'uuid'; import { EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiTextAlign } from '@elastic/eui'; +import React, { useEffect, useMemo, useState, useCallback, FC } from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; +import uuid from 'uuid'; import { decorators } from './decorators'; +import { ControlsPanels } from '../control_group/types'; +import { ViewMode } from '../../../../../embeddable/public'; +import { getFlightOptionsAsync, storybookFlightsDataView } from './fixtures/flights'; import { pluginServices, registry } from '../../../services/storybook'; +import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from '../../..'; +import { replaceValueSuggestionMethod } from '../../../services/storybook/data'; +import { injectStorybookDataView } from '../../../services/storybook/data_views'; import { populateStorybookControlFactories } from './storybook_control_factories'; +import { EmbeddablePersistableStateService } from '../../../../../embeddable/common'; import { ControlGroupContainerFactory } from '../control_group/embeddable/control_group_container_factory'; -import { ControlsPanels } from '../control_group/types'; -import { - OptionsListEmbeddableInput, - OPTIONS_LIST_CONTROL, -} from '../control_types/options_list/options_list_embeddable'; -import { ViewMode } from '../control_group/types'; export default { title: 'Controls', @@ -31,7 +32,10 @@ export default { type UnwrapPromise = T extends Promise ? P : T; type EmbeddableType = UnwrapPromise>; -const EmptyControlGroupStoryComponent: FC<{ +injectStorybookDataView(storybookFlightsDataView); +replaceValueSuggestionMethod(getFlightOptionsAsync); + +const ControlGroupStoryComponent: FC<{ panels?: ControlsPanels; edit?: boolean; }> = ({ panels, edit }) => { @@ -54,13 +58,10 @@ const EmptyControlGroupStoryComponent: FC<{ useEffectOnce(() => { (async () => { - const factory = new ControlGroupContainerFactory(); + const factory = new ControlGroupContainerFactory( + {} as unknown as EmbeddablePersistableStateService + ); const controlGroupContainerEmbeddable = await factory.create({ - inheritParentState: { - useQuery: false, - useFilters: false, - useTimerange: false, - }, controlStyle: 'oneLine', panels: panels ?? {}, id: uuid.v4(), @@ -102,9 +103,9 @@ const EmptyControlGroupStoryComponent: FC<{ ); }; -export const EmptyControlGroupStory = () => ; +export const EmptyControlGroupStory = () => ; export const ConfiguredControlGroupStory = () => ( - ( explicitInput: { title: 'Origin City', id: 'optionsList1', - indexPattern: { - title: 'demo data flights', - }, - field: { - name: 'OriginCityName', - type: 'string', - aggregatable: true, - }, + dataViewId: 'demoDataFlights', + fieldName: 'OriginCityName', selectedOptions: ['Toronto'], } as OptionsListEmbeddableInput, }, @@ -131,14 +126,8 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Destination City', id: 'optionsList2', - indexPattern: { - title: 'demo data flights', - }, - field: { - name: 'DestCityName', - type: 'string', - aggregatable: true, - }, + dataViewId: 'demoDataFlights', + fieldName: 'DestCityName', selectedOptions: ['London'], } as OptionsListEmbeddableInput, }, @@ -149,14 +138,8 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Carrier', id: 'optionsList3', - indexPattern: { - title: 'demo data flights', - }, - field: { - name: 'Carrier', - type: 'string', - aggregatable: true, - }, + dataViewId: 'demoDataFlights', + fieldName: 'Carrier', } as OptionsListEmbeddableInput, }, }} diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts deleted file mode 100644 index 3f89e2e549d2a..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 { ControlsService } from '../controls_service'; -import { InputControlFactory } from '../../../services/controls'; -import { flightFields, getFlightSearchOptions } from './flights'; -import { OptionsListEmbeddableFactory } from '../control_types/options_list'; - -export const getControlsServiceStub = () => { - const controlsServiceStub = new ControlsService(); - - const optionsListFactoryStub = new OptionsListEmbeddableFactory( - ({ field, search }) => - new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), - () => Promise.resolve([{ title: 'demo data flights', fields: [] }]), - () => Promise.resolve(flightFields) - ); - - // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory - const optionsListControlFactory = optionsListFactoryStub as unknown as InputControlFactory; - optionsListControlFactory.getDefaultInput = () => ({}); - controlsServiceStub.registerInputControlType(optionsListControlFactory); - return controlsServiceStub; -}; diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights.ts b/src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights.ts new file mode 100644 index 0000000000000..921b7f3999faa --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights.ts @@ -0,0 +1,82 @@ +/* + * 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 { map, uniq } from 'lodash'; +import { flights } from '../fixtures/flights_data'; +import { + DataView, + DataViewField, + IIndexPatternFieldList, +} from '../../../../../../data_views/common'; + +export type Flight = typeof flights[number]; +export type FlightField = keyof Flight; + +export const flightFieldNames: FlightField[] = [ + 'AvgTicketPrice', + 'Cancelled', + 'Carrier', + 'dayOfWeek', + 'Dest', + 'DestAirportID', + 'DestCityName', + 'DestCountry', + 'DestLocation', + 'DestRegion', + 'DestWeather', + 'DistanceKilometers', + 'DistanceMiles', + 'FlightDelay', + 'FlightDelayMin', + 'FlightDelayType', + 'FlightNum', + 'FlightTimeHour', + 'FlightTimeMin', + 'Origin', + 'OriginAirportID', + 'OriginCityName', + 'OriginCountry', + 'OriginLocation', + 'OriginRegion', + 'OriginWeather', + 'timestamp', +]; + +export const flightFieldByName: { [key: string]: DataViewField } = {}; +flightFieldNames.forEach( + (flightFieldName) => + (flightFieldByName[flightFieldName] = { + name: flightFieldName, + type: 'string', + aggregatable: true, + } as unknown as DataViewField) +); +flightFieldByName.Cancelled = { name: 'Cancelled', type: 'boolean' } as DataViewField; +flightFieldByName.timestamp = { name: 'timestamp', type: 'date' } as DataViewField; + +export const flightFields: DataViewField[] = Object.values(flightFieldByName); + +export const storybookFlightsDataView: DataView = { + id: 'demoDataFlights', + title: 'demo data flights', + fields: flightFields as unknown as IIndexPatternFieldList, + getFieldByName: (name: string) => flightFieldByName[name], +} as unknown as DataView; + +export const getFlightOptions = (field: string) => uniq(map(flights, field)).sort(); + +export const getFlightSearchOptions = (field: string, search?: string): string[] => { + const options = getFlightOptions(field) + .map((option) => option + '') + .filter((option) => !search || option.toLowerCase().includes(search.toLowerCase())); + if (options.length > 10) options.length = 10; + return options; +}; + +export const getFlightOptionsAsync = ({ field, query }: { field: DataViewField; query: string }) => + new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, query)), 120)); diff --git a/src/plugins/presentation_util/public/components/fixtures/flights.ts b/src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights_data.ts similarity index 100% rename from src/plugins/presentation_util/public/components/fixtures/flights.ts rename to src/plugins/presentation_util/public/components/controls/__stories__/fixtures/flights_data.ts diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts b/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts deleted file mode 100644 index 941b91c0c92f1..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 { map, uniq } from 'lodash'; -import { flights } from '../../fixtures/flights'; - -export type Flight = typeof flights[number]; -export type FlightField = keyof Flight; - -export const getFlightOptions = (field: string) => uniq(map(flights, field)).sort(); - -export const getFlightSearchOptions = (field: string, search?: string): string[] => { - const options = getFlightOptions(field) - .map((option) => option + '') - .filter((option) => !search || option.toLowerCase().includes(search.toLowerCase())); - if (options.length > 10) options.length = 10; - return options; -}; - -export const flightFieldLabels: Record = { - AvgTicketPrice: 'Average Ticket Price', - Cancelled: 'Cancelled', - Carrier: 'Carrier', - dayOfWeek: 'Day of Week', - Dest: 'Destination', - DestAirportID: 'Destination Airport ID', - DestCityName: 'Destination City', - DestCountry: 'Destination Country', - DestLocation: 'Destination Location', - DestRegion: 'Destination Region', - DestWeather: 'Destination Weather', - DistanceKilometers: 'Distance (km)', - DistanceMiles: 'Distance (mi)', - FlightDelay: 'Flight Delay', - FlightDelayMin: 'Flight Delay (min)', - FlightDelayType: 'Flight Delay Type', - FlightNum: 'Flight Number', - FlightTimeHour: 'Flight Time (hr)', - FlightTimeMin: 'Flight Time (min)', - Origin: 'Origin', - OriginAirportID: 'Origin Airport ID', - OriginCityName: 'Origin City', - OriginCountry: 'Origin Country', - OriginLocation: 'Origin Location', - OriginRegion: 'Origin Region', - OriginWeather: 'Origin Weather', - timestamp: 'Timestamp', -}; - -export const flightFields = Object.keys(flightFieldLabels).map((field) => ({ - name: field, - type: 'string', - aggregatable: true, -})); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts index deb5b85336f27..e4429c1d69b13 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts @@ -6,28 +6,17 @@ * Side Public License, v 1. */ -import { flightFields, getFlightSearchOptions } from './flights'; import { OptionsListEmbeddableFactory } from '../control_types/options_list'; -import { InputControlFactory, PresentationControlsService } from '../../../services/controls'; +import { PresentationControlsService } from '../../../services/controls'; +import { ControlFactory } from '..'; export const populateStorybookControlFactories = ( controlsServiceStub: PresentationControlsService ) => { - const optionsListFactoryStub = new OptionsListEmbeddableFactory( - ({ field, search }) => - new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), - () => - Promise.resolve([ - { - title: 'demo data flights', - fields: [], - }, - ]), - () => Promise.resolve(flightFields) - ); + const optionsListFactoryStub = new OptionsListEmbeddableFactory(); // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory - const optionsListControlFactory = optionsListFactoryStub as unknown as InputControlFactory; + const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory; optionsListControlFactory.getDefaultInput = () => ({}); - controlsServiceStub.registerInputControlType(optionsListControlFactory); + controlsServiceStub.registerControlType(optionsListControlFactory); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx index 7d8893cb6b5a5..f94d2f8fee0dc 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx @@ -13,6 +13,7 @@ import { EuiFormControlLayout, EuiFormLabel, EuiFormRow, + EuiLoadingChart, EuiToolTip, } from '@elastic/eui'; @@ -52,7 +53,9 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con embeddable.render(embeddableRoot.current); } const subscription = embeddable?.getInput$().subscribe((newInput) => setTitle(newInput.title)); - return () => subscription?.unsubscribe(); + return () => { + subscription?.unsubscribe(); + }; }, [embeddable, embeddableRoot]); const floatingActions = ( @@ -87,13 +90,18 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con ); + const embeddableParentClassNames = classNames('controlFrame__control', { + 'controlFrame--twoLine': controlStyle === 'twoLine', + 'controlFrame--oneLine': controlStyle === 'oneLine', + }); + const form = ( - {customPrepend ?? null} + {(embeddable && customPrepend) ?? null} {usingTwoLineLayout ? undefined : ( {title} @@ -102,21 +110,34 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } > -
+ {embeddable && ( +
+ )} + {!embeddable && ( +
+
+ +
+
+ )} ); return ( <> - {enableActions && floatingActions} - + {embeddable && enableActions && floatingActions} + {form} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx index 86bcd7de425e0..16ae4c1858660 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx @@ -36,15 +36,16 @@ import { LayoutMeasuringStrategy, } from '@dnd-kit/core'; -import { ControlGroupInput, ViewMode } from '../types'; +import { ControlGroupInput } from '../types'; import { pluginServices } from '../../../../services'; import { ControlGroupStrings } from '../control_group_strings'; import { CreateControlButton } from '../editor/create_control'; +import { ViewMode } from '../../../../../../embeddable/public'; import { EditControlGroup } from '../editor/edit_control_group'; import { forwardAllContext } from '../editor/forward_all_context'; +import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlClone, SortableControl } from './control_group_sortable_item'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; -import { controlGroupReducers } from '../state/control_group_reducers'; export const ControlGroup = () => { // Presentation Services Context diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss index 00a135c65a75e..c69674df29616 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss @@ -118,6 +118,13 @@ $controlMinWidth: $euiSize * 14; width: 100%; } } + + .controlFrame--controlLoading { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } } &--small { diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts index 657add5ef048f..111b247d7417e 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const ControlGroupStrings = { getEmbeddableTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.title', { + i18n.translate('presentationUtil.controls.controlGroup.title', { defaultMessage: 'Control group', }), emptyState: { @@ -25,6 +25,10 @@ export const ControlGroupStrings = { defaultMessage: 'Add control', } ), + getTwoLineLoadingTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.emptyState.twoLineLoadingTitle', { + defaultMessage: '...', + }), }, manageControl: { getFlyoutCreateTitle: () => @@ -39,7 +43,7 @@ export const ControlGroupStrings = { defaultMessage: 'Edit control', }), getTitleInputTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.titleInputTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Title', }), getWidthInputTitle: () => @@ -47,17 +51,17 @@ export const ControlGroupStrings = { defaultMessage: 'Control size', }), getSaveChangesTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.saveChangesTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.saveChangesTitle', { defaultMessage: 'Save and close', }), getCancelTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.cancelTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.cancelTitle', { defaultMessage: 'Cancel', }), }, management: { getAddControlTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.addControl', { + i18n.translate('presentationUtil.controls.controlGroup.management.addControl', { defaultMessage: 'Add control', }), getManageButtonTitle: () => @@ -73,11 +77,11 @@ export const ControlGroupStrings = { defaultMessage: 'Default size', }), getLayoutTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', { + i18n.translate('presentationUtil.controls.controlGroup.management.layoutTitle', { defaultMessage: 'Layout', }), getDeleteButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete', { defaultMessage: 'Delete control', }), getSetAllWidthsToDefaultTitle: () => @@ -85,38 +89,38 @@ export const ControlGroupStrings = { defaultMessage: 'Set all sizes to default', }), getDeleteAllButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteAll', { defaultMessage: 'Delete all', }), controlWidth: { getWidthSwitchLegend: () => i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.layout.controlWidthLegend', + 'presentationUtil.controls.controlGroup.management.layout.controlWidthLegend', { defaultMessage: 'Change control size', } ), getAutoWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.auto', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.auto', { defaultMessage: 'Auto', }), getSmallWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.small', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.small', { defaultMessage: 'Small', }), getMediumWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.medium', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.medium', { defaultMessage: 'Medium', }), getLargeWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.large', { + i18n.translate('presentationUtil.controls.controlGroup.management.layout.large', { defaultMessage: 'Large', }), }, controlStyle: { getDesignSwitchLegend: () => i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.layout.designSwitchLegend', + 'presentationUtil.controls.controlGroup.management.layout.designSwitchLegend', { defaultMessage: 'Switch control designs', } @@ -132,29 +136,23 @@ export const ControlGroupStrings = { }, deleteControls: { getDeleteAllTitle: () => - i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.delete.deleteAllTitle', - { - defaultMessage: 'Delete all controls?', - } - ), + i18n.translate('presentationUtil.controls.controlGroup.management.delete.deleteAllTitle', { + defaultMessage: 'Delete all controls?', + }), getDeleteTitle: () => - i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.delete.deleteTitle', - { - defaultMessage: 'Delete control?', - } - ), + i18n.translate('presentationUtil.controls.controlGroup.management.delete.deleteTitle', { + defaultMessage: 'Delete control?', + }), getSubtitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.sub', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete.sub', { defaultMessage: 'Controls are not recoverable once removed.', }), getConfirm: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.confirm', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete.confirm', { defaultMessage: 'Delete', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.delete.cancel', { defaultMessage: 'Cancel', }), }, @@ -172,7 +170,7 @@ export const ControlGroupStrings = { defaultMessage: 'Discard changes', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.discard.cancel', { defaultMessage: 'Cancel', }), }, @@ -190,7 +188,7 @@ export const ControlGroupStrings = { defaultMessage: 'Discard control', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteNew.cancel', { defaultMessage: 'Cancel', }), }, @@ -201,7 +199,7 @@ export const ControlGroupStrings = { defaultMessage: 'Edit control', }), getRemoveButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', { + i18n.translate('presentationUtil.controls.controlGroup.floatingActions.removeTitle', { defaultMessage: 'Remove control', }), }, diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx index a55dd381857b7..0fdcba570c941 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx @@ -14,7 +14,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { EuiFlyoutHeader, EuiButtonGroup, @@ -32,39 +32,48 @@ import { } from '@elastic/eui'; import { ControlGroupStrings } from '../control_group_strings'; -import { ControlEditorComponent, ControlWidth } from '../../types'; -import { CONTROL_WIDTH_OPTIONS } from '../control_group_constants'; +import { + ControlEmbeddable, + ControlInput, + ControlWidth, + IEditableControlFactory, +} from '../../types'; +import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; -interface ManageControlProps { - title?: string; +interface EditControlProps { + factory: IEditableControlFactory; + embeddable?: ControlEmbeddable; + width: ControlWidth; isCreate: boolean; + title?: string; onSave: () => void; - width: ControlWidth; onCancel: () => void; removeControl?: () => void; - controlEditorComponent?: ControlEditorComponent; - updateTitle: (title: string) => void; + updateTitle: (title?: string) => void; updateWidth: (newWidth: ControlWidth) => void; + onTypeEditorChange: (partial: Partial) => void; } export const ControlEditor = ({ - controlEditorComponent, + onTypeEditorChange, removeControl, updateTitle, updateWidth, + embeddable, isCreate, onCancel, + factory, onSave, title, width, -}: ManageControlProps) => { +}: EditControlProps) => { const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); const [controlEditorValid, setControlEditorValid] = useState(false); - const [editorValid, setEditorValid] = useState(false); + const [defaultTitle, setDefaultTitle] = useState(); - useEffect(() => setEditorValid(Boolean(currentTitle)), [currentTitle]); + const ControlTypeEditor = factory.controlEditorComponent; return ( <> @@ -79,17 +88,6 @@ export const ControlEditor = ({ - - { - updateTitle(e.target.value); - setCurrentTitle(e.target.value); - }} - aria-label="Use aria labels when no actual label is in use" - /> - - {controlEditorComponent && - controlEditorComponent({ setValidState: setControlEditorValid })} + {ControlTypeEditor && ( + { + if (!currentTitle || currentTitle === defaultTitle) { + setCurrentTitle(newDefaultTitle); + updateTitle(newDefaultTitle); + } + setDefaultTitle(newDefaultTitle); + }} + /> + )} + + { + updateTitle(e.target.value || defaultTitle); + setCurrentTitle(e.target.value); + }} + /> + {removeControl && ( { - onSave(); - }} + disabled={!controlEditorValid} + onClick={() => onSave()} > {ControlGroupStrings.manageControl.getSaveChangesTitle()} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx index 150977c113cd7..3676fe6617e1b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx @@ -20,19 +20,18 @@ import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; import { pluginServices } from '../../../../services'; import { forwardAllContext } from './forward_all_context'; +import { DEFAULT_CONTROL_WIDTH } from './editor_constants'; import { OverlayRef } from '../../../../../../../core/public'; import { ControlGroupStrings } from '../control_group_strings'; -import { InputControlInput } from '../../../../services/controls'; -import { DEFAULT_CONTROL_WIDTH } from '../control_group_constants'; -import { ControlWidth, IEditableControlFactory } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { ControlWidth, IEditableControlFactory, ControlInput } from '../../types'; import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) => { // Presentation Services Context const { overlays, controls } = pluginServices.getHooks(); - const { getInputControlTypes, getControlFactory } = controls.useService(); + const { getControlTypes, getControlFactory } = controls.useService(); const { openFlyout, openConfirm } = overlays.useService(); // Redux embeddable container Context @@ -56,8 +55,8 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) const factory = getControlFactory(type); if (!factory) throw new EmbeddableFactoryNotFoundError(type); - const initialInputPromise = new Promise>((resolve, reject) => { - let inputToReturn: Partial = {}; + const initialInputPromise = new Promise>((resolve, reject) => { + let inputToReturn: Partial = {}; const onCancel = (ref: OverlayRef) => { if (Object.keys(inputToReturn).length === 0) { @@ -78,19 +77,23 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) }); }; + const editableFactory = factory as IEditableControlFactory; + const flyoutInstance = openFlyout( forwardAllContext( (inputToReturn.title = newTitle)} updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))} - controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ - onChange: (partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }, - })} + onTypeEditorChange={(partialInput) => + (inputToReturn = { ...inputToReturn, ...partialInput }) + } onSave={() => { + if (editableFactory.presaveTransformFunction) { + inputToReturn = editableFactory.presaveTransformFunction(inputToReturn); + } resolve(inputToReturn); flyoutInstance.close(); }} @@ -103,6 +106,7 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) } ); }); + initialInputPromise.then( async (explicitInput) => { await addNewEmbeddable(type, explicitInput); @@ -111,7 +115,7 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) ); }; - if (getInputControlTypes().length === 0) return null; + if (getControlTypes().length === 0) return null; const commonButtonProps = { iconType: 'plusInCircle', @@ -121,11 +125,11 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) }; const onCreateButtonClick = () => { - if (getInputControlTypes().length > 1) { + if (getControlTypes().length > 1) { setIsControlTypePopoverOpen(!isControlTypePopoverOpen); return; } - createNewControl(getInputControlTypes()[0]); + createNewControl(getControlTypes()[0]); }; const createControlButton = isIconButton ? ( @@ -141,9 +145,9 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) ); - if (getInputControlTypes().length > 1) { + if (getControlTypes().length > 1) { const items: ReactElement[] = []; - getInputControlTypes().forEach((type) => { + getControlTypes().forEach((type) => { const factory = getControlFactory(type); items.push( { // Presentation Services Context @@ -55,7 +54,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const factory = getControlFactory(panel.type); const embeddable = await untilEmbeddableLoaded(embeddableId); - let inputToReturn: Partial = {}; + let inputToReturn: Partial = {}; if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); @@ -85,12 +84,29 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }); }; + const editableFactory = factory as IEditableControlFactory; + const flyoutInstance = openFlyout( forwardAllContext( onCancel(flyoutInstance)} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} + onTypeEditorChange={(partialInput) => + (inputToReturn = { ...inputToReturn, ...partialInput }) + } + onSave={() => { + if (editableFactory.presaveTransformFunction) { + inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); + } + updateInputForChild(embeddableId, inputToReturn); + flyoutInstance.close(); + }} removeControl={() => { openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), @@ -105,19 +121,6 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => } }); }} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ - onChange: (partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }, - initialInput: embeddable.getInput(), - })} - onCancel={() => onCancel(flyoutInstance)} - onSave={() => { - updateInputForChild(embeddableId, inputToReturn); - flyoutInstance.close(); - }} - updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} />, reduxContainerContext ), diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx index 681af9c10ba20..9828f6317ad53 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx @@ -26,7 +26,7 @@ import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS, DEFAULT_CONTROL_WIDTH, -} from '../control_group_constants'; +} from './editor_constants'; import { ControlGroupInput } from '../types'; import { pluginServices } from '../../../../services'; import { ControlStyle, ControlWidth } from '../../types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts b/src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts similarity index 87% rename from src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts rename to src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts index 3c22b1ffbcd23..812f794efc8c3 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_constants.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/editor_constants.ts @@ -6,10 +6,8 @@ * Side Public License, v 1. */ -import { ControlWidth } from '../types'; -import { ControlGroupStrings } from './control_group_strings'; - -export const CONTROL_GROUP_TYPE = 'control_group'; +import { ControlWidth } from '../../types'; +import { ControlGroupStrings } from '../control_group_strings'; export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx index a722bed6c07d2..ff25286a75211 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx @@ -7,23 +7,60 @@ */ import React from 'react'; +import { uniqBy } from 'lodash'; import ReactDOM from 'react-dom'; +import deepEqual from 'fast-deep-equal'; +import { Filter, uniqFilters } from '@kbn/es-query'; +import { EMPTY, merge, pipe, Subscription, concat } from 'rxjs'; +import { + distinctUntilChanged, + debounceTime, + catchError, + switchMap, + map, + take, +} from 'rxjs/operators'; import { - InputControlEmbeddable, - InputControlInput, - InputControlOutput, -} from '../../../../services/controls'; + ControlGroupInput, + ControlGroupOutput, + ControlPanelState, + CONTROL_GROUP_TYPE, +} from '../types'; import { pluginServices } from '../../../../services'; -import { ControlGroupInput, ControlPanelState } from '../types'; +import { DataView } from '../../../../../../data_views/public'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; import { Container, EmbeddableFactory } from '../../../../../../embeddable/public'; -import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from '../control_group_constants'; import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; +import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; -export class ControlGroupContainer extends Container { +export class ControlGroupContainer extends Container< + ControlInput, + ControlGroupInput, + ControlGroupOutput +> { public readonly type = CONTROL_GROUP_TYPE; + private subscriptions: Subscription = new Subscription(); + private domNode?: HTMLElement; + + public untilReady = () => { + const panelsLoading = () => + Object.values(this.getOutput().embeddableLoaded).some((loaded) => !loaded); + if (panelsLoading()) { + return new Promise((resolve, reject) => { + const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { + if (this.destroyed) reject(); + if (!panelsLoading()) { + subscription.unsubscribe(); + resolve(); + } + }); + }); + } + return Promise.resolve(); + }; constructor(initialInput: ControlGroupInput, parent?: Container) { super( @@ -32,10 +69,44 @@ export class ControlGroupContainer extends Container this.getChildIds()), + distinctUntilChanged(deepEqual), + + // children may change, so make sure we subscribe/unsubscribe with switchMap + switchMap((newChildIds: string[]) => + merge( + ...newChildIds.map((childId) => + this.getChild(childId) + .getOutput$() + // Embeddables often throw errors into their output streams. + .pipe(catchError(() => EMPTY)) + ) + ) + ) + ); + + this.subscriptions.add( + concat( + merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe)).pipe(take(1)), // the first time filters are built, don't debounce so that initial filters are built immediately + merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe)).pipe(debounceTime(10)) + ).subscribe(this.recalculateOutput) + ); } - protected createNewPanelState( - factory: EmbeddableFactory, + private recalculateOutput = () => { + const allFilters: Filter[] = []; + const allDataViews: DataView[] = []; + Object.values(this.children).map((child) => { + const childOutput = child.getOutput() as ControlOutput; + allFilters.push(...(childOutput?.filters ?? [])); + allDataViews.push(...(childOutput.dataViews ?? [])); + }); + this.updateOutput({ filters: uniqFilters(allFilters), dataViews: uniqBy(allDataViews, 'id') }); + }; + + protected createNewPanelState( + factory: EmbeddableFactory, partial: Partial = {} ): ControlPanelState { const panelState = super.createNewPanelState(factory, partial); @@ -50,17 +121,27 @@ export class ControlGroupContainer extends Container; } - protected getInheritedInput(id: string): InputControlInput { - const { filters, query, timeRange, inheritParentState } = this.getInput(); + protected getInheritedInput(id: string): ControlInput { + const { filters, query, ignoreParentSettings, timeRange } = this.getInput(); return { - filters: inheritParentState.useFilters ? filters : undefined, - query: inheritParentState.useQuery ? query : undefined, - timeRange: inheritParentState.useTimerange ? timeRange : undefined, + filters: ignoreParentSettings?.ignoreFilters ? undefined : filters, + query: ignoreParentSettings?.ignoreQuery ? undefined : query, + timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange, id, }; } + public destroy() { + super.destroy(); + this.subscriptions.unsubscribe(); + if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); + } + public render(dom: HTMLElement) { + if (this.domNode) { + ReactDOM.unmountComponentAtNode(this.domNode); + } + this.domNode = dom; const PresentationUtilProvider = pluginServices.getContextProvider(); ReactDOM.render( diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts index e50b1c5d734e4..c5b2972bf0d97 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts @@ -14,29 +14,21 @@ * Side Public License, v 1. */ -import { - Container, - ContainerOutput, - EmbeddableFactory, - EmbeddableFactoryDefinition, - ErrorEmbeddable, -} from '../../../../../../embeddable/public'; -import { ControlGroupInput } from '../types'; +import { Container, EmbeddableFactoryDefinition } from '../../../../../../embeddable/public'; +import { EmbeddablePersistableStateService } from '../../../../../../embeddable/common'; +import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types'; import { ControlGroupStrings } from '../control_group_strings'; -import { CONTROL_GROUP_TYPE } from '../control_group_constants'; -import { ControlGroupContainer } from './control_group_container'; - -export type DashboardContainerFactory = EmbeddableFactory< - ControlGroupInput, - ContainerOutput, - ControlGroupContainer ->; -export class ControlGroupContainerFactory - implements EmbeddableFactoryDefinition -{ +import { + createControlGroupExtract, + createControlGroupInject, +} from '../../../../../common/controls/control_group/control_group_persistable_state'; + +export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition { public readonly isContainerType = true; public readonly type = CONTROL_GROUP_TYPE; + constructor(private persistableStateService: EmbeddablePersistableStateService) {} + public isEditable = async () => false; public readonly getDisplayName = () => { @@ -46,18 +38,19 @@ export class ControlGroupContainerFactory public getDefaultInput(): Partial { return { panels: {}, - inheritParentState: { - useFilters: true, - useQuery: true, - useTimerange: true, + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, }, }; } - public create = async ( - initialInput: ControlGroupInput, - parent?: Container - ): Promise => { + public create = async (initialInput: ControlGroupInput, parent?: Container) => { + const { ControlGroupContainer } = await import('./control_group_container'); return new ControlGroupContainer(initialInput, parent); }; + + public inject = createControlGroupInject(this.persistableStateService); + public extract = createControlGroupExtract(this.persistableStateService); } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/index.ts b/src/plugins/presentation_util/public/components/controls/control_group/index.ts new file mode 100644 index 0000000000000..45a91a87a7962 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export { ControlGroupInput, ControlGroupOutput } from './types'; +export type { ControlGroupContainer } from './embeddable/control_group_container'; +export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/types.ts b/src/plugins/presentation_util/public/components/controls/control_group/types.ts index f6639b6a55bca..3d0123eb4192f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/types.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/types.ts @@ -6,31 +6,9 @@ * Side Public License, v 1. */ -import { PanelState, EmbeddableInput, ViewMode } from '../../../../../embeddable/public'; -import { InputControlInput } from '../../../services/controls'; -import { ControlStyle, ControlWidth } from '../types'; +import { CommonControlOutput } from '../types'; +import { ContainerOutput } from '../../../../../embeddable/public'; -export { ViewMode }; +export type ControlGroupOutput = ContainerOutput & CommonControlOutput; -export interface ControlGroupInput - extends EmbeddableInput, - Omit { - inheritParentState: { - useFilters: boolean; - useQuery: boolean; - useTimerange: boolean; - }; - defaultControlWidth?: ControlWidth; - controlStyle: ControlStyle; - panels: ControlsPanels; -} - -export interface ControlPanelState - extends PanelState { - order: number; - width: ControlWidth; -} - -export interface ControlsPanels { - [panelId: string]: ControlPanelState; -} +export * from '../../../../common/controls/control_group/types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts index 63275f12076ff..f2d9c29701a5f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/index.ts @@ -7,4 +7,4 @@ */ export { OptionsListEmbeddableFactory } from './options_list_embeddable_factory'; -export { OptionsListEmbeddable } from './options_list_embeddable'; +export type { OptionsListEmbeddable } from './options_list_embeddable'; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss index b74a08d96c8c3..e9a4ef215733e 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss @@ -7,24 +7,10 @@ height: 100%; } -.optionsList--loadingOverlay { - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - position: absolute; - align-items: center; - justify-content: center; - background-color: $euiColorEmptyShade; -} - .optionsList--items { @include euiScrollBar; overflow-y: auto; - position: relative; - min-height: $euiSize * 5; max-height: $euiSize * 30; width: $euiSize * 25; max-width: 100%; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx index 900570b38ca4d..9c8af47a1f598 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx @@ -8,17 +8,18 @@ import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { BehaviorSubject, Subject } from 'rxjs'; import classNames from 'classnames'; -import { Subject } from 'rxjs'; +import { debounce } from 'lodash'; -import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; -import { OptionsListEmbeddableInput } from './options_list_embeddable'; -import { OptionsListPopover } from './options_list_popover_component'; -import { optionsListReducers } from './options_list_reducers'; import { OptionsListStrings } from './options_list_strings'; +import { optionsListReducers } from './options_list_reducers'; +import { OptionsListPopover } from './options_list_popover_component'; +import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; import './options_list.scss'; import { useStateObservable } from '../../hooks/use_state_observable'; +import { OptionsListEmbeddableInput } from './types'; // Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. export interface OptionsListComponentState { @@ -31,7 +32,7 @@ export const OptionsListComponent = ({ componentStateSubject, }: { typeaheadSubject: Subject; - componentStateSubject: Subject; + componentStateSubject: BehaviorSubject; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [searchString, setSearchString] = useState(''); @@ -43,15 +44,21 @@ export const OptionsListComponent = ({ actions: { replaceSelection }, } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { twoLineLayout, selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); + const { controlStyle, selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); // useStateObservable to get component state from Embeddable const { availableOptions, loading } = useStateObservable( componentStateSubject, - { - loading: true, - } + componentStateSubject.getValue() + ); + + // debounce loading state so loading doesn't flash when user types + const [buttonLoading, setButtonLoading] = useState(true); + const debounceSetButtonLoading = useMemo( + () => debounce((latestLoading: boolean) => setButtonLoading(latestLoading), 100), + [] ); + useEffect(() => debounceSetButtonLoading(loading), [loading, debounceSetButtonLoading]); // remove all other selections if this control is single select useEffect(() => { @@ -78,13 +85,13 @@ export const OptionsListComponent = ({ const button = ( setIsPopoverOpen((openState) => !openState)} isSelected={isPopoverOpen} - numFilters={availableOptions?.length ?? 0} // Remove this once https://github.com/elastic/eui/pull/5268 is in an EUI release numActiveFilters={selectedOptionsCount} hasActiveFilters={(selectedOptionsCount ?? 0) > 0} > @@ -95,7 +102,7 @@ export const OptionsListComponent = ({ return ( ['onChange']; - fetchIndexPatterns: OptionsListIndexPatternFetcher; - initialInput?: Partial; - fetchFields: OptionsListFieldFetcher; -} +import { ControlEditorProps } from '../../types'; +import { DataViewListItem, DataView } from '../../../../../../data_views/common'; +import { DataViewPicker } from '../../../data_view_picker/data_view_picker'; +import { OptionsListStrings } from './options_list_strings'; +import { pluginServices } from '../../../../services'; +import { OptionsListEmbeddableInput } from './types'; +import { FieldPicker } from '../../../field_picker/field_picker'; interface OptionsListEditorState { singleSelect?: boolean; - indexPatternSelectOptions: Array>; - availableIndexPatterns?: { [key: string]: IIndexPattern }; - indexPattern?: IIndexPattern; + dataViewListItems: DataViewListItem[]; - fieldSelectOptions: Array>; - availableFields?: { [key: string]: IFieldType }; - field?: IFieldType; + dataView?: DataView; + fieldName?: string; } export const OptionsListEditor = ({ onChange, - fetchFields, initialInput, setValidState, - fetchIndexPatterns, -}: OptionsListEditorProps) => { + setDefaultTitle, +}: ControlEditorProps) => { + // Presentation Services Context + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); + const [state, setState] = useState({ - indexPattern: initialInput?.indexPattern, - field: initialInput?.field, + fieldName: initialInput?.fieldName, singleSelect: initialInput?.singleSelect, - indexPatternSelectOptions: [], - fieldSelectOptions: [], + dataViewListItems: [], }); - const applySelection = ({ - field, - singleSelect, - indexPattern, - }: { - field?: IFieldType; - singleSelect?: boolean; - indexPattern?: IIndexPattern; - }) => { - const newState = { - ...(field ? { field } : {}), - ...(indexPattern ? { indexPattern } : {}), - ...(singleSelect !== undefined ? { singleSelect } : {}), - }; - /** - * apply state and run onChange concurrently. State is copied here rather than by subscribing to embeddable - * input so that the same editor component can cover the 'create' use case. - */ - - setState((currentState) => { - return { ...currentState, ...newState }; - }); - onChange(newState); - }; - useMount(() => { + let mounted = true; + if (state.fieldName) setDefaultTitle(state.fieldName); (async () => { - const newIndexPatterns = await fetchIndexPatterns(); - const newAvailableIndexPatterns = newIndexPatterns.reduce( - (acc: { [key: string]: IIndexPattern }, curr) => ((acc[curr.title] = curr), acc), - {} - ); - const newIndexPatternSelectOptions = newIndexPatterns.map((indexPattern) => ({ - value: indexPattern.title, - inputDisplay: indexPattern.title, - })); - setState((currentState) => ({ - ...currentState, - availableIndexPatterns: newAvailableIndexPatterns, - indexPatternSelectOptions: newIndexPatternSelectOptions, - })); - })(); - }); - - useEffect(() => { - (async () => { - let newFieldSelectOptions: Array> = []; - let newAvailableFields: { [key: string]: IFieldType } = {}; - if (state.indexPattern) { - const newFields = await fetchFields(state.indexPattern); - newAvailableFields = newFields.reduce( - (acc: { [key: string]: IFieldType }, curr) => ((acc[curr.name] = curr), acc), - {} - ); - newFieldSelectOptions = newFields.map((field) => ({ - value: field.name, - inputDisplay: field.displayName ?? field.name, - })); + const dataViewListItems = await getIdsWithTitle(); + const initialId = initialInput?.dataViewId ?? (await getDefaultId()); + let dataView: DataView | undefined; + if (initialId) { + onChange({ dataViewId: initialId }); + dataView = await get(initialId); } - setState((currentState) => ({ - ...currentState, - fieldSelectOptions: newFieldSelectOptions, - availableFields: newAvailableFields, - })); + if (!mounted) return; + setState((s) => ({ ...s, dataView, dataViewListItems })); })(); - }, [state.indexPattern, fetchFields]); + return () => { + mounted = false; + }; + }); useEffect( - () => setValidState(Boolean(state.field) && Boolean(state.indexPattern)), - [state.field, setValidState, state.indexPattern] + () => setValidState(Boolean(state.fieldName) && Boolean(state.dataView)), + [state.fieldName, setValidState, state.dataView] ); + const { dataView, fieldName } = state; return ( <> - - - applySelection({ indexPattern: state.availableIndexPatterns?.[patternTitle] }) - } - valueOfSelected={state.indexPattern?.title} + + { + onChange({ dataViewId }); + get(dataViewId).then((newDataView) => + setState((s) => ({ ...s, dataView: newDataView })) + ); + }} + trigger={{ + label: state.dataView?.title ?? OptionsListStrings.editor.getNoDataViewTitle(), + }} /> - - applySelection({ field: state.availableFields?.[fieldName] })} - valueOfSelected={state.field?.name} + + + (field.aggregatable && field.type === 'string') || field.type === 'boolean' + } + selectedFieldName={fieldName} + dataView={dataView} + onSelectField={(field) => { + setDefaultTitle(field.displayName ?? field.name); + onChange({ fieldName: field.name }); + setState((s) => ({ ...s, fieldName: field.name })); + }} /> - + applySelection({ singleSelect: !e.target.checked })} + onChange={() => { + onChange({ singleSelect: !state.singleSelect }); + setState((s) => ({ ...s, singleSelect: !s.singleSelect })); + }} /> diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx index 93330772d7cad..b980ee10293e5 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx @@ -8,17 +8,29 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { merge, Subject, Subscription } from 'rxjs'; +import { + buildEsQuery, + buildPhraseFilter, + buildPhrasesFilter, + compareFilters, + Filter, +} from '@kbn/es-query'; +import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs'; import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; -import { isEqual } from 'lodash'; import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; -import { InputControlInput, InputControlOutput } from '../../../../services/controls'; -import { esFilters, IIndexPattern, IFieldType } from '../../../../../../data/public'; -import { Embeddable, IContainer } from '../../../../../../embeddable/public'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; +import { PresentationDataViewsService } from '../../../../services/data_views'; +import { Embeddable, IContainer } from '../../../../../../embeddable/public'; +import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; +import { PresentationDataService } from '../../../../services/data'; +import { DataView } from '../../../../../../data_views/public'; import { optionsListReducers } from './options_list_reducers'; +import { OptionsListStrings } from './options_list_strings'; +import { pluginServices } from '../../../../services'; +import { ControlInput, ControlOutput } from '../..'; const diffDataFetchProps = ( current?: OptionsListDataFetchProps, @@ -28,73 +40,64 @@ const diffDataFetchProps = ( const { filters: currentFilters, ...currentWithoutFilters } = current; const { filters: lastFilters, ...lastWithoutFilters } = last; if (!deepEqual(currentWithoutFilters, lastWithoutFilters)) return false; - if (!esFilters.compareFilters(lastFilters ?? [], currentFilters ?? [])) return false; + if (!compareFilters(lastFilters ?? [], currentFilters ?? [])) return false; return true; }; interface OptionsListDataFetchProps { search?: string; - field: IFieldType; - indexPattern: IIndexPattern; - query?: InputControlInput['query']; - filters?: InputControlInput['filters']; - timeRange?: InputControlInput['timeRange']; + fieldName: string; + dataViewId: string; + query?: ControlInput['query']; + filters?: ControlInput['filters']; } -export type OptionsListIndexPatternFetcher = () => Promise; -export type OptionsListFieldFetcher = (indexPattern: IIndexPattern) => Promise; - -export type OptionsListDataFetcher = (props: OptionsListDataFetchProps) => Promise; - -export const OPTIONS_LIST_CONTROL = 'optionsListControl'; -export interface OptionsListEmbeddableInput extends InputControlInput { - field: IFieldType; - indexPattern: IIndexPattern; - - selectedOptions?: string[]; - singleSelect?: boolean; - loading?: boolean; -} +const fieldMissingError = (fieldName: string) => + new Error(`field ${fieldName} not found in index pattern`); -export class OptionsListEmbeddable extends Embeddable< - OptionsListEmbeddableInput, - InputControlOutput -> { +export class OptionsListEmbeddable extends Embeddable { public readonly type = OPTIONS_LIST_CONTROL; + public deferEmbeddableLoad = true; + + private subscriptions: Subscription = new Subscription(); private node?: HTMLElement; - // internal state for this input control. + // Presentation Util services + private dataService: PresentationDataService; + private dataViewsService: PresentationDataViewsService; + + // Internal data fetching state for this input control. private typeaheadSubject: Subject = new Subject(); + private dataView?: DataView; private searchString = ''; + // State to be passed down to component private componentState: OptionsListComponentState; - private componentStateSubject$ = new Subject(); - private updateComponentState(changes: Partial) { - this.componentState = { - ...this.componentState, - ...changes, - }; - this.componentStateSubject$.next(this.componentState); - } + private componentStateSubject$ = new BehaviorSubject({ + loading: true, + }); - private subscriptions: Subscription = new Subscription(); + constructor(input: OptionsListEmbeddableInput, output: ControlOutput, parent?: IContainer) { + super(input, output, parent); // get filters for initial output... + + // Destructure presentation util services + ({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices()); + + this.componentState = { loading: true }; + this.updateComponentState(this.componentState); - constructor( - input: OptionsListEmbeddableInput, - output: InputControlOutput, - private fetchData: OptionsListDataFetcher, - parent?: IContainer - ) { - super({ ...input, loading: true }, output, parent); - this.fetchData = fetchData; + this.initialize(); + } + private setupSubscriptions = () => { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ - field: newInput.field, - indexPattern: newInput.indexPattern, - query: newInput.query, - filters: newInput.filters, + lastReloadRequestTime: newInput.lastReloadRequestTime, + dataViewId: newInput.dataViewId, + fieldName: newInput.fieldName, timeRange: newInput.timeRange, + filters: newInput.filters, + query: newInput.query, })), distinctUntilChanged(diffDataFetchProps) ); @@ -102,7 +105,8 @@ export class OptionsListEmbeddable extends Embeddable< // push searchString changes into a debounced typeahead subject this.typeaheadSubject = new Subject(); const typeaheadPipe = this.typeaheadSubject.pipe( - tap((newSearchString) => (this.searchString = newSearchString), debounceTime(100)) + tap((newSearchString) => (this.searchString = newSearchString)), + debounceTime(100) ); // fetch available options when input changes or when search string has changed @@ -110,45 +114,108 @@ export class OptionsListEmbeddable extends Embeddable< merge(dataFetchPipe, typeaheadPipe).subscribe(this.fetchAvailableOptions) ); - // clear all selections when field or index pattern change + // build filters when selectedOptions change this.subscriptions.add( this.getInput$() .pipe( - distinctUntilChanged( - (a, b) => isEqual(a.field, b.field) && isEqual(a.indexPattern, b.indexPattern) - ), - skip(1) // skip the first change to preserve default selections after init + debounceTime(400), + distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions)), + skip(1) // skip the first input update because initial filters will be built by initialize. ) - .subscribe(() => this.updateInput({ selectedOptions: [] })) + .subscribe(() => this.buildFilter()) ); + }; - this.componentState = { loading: true }; - this.updateComponentState(this.componentState); + private getCurrentDataView = async (): Promise => { + const { dataViewId } = this.getInput(); + if (this.dataView && this.dataView.id === dataViewId) return this.dataView; + this.dataView = await this.dataViewsService.get(dataViewId); + if (this.dataView === undefined) { + this.onFatalError(new Error(OptionsListStrings.errors.getDataViewNotFoundError(dataViewId))); + } + this.updateOutput({ dataViews: [this.dataView] }); + return this.dataView; + }; + + private updateComponentState(changes: Partial) { + this.componentState = { + ...this.componentState, + ...changes, + }; + this.componentStateSubject$.next(this.componentState); } private fetchAvailableOptions = async () => { this.updateComponentState({ loading: true }); - const { indexPattern, timeRange, filters, field, query } = this.getInput(); - const newOptions = await this.fetchData({ - search: this.searchString, - indexPattern, - timeRange, - filters, + const { ignoreParentSettings, filters, fieldName, query } = this.getInput(); + const dataView = await this.getCurrentDataView(); + const field = dataView.getFieldByName(fieldName); + + if (!field) throw fieldMissingError(fieldName); + + const boolFilter = [ + buildEsQuery( + dataView, + ignoreParentSettings?.ignoreQuery ? [] : query ?? [], + ignoreParentSettings?.ignoreFilters ? [] : filters ?? [] + ), + ]; + + // TODO Switch between `terms_agg` and `terms_enum` method depending on the value of ignoreParentSettings + // const method = Object.values(ignoreParentSettings || {}).includes(false) ? + + const newOptions = await this.dataService.autocomplete.getValueSuggestions({ + query: this.searchString, + indexPattern: dataView, + useTimeRange: !ignoreParentSettings?.ignoreTimerange, + method: 'terms_agg', // terms_agg method is required to use timeRange + boolFilter, field, - query, }); this.updateComponentState({ availableOptions: newOptions, loading: false }); }; - public destroy = () => { - super.destroy(); - this.subscriptions.unsubscribe(); + private initialize = async () => { + const initialSelectedOptions = this.getInput().selectedOptions; + if (initialSelectedOptions) { + await this.getCurrentDataView(); + await this.buildFilter(); + } + this.setInitializationFinished(); + this.setupSubscriptions(); + }; + + private buildFilter = async () => { + const { fieldName, selectedOptions } = this.getInput(); + if (!selectedOptions || selectedOptions.length === 0) { + this.updateOutput({ filters: [] }); + return; + } + const dataView = await this.getCurrentDataView(); + const field = dataView.getFieldByName(this.getInput().fieldName); + + if (!field) throw fieldMissingError(fieldName); + + let newFilter: Filter; + if (selectedOptions.length === 1) { + newFilter = buildPhraseFilter(field, selectedOptions[0], dataView); + } else { + newFilter = buildPhrasesFilter(field, selectedOptions, dataView); + } + + newFilter.meta.key = field?.name; + this.updateOutput({ filters: [newFilter] }); }; reload = () => { this.fetchAvailableOptions(); }; + public destroy = () => { + super.destroy(); + this.subscriptions.unsubscribe(); + }; + public render = (node: HTMLElement) => { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx index 01c31a0bcbc51..cb53ac463be3f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable_factory.tsx @@ -6,58 +6,51 @@ * Side Public License, v 1. */ -import React from 'react'; -import { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public'; -import { - ControlEditorProps, - GetControlEditorComponentProps, - IEditableControlFactory, -} from '../../types'; +import deepEqual from 'fast-deep-equal'; + import { OptionsListEditor } from './options_list_editor'; +import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; +import { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public'; import { - OptionsListDataFetcher, - OptionsListEmbeddable, - OptionsListEmbeddableInput, - OptionsListFieldFetcher, - OptionsListIndexPatternFetcher, - OPTIONS_LIST_CONTROL, -} from './options_list_embeddable'; + createOptionsListExtract, + createOptionsListInject, +} from '../../../../../common/controls/control_types/options_list/options_list_persistable_state'; export class OptionsListEmbeddableFactory - implements EmbeddableFactoryDefinition, IEditableControlFactory + implements EmbeddableFactoryDefinition, IEditableControlFactory { public type = OPTIONS_LIST_CONTROL; + public canCreateNew = () => false; - constructor( - private fetchData: OptionsListDataFetcher, - private fetchIndexPatterns: OptionsListIndexPatternFetcher, - private fetchFields: OptionsListFieldFetcher - ) { - this.fetchIndexPatterns = fetchIndexPatterns; - this.fetchFields = fetchFields; - this.fetchData = fetchData; - } + constructor() {} - public create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) { - return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, this.fetchData, parent)); + public async create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) { + const { OptionsListEmbeddable } = await import('./options_list_embeddable'); + return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, parent)); } - public getControlEditor = ({ - onChange, - initialInput, - }: GetControlEditorComponentProps) => { - return ({ setValidState }: ControlEditorProps) => ( - - ); + public presaveTransformFunction = ( + newInput: Partial, + embeddable?: ControlEmbeddable + ) => { + if ( + embeddable && + (!deepEqual(newInput.fieldName, embeddable.getInput().fieldName) || + !deepEqual(newInput.dataViewId, embeddable.getInput().dataViewId)) + ) { + // if the field name or data view id has changed in this editing session, selected options are invalid, so reset them. + newInput.selectedOptions = []; + } + return newInput; }; + public controlEditorComponent = OptionsListEditor; + public isEditable = () => Promise.resolve(false); public getDisplayName = () => 'Options List Control'; + + public inject = createOptionsListInject(); + public extract = createOptionsListExtract(); } diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx index 35dca40a26ab9..eb9829cd78840 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx @@ -9,7 +9,6 @@ import React, { useMemo, useState } from 'react'; import { EuiFilterSelectItem, - EuiLoadingChart, EuiPopoverTitle, EuiFieldSearch, EuiButtonIcon, @@ -21,11 +20,11 @@ import { EuiIcon, } from '@elastic/eui'; +import { OptionsListEmbeddableInput } from './types'; import { OptionsListStrings } from './options_list_strings'; -import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; -import { OptionsListEmbeddableInput } from './options_list_embeddable'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListComponentState } from './options_list_component'; +import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; export const OptionsListPopover = ({ loading, @@ -122,20 +121,9 @@ export const OptionsListPopover = ({ dispatch(selectOption(availableOption)); }} > - {availableOption} + {`${availableOption}`} ))} - {loading && ( -
-
-
- - -

{OptionsListStrings.popover.getLoadingMessage()}

-
-
-
- )} {!loading && (!availableOptions || availableOptions.length === 0) && (
@@ -157,7 +145,7 @@ export const OptionsListPopover = ({ key={index} onClick={() => dispatch(deselectOption(availableOption))} > - {availableOption} + {`${availableOption}`} ))} {(!selectedOptions || selectedOptions.length === 0) && ( diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts index 3e4104f62f914..39f6281a11c6b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts @@ -9,7 +9,7 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; -import { OptionsListEmbeddableInput } from './options_list_embeddable'; +import { OptionsListEmbeddableInput } from './types'; export const optionsListReducers = { deselectOption: ( diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index 40828f9e335f2..52b5dc6d44910 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -11,21 +11,25 @@ import { i18n } from '@kbn/i18n'; export const OptionsListStrings = { summary: { getSeparator: () => - i18n.translate('presentationUtil.inputControls.optionsList.summary.separator', { + i18n.translate('presentationUtil.controls.optionsList.summary.separator', { defaultMessage: ', ', }), getPlaceholder: () => - i18n.translate('presentationUtil.inputControls.optionsList.summary.placeholder', { + i18n.translate('presentationUtil.controls.optionsList.summary.placeholder', { defaultMessage: 'Select...', }), }, editor: { getIndexPatternTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.editor.indexPatternTitle', { + i18n.translate('presentationUtil.controls.optionsList.editor.indexPatternTitle', { defaultMessage: 'Index pattern', }), + getNoDataViewTitle: () => + i18n.translate('presentationUtil.controls.optionsList.editor.noDataViewTitle', { + defaultMessage: 'Select data view', + }), getFieldTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.editor.fieldTitle', { + i18n.translate('presentationUtil.controls.optionsList.editor.fieldTitle', { defaultMessage: 'Field', }), getAllowMultiselectTitle: () => @@ -35,19 +39,19 @@ export const OptionsListStrings = { }, popover: { getLoadingMessage: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.loading', { + i18n.translate('presentationUtil.controls.optionsList.popover.loading', { defaultMessage: 'Loading filters', }), getEmptyMessage: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.empty', { + i18n.translate('presentationUtil.controls.optionsList.popover.empty', { defaultMessage: 'No filters found', }), getSelectionsEmptyMessage: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.selectionsEmpty', { + i18n.translate('presentationUtil.controls.optionsList.popover.selectionsEmpty', { defaultMessage: 'You have no selections', }), getAllOptionsButtonTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.allOptionsTitle', { + i18n.translate('presentationUtil.controls.optionsList.popover.allOptionsTitle', { defaultMessage: 'Show all options', }), getSelectedOptionsButtonTitle: () => @@ -55,8 +59,15 @@ export const OptionsListStrings = { defaultMessage: 'Show only selected options', }), getClearAllSelectionsButtonTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.popover.clearAllSelectionsTitle', { + i18n.translate('presentationUtil.controls.optionsList.popover.clearAllSelectionsTitle', { defaultMessage: 'Clear selections', }), }, + errors: { + getDataViewNotFoundError: (dataViewId: string) => + i18n.translate('presentationUtil.controls.optionsList.errors.dataViewNotFound', { + defaultMessage: 'Could not locate data view: {dataViewId}', + values: { dataViewId }, + }), + }, }; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts new file mode 100644 index 0000000000000..06b6526f38db4 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/types.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from '../../../../../common/controls/control_types/options_list/types'; diff --git a/src/plugins/presentation_util/public/components/controls/controls_service.ts b/src/plugins/presentation_util/public/components/controls/controls_service.ts index 82242946e4563..436d36fcc9db0 100644 --- a/src/plugins/presentation_util/public/components/controls/controls_service.ts +++ b/src/plugins/presentation_util/public/components/controls/controls_service.ts @@ -6,31 +6,26 @@ * Side Public License, v 1. */ +import { ControlEmbeddable, ControlFactory, ControlInput, ControlOutput } from '.'; import { EmbeddableFactory } from '../../../../embeddable/public'; -import { - InputControlEmbeddable, - ControlTypeRegistry, - InputControlFactory, - InputControlOutput, - InputControlInput, -} from '../../services/controls'; +import { ControlTypeRegistry } from '../../services/controls'; export class ControlsService { private controlsFactoriesMap: ControlTypeRegistry = {}; - public registerInputControlType = (factory: InputControlFactory) => { + public registerControlType = (factory: ControlFactory) => { this.controlsFactoriesMap[factory.type] = factory; }; public getControlFactory = < - I extends InputControlInput = InputControlInput, - O extends InputControlOutput = InputControlOutput, - E extends InputControlEmbeddable = InputControlEmbeddable + I extends ControlInput = ControlInput, + O extends ControlOutput = ControlOutput, + E extends ControlEmbeddable = ControlEmbeddable >( type: string ) => { return this.controlsFactoriesMap[type] as EmbeddableFactory; }; - public getInputControlTypes = () => Object.keys(this.controlsFactoriesMap); + public getControlTypes = () => Object.keys(this.controlsFactoriesMap); } diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts index c4f700ec059d9..379dff97cc871 100644 --- a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts +++ b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts @@ -6,16 +6,16 @@ * Side Public License, v 1. */ import { useEffect, useState } from 'react'; -import { InputControlEmbeddable } from '../../../services/controls'; +import { ControlEmbeddable } from '../types'; export const useChildEmbeddable = ({ untilEmbeddableLoaded, embeddableId, }: { - untilEmbeddableLoaded: (embeddableId: string) => Promise; + untilEmbeddableLoaded: (embeddableId: string) => Promise; embeddableId: string; }) => { - const [embeddable, setEmbeddable] = useState(); + const [embeddable, setEmbeddable] = useState(); useEffect(() => { let mounted = true; diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts b/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts index c317f11979f54..79decd14ba358 100644 --- a/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts +++ b/src/plugins/presentation_util/public/components/controls/hooks/use_state_observable.ts @@ -13,11 +13,11 @@ export const useStateObservable = ( stateObservable: Observable, initialState: T ) => { + const [innerState, setInnerState] = useState(initialState); useEffect(() => { const subscription = stateObservable.subscribe((newState) => setInnerState(newState)); return () => subscription.unsubscribe(); }, [stateObservable]); - const [innerState, setInnerState] = useState(initialState); return innerState; }; diff --git a/src/plugins/presentation_util/public/components/controls/index.ts b/src/plugins/presentation_util/public/components/controls/index.ts new file mode 100644 index 0000000000000..dbea24336699d --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export * from './control_group'; +export * from './types'; diff --git a/src/plugins/presentation_util/public/components/controls/types.ts b/src/plugins/presentation_util/public/components/controls/types.ts index 0704a601640e6..9d530fefe7373 100644 --- a/src/plugins/presentation_util/public/components/controls/types.ts +++ b/src/plugins/presentation_util/public/components/controls/types.ts @@ -6,28 +6,43 @@ * Side Public License, v 1. */ -import { InputControlInput } from '../../services/controls'; +import { Filter } from '@kbn/es-query'; +import { DataView } from '../../../../data_views/public'; +import { ControlInput } from '../../../common/controls/types'; +import { EmbeddableFactory, EmbeddableOutput, IEmbeddable } from '../../../../embeddable/public'; -export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; -export type ControlStyle = 'twoLine' | 'oneLine'; +export interface CommonControlOutput { + filters?: Filter[]; + dataViews?: DataView[]; +} + +export type ControlOutput = EmbeddableOutput & CommonControlOutput; + +export type ControlFactory = EmbeddableFactory; + +export type ControlEmbeddable< + TControlEmbeddableInput extends ControlInput = ControlInput, + TControlEmbeddableOutput extends ControlOutput = ControlOutput +> = IEmbeddable; /** * Control embeddable editor types */ -export interface IEditableControlFactory { - getControlEditor?: GetControlEditorComponent; +export interface IEditableControlFactory { + controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; + presaveTransformFunction?: ( + newState: Partial, + embeddable?: ControlEmbeddable + ) => Partial; } - -export type GetControlEditorComponent = ( - props: GetControlEditorComponentProps -) => ControlEditorComponent; -export interface GetControlEditorComponentProps { - onChange: (partial: Partial) => void; +export interface ControlEditorProps { initialInput?: Partial; -} - -export type ControlEditorComponent = (props: ControlEditorProps) => JSX.Element; - -export interface ControlEditorProps { + onChange: (partial: Partial) => void; setValidState: (valid: boolean) => void; + setDefaultTitle: (defaultTitle: string) => void; } + +/** + * Re-export control types from common + */ +export * from '../../../common/controls/types'; diff --git a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx index 1a29d0536a290..b8b0c46e7823d 100644 --- a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx +++ b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx @@ -8,61 +8,12 @@ import React, { useState } from 'react'; -import { DataView, DataViewField, IIndexPatternFieldList } from '../../../../data_views/common'; - -import { StorybookParams } from '../../services/storybook'; +import useMount from 'react-use/lib/useMount'; import { DataViewPicker } from './data_view_picker'; - -// TODO: we probably should remove this once the PR is merged that has better data views for stories -const flightFieldNames: string[] = [ - 'AvgTicketPrice', - 'Cancelled', - 'Carrier', - 'dayOfWeek', - 'Dest', - 'DestAirportID', - 'DestCityName', - 'DestCountry', - 'DestLocation', - 'DestRegion', - 'DestWeather', - 'DistanceKilometers', - 'DistanceMiles', - 'FlightDelay', - 'FlightDelayMin', - 'FlightDelayType', - 'FlightNum', - 'FlightTimeHour', - 'FlightTimeMin', - 'Origin', - 'OriginAirportID', - 'OriginCityName', - 'OriginCountry', - 'OriginLocation', - 'OriginRegion', - 'OriginWeather', - 'timestamp', -]; -const flightFieldByName: { [key: string]: DataViewField } = {}; -flightFieldNames.forEach( - (flightFieldName) => - (flightFieldByName[flightFieldName] = { - name: flightFieldName, - type: 'string', - } as unknown as DataViewField) -); - -// Change some types manually for now -flightFieldByName.Cancelled = { name: 'Cancelled', type: 'boolean' } as DataViewField; -flightFieldByName.timestamp = { name: 'timestamp', type: 'date' } as DataViewField; - -const flightFields: DataViewField[] = Object.values(flightFieldByName); -const storybookFlightsDataView: DataView = { - id: 'demoDataFlights', - title: 'demo data flights', - fields: flightFields as unknown as IIndexPatternFieldList, - getFieldByName: (name: string) => flightFieldByName[name], -} as unknown as DataView; +import { DataView, DataViewListItem } from '../../../../data_views/common'; +import { injectStorybookDataView } from '../../services/storybook/data_views'; +import { storybookFlightsDataView } from '../controls/__stories__/fixtures/flights'; +import { pluginServices, registry, StorybookParams } from '../../services/storybook'; export default { component: DataViewPicker, @@ -70,15 +21,29 @@ export default { argTypes: {}, }; +injectStorybookDataView(storybookFlightsDataView); + export function Example({}: {} & StorybookParams) { - const dataViews = [storybookFlightsDataView]; + pluginServices.setRegistry(registry.start({})); + + const { + dataViews: { getIdsWithTitle, get }, + } = pluginServices.getServices(); + const [dataViews, setDataViews] = useState(); const [dataView, setDataView] = useState(undefined); - const onChange = (newId: string) => { - const newIndexPattern = dataViews.find((ip) => ip.id === newId); + useMount(() => { + (async () => { + const listItems = await getIdsWithTitle(); + setDataViews(listItems); + })(); + }); - setDataView(newIndexPattern); + const onChange = (newId: string) => { + get(newId).then((newDataView) => { + setDataView(newDataView); + }); }; const triggerLabel = dataView?.title || 'Choose Data View'; @@ -86,9 +51,9 @@ export function Example({}: {} & StorybookParams) { return ( ); } diff --git a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx index 38ec4f16f9432..237a9666deb30 100644 --- a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx +++ b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; -import { DataView } from '../../../../data_views/common'; +import { DataViewListItem } from '../../../../data_views/common'; import { ToolbarButton, ToolbarButtonProps } from '../../../../kibana_react/public'; @@ -21,14 +21,14 @@ export type DataViewTriggerProps = ToolbarButtonProps & { export function DataViewPicker({ dataViews, selectedDataViewId, - onChangeIndexPattern, + onChangeDataViewId, trigger, selectableProps, }: { - dataViews: DataView[]; + dataViews: DataViewListItem[]; selectedDataViewId?: string; trigger: DataViewTriggerProps; - onChangeIndexPattern: (newId: string) => void; + onChangeDataViewId: (newId: string) => void; selectableProps?: EuiSelectableProps; }) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); @@ -92,7 +92,7 @@ export function DataViewPicker({ const choice = choices.find(({ checked }) => checked) as unknown as { value: string; }; - onChangeIndexPattern(choice.value); + onChangeDataViewId(choice.value); setPopoverIsOpen(false); }} searchProps={{ diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.scss b/src/plugins/presentation_util/public/components/field_picker/field_picker.scss index c07cf99ed03d6..eac1979fd003a 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_picker.scss +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.scss @@ -4,6 +4,10 @@ border: 1px dashed transparent; } +.presFieldPickerFieldButtonActive { + box-shadow: 0 0 0 2px $euiColorPrimary; +} + .presFieldPicker__fieldPanel { height: 300px; overflow-y: scroll; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx index c5654254ea70a..023d2be949a73 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx @@ -9,59 +9,8 @@ import React from 'react'; import { FieldPicker } from './field_picker'; - -import { DataView, DataViewField, IIndexPatternFieldList } from '../../../../data_views/common'; - -// TODO: we probably should remove this once the PR is merged that has better data views for stories -const flightFieldNames: string[] = [ - 'AvgTicketPrice', - 'Cancelled', - 'Carrier', - 'dayOfWeek', - 'Dest', - 'DestAirportID', - 'DestCityName', - 'DestCountry', - 'DestLocation', - 'DestRegion', - 'DestWeather', - 'DistanceKilometers', - 'DistanceMiles', - 'FlightDelay', - 'FlightDelayMin', - 'FlightDelayType', - 'FlightNum', - 'FlightTimeHour', - 'FlightTimeMin', - 'Origin', - 'OriginAirportID', - 'OriginCityName', - 'OriginCountry', - 'OriginLocation', - 'OriginRegion', - 'OriginWeather', - 'timestamp', -]; -const flightFieldByName: { [key: string]: DataViewField } = {}; -flightFieldNames.forEach( - (flightFieldName) => - (flightFieldByName[flightFieldName] = { - name: flightFieldName, - type: 'string', - } as unknown as DataViewField) -); - -// Change some types manually for now -flightFieldByName.Cancelled = { name: 'Cancelled', type: 'boolean' } as DataViewField; -flightFieldByName.timestamp = { name: 'timestamp', type: 'date' } as DataViewField; - -const flightFields: DataViewField[] = Object.values(flightFieldByName); -const storybookFlightsDataView: DataView = { - id: 'demoDataFlights', - title: 'demo data flights', - fields: flightFields as unknown as IIndexPatternFieldList, - getFieldByName: (name: string) => flightFieldByName[name], -} as unknown as DataView; +import { DataViewField } from '../../../../data_views/common'; +import { storybookFlightsDataView } from '../controls/__stories__/fixtures/flights'; export default { component: FieldPicker, @@ -85,5 +34,5 @@ export const FieldPickerWithFilter = () => { }; export const FieldPickerWithoutIndexPattern = () => { - return ; + return ; }; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx index bbdf389ccee14..c9be9993c3ec1 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx @@ -6,27 +6,33 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import classNames from 'classnames'; import { sortBy, uniq } from 'lodash'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle, EuiText } from '@elastic/eui'; +import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { FieldSearch } from './field_search'; import { DataView, DataViewField } from '../../../../data_views/common'; import { FieldIcon, FieldButton } from '../../../../kibana_react/public'; -import { FieldSearch } from './field_search'; - import './field_picker.scss'; -export interface Props { - dataView: DataView | null; +export interface FieldPickerProps { + dataView?: DataView; + selectedFieldName?: string; filterPredicate?: (f: DataViewField) => boolean; + onSelectField?: (selectedField: DataViewField) => void; } -export const FieldPicker = ({ dataView, filterPredicate }: Props) => { +export const FieldPicker = ({ + dataView, + onSelectField, + filterPredicate, + selectedFieldName, +}: FieldPickerProps) => { const [nameFilter, setNameFilter] = useState(''); const [typesFilter, setTypesFilter] = useState([]); - const [selectedField, setSelectedField] = useState(null); // Retrieve, filter, and sort fields from data view const fields = dataView @@ -42,7 +48,13 @@ export const FieldPicker = ({ dataView, filterPredicate }: Props) => { ) : []; - const uniqueTypes = dataView ? uniq(dataView.fields.map((f) => f.type as string)) : []; + const uniqueTypes = dataView + ? uniq( + dataView.fields + .filter((f) => (filterPredicate ? filterPredicate(f) : true)) + .map((f) => f.type as string) + ) + : []; return ( { return ( setSelectedField(f)} - isActive={f.name === selectedField?.name} + className={classNames('presFieldPicker__fieldButton', { + presFieldPickerFieldButtonActive: f.name === selectedFieldName, + })} + onClick={() => { + onSelectField?.(f); + }} + isActive={f.name === selectedFieldName} fieldName={f.name} fieldIcon={} /> @@ -122,31 +138,6 @@ export const FieldPicker = ({ dataView, filterPredicate }: Props) => { )} - {selectedField && ( - - -

- -

-
-
- - } - /> -
-
- )}
); }; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts index 36ba1fcaa49b9..fe5a647e7e327 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts +++ b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts @@ -18,7 +18,7 @@ type ManagedEmbeddableReduxStore = EnhancedStore & { asyncReducers: { [key: string]: Reducer }; injectReducer: (props: InjectReducerProps) => void; }; -const embeddablesStore = configureStore({ reducer: {} as { [key: string]: Reducer } }); +const embeddablesStore = configureStore({ reducer: (state) => state }); // store with blank reducers const managedEmbeddablesStore = embeddablesStore as ManagedEmbeddableReduxStore; managedEmbeddablesStore.asyncReducers = {}; @@ -27,10 +27,12 @@ managedEmbeddablesStore.injectReducer = ({ key, asyncReducer, }: InjectReducerProps) => { - managedEmbeddablesStore.asyncReducers[key] = asyncReducer as Reducer; - managedEmbeddablesStore.replaceReducer( - combineReducers({ ...managedEmbeddablesStore.asyncReducers }) - ); + if (!managedEmbeddablesStore.asyncReducers[key]) { + managedEmbeddablesStore.asyncReducers[key] = asyncReducer as Reducer; + managedEmbeddablesStore.replaceReducer( + combineReducers({ ...managedEmbeddablesStore.asyncReducers }) + ); + } }; /** diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index 4a112f7d6e574..9e7b53fb21c3b 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -10,6 +10,8 @@ import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react- import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Draft } from 'immer/dist/types/types-external'; +import { debounceTime, finalize } from 'rxjs/operators'; +import { Filter } from '@kbn/es-query'; import { isEqual } from 'lodash'; import { @@ -18,14 +20,30 @@ import { ReduxEmbeddableWrapperProps, } from './types'; import { + IContainer, IEmbeddable, EmbeddableInput, EmbeddableOutput, - IContainer, + isErrorEmbeddable, } from '../../../../embeddable/public'; import { getManagedEmbeddablesStore } from './generic_embeddable_store'; import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; +type InputWithFilters = Partial & { filters: Filter[] }; +export const stateContainsFilters = ( + state: Partial +): state is InputWithFilters => { + if ((state as InputWithFilters).filters) return true; + return false; +}; + +export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => { + return filters.map((filter) => { + if (filter.meta.value) delete filter.meta.value; + return filter; + }); +}; + const getDefaultProps = (): Required< Pick, 'diffInput'> > => ({ @@ -43,6 +61,17 @@ const embeddableIsContainer = ( embeddable: IEmbeddable ): embeddable is IContainer => embeddable.isContainer; +export const getExplicitInput = ( + embeddable: IEmbeddable +): InputType => { + const root = embeddable.getRoot(); + if (!embeddableIsContainer(embeddable) && embeddableIsContainer(root)) { + return (root.getInput().panels[embeddable.id]?.explicitInput ?? + embeddable.getInput()) as InputType; + } + return embeddable.getInput() as InputType; +}; + /** * Place this wrapper around the react component when rendering an embeddable to automatically set up * redux for use with the embeddable via the supplied reducers. Any child components can then use ReduxEmbeddableContext @@ -72,6 +101,12 @@ export const ReduxEmbeddableWrapper = { const key = `${embeddable.type}_${embeddable.id}`; + const store = getManagedEmbeddablesStore(); + + const initialState = getExplicitInput(embeddable); + if (stateContainsFilters(initialState)) { + initialState.filters = cleanFiltersForSerialize(initialState.filters); + } // A generic reducer used to update redux state when the embeddable input changes const updateEmbeddableReduxState = ( @@ -81,17 +116,28 @@ export const ReduxEmbeddableWrapper = { + return undefined; + }; + const slice = createSlice>({ - initialState: embeddable.getInput(), + initialState, name: key, - reducers: { ...reducers, updateEmbeddableReduxState }, + reducers: { ...reducers, updateEmbeddableReduxState, clearEmbeddableReduxState }, }); - const store = getManagedEmbeddablesStore(); - store.injectReducer({ - key, - asyncReducer: slice.reducer, - }); + if (store.asyncReducers[key]) { + // if the store already has reducers set up for this embeddable type & id, update the existing state. + const updateExistingState = (slice.actions as ReduxEmbeddableContextServices['actions']) + .updateEmbeddableReduxState; + store.dispatch(updateExistingState(initialState)); + } else { + store.injectReducer({ + key, + asyncReducer: slice.reducer, + }); + } const useEmbeddableSelector: TypedUseSelectorHook = () => useSelector((state: ReturnType) => state[key]); @@ -132,32 +178,47 @@ const ReduxEmbeddableSync = (); const dispatch = useEmbeddableDispatch(); const currentState = useEmbeddableSelector((state) => state); const stateRef = useRef(currentState); + const destroyedRef = useRef(false); useEffect(() => { // When Embeddable Input changes, push differences to redux. const inputSubscription = embeddable .getInput$() - // .pipe(debounceTime(0)) // debounce input changes to ensure that when many updates are made in one render the latest wins out + .pipe( + finalize(() => { + // empty redux store, when embeddable is destroyed. + destroyedRef.current = true; + dispatch(clearEmbeddableReduxState(undefined)); + }), + debounceTime(0) + ) // debounce input changes to ensure that when many updates are made in one render the latest wins out .subscribe(() => { - const differences = diffInput(embeddable.getInput(), stateRef.current); + const differences = diffInput(getExplicitInput(embeddable), stateRef.current); if (differences && Object.keys(differences).length > 0) { + if (stateContainsFilters(differences)) { + differences.filters = cleanFiltersForSerialize(differences.filters); + } dispatch(updateEmbeddableReduxState(differences)); } }); return () => inputSubscription.unsubscribe(); - }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState]); + }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState, clearEmbeddableReduxState]); useEffect(() => { + if (isErrorEmbeddable(embeddable) || destroyedRef.current) return; // When redux state changes, push differences to Embeddable Input. stateRef.current = currentState; - const differences = diffInput(currentState, embeddable.getInput()); + const differences = diffInput(currentState, getExplicitInput(embeddable)); if (differences && Object.keys(differences).length > 0) { + if (stateContainsFilters(differences)) { + differences.filters = cleanFiltersForSerialize(differences.filters); + } embeddable.updateInput(differences); } }, [currentState, diffInput, embeddable]); diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 6628124717a1c..478e8a7cda032 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -54,6 +54,8 @@ export { SolutionToolbarPopover, } from './components/solution_toolbar'; +export * from './components/controls'; + export function plugin() { return new PresentationUtilPlugin(); } diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts index ddb02ce464e22..8b81890c51e2a 100644 --- a/src/plugins/presentation_util/public/mocks.ts +++ b/src/plugins/presentation_util/public/mocks.ts @@ -12,7 +12,9 @@ import { pluginServices } from './services'; import { registry } from './services/kibana'; const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart => { - pluginServices.setRegistry(registry.start({ coreStart, startPlugins: {} as any })); + pluginServices.setRegistry( + registry.start({ coreStart, startPlugins: { dataViews: {}, data: {} } as any }) + ); const startContract: PresentationUtilPluginStart = { ContextProvider: pluginServices.getContextProvider(), diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index f697f1a29eb82..f531d99dfb99c 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -10,11 +10,18 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { pluginServices } from './services'; import { registry } from './services/kibana'; import { - PresentationUtilPluginSetup, - PresentationUtilPluginStart, PresentationUtilPluginSetupDeps, PresentationUtilPluginStartDeps, + ControlGroupContainerFactory, + PresentationUtilPluginSetup, + PresentationUtilPluginStart, + IEditableControlFactory, + ControlEditorProps, + ControlInput, + ControlEmbeddable, } from './types'; +import { OptionsListEmbeddableFactory } from './components/controls/control_types/options_list'; +import { CONTROL_GROUP_TYPE, OPTIONS_LIST_CONTROL } from '.'; export class PresentationUtilPlugin implements @@ -25,10 +32,39 @@ export class PresentationUtilPlugin PresentationUtilPluginStartDeps > { + private inlineEditors: { + [key: string]: { + controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; + presaveTransformFunction?: ( + newInput: Partial, + embeddable?: ControlEmbeddable + ) => Partial; + }; + } = {}; + public setup( - _coreSetup: CoreSetup, + _coreSetup: CoreSetup, _setupPlugins: PresentationUtilPluginSetupDeps ): PresentationUtilPluginSetup { + _coreSetup.getStartServices().then(([coreStart, deps]) => { + // register control group embeddable factory + embeddable.registerEmbeddableFactory( + CONTROL_GROUP_TYPE, + new ControlGroupContainerFactory(deps.embeddable) + ); + }); + + const { embeddable } = _setupPlugins; + + // create control type embeddable factories. + const optionsListFactory = new OptionsListEmbeddableFactory(); + const editableOptionsListFactory = optionsListFactory as IEditableControlFactory; + this.inlineEditors[OPTIONS_LIST_CONTROL] = { + controlEditorComponent: editableOptionsListFactory.controlEditorComponent, + presaveTransformFunction: editableOptionsListFactory.presaveTransformFunction, + }; + embeddable.registerEmbeddableFactory(OPTIONS_LIST_CONTROL, optionsListFactory); + return {}; } @@ -37,9 +73,25 @@ export class PresentationUtilPlugin startPlugins: PresentationUtilPluginStartDeps ): PresentationUtilPluginStart { pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); + const { controls: controlsService } = pluginServices.getServices(); + const { embeddable } = startPlugins; + + // register control types with controls service. + const optionsListFactory = embeddable.getEmbeddableFactory(OPTIONS_LIST_CONTROL); + // Temporarily pass along inline editors - inline editing should be made a first-class feature of embeddables + const editableOptionsListFactory = optionsListFactory as IEditableControlFactory; + const { + controlEditorComponent: optionsListControlEditor, + presaveTransformFunction: optionsListPresaveTransform, + } = this.inlineEditors[OPTIONS_LIST_CONTROL]; + editableOptionsListFactory.controlEditorComponent = optionsListControlEditor; + editableOptionsListFactory.presaveTransformFunction = optionsListPresaveTransform; + + if (optionsListFactory) controlsService.registerControlType(optionsListFactory); + return { ContextProvider: pluginServices.getContextProvider(), - controlsService: pluginServices.getServices().controls, + controlsService, labsService: pluginServices.getServices().labs, }; } diff --git a/src/plugins/presentation_util/public/services/controls.ts b/src/plugins/presentation_util/public/services/controls.ts index 197e986381b10..76af24960bfe3 100644 --- a/src/plugins/presentation_util/public/services/controls.ts +++ b/src/plugins/presentation_util/public/services/controls.ts @@ -6,80 +6,54 @@ * Side Public License, v 1. */ -import { Filter } from '@kbn/es-query'; -import { Query, TimeRange } from '../../../data/public'; +import { EmbeddableFactory } from '../../../embeddable/public'; import { - EmbeddableFactory, - EmbeddableInput, - EmbeddableOutput, - IEmbeddable, -} from '../../../embeddable/public'; - -/** - * Control embeddable types - */ -export type InputControlFactory = EmbeddableFactory< - InputControlInput, - InputControlOutput, - InputControlEmbeddable ->; - -export type InputControlInput = EmbeddableInput & { - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - twoLineLayout?: boolean; -}; - -export type InputControlOutput = EmbeddableOutput & { - filters?: Filter[]; -}; - -export type InputControlEmbeddable< - TInputControlEmbeddableInput extends InputControlInput = InputControlInput, - TInputControlEmbeddableOutput extends InputControlOutput = InputControlOutput -> = IEmbeddable; + ControlEmbeddable, + ControlFactory, + ControlOutput, + ControlInput, +} from '../components/controls/types'; export interface ControlTypeRegistry { - [key: string]: InputControlFactory; + [key: string]: ControlFactory; } export interface PresentationControlsService { - registerInputControlType: (factory: InputControlFactory) => void; + registerControlType: (factory: ControlFactory) => void; getControlFactory: < - I extends InputControlInput = InputControlInput, - O extends InputControlOutput = InputControlOutput, - E extends InputControlEmbeddable = InputControlEmbeddable + I extends ControlInput = ControlInput, + O extends ControlOutput = ControlOutput, + E extends ControlEmbeddable = ControlEmbeddable >( type: string ) => EmbeddableFactory; - getInputControlTypes: () => string[]; + getControlTypes: () => string[]; } export const getCommonControlsService = () => { const controlsFactoriesMap: ControlTypeRegistry = {}; - const registerInputControlType = (factory: InputControlFactory) => { + const registerControlType = (factory: ControlFactory) => { controlsFactoriesMap[factory.type] = factory; }; const getControlFactory = < - I extends InputControlInput = InputControlInput, - O extends InputControlOutput = InputControlOutput, - E extends InputControlEmbeddable = InputControlEmbeddable + I extends ControlInput = ControlInput, + O extends ControlOutput = ControlOutput, + E extends ControlEmbeddable = ControlEmbeddable >( type: string ) => { return controlsFactoriesMap[type] as EmbeddableFactory; }; - const getInputControlTypes = () => Object.keys(controlsFactoriesMap); + const getControlTypes = () => Object.keys(controlsFactoriesMap); return { - registerInputControlType, + registerControlType, getControlFactory, - getInputControlTypes, + getControlTypes, }; }; diff --git a/src/plugins/presentation_util/public/services/data.ts b/src/plugins/presentation_util/public/services/data.ts new file mode 100644 index 0000000000000..44f29dcd2d3ad --- /dev/null +++ b/src/plugins/presentation_util/public/services/data.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 { DataPublicPluginStart } from '../../../data/public'; + +export interface PresentationDataService { + autocomplete: DataPublicPluginStart['autocomplete']; +} diff --git a/src/plugins/presentation_util/public/services/data_views.ts b/src/plugins/presentation_util/public/services/data_views.ts new file mode 100644 index 0000000000000..9ab260034a1db --- /dev/null +++ b/src/plugins/presentation_util/public/services/data_views.ts @@ -0,0 +1,15 @@ +/* + * 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 { DataViewsPublicPluginStart } from '../../../data_views/public'; + +export interface PresentationDataViewsService { + get: DataViewsPublicPluginStart['get']; + getDefaultId: DataViewsPublicPluginStart['getDefaultId']; + getIdsWithTitle: DataViewsPublicPluginStart['getIdsWithTitle']; +} diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index 21012971ca86d..c7d8d2617888a 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -14,12 +14,16 @@ import { PresentationLabsService } from './labs'; import { registry as stubRegistry } from './stub'; import { PresentationOverlaysService } from './overlays'; import { PresentationControlsService } from './controls'; +import { PresentationDataViewsService } from './data_views'; +import { PresentationDataService } from './data'; export { PresentationCapabilitiesService } from './capabilities'; export { PresentationDashboardsService } from './dashboards'; export { PresentationLabsService } from './labs'; export interface PresentationUtilServices { dashboards: PresentationDashboardsService; + dataViews: PresentationDataViewsService; + data: PresentationDataService; capabilities: PresentationCapabilitiesService; overlays: PresentationOverlaysService; controls: PresentationControlsService; diff --git a/src/plugins/presentation_util/public/services/kibana/data.ts b/src/plugins/presentation_util/public/services/kibana/data.ts new file mode 100644 index 0000000000000..408e59fd4906c --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/data.ts @@ -0,0 +1,25 @@ +/* + * 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 { PresentationUtilPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../create'; +import { PresentationDataService } from '../data'; + +export type DataServiceFactory = KibanaPluginServiceFactory< + PresentationDataService, + PresentationUtilPluginStartDeps +>; + +export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => { + const { + data: { autocomplete }, + } = startPlugins; + return { + autocomplete, + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/data_views.ts b/src/plugins/presentation_util/public/services/kibana/data_views.ts new file mode 100644 index 0000000000000..ecebecce3b3c0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/data_views.ts @@ -0,0 +1,28 @@ +/* + * 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 { PresentationUtilPluginStartDeps } from '../../types'; +import { PresentationDataViewsService } from '../data_views'; +import { KibanaPluginServiceFactory } from '../create'; + +export type DataViewsServiceFactory = KibanaPluginServiceFactory< + PresentationDataViewsService, + PresentationUtilPluginStartDeps +>; + +export const dataViewsServiceFactory: DataViewsServiceFactory = ({ startPlugins }) => { + const { + dataViews: { get, getIdsWithTitle, getDefaultId }, + } = startPlugins; + + return { + get, + getDefaultId, + getIdsWithTitle, + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts index 48c921bff1efd..3820442555c26 100644 --- a/src/plugins/presentation_util/public/services/kibana/index.ts +++ b/src/plugins/presentation_util/public/services/kibana/index.ts @@ -6,10 +6,6 @@ * Side Public License, v 1. */ -import { capabilitiesServiceFactory } from './capabilities'; -import { dashboardsServiceFactory } from './dashboards'; -import { overlaysServiceFactory } from './overlays'; -import { labsServiceFactory } from './labs'; import { PluginServiceProviders, KibanaPluginServiceParams, @@ -18,12 +14,14 @@ import { } from '../create'; import { PresentationUtilPluginStartDeps } from '../../types'; import { PresentationUtilServices } from '..'; -import { controlsServiceFactory } from './controls'; -export { capabilitiesServiceFactory } from './capabilities'; -export { dashboardsServiceFactory } from './dashboards'; -export { overlaysServiceFactory } from './overlays'; -export { labsServiceFactory } from './labs'; +import { capabilitiesServiceFactory } from './capabilities'; +import { dataViewsServiceFactory } from './data_views'; +import { dashboardsServiceFactory } from './dashboards'; +import { controlsServiceFactory } from './controls'; +import { overlaysServiceFactory } from './overlays'; +import { dataServiceFactory } from './data'; +import { labsServiceFactory } from './labs'; export const providers: PluginServiceProviders< PresentationUtilServices, @@ -31,6 +29,8 @@ export const providers: PluginServiceProviders< > = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), + data: new PluginServiceProvider(dataServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), diff --git a/src/plugins/presentation_util/public/services/storybook/data.ts b/src/plugins/presentation_util/public/services/storybook/data.ts new file mode 100644 index 0000000000000..841ee1bd9be71 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/data.ts @@ -0,0 +1,25 @@ +/* + * 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 { DataPublicPluginStart } from '../../../../data/public'; +import { DataViewField } from '../../../../data_views/common'; +import { PresentationDataService } from '../data'; +import { PluginServiceFactory } from '../create'; + +let valueSuggestionMethod = ({ field, query }: { field: DataViewField; query: string }) => + Promise.resolve(['storybook', 'default', 'values']); +export const replaceValueSuggestionMethod = ( + newMethod: ({ field, query }: { field: DataViewField; query: string }) => Promise +) => (valueSuggestionMethod = newMethod); + +export type DataServiceFactory = PluginServiceFactory; +export const dataServiceFactory: DataServiceFactory = () => ({ + autocomplete: { + getValueSuggestions: valueSuggestionMethod, + } as unknown as DataPublicPluginStart['autocomplete'], +}); diff --git a/src/plugins/presentation_util/public/services/storybook/data_views.ts b/src/plugins/presentation_util/public/services/storybook/data_views.ts new file mode 100644 index 0000000000000..ecdd3d48c4658 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/data_views.ts @@ -0,0 +1,29 @@ +/* + * 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 { PluginServiceFactory } from '../create'; +import { PresentationDataViewsService } from '../data_views'; +import { DataViewsPublicPluginStart } from '../../../../data_views/public'; +import { DataView } from '../../../../data_views/common'; + +export type DataViewsServiceFactory = PluginServiceFactory; + +let currentDataView: DataView; +export const injectStorybookDataView = (dataView: DataView) => (currentDataView = dataView); + +export const dataViewsServiceFactory: DataViewsServiceFactory = () => ({ + get: (() => + new Promise((r) => + setTimeout(() => r(currentDataView), 100) + ) as unknown) as DataViewsPublicPluginStart['get'], + getIdsWithTitle: (() => + new Promise((r) => + setTimeout(() => r([{ id: currentDataView.id, title: currentDataView.title }]), 100) + ) as unknown) as DataViewsPublicPluginStart['getIdsWithTitle'], + getDefaultId: () => Promise.resolve(currentDataView?.id ?? null), +}); diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts index 9de4934d51300..1639316a1fe19 100644 --- a/src/plugins/presentation_util/public/services/storybook/index.ts +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -18,6 +18,8 @@ import { capabilitiesServiceFactory } from './capabilities'; import { PresentationUtilServices } from '..'; import { overlaysServiceFactory } from './overlays'; import { controlsServiceFactory } from './controls'; +import { dataViewsServiceFactory } from './data_views'; +import { dataServiceFactory } from './data'; export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; export { PresentationUtilServices } from '..'; @@ -32,6 +34,8 @@ export interface StorybookParams { export const providers: PluginServiceProviders = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), + data: new PluginServiceProvider(dataServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts index 35aabdb465b14..2e312ff682927 100644 --- a/src/plugins/presentation_util/public/services/stub/index.ts +++ b/src/plugins/presentation_util/public/services/stub/index.ts @@ -16,12 +16,17 @@ import { controlsServiceFactory } from './controls'; export { dashboardsServiceFactory } from './dashboards'; export { capabilitiesServiceFactory } from './capabilities'; +import { dataServiceFactory } from '../storybook/data'; +import { dataViewsServiceFactory } from '../storybook/data_views'; + export const providers: PluginServiceProviders = { dashboards: new PluginServiceProvider(dashboardsServiceFactory), capabilities: new PluginServiceProvider(capabilitiesServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), + data: new PluginServiceProvider(dataServiceFactory), + dataViews: new PluginServiceProvider(dataViewsServiceFactory), }; export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index 3903d1bc2786e..63690901b9be6 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -6,8 +6,11 @@ * Side Public License, v 1. */ -import { PresentationControlsService } from './services/controls'; +import { DataPublicPluginStart } from '../../data/public'; import { PresentationLabsService } from './services/labs'; +import { PresentationControlsService } from './services/controls'; +import { DataViewsPublicPluginStart } from '../../data_views/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PresentationUtilPluginSetup {} @@ -18,7 +21,13 @@ export interface PresentationUtilPluginStart { controlsService: PresentationControlsService; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PresentationUtilPluginSetupDeps {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PresentationUtilPluginStartDeps {} +export interface PresentationUtilPluginSetupDeps { + embeddable: EmbeddableSetup; +} +export interface PresentationUtilPluginStartDeps { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + dataViews: DataViewsPublicPluginStart; +} + +export * from './components/controls'; diff --git a/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts b/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts new file mode 100644 index 0000000000000..17dcbbd249435 --- /dev/null +++ b/src/plugins/presentation_util/server/controls/control_group/control_group_container_factory.ts @@ -0,0 +1,25 @@ +/* + * 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 { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; +import { EmbeddableRegistryDefinition } from '../../../../embeddable/server'; +import { CONTROL_GROUP_TYPE } from '../../../common/controls'; +import { + createControlGroupExtract, + createControlGroupInject, +} from '../../../common/controls/control_group/control_group_persistable_state'; + +export const controlGroupContainerPersistableStateServiceFactory = ( + persistableStateService: EmbeddablePersistableStateService +): EmbeddableRegistryDefinition => { + return { + id: CONTROL_GROUP_TYPE, + extract: createControlGroupExtract(persistableStateService), + inject: createControlGroupInject(persistableStateService), + }; +}; diff --git a/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts b/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts new file mode 100644 index 0000000000000..b9d69ea489274 --- /dev/null +++ b/src/plugins/presentation_util/server/controls/control_types/options_list/options_list_embeddable_factory.ts @@ -0,0 +1,22 @@ +/* + * 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 { EmbeddableRegistryDefinition } from '../../../../../embeddable/server'; +import { OPTIONS_LIST_CONTROL } from '../../../../common/controls'; +import { + createOptionsListExtract, + createOptionsListInject, +} from '../../../../common/controls/control_types/options_list/options_list_persistable_state'; + +export const optionsListPersistableStateServiceFactory = (): EmbeddableRegistryDefinition => { + return { + id: OPTIONS_LIST_CONTROL, + extract: createOptionsListExtract(), + inject: createOptionsListInject(), + }; +}; diff --git a/src/plugins/presentation_util/server/plugin.ts b/src/plugins/presentation_util/server/plugin.ts index eb55373920625..2c52fa1f6c2d8 100644 --- a/src/plugins/presentation_util/server/plugin.ts +++ b/src/plugins/presentation_util/server/plugin.ts @@ -7,11 +7,24 @@ */ import { CoreSetup, Plugin } from 'kibana/server'; +import { EmbeddableSetup } from '../../embeddable/server'; +import { controlGroupContainerPersistableStateServiceFactory } from './controls/control_group/control_group_container_factory'; +import { optionsListPersistableStateServiceFactory } from './controls/control_types/options_list/options_list_embeddable_factory'; import { getUISettings } from './ui_settings'; -export class PresentationUtilPlugin implements Plugin { - public setup(core: CoreSetup) { +interface SetupDeps { + embeddable: EmbeddableSetup; +} + +export class PresentationUtilPlugin implements Plugin { + public setup(core: CoreSetup, plugins: SetupDeps) { core.uiSettings.register(getUISettings()); + + plugins.embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory()); + + plugins.embeddable.registerEmbeddableFactory( + controlGroupContainerPersistableStateServiceFactory(plugins.embeddable) + ); return {}; } diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index caff10a90e84c..caabd0b18af71 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -22,6 +22,7 @@ { "path": "../saved_objects/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json"}, { "path": "../data/tsconfig.json" } ] } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index d1c9f3fd5d657..138ce3f097ce9 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7695,6 +7695,12 @@ "description": "Non-default value of setting." } }, + "labs:dashboard:dashboardControls": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "discover:showFieldStatistics": { "type": "boolean", "_meta": { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 16236073a1c7e..e486b367ad577 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4213,10 +4213,6 @@ "newsfeed.headerButton.unreadAriaLabel": "ニュースフィードメニュー - 未読の項目があります", "newsfeed.loadingPrompt.gettingNewsText": "最新ニュースを取得しています...", "presentationUtil.dashboardPicker.searchDashboardPlaceholder": "ダッシュボードを検索...", - "presentationUtil.inputControls.optionsList.popover.empty": "フィルターが見つかりません", - "presentationUtil.inputControls.optionsList.popover.loading": "フィルターを読み込み中", - "presentationUtil.inputControls.optionsList.summary.placeholder": "選択してください...", - "presentationUtil.inputControls.optionsList.summary.separator": ",", "presentationUtil.labs.components.browserSwitchHelp": "このブラウザーでラボを有効にします。ブラウザーを閉じた後も永続します。", "presentationUtil.labs.components.browserSwitchName": "ブラウザー", "presentationUtil.labs.components.calloutHelp": "変更を適用するには更新します", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e67130d838b75..06ecda6920f5d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4251,10 +4251,6 @@ "newsfeed.headerButton.unreadAriaLabel": "新闻源菜单 - 存在未读项目", "newsfeed.loadingPrompt.gettingNewsText": "正在获取最近的新闻......", "presentationUtil.dashboardPicker.searchDashboardPlaceholder": "搜索仪表板......", - "presentationUtil.inputControls.optionsList.popover.empty": "未找到任何筛选", - "presentationUtil.inputControls.optionsList.popover.loading": "正在加载筛选", - "presentationUtil.inputControls.optionsList.summary.placeholder": "选择......", - "presentationUtil.inputControls.optionsList.summary.separator": ",", "presentationUtil.labs.components.browserSwitchHelp": "启用此浏览器的实验并在其关闭后继续保持。", "presentationUtil.labs.components.browserSwitchName": "浏览器", "presentationUtil.labs.components.calloutHelp": "刷新以应用更改",