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/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 86f81aa1ee10d..2ab7bcac754e4 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -39,6 +39,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, + createAndSyncDashboardControlGroup, +} from '../lib/dashboard_control_group'; +import { ControlGroupContainer } from '../../../../presentation_util/public'; export interface DashboardContainerServices { ExitFullScreenButton: React.ComponentType; @@ -88,6 +93,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; }; @@ -106,6 +114,20 @@ export class DashboardContainer extends Container { + if (!result) return; + const { controlGroup, onDestroyControlGroup } = result; + this.controlGroup = controlGroup; + this.onDestroyControlGroup = onDestroyControlGroup; + } + ); + } } protected createNewPanelState< @@ -232,7 +254,7 @@ export class DashboardContainer extends Container - + , @@ -240,6 +262,11 @@ export class DashboardContainer extends Container { 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/lib/convert_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/convert_dashboard_state.ts index 123ef381f25f6..e0b528f0f9952 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_saved_panels_to_panel_map'; +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..40f206f04d43c --- /dev/null +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -0,0 +1,215 @@ +/* + * 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 uuid from 'uuid'; +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 { + ControlGroupContainer, + ControlGroupInput, + ControlGroupOutput, + CONTROL_GROUP_TYPE, + ControlStyle, +} from '../../../../presentation_util/public'; +import { DashboardContainer } from '..'; +import { EmbeddableStart, isErrorEmbeddable } from '../../services/embeddable'; +import { DashboardContainerInput, DashboardSavedObject } from '../..'; +import { DashboardState } from '../../types'; + +// 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']; +} + +type DashboardControlGroupCommonKeys = keyof Pick< + DashboardContainerInput | ControlGroupInput, + 'filters' | 'lastReloadRequestTime' | 'timeRange' | 'query' +>; + +export const getDefaultDashboardControlGroupInput = () => ({ + controlStyle: 'oneLine' as ControlStyle, + panels: {}, +}); + +export const createAndSyncDashboardControlGroup = async ({ + dashboardContainer, + getEmbeddableFactory, +}: { + dashboardContainer: DashboardContainer; + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; +}) => { + const subscriptions = new Subscription(); + const controlsGroupFactory = getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + ControlGroupContainer + >(CONTROL_GROUP_TYPE); + const controlGroup = await controlsGroupFactory?.create({ + ...getDefaultDashboardControlGroupInput(), + ...(dashboardContainer.getInput().controlGroupInput ?? {}), + id: uuid.v4(), + }); + if (!controlGroup || isErrorEmbeddable(controlGroup)) return; + + 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 + subscriptions.add( + controlGroup.getInput$().subscribe(() => { + const { panels, controlStyle } = controlGroup.getInput(); + if (!isControlGroupInputEqual()) { + dashboardContainer.updateInput({ controlGroupInput: { panels, controlStyle } }); + } + }) + ); + + const refetchDiffMethods: { + [key: string]: (a?: unknown, b?: unknown) => boolean; + } = { + filters: (a, b) => + compareFilters((a as Filter[]) ?? [], (b as Filter[]) ?? [], COMPARE_ALL_OPTIONS), + lastReloadRequestTime: deepEqual, + timeRange: deepEqual, + query: 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) => + !(Object.keys(refetchDiffMethods) as DashboardControlGroupCommonKeys[]) + .map((key) => deepEqual(a[key], b[key])) + .includes(false) + ) + ) + .subscribe(() => { + const newInput: { [key: string]: unknown } = {}; + (Object.keys(refetchDiffMethods) as DashboardControlGroupCommonKeys[]).forEach((key) => { + if ( + !refetchDiffMethods[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 }; +}; + +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..0624733ec559a 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; @@ -50,6 +51,9 @@ export const syncDashboardIndexPatterns = ({ ) { return; } + if (container.controlGroup) { + panelIndexPatterns.push(...(container.controlGroup.getOutput().dataViews ?? [])); + } return panelIndexPatterns; }), distinctUntilChanged((a, b) => @@ -77,6 +81,14 @@ export const syncDashboardIndexPatterns = ({ }) ); + if (dashboardContainer.controlGroup) { + return combineLatest([ + dashboardContainer.getOutput$(), + dashboardContainer.controlGroup.getOutput$(), + ]) + .pipe(mapTo(dashboardContainer), updateIndexPatternsOperator) + .subscribe(); + } return dashboardContainer .getOutput$() .pipe(mapTo(dashboardContainer), updateIndexPatternsOperator) 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..5463b52cceb55 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 { 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/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 8772f14a6ec4c..38fe62e40f1f8 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..70221f4fb9474 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -37,6 +37,7 @@ import { DashboardContainer, DashboardSavedObject } from '.'; import { VisualizationsStart } from '../../visualizations/public'; import { DashboardAppLocatorParams } from './locator'; import { SpacesPluginStart } from './services/spaces'; +import { 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 068883c429e61..22ef0a1a1921a 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -51,6 +51,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/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 06133fb2160c0..b8e4c2d0e016c 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -246,7 +246,6 @@ export abstract class Container< [panelState.explicitInput.id]: panelState, }, } as Partial); - return await this.untilEmbeddableLoaded(panelState.explicitInput.id); } 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 b958f3de0814f..675366f3a3691 100644 --- a/src/plugins/presentation_util/common/labs.ts +++ b/src/plugins/presentation_util/common/labs.ts @@ -10,8 +10,9 @@ 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 projectIDs = [DEFER_BELOW_FOLD] as const; +export const projectIDs = [DEFER_BELOW_FOLD, DASHBOARD_CONTROLS] as const; export const environmentNames = ['kibana', 'browser', 'session'] as const; export const solutionNames = ['canvas', 'dashboard', 'presentation'] as const; @@ -34,6 +35,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'], + }, }; export type ProjectID = typeof projectIDs[number]; 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 d7fe9b558e606..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"], + "requiredPlugins": ["savedObjects", "data", "dataViews", "embeddable", "kibanaReact"], "optionalPlugins": [] } 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 faaa155249949..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, getEuiSelectableOptions } 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(getEuiSelectableOptions(field, search)), 500)), - () => Promise.resolve(['demo data flights']), - () => 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__/decorators.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx index c5d3cf2c815be..4ad5309ba604f 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx @@ -23,12 +23,10 @@ const panelStyle = { const kqlBarStyle = { background: bar, padding: 16, minHeight, fontStyle: 'italic' }; -const inputBarStyle = { background: '#fff', padding: 4 }; - const layout = (OptionStory: Story) => ( KQL Bar - + 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 e405b704796ec..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts +++ /dev/null @@ -1,60 +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 { EuiSelectableOption } from '@elastic/eui'; - -import { flights } from '../../fixtures/flights'; - -export type Flight = typeof flights[number]; -export type FlightField = keyof Flight; - -export const getOptions = (field: string) => uniq(map(flights, field)).sort(); - -export const getEuiSelectableOptions = (field: string, search?: string): EuiSelectableOption[] => { - const options = getOptions(field) - .map((option) => ({ - label: option + '', - searchableLabel: option + '', - })) - .filter((option) => !search || option.label.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) as FlightField[]; diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx index 66f1d8b36399e..f46cf006d5e04 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx @@ -7,8 +7,8 @@ */ import React, { useEffect, useMemo } from 'react'; -import uuid from 'uuid'; +import uuid from 'uuid'; import { decorators } from './decorators'; import { pluginServices, registry } from '../../../services/storybook'; import { populateStorybookControlFactories } from './storybook_control_factories'; @@ -25,7 +25,7 @@ export default { decorators, }; -const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels }) => { +const ControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels }) => { const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); pluginServices.setRegistry(registry.start({})); @@ -35,11 +35,6 @@ const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels } (async () => { const factory = new ControlGroupContainerFactory(); const controlGroupContainerEmbeddable = await factory.create({ - inheritParentState: { - useQuery: false, - useFilters: false, - useTimerange: false, - }, controlStyle: 'oneLine', panels: panels ?? {}, id: uuid.v4(), @@ -53,9 +48,9 @@ const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels } return
; }; -export const EmptyControlGroupStory = () => ; +export const EmptyControlGroupStory = () => ; export const ConfiguredControlGroupStory = () => ( - ( explicitInput: { title: 'Origin City', id: 'optionsList1', - indexPattern: 'demo data flights', - field: 'OriginCityName', - defaultSelections: ['Toronto'], + dataViewId: 'demoDataFlights', + fieldName: 'OriginCityName', + selectedOptions: ['Toronto'], } as OptionsListEmbeddableInput, }, optionsList2: { @@ -76,9 +71,9 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Destination City', id: 'optionsList2', - indexPattern: 'demo data flights', - field: 'DestCityName', - defaultSelections: ['London'], + dataViewId: 'demoDataFlights', + fieldName: 'DestCityName', + selectedOptions: ['London'], } as OptionsListEmbeddableInput, }, optionsList3: { @@ -88,8 +83,8 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Carrier', id: 'optionsList3', - indexPattern: 'demo data flights', - field: 'Carrier', + dataViewId: 'demoDataFlights', + fieldName: 'Carrier', } as OptionsListEmbeddableInput, }, }} 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 3048adc74d8c7..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,22 +6,17 @@ * Side Public License, v 1. */ -import { flightFields, getEuiSelectableOptions } 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(getEuiSelectableOptions(field, search)), 500)), - () => Promise.resolve(['demo data flights']), - () => 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 103ce6dd0e27c..d2e10dea1ee8a 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'; @@ -21,6 +22,7 @@ import { EditControlButton } from '../editor/edit_control'; import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; import { ControlGroupStrings } from '../control_group_strings'; +import { pluginServices } from '../../../../services'; export interface ControlFrameProps { customPrepend?: JSX.Element; @@ -36,6 +38,10 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } = useReduxContainerContext(); const { controlStyle } = useEmbeddableSelector((state) => state); + // Presentation Services Context + const { overlays } = pluginServices.getHooks(); + const { openConfirm } = overlays.useService(); + const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId }); const [title, setTitle] = useState(); @@ -63,7 +69,18 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con removeEmbeddable(embeddableId)} + onClick={() => + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + } + }) + } iconType="cross" color="danger" /> @@ -71,35 +88,46 @@ 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} )} } > -
+ {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 4d5e8bc270e23..98eaa08be8cf1 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 @@ -8,7 +8,14 @@ import '../control_group.scss'; -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiPanel, + EuiText, +} from '@elastic/eui'; import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { @@ -57,7 +64,7 @@ export const ControlGroup = () => { const dispatch = useEmbeddableDispatch(); // current state - const { panels } = useEmbeddableSelector((state) => state); + const { panels, controlStyle } = useEmbeddableSelector((state) => state); const idsInOrder = useMemo( () => @@ -92,63 +99,98 @@ export const ControlGroup = () => { setDraggingId(null); }; + const emptyState = !(idsInOrder && idsInOrder.length > 0); + return ( - - - setDraggingId(active.id)} - onDragEnd={onDragEnd} - onDragCancel={() => setDraggingId(null)} - sensors={sensors} - collisionDetection={closestCenter} - layoutMeasuring={{ - strategy: LayoutMeasuringStrategy.Always, - }} - > - - - {idsInOrder.map( - (controlId, index) => - panels[controlId] && ( - - ) - )} - - - {draggingId ? : null} - - - - + + {idsInOrder.length > 0 ? ( + - - - openFlyout(forwardAllContext(, reduxContainerContext)) - } - /> - + setDraggingId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setDraggingId(null)} + sensors={sensors} + collisionDetection={closestCenter} + layoutMeasuring={{ + strategy: LayoutMeasuringStrategy.Always, + }} + > + + + {idsInOrder.map( + (controlId, index) => + panels[controlId] && ( + + ) + )} + + + + {draggingId ? : null} + + - - - - + + + + + { + const flyoutInstance = openFlyout( + forwardAllContext( + flyoutInstance.close()} />, + reduxContainerContext + ) + ); + }} + /> + + + + + + + + - - + ) : ( + <> + + + +

{ControlGroupStrings.emptyState.getCallToAction()}

+
+
+ +
+ +
+
+
+ + )} + ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx index 5c222e3c130b5..e3a00ca221087 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx @@ -74,13 +74,13 @@ const SortableControlInner = forwardRef< return ( (draggingIndex ?? -1), + className={classNames('controlFrameWrapper', { + 'controlFrameWrapper-isDragging': isDragging, + 'controlFrameWrapper--small': width === 'small', + 'controlFrameWrapper--medium': width === 'medium', + 'controlFrameWrapper--large': width === 'large', + 'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1), + 'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1), })} style={style} > @@ -106,11 +106,11 @@ export const ControlClone = ({ draggingId }: { draggingId: string }) => { const title = panels[draggingId].explicitInput.title; return ( {controlStyle === 'twoLine' ? {title} : undefined} 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 f49efa7aab043..e18b66da606ad 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 @@ -4,32 +4,58 @@ $largeControl: $euiSize * 50; $controlMinWidth: $euiSize * 14; .controlGroup { - margin-left: $euiSizeXS; overflow-x: clip; // sometimes when using auto width, removing a control can cause a horizontal scrollbar to appear. min-height: $euiSize * 4; - padding: $euiSize 0; } -.controlFrame--cloneWrapper { +.controlsWrapper { + &--empty { + display: flex; + @include euiBreakpoint('m', 'l', 'xl') { + background: url(opt_a.svg); + background-position: left top; + background-repeat: no-repeat; + .addControlButton { + text-align: center; + } + .emptyStateText { + padding-left: $euiSize * 2; + } + } + @include euiBreakpoint('xs', 's') { + .addControlButton { + text-align: center; + } + } + } + + &--twoLine { + .groupEditActions { + padding-top: $euiSize; + } + } +} + +.controlFrameCloneWrapper { width: max-content; .euiFormLabel { padding-bottom: $euiSizeXS; } - &-small { + &--small { width: $smallControl; } - &-medium { + &--medium { width: $mediumControl; } - &-large { + &--large { width: $largeControl; } - &-twoLine { + &--twoLine { margin-top: -$euiSize * 1.25; } @@ -49,7 +75,7 @@ $controlMinWidth: $euiSize * 14; min-width: $controlMinWidth; } - .controlFrame--formControlLayout, .controlFrame--draggable { + .controlFrame__formControlLayout, .controlFrame--draggable { &-clone { box-shadow: 0 0 0 1px $euiShadowColor, 0 1px 6px 0 $euiShadowColor; @@ -62,28 +88,28 @@ $controlMinWidth: $euiSize * 14; } } -.controlFrame--wrapper { +.controlFrameWrapper { flex-basis: auto; position: relative; display: block; - .controlFrame--formControlLayout { + .controlFrame__formControlLayout { width: 100%; min-width: $controlMinWidth; transition:background-color .1s, color .1s; - &__label { + &Label { @include euiTextTruncate; max-width: 50%; } - &:not(.controlFrame--formControlLayout-clone) { + &:not(.controlFrame__formControlLayout-clone) { .controlFrame--dragHandle { cursor: grab; } } - .controlFrame--control { + .controlFrame__control { height: 100%; transition: opacity .1s; @@ -91,23 +117,30 @@ $controlMinWidth: $euiSize * 14; width: 100%; } } + + .controlFrame--controlLoading { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } } - &-small { + &--small { width: $smallControl; } - &-medium { + &--medium { width: $mediumControl; } - &-large { + &--large { width: $largeControl; } - &-insertBefore, - &-insertAfter { - .controlFrame--formControlLayout:after { + &--insertBefore, + &--insertAfter { + .controlFrame__formControlLayout:after { content: ''; position: absolute; background-color: transparentize($euiColorPrimary, .5); @@ -118,14 +151,14 @@ $controlMinWidth: $euiSize * 14; } } - &-insertBefore { - .controlFrame--formControlLayout:after { + &--insertBefore { + .controlFrame__formControlLayout:after { left: -$euiSizeS; } } - &-insertAfter { - .controlFrame--formControlLayout:after { + &--insertAfter { + .controlFrame__formControlLayout:after { right: -$euiSizeS; } } @@ -167,7 +200,7 @@ $controlMinWidth: $euiSize * 14; .euiFormRow__labelWrapper { opacity: 0; } - .controlFrame--formControlLayout { + .controlFrame__formControlLayout { background-color: $euiColorEmptyShade !important; color: transparent !important; box-shadow: none; @@ -176,7 +209,7 @@ $controlMinWidth: $euiSize * 14; opacity: 0; } - .controlFrame--control { + .controlFrame__control { opacity: 0; } } 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 35e490b0ea530..cbe7275e0347f 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,168 +10,181 @@ 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: { + getCallToAction: () => + i18n.translate('presentationUtil.inputControls.controlGroup.emptyState.callToAction', { + defaultMessage: 'Controls let you filter and interact with your dashboard data', + }), + getAddControlButtonTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.emptyState.addControlButtonTitle', + { + defaultMessage: 'Add control', + } + ), + }, manageControl: { - getFlyoutTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { - defaultMessage: 'Manage control', + getFlyoutCreateTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.manageControl.createFlyoutTitle', + { + defaultMessage: 'Create control', + } + ), + getFlyoutEditTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.editFlyoutTitle', { + defaultMessage: 'Edit control', }), getTitleInputTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.titleInputTitle', { + i18n.translate('presentationUtil.controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Title', }), getWidthInputTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.widthInputTitle', { - defaultMessage: 'Control width', + 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: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.buttonTitle', { - defaultMessage: 'Manage controls', + defaultMessage: 'Configure controls', }), getFlyoutTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', { - defaultMessage: 'Manage controls', + defaultMessage: 'Configure controls', }), getDefaultWidthTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.defaultWidthTitle', { - defaultMessage: 'Default width', + 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: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.setAllWidths', { - defaultMessage: 'Set all widths to default', + 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 width', + 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', } ), getSingleLineTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.singleLine', { - defaultMessage: 'Single line layout', + defaultMessage: 'Single line', }), getTwoLineTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.twoLine', { - defaultMessage: 'Two line layout', + defaultMessage: 'Double line', }), }, 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', }), }, discardChanges: { getTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.title', { - defaultMessage: 'Discard?', + defaultMessage: 'Discard changes?', }), getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', { - defaultMessage: - 'Discard changes to this control? Changes are not recoverable once discardsd.', + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', { - defaultMessage: 'Discard', + defaultMessage: 'Discard changes', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.discard.cancel', { defaultMessage: 'Cancel', }), }, discardNewControl: { getTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.title', { - defaultMessage: 'Discard?', + defaultMessage: 'Discard new control', }), getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', { - defaultMessage: 'Discard new control? Controls are not recoverable once discarded.', + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', { - defaultMessage: 'Discard', + defaultMessage: 'Discard control', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.cancel', { + i18n.translate('presentationUtil.controls.controlGroup.management.deleteNew.cancel', { defaultMessage: 'Cancel', }), }, @@ -179,10 +192,10 @@ export const ControlGroupStrings = { floatingActions: { getEditButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { - defaultMessage: 'Manage control', + 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 38d8faf37397a..3ed2b5efc3af5 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 @@ -33,10 +33,11 @@ import { import { ControlGroupStrings } from '../control_group_strings'; import { ControlEditorComponent, ControlWidth } from '../../types'; -import { CONTROL_WIDTH_OPTIONS } from '../control_group_constants'; +import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; interface ManageControlProps { title?: string; + isCreate: boolean; onSave: () => void; width: ControlWidth; onCancel: () => void; @@ -51,6 +52,7 @@ export const ControlEditor = ({ removeControl, updateTitle, updateWidth, + isCreate, onCancel, onSave, title, @@ -68,7 +70,11 @@ export const ControlEditor = ({ <> -

{ControlGroupStrings.manageControl.getFlyoutTitle()}

+

+ {isCreate + ? ControlGroupStrings.manageControl.getFlyoutCreateTitle() + : ControlGroupStrings.manageControl.getFlyoutEditTitle()} +

@@ -121,7 +127,7 @@ export const ControlEditor = ({ { onCancel(); @@ -132,7 +138,7 @@ export const ControlEditor = ({ { +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 @@ -49,14 +49,14 @@ export const CreateControlButton = () => { // current state const { defaultControlWidth } = useEmbeddableSelector((state) => state); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isControlTypePopoverOpen, setIsControlTypePopoverOpen] = useState(false); const createNewControl = async (type: string) => { 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) { @@ -80,6 +80,7 @@ export const CreateControlButton = () => { const flyoutInstance = openFlyout( forwardAllContext( (inputToReturn.title = newTitle)} updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))} @@ -109,25 +110,46 @@ export const CreateControlButton = () => { ); }; - if (getInputControlTypes().length === 0) return null; + if (getControlTypes().length === 0) return null; const commonButtonProps = { - iconType: 'plus', - color: 'text' as EuiButtonIconColor, - 'data-test-subj': 'inputControlsSortingButton', + iconType: 'plusInCircle', + color: 'primary' as EuiButtonIconColor, + 'data-test-subj': 'controlsCreateButton', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), }; - if (getInputControlTypes().length > 1) { + const onCreateButtonClick = () => { + if (getControlTypes().length > 1) { + setIsControlTypePopoverOpen(!isControlTypePopoverOpen); + return; + } + createNewControl(getControlTypes()[0]); + }; + + const createControlButton = isIconButton ? ( + + ) : ( + + {ControlGroupStrings.emptyState.getAddControlButtonTitle()} + + ); + + if (getControlTypes().length > 1) { const items: ReactElement[] = []; - getInputControlTypes().forEach((type) => { + getControlTypes().forEach((type) => { const factory = getControlFactory(type); items.push( { - setIsPopoverOpen(false); + setIsControlTypePopoverOpen(false); createNewControl(type); }} > @@ -135,24 +157,18 @@ export const CreateControlButton = () => { ); }); - const button = setIsPopoverOpen(true)} />; return ( setIsPopoverOpen(false)} + closePopover={() => setIsControlTypePopoverOpen(false)} > ); } - return ( - createNewControl(getInputControlTypes()[0])} - /> - ); + return createControlButton; }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx index 58c59c8f84fe0..14d4febeef3a8 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx @@ -12,11 +12,11 @@ import React, { useEffect, useRef } from 'react'; import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; -import { IEditableControlFactory } from '../../types'; import { pluginServices } from '../../../../services'; import { forwardAllContext } from './forward_all_context'; import { OverlayRef } from '../../../../../../../core/public'; import { ControlGroupStrings } from '../control_group_strings'; +import { IEditableControlFactory, ControlInput } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; @@ -54,13 +54,18 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const factory = getControlFactory(panel.type); const embeddable = await untilEmbeddableLoaded(embeddableId); + let inputToReturn: Partial = {}; + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); let removed = false; const onCancel = (ref: OverlayRef) => { if ( removed || - (isEqual(latestPanelState.current.explicitInput, panel.explicitInput) && + (isEqual(latestPanelState.current.explicitInput, { + ...panel.explicitInput, + ...inputToReturn, + }) && isEqual(latestPanelState.current.width, panel.width)) ) { ref.close(); @@ -73,7 +78,6 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => buttonColor: 'danger', }).then((confirmed) => { if (confirmed) { - updateInputForChild(embeddableId, panel.explicitInput); dispatch(setControlWidth({ width: panel.width, embeddableId })); ref.close(); } @@ -83,6 +87,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const flyoutInstance = openFlyout( forwardAllContext( { @@ -99,13 +104,18 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => } }); }} - updateTitle={(newTitle) => updateInputForChild(embeddableId, { title: newTitle })} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ - onChange: (partialInput) => updateInputForChild(embeddableId, partialInput), + onChange: (partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }, initialInput: embeddable.getInput(), })} onCancel={() => onCancel(flyoutInstance)} - onSave={() => flyoutInstance.close()} + 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 9438091e2fb1d..ce32b3e2eced2 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 @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiTitle, EuiSpacer, @@ -17,13 +17,16 @@ import { EuiButtonGroup, EuiButtonEmpty, EuiFlyoutHeader, + EuiCheckbox, + EuiFlyoutFooter, + EuiButton, } from '@elastic/eui'; 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'; @@ -31,7 +34,13 @@ import { ControlGroupStrings } from '../control_group_strings'; import { controlGroupReducers } from '../state/control_group_reducers'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; -export const EditControlGroup = () => { +interface EditControlGroupState { + newControlStyle: ControlGroupInput['controlStyle']; + newDefaultWidth: ControlGroupInput['defaultControlWidth']; + setAllWidths: boolean; +} + +export const EditControlGroup = ({ closeFlyout }: { closeFlyout: () => void }) => { const { overlays } = pluginServices.getHooks(); const { openConfirm } = overlays.useService(); @@ -41,10 +50,29 @@ export const EditControlGroup = () => { useEmbeddableDispatch, actions: { setControlStyle, setAllControlWidths, setDefaultControlWidth }, } = useReduxContainerContext(); - const dispatch = useEmbeddableDispatch(); const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state); + const [state, setState] = useState({ + newControlStyle: controlStyle, + newDefaultWidth: defaultControlWidth, + setAllWidths: false, + }); + + const onSave = () => { + const { newControlStyle, newDefaultWidth, setAllWidths } = state; + if (newControlStyle && newControlStyle !== controlStyle) { + dispatch(setControlStyle(newControlStyle)); + } + if (newDefaultWidth && newDefaultWidth !== defaultControlWidth) { + dispatch(setDefaultControlWidth(newDefaultWidth)); + } + if (setAllWidths && newDefaultWidth) { + dispatch(setAllControlWidths(newDefaultWidth)); + } + closeFlyout(); + }; + return ( <> @@ -58,46 +86,37 @@ export const EditControlGroup = () => { color="primary" legend={ControlGroupStrings.management.controlStyle.getDesignSwitchLegend()} options={CONTROL_LAYOUT_OPTIONS} - idSelected={controlStyle} + idSelected={state.newControlStyle} onChange={(newControlStyle) => - dispatch(setControlStyle(newControlStyle as ControlStyle)) + setState((s) => ({ ...s, newControlStyle: newControlStyle as ControlStyle })) } /> - - - - dispatch(setDefaultControlWidth(newWidth as ControlWidth)) - } - /> - - - - dispatch(setAllControlWidths(defaultControlWidth ?? DEFAULT_CONTROL_WIDTH)) - } - aria-label={'delete-all'} - iconType="returnKey" - size="s" - > - {ControlGroupStrings.management.getSetAllWidthsToDefaultTitle()} - - - + + setState((s) => ({ ...s, newDefaultWidth: newDefaultWidth as ControlWidth })) + } + /> - - + + setState((s) => ({ ...s, setAllWidths: e.target.checked }))} + /> + { if (!containerActions?.removeEmbeddable) return; + closeFlyout(); openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), @@ -119,6 +138,33 @@ export const EditControlGroup = () => { {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + + { + closeFlyout(); + }} + > + {ControlGroupStrings.manageControl.getCancelTitle()} + + + + { + onSave(); + }} + > + {ControlGroupStrings.manageControl.getSaveChangesTitle()} + + + + ); }; 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..06fb86f022253 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,59 @@ */ 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(); + + 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 +68,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,16 +120,21 @@ 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(); + } + public render(dom: HTMLElement) { 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..1a323b6aa9e8d 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 @@ -21,10 +21,14 @@ import { EmbeddableFactoryDefinition, ErrorEmbeddable, } from '../../../../../../embeddable/public'; -import { ControlGroupInput } from '../types'; import { ControlGroupStrings } from '../control_group_strings'; -import { CONTROL_GROUP_TYPE } from '../control_group_constants'; +import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types'; import { ControlGroupContainer } from './control_group_container'; +import { EmbeddablePersistableStateService } from '../../../../../../embeddable/common'; +import { + createControlGroupExtract, + createControlGroupInject, +} from '../../../../../common/controls/control_group/control_group_persistable_state'; export type DashboardContainerFactory = EmbeddableFactory< ControlGroupInput, @@ -37,6 +41,8 @@ export class ControlGroupContainerFactory public readonly isContainerType = true; public readonly type = CONTROL_GROUP_TYPE; + constructor(private persistableStateService: EmbeddablePersistableStateService) {} + public isEditable = async () => false; public readonly getDisplayName = () => { @@ -46,10 +52,10 @@ export class ControlGroupContainerFactory public getDefaultInput(): Partial { return { panels: {}, - inheritParentState: { - useFilters: true, - useQuery: true, - useTimerange: true, + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, }, }; } @@ -60,4 +66,7 @@ export class ControlGroupContainerFactory ): Promise => { 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..e61006858b953 --- /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 { ControlGroupContainer } from './embeddable/control_group_container'; +export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; +export { ControlGroupContainerEmbeddable, ControlGroupInput, ControlGroupOutput } from './types'; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg b/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg new file mode 100644 index 0000000000000..6722db6f26a55 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg @@ -0,0 +1,5 @@ + + + + + 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 438eee1c461dd..23d916e6f6785 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,29 +6,16 @@ * Side Public License, v 1. */ -import { PanelState, EmbeddableInput } from '../../../../../embeddable/public'; -import { InputControlInput } from '../../../services/controls'; -import { ControlStyle, ControlWidth } from '../types'; +import { CommonControlOutput, ControlInput } from '../types'; +import { Container, ContainerOutput } from '../../../../../embeddable/public'; +import { ControlGroupInput } from '../../../../common/controls/control_group/types'; -export interface ControlGroupInput - extends EmbeddableInput, - Omit { - inheritParentState: { - useFilters: boolean; - useQuery: boolean; - useTimerange: boolean; - }; - defaultControlWidth?: ControlWidth; - controlStyle: ControlStyle; - panels: ControlsPanels; -} +export type ControlGroupOutput = ContainerOutput & CommonControlOutput; -export interface ControlPanelState - extends PanelState { - order: number; - width: ControlWidth; -} +export type ControlGroupContainerEmbeddable = Container< + ControlInput, + ControlGroupInput, + ControlGroupOutput +>; -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/index.ts b/src/plugins/presentation_util/public/components/controls/control_types/index.ts new file mode 100644 index 0000000000000..c6bc23568702f --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/index.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 { OPTIONS_LIST_CONTROL } from './options_list/options_list_embeddable'; 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..d547b42078b3b 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 { OptionsListEmbeddable, OPTIONS_LIST_CONTROL } from './options_list_embeddable'; 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 0d12c69fdab46..554d50922bf8d 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 @@ -6,61 +6,95 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; +import { BehaviorSubject, Subject } from 'rxjs'; -import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiSelectableOption } from '@elastic/eui'; -import { Subject } from 'rxjs'; -import { OptionsListStrings } from './options_list_strings'; +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 './options_list.scss'; import { useStateObservable } from '../../hooks/use_state_observable'; +// Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. export interface OptionsListComponentState { - availableOptions?: EuiSelectableOption[]; - selectedOptionsString?: string; - selectedOptionsCount?: number; - twoLineLayout?: boolean; - searchString?: string; + availableOptions?: string[]; loading: boolean; } export const OptionsListComponent = ({ - componentStateSubject, typeaheadSubject, - updateOption, + componentStateSubject, }: { - componentStateSubject: Subject; typeaheadSubject: Subject; - updateOption: (index: number) => void; + componentStateSubject: BehaviorSubject; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const optionsListState = useStateObservable(componentStateSubject, { - loading: true, - }); + const [searchString, setSearchString] = useState(''); + // Redux embeddable Context to get state from Embeddable input const { - selectedOptionsString, - selectedOptionsCount, - availableOptions, - twoLineLayout, - searchString, - loading, - } = optionsListState; + useEmbeddableDispatch, + useEmbeddableSelector, + actions: { replaceSelection }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + const { controlStyle, selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); + + // useStateObservable to get component state from Embeddable + const { availableOptions, loading } = useStateObservable( + componentStateSubject, + 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(() => { + if (singleSelect && selectedOptions && selectedOptions?.length > 1) { + dispatch(replaceSelection(selectedOptions[0])); + } + }, [selectedOptions, singleSelect, dispatch, replaceSelection]); + + const updateSearchString = useCallback( + (newSearchString: string) => { + typeaheadSubject.next(newSearchString); + setSearchString(newSearchString); + }, + [typeaheadSubject] + ); + + const { selectedOptionsCount, selectedOptionsString } = useMemo(() => { + return { + selectedOptionsCount: selectedOptions?.length, + selectedOptionsString: selectedOptions?.join(OptionsListStrings.summary.getSeparator()), + }; + }, [selectedOptions]); const button = ( setIsPopoverOpen((openState) => !openState)} isSelected={isPopoverOpen} - numFilters={availableOptions?.length ?? 0} - hasActiveFilters={(selectedOptionsCount ?? 0) > 0} + 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} > {!selectedOptionsCount ? OptionsListStrings.summary.getPlaceholder() : selectedOptionsString} @@ -69,7 +103,7 @@ export const OptionsListComponent = ({ return ( setIsPopoverOpen(false)} panelPaddingSize="none" - anchorPosition="upLeft" + anchorPosition="downCenter" ownFocus repositionOnScroll > diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx index 3e5770da22ce9..5660cdc330a5b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx @@ -6,47 +6,61 @@ * Side Public License, v 1. */ -import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; import useMount from 'react-use/lib/useMount'; +import React, { useEffect, useState } from 'react'; +import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption, EuiSwitch } from '@elastic/eui'; + import { ControlEditorProps, GetControlEditorComponentProps } from '../../types'; -import { - OptionsListEmbeddableInput, - OptionsListFieldFetcher, - OptionsListIndexPatternFetcher, -} from './options_list_embeddable'; +import { OptionsListEmbeddableInput } from './options_list_embeddable'; import { OptionsListStrings } from './options_list_strings'; +import { pluginServices } from '../../../../services'; interface OptionsListEditorProps extends ControlEditorProps { onChange: GetControlEditorComponentProps['onChange']; - fetchIndexPatterns: OptionsListIndexPatternFetcher; initialInput?: Partial; - fetchFields: OptionsListFieldFetcher; } interface OptionsListEditorState { - availableIndexPatterns: Array>; - indexPattern?: string; - availableFields: Array>; - field?: string; + singleSelect?: boolean; + + dataViewIdSelectOptions: Array>; + dataViewId?: string; + + fieldNameSelectOptions: Array>; + fieldName?: string; } export const OptionsListEditor = ({ onChange, - fetchFields, initialInput, setValidState, - fetchIndexPatterns, }: OptionsListEditorProps) => { + // Presentation Services Context + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, get } = dataViews.useService(); + const [state, setState] = useState({ - indexPattern: initialInput?.indexPattern, - field: initialInput?.field, - availableIndexPatterns: [], - availableFields: [], + dataViewId: initialInput?.dataViewId, + fieldName: initialInput?.fieldName, + singleSelect: initialInput?.singleSelect, + dataViewIdSelectOptions: [], + fieldNameSelectOptions: [], }); - const applySelection = ({ field, indexPattern }: { field?: string; indexPattern?: string }) => { - const newState = { ...(field ? { field } : {}), ...(indexPattern ? { indexPattern } : {}) }; + const applySelection = ({ + fieldName, + singleSelect, + dataViewId, + }: { + fieldName?: string; + singleSelect?: boolean; + dataViewId?: string; + }) => { + const newState = { + ...(fieldName ? { fieldName } : {}), + ...(dataViewId ? { dataViewId } : {}), + ...(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. @@ -60,47 +74,63 @@ export const OptionsListEditor = ({ useMount(() => { (async () => { - const indexPatterns = (await fetchIndexPatterns()).map((indexPattern) => ({ - value: indexPattern, - inputDisplay: indexPattern, + const newDataViews = await getIdsWithTitle(); + const newDataViewSelectOptions = newDataViews.map((dataView) => ({ + value: dataView.id, + inputDisplay: dataView.title, + })); + + setState((currentState) => ({ + ...currentState, + dataViewIdSelectOptions: newDataViewSelectOptions, })); - setState((currentState) => ({ ...currentState, availableIndexPatterns: indexPatterns })); })(); }); useEffect(() => { (async () => { - let availableFields: Array> = []; - if (state.indexPattern) { - availableFields = (await fetchFields(state.indexPattern)).map((field) => ({ - value: field, - inputDisplay: field, + let newFieldNameSelectOptions: Array> = []; + if (state.dataViewId) { + const newFields = (await get(state.dataViewId)).fields; + newFieldNameSelectOptions = newFields.map((field) => ({ + value: field.name, + inputDisplay: field.displayName ?? field.name, })); } - setState((currentState) => ({ ...currentState, availableFields })); + setState((currentState) => ({ + ...currentState, + fieldNameSelectOptions: newFieldNameSelectOptions, + })); })(); - }, [state.indexPattern, fetchFields]); + }, [get, state.dataViewId]); useEffect( - () => setValidState(Boolean(state.field) && Boolean(state.indexPattern)), - [state.field, setValidState, state.indexPattern] + () => setValidState(Boolean(state.fieldName) && Boolean(state.dataViewId)), + [state.fieldName, setValidState, state.dataViewId] ); return ( <> applySelection({ indexPattern })} - valueOfSelected={state.indexPattern} + options={state.dataViewIdSelectOptions} + onChange={(dataViewId) => applySelection({ dataViewId })} + valueOfSelected={state.dataViewId} /> applySelection({ field })} - valueOfSelected={state.field} + disabled={!state.dataViewId} + options={state.fieldNameSelectOptions} + onChange={(fieldName) => applySelection({ fieldName })} + valueOfSelected={state.fieldName} + /> + + + applySelection({ singleSelect: !e.target.checked })} /> 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 97a128c3e84eb..7253f2b060812 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,26 +8,29 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { merge, Subject } from 'rxjs'; +import { isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { EuiSelectableOption } from '@elastic/eui'; -import { tap, debounceTime, map, distinctUntilChanged } from 'rxjs/operators'; - -import { esFilters } from '../../../../../../data/public'; -import { OptionsListStrings } from './options_list_strings'; -import { Embeddable, IContainer } from '../../../../../../embeddable/public'; -import { InputControlInput, InputControlOutput } from '../../../../services/controls'; +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 { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; - -const toggleAvailableOptions = ( - indices: number[], - availableOptions: EuiSelectableOption[], - enabled?: boolean -) => { - const newAvailableOptions = [...availableOptions]; - indices.forEach((index) => (newAvailableOptions[index].checked = enabled ? 'on' : undefined)); - return newAvailableOptions; -}; +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, @@ -37,173 +40,209 @@ 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 { - field: string; search?: string; - indexPattern: string; - query?: InputControlInput['query']; - filters?: InputControlInput['filters']; - timeRange?: InputControlInput['timeRange']; + fieldName: string; + dataViewId: string; + query?: ControlInput['query']; + filters?: ControlInput['filters']; } -export type OptionsListIndexPatternFetcher = () => Promise; // TODO: use the proper types here. -export type OptionsListFieldFetcher = (indexPattern: string) => Promise; // TODO: use the proper types here. - -export type OptionsListDataFetcher = ( - props: OptionsListDataFetchProps -) => Promise; +const fieldMissingError = (fieldName: string) => + new Error(`field ${fieldName} not found in index pattern`); -export const OPTIONS_LIST_CONTROL = 'optionsListControl'; -export interface OptionsListEmbeddableInput extends InputControlInput { - field: string; - indexPattern: string; - singleSelect?: boolean; - defaultSelections?: string[]; -} -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. - private selectedOptions: Set; + // 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, + }); - constructor( - input: OptionsListEmbeddableInput, - output: InputControlOutput, - private fetchData: OptionsListDataFetcher, - parent?: IContainer - ) { - super(input, output, parent); - this.fetchData = fetchData; + constructor(input: OptionsListEmbeddableInput, output: ControlOutput, parent?: IContainer) { + super(input, output, parent); // get filters for initial output... - // populate default selections from input - this.selectedOptions = new Set(input.defaultSelections ?? []); - const { selectedOptionsCount, selectedOptionsString } = this.buildSelectedOptionsString(); + // Destructure presentation util services + ({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices()); - // fetch available options when input changes or when search string has changed + this.componentState = { loading: true }; + this.updateComponentState(this.componentState); + + this.initialize(); + } + + private setupSubscriptions = () => { + const dataFetchPipe = this.getInput$().pipe( + map((newInput) => ({ + lastReloadRequestTime: newInput.lastReloadRequestTime, + dataViewId: newInput.dataViewId, + fieldName: newInput.fieldName, + timeRange: newInput.timeRange, + filters: newInput.filters, + query: newInput.query, + })), + distinctUntilChanged(diffDataFetchProps) + ); + + // push searchString changes into a debounced typeahead subject + this.typeaheadSubject = new Subject(); const typeaheadPipe = this.typeaheadSubject.pipe( - tap((newSearchString) => this.updateComponentState({ searchString: newSearchString })), + tap((newSearchString) => (this.searchString = newSearchString)), debounceTime(100) ); - const inputPipe = this.getInput$().pipe( - map( - (newInput) => ({ - field: newInput.field, - indexPattern: newInput.indexPattern, - query: newInput.query, - filters: newInput.filters, - timeRange: newInput.timeRange, - }), - distinctUntilChanged(diffDataFetchProps) - ) + + // clear all selections when field or index pattern change + this.subscriptions.add( + this.getInput$() + .pipe( + distinctUntilChanged( + (a, b) => isEqual(a.fieldName, b.fieldName) && isEqual(a.dataViewId, b.dataViewId) + ), + skip(1) // skip the first input update to preserve initial filters. + ) + .subscribe(() => this.updateInput({ selectedOptions: [] })) + ); + + // fetch available options when input changes or when search string has changed + this.subscriptions.add( + merge(dataFetchPipe, typeaheadPipe).subscribe(this.fetchAvailableOptions) ); - merge(typeaheadPipe, inputPipe).subscribe(this.fetchAvailableOptions); - // push changes from input into component state - this.getInput$().subscribe((newInput) => { - if (newInput.twoLineLayout !== this.componentState.twoLineLayout) - this.updateComponentState({ twoLineLayout: newInput.twoLineLayout }); - }); + // build filters when selectedOptions change + this.subscriptions.add( + this.getInput$() + .pipe( + 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.buildFilter()) + ); + }; + + 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 = { - loading: true, - selectedOptionsCount, - selectedOptionsString, - twoLineLayout: input.twoLineLayout, + ...this.componentState, + ...changes, }; - this.updateComponentState(this.componentState); + this.componentStateSubject$.next(this.componentState); } private fetchAvailableOptions = async () => { this.updateComponentState({ loading: true }); - - const { indexPattern, timeRange, filters, field, query } = this.getInput(); - let newOptions = await this.fetchData({ - search: this.componentState.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 }); + }; - // We now have new 'availableOptions', we need to ensure the selected options are still selected in the new list. - const enabledIndices: number[] = []; - this.selectedOptions?.forEach((selectedOption) => { - const optionIndex = newOptions.findIndex( - (availableOption) => availableOption.label === selectedOption - ); - if (optionIndex >= 0) enabledIndices.push(optionIndex); - }); - newOptions = toggleAvailableOptions(enabledIndices, newOptions, true); - this.updateComponentState({ loading: false, availableOptions: newOptions }); + private initialize = async () => { + const initialSelectedOptions = this.getInput().selectedOptions; + if (initialSelectedOptions) { + await this.getCurrentDataView(); + await this.buildFilter(); + } + this.setInitializationFinished(); + this.setupSubscriptions(); }; - private updateOption = (index: number) => { - const item = this.componentState.availableOptions?.[index]; - if (!item) return; - const toggleOff = item.checked === 'on'; + 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); - // update availableOptions to show selection check marks - const newAvailableOptions = toggleAvailableOptions( - [index], - this.componentState.availableOptions ?? [], - !toggleOff - ); - this.componentState.availableOptions = newAvailableOptions; + if (!field) throw fieldMissingError(fieldName); - // update selectedOptions string - if (toggleOff) this.selectedOptions.delete(item.label); - else this.selectedOptions.add(item.label); - const { selectedOptionsString, selectedOptionsCount } = this.buildSelectedOptionsString(); - this.updateComponentState({ selectedOptionsString, selectedOptionsCount }); - }; + let newFilter: Filter; + if (selectedOptions.length === 1) { + newFilter = buildPhraseFilter(field, selectedOptions[0], dataView); + } else { + newFilter = buildPhrasesFilter(field, selectedOptions, dataView); + } - private buildSelectedOptionsString(): { - selectedOptionsString: string; - selectedOptionsCount: number; - } { - const selectedOptionsArray = Array.from(this.selectedOptions ?? []); - const selectedOptionsString = selectedOptionsArray.join( - OptionsListStrings.summary.getSeparator() - ); - const selectedOptionsCount = selectedOptionsArray.length; - return { selectedOptionsString, selectedOptionsCount }; - } + 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); } this.node = node; ReactDOM.render( - , + + embeddable={this} + reducers={optionsListReducers} + > + + , 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..ef367faacb4ad 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 @@ -7,39 +7,31 @@ */ import React from 'react'; -import { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public'; + import { ControlEditorProps, GetControlEditorComponentProps, IEditableControlFactory, } from '../../types'; import { OptionsListEditor } from './options_list_editor'; +import { OptionsListEmbeddable } from './options_list_embeddable'; +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 { 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)); + return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, parent)); } public getControlEditor = ({ @@ -48,8 +40,6 @@ export class OptionsListEmbeddableFactory }: GetControlEditorComponentProps) => { return ({ setValidState }: ControlEditorProps) => ( 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 4bfce9eb377e9..12f4cb3264202 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 @@ -6,76 +6,158 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { - EuiFieldSearch, EuiFilterSelectItem, - EuiIcon, - EuiLoadingChart, EuiPopoverTitle, - EuiSelectableOption, + EuiFieldSearch, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiFormRow, EuiSpacer, + EuiIcon, } from '@elastic/eui'; -import { Subject } from 'rxjs'; import { OptionsListStrings } from './options_list_strings'; - -interface OptionsListPopoverProps { - loading: boolean; - typeaheadSubject: Subject; - searchString?: string; - updateOption: (index: number) => void; - availableOptions?: EuiSelectableOption[]; -} +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'; export const OptionsListPopover = ({ loading, - updateOption, searchString, - typeaheadSubject, availableOptions, -}: OptionsListPopoverProps) => { + updateSearchString, +}: { + searchString: string; + loading: OptionsListComponentState['loading']; + updateSearchString: (newSearchString: string) => void; + availableOptions: OptionsListComponentState['availableOptions']; +}) => { + // Redux embeddable container Context + const { + useEmbeddableSelector, + useEmbeddableDispatch, + actions: { selectOption, deselectOption, clearSelections, replaceSelection }, + } = useReduxEmbeddableContext(); + + const dispatch = useEmbeddableDispatch(); + const { selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); + + // track selectedOptions in a set for more efficient lookup + const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); + const [showOnlySelected, setShowOnlySelected] = useState(false); + return ( <> - { - typeaheadSubject.next(event.target.value); - }} - value={searchString} - /> + + + + updateSearchString(event.target.value)} + value={searchString} + /> + + + + dispatch(clearSelections({}))} + /> + + + + + setShowOnlySelected(!showOnlySelected)} + /> + + + + +
- {!loading && - availableOptions && - availableOptions.map((item, index) => ( - updateOption(index)} - > - {item.label} - - ))} - {loading && ( -
-
- - -

{OptionsListStrings.popover.getLoadingMessage()}

-
-
- )} + {!showOnlySelected && ( + <> + {availableOptions?.map((availableOption, index) => ( + { + if (singleSelect) { + dispatch(replaceSelection(availableOption)); + return; + } + if (selectedOptionsSet.has(availableOption)) { + dispatch(deselectOption(availableOption)); + return; + } + dispatch(selectOption(availableOption)); + }} + > + {`${availableOption}`} + + ))} - {!loading && (!availableOptions || availableOptions.length === 0) && ( -
-
- - -

{OptionsListStrings.popover.getEmptyMessage()}

-
-
+ {!loading && (!availableOptions || availableOptions.length === 0) && ( +
+
+ + +

{OptionsListStrings.popover.getEmptyMessage()}

+
+
+ )} + + )} + {showOnlySelected && ( + <> + {selectedOptions && + selectedOptions.map((availableOption, index) => ( + dispatch(deselectOption(availableOption))} + > + {`${availableOption}`} + + ))} + {(!selectedOptions || selectedOptions.length === 0) && ( +
+
+ + +

{OptionsListStrings.popover.getSelectionsEmptyMessage()}

+
+
+ )} + )}
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 new file mode 100644 index 0000000000000..3e4104f62f914 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts @@ -0,0 +1,43 @@ +/* + * 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 { PayloadAction } from '@reduxjs/toolkit'; +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { OptionsListEmbeddableInput } from './options_list_embeddable'; + +export const optionsListReducers = { + deselectOption: ( + state: WritableDraft, + action: PayloadAction + ) => { + if (!state.selectedOptions) return; + const itemIndex = state.selectedOptions.indexOf(action.payload); + if (itemIndex !== -1) { + const newSelections = [...state.selectedOptions]; + newSelections.splice(itemIndex, 1); + state.selectedOptions = newSelections; + } + }, + selectOption: ( + state: WritableDraft, + action: PayloadAction + ) => { + if (!state.selectedOptions) state.selectedOptions = []; + state.selectedOptions?.push(action.payload); + }, + replaceSelection: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.selectedOptions = [action.payload]; + }, + clearSelections: (state: WritableDraft) => { + state.selectedOptions = []; + }, +}; 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 c07881020c9c2..671d4a27c3518 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,32 +11,59 @@ 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', }), getFieldTitle: () => - i18n.translate('presentationUtil.inputControls.optionsList.editor.fieldTitle', { + i18n.translate('presentationUtil.controls.optionsList.editor.fieldTitle', { defaultMessage: 'Field', }), + getAllowMultiselectTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.editor.allowMultiselectTitle', { + defaultMessage: 'Allow multiple selections in dropdown', + }), }, 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.controls.optionsList.popover.selectionsEmpty', { + defaultMessage: 'You have no selections', + }), + getAllOptionsButtonTitle: () => + i18n.translate('presentationUtil.controls.optionsList.popover.allOptionsTitle', { + defaultMessage: 'Show all options', + }), + getSelectedOptionsButtonTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.selectedOptionsTitle', { + defaultMessage: 'Show only selected options', + }), + getClearAllSelectionsButtonTitle: () => + 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..c110bc348498d --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/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 * from './control_group'; +export * from './control_types'; +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..48abc4ed97460 100644 --- a/src/plugins/presentation_util/public/components/controls/types.ts +++ b/src/plugins/presentation_util/public/components/controls/types.ts @@ -6,22 +6,36 @@ * 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 { +export interface IEditableControlFactory { getControlEditor?: GetControlEditorComponent; } -export type GetControlEditorComponent = ( +export type GetControlEditorComponent = ( props: GetControlEditorComponentProps ) => ControlEditorComponent; -export interface GetControlEditorComponentProps { +export interface GetControlEditorComponentProps { onChange: (partial: Partial) => void; initialInput?: Partial; } @@ -31,3 +45,8 @@ export type ControlEditorComponent = (props: ControlEditorProps) => JSX.Element; export interface ControlEditorProps { setValidState: (valid: boolean) => void; } + +/** + * Re-export control types from common + */ +export * from '../../../common/controls/types'; 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..22883b8f0c813 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 = {}; 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 a4912b5b5f2fc..a2e7f119e4403 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 @@ -6,26 +6,44 @@ * Side Public License, v 1. */ -import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +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 } from 'rxjs/operators'; +import { Filter } from '@kbn/es-query'; import { isEqual } from 'lodash'; -import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; import { + ReduxContainerContextServices, + ReduxEmbeddableContextServices, + ReduxEmbeddableWrapperProps, +} from './types'; +import { + IContainer, IEmbeddable, EmbeddableInput, EmbeddableOutput, - IContainer, + isErrorEmbeddable, } from '../../../../embeddable/public'; import { getManagedEmbeddablesStore } from './generic_embeddable_store'; -import { - ReduxContainerContextServices, - ReduxEmbeddableContextServices, - ReduxEmbeddableWrapperProps, -} from './types'; 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 @@ -81,8 +110,12 @@ export const ReduxEmbeddableWrapper = (embeddable); + if (stateContainsFilters(initialState)) { + initialState.filters = cleanFiltersForSerialize(initialState.filters); + } const slice = createSlice>({ - initialState: embeddable.getInput(), + initialState, name: key, reducers: { ...reducers, updateEmbeddableReduxState }, }); @@ -138,21 +171,34 @@ const ReduxEmbeddableSync = state); const stateRef = useRef(currentState); + const destroyedRef = useRef(false); - // When Embeddable Input changes, push differences to redux. useEffect(() => { - embeddable.getInput$().subscribe(() => { - const differences = diffInput(embeddable.getInput(), stateRef.current); - if (differences && Object.keys(differences).length > 0) { - dispatch(updateEmbeddableReduxState(differences)); - } - }); + // 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 + .subscribe( + () => { + 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)); + } + }, + undefined, + () => (destroyedRef.current = true) // when input observer is complete, embeddable is destroyed + ); + return () => inputSubscription.unsubscribe(); }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState]); - // When redux state changes, push differences to Embeddable Input. 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) { embeddable.updateInput(differences); } 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/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index f697f1a29eb82..1db25fbc206d2 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -10,11 +10,17 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { pluginServices } from './services'; import { registry } from './services/kibana'; import { - PresentationUtilPluginSetup, - PresentationUtilPluginStart, PresentationUtilPluginSetupDeps, PresentationUtilPluginStartDeps, + ControlGroupContainerFactory, + PresentationUtilPluginSetup, + PresentationUtilPluginStart, + GetControlEditorComponent, + IEditableControlFactory, + OPTIONS_LIST_CONTROL, } from './types'; +import { OptionsListEmbeddableFactory } from './components/controls/control_types/options_list'; +import { CONTROL_GROUP_TYPE } from '.'; export class PresentationUtilPlugin implements @@ -25,10 +31,29 @@ export class PresentationUtilPlugin PresentationUtilPluginStartDeps > { + private inlineEditors: { [key: string]: GetControlEditorComponent | undefined } = {}; + 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(); + this.inlineEditors[OPTIONS_LIST_CONTROL] = ( + optionsListFactory as IEditableControlFactory + ).getControlEditor; + embeddable.registerEmbeddableFactory(OPTIONS_LIST_CONTROL, optionsListFactory); + return {}; } @@ -37,9 +62,19 @@ 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 + (optionsListFactory as IEditableControlFactory).getControlEditor = + this.inlineEditors[OPTIONS_LIST_CONTROL]; + 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..912553893be3f --- /dev/null +++ b/src/plugins/presentation_util/public/services/data_views.ts @@ -0,0 +1,14 @@ +/* + * 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']; + 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..254dae7791520 --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/data_views.ts @@ -0,0 +1,27 @@ +/* + * 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 }, + } = startPlugins; + + return { + get, + 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..5f0bc3e2273d0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/data.ts @@ -0,0 +1,19 @@ +/* + * 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 { PluginServiceFactory } from '../create'; +import { PresentationDataService } from '../data'; +import { getFlightOptionsAsync } from './fixtures/flights'; + +export type DataServiceFactory = PluginServiceFactory; +export const dataServiceFactory: DataServiceFactory = () => ({ + autocomplete: { + getValueSuggestions: getFlightOptionsAsync, + } 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..bafcae442c2e0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/data_views.ts @@ -0,0 +1,27 @@ +/* + * 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 { storybookFlightsDataView } from './fixtures/flights'; +import { DataViewsPublicPluginStart } from '../../../../data_views/public'; + +export type DataViewsServiceFactory = PluginServiceFactory; +export const dataViewsServiceFactory: DataViewsServiceFactory = () => ({ + get: (() => + new Promise((r) => + setTimeout(() => r(storybookFlightsDataView), 100) + ) as unknown) as DataViewsPublicPluginStart['get'], + getIdsWithTitle: (() => + new Promise((r) => + setTimeout( + () => r([{ id: storybookFlightsDataView.id, title: storybookFlightsDataView.title }]), + 100 + ) + ) as unknown) as DataViewsPublicPluginStart['getIdsWithTitle'], +}); diff --git a/src/plugins/presentation_util/public/services/storybook/fixtures/flights.ts b/src/plugins/presentation_util/public/services/storybook/fixtures/flights.ts new file mode 100644 index 0000000000000..55ec4d13bd515 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/fixtures/flights.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 { map, uniq } from 'lodash'; +import { flights } from '../fixtures/flights_data'; +import { + DataView, + DataViewField, + IndexPatternField, + 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', + } as unknown 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: IndexPatternField; + 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/services/storybook/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/services/storybook/fixtures/flights_data.ts 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" } ] }