diff --git a/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts b/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts index 9e4b18aaffa26..b02cf450cdd73 100644 --- a/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts +++ b/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts @@ -90,12 +90,12 @@ const initialSerializedControlGroupState = { } as object, references: [ { - name: `controlGroup_${rangeSliderControlId}:rangeSliderDataView`, + name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL}DataView`, type: 'index-pattern', id: WEB_LOGS_DATA_VIEW_ID, }, { - name: `controlGroup_${optionsListId}:optionsListDataView`, + name: `controlGroup_${optionsListId}:${OPTIONS_LIST_CONTROL}DataView`, type: 'index-pattern', id: WEB_LOGS_DATA_VIEW_ID, }, diff --git a/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx b/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx index f63df505f5d85..ac902c72e851f 100644 --- a/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx +++ b/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx @@ -11,7 +11,8 @@ import React, { useEffect, useState } from 'react'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { controlGroupStateBuilder } from '@kbn/controls-plugin/public'; +import { controlGroupInputBuilder } from '@kbn/controls-plugin/public'; +import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common'; import { AwaitingDashboardAPI, DashboardRenderer, @@ -62,15 +63,16 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView => { - const controlGroupState = {}; - await controlGroupStateBuilder.addDataControlFromField(controlGroupState, { + const builder = controlGroupInputBuilder; + const controlGroupInput = getDefaultControlGroupInput(); + await builder.addDataControlFromField(controlGroupInput, { dataViewId: dataView.id ?? '', title: 'Destintion country', fieldName: 'geo.dest', width: 'medium', grow: false, }); - await controlGroupStateBuilder.addDataControlFromField(controlGroupState, { + await builder.addDataControlFromField(controlGroupInput, { dataViewId: dataView.id ?? '', fieldName: 'bytes', width: 'medium', @@ -83,7 +85,7 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView getInitialInput: () => ({ timeRange: { from: 'now-30d', to: 'now' }, viewMode: ViewMode.VIEW, - controlGroupState, + controlGroupInput, }), }; }} diff --git a/src/plugins/controls/common/control_group/control_group_persistence.ts b/src/plugins/controls/common/control_group/control_group_persistence.ts index 0de1238b9575c..8e9a795c2ec4c 100644 --- a/src/plugins/controls/common/control_group/control_group_persistence.ts +++ b/src/plugins/controls/common/control_group/control_group_persistence.ts @@ -9,6 +9,7 @@ import deepEqual from 'fast-deep-equal'; import { SerializableRecord } from '@kbn/utility-types'; +import { v4 } from 'uuid'; import { pick, omit, xor } from 'lodash'; import { @@ -22,6 +23,7 @@ import { } from './control_group_panel_diff_system'; import { ControlGroupInput } from '..'; import { + ControlsPanels, PersistableControlGroupInput, persistableControlGroupInputKeys, RawControlGroupAttributes, @@ -101,6 +103,32 @@ const getPanelsAreEqual = ( return true; }; +export const controlGroupInputToRawControlGroupAttributes = ( + controlGroupInput: Omit +): RawControlGroupAttributes => { + return { + controlStyle: controlGroupInput.controlStyle, + chainingSystem: controlGroupInput.chainingSystem, + showApplySelections: controlGroupInput.showApplySelections, + panelsJSON: JSON.stringify(controlGroupInput.panels), + ignoreParentSettingsJSON: JSON.stringify(controlGroupInput.ignoreParentSettings), + }; +}; + +export const generateNewControlIds = (controlGroupInput?: PersistableControlGroupInput) => { + if (!controlGroupInput?.panels) return; + + const newPanelsMap: ControlsPanels = {}; + for (const panel of Object.values(controlGroupInput.panels)) { + const newId = v4(); + newPanelsMap[newId] = { + ...panel, + explicitInput: { ...panel.explicitInput, id: newId }, + }; + } + return { ...controlGroupInput, panels: newPanelsMap }; +}; + export const rawControlGroupAttributesToControlGroupInput = ( rawControlGroupAttributes: RawControlGroupAttributes ): PersistableControlGroupInput | undefined => { diff --git a/src/plugins/controls/common/index.ts b/src/plugins/controls/common/index.ts index 6be0bc5818f57..69af581fc5fad 100644 --- a/src/plugins/controls/common/index.ts +++ b/src/plugins/controls/common/index.ts @@ -22,12 +22,14 @@ export { persistableControlGroupInputKeys, } from './control_group/types'; export { + controlGroupInputToRawControlGroupAttributes, rawControlGroupAttributesToControlGroupInput, rawControlGroupAttributesToSerializable, serializableToRawControlGroupAttributes, getDefaultControlGroupPersistableInput, persistableControlGroupInputIsEqual, getDefaultControlGroupInput, + generateNewControlIds, } from './control_group/control_group_persistence'; export { diff --git a/src/plugins/controls/public/react_controls/control_group/components/control_group.tsx b/src/plugins/controls/public/react_controls/control_group/components/control_group.tsx index 4a2a4c802272d..c825e9021b48d 100644 --- a/src/plugins/controls/public/react_controls/control_group/components/control_group.tsx +++ b/src/plugins/controls/public/react_controls/control_group/components/control_group.tsx @@ -121,7 +121,6 @@ export function ControlGroup({ paddingSize="none" color={draggingId ? 'success' : 'transparent'} className="controlsWrapper" - data-test-subj="controls-group-wrapper" > & { controlsInOrder: ControlsInOrder; }; @@ -34,7 +38,6 @@ export function initializeControlGroupUnsavedChanges( children$: PresentationContainer['children$'], comparators: StateComparators, snapshotControlsRuntimeState: () => ControlPanelsState, - resetControlsUnsavedChanges: () => void, parentApi: unknown, lastSavedRuntimeState: ControlGroupRuntimeState ) { @@ -44,6 +47,7 @@ export function initializeControlGroupUnsavedChanges( chainingSystem: lastSavedRuntimeState.chainingSystem, controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState), ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings, + initialChildControlState: lastSavedRuntimeState.initialChildControlState, labelPosition: lastSavedRuntimeState.labelPosition, }, parentApi, @@ -68,7 +72,6 @@ export function initializeControlGroupUnsavedChanges( ), asyncResetUnsavedChanges: async () => { controlGroupUnsavedChanges.api.resetUnsavedChanges(); - resetControlsUnsavedChanges(); const filtersReadyPromises: Array> = []; Object.values(children$.value).forEach((controlApi) => { diff --git a/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx b/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx index 2e6519b69343f..45802689e81a1 100644 --- a/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx +++ b/src/plugins/controls/public/react_controls/control_group/get_control_group_factory.tsx @@ -34,19 +34,12 @@ import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch'; import { initControlsManager } from './init_controls_manager'; import { openEditControlGroupFlyout } from './open_edit_control_group_flyout'; import { deserializeControlGroup } from './serialization_utils'; -import { - ControlGroupApi, - ControlGroupRuntimeState, - ControlGroupSerializedState, - ControlPanelsState, -} from './types'; +import { ControlGroupApi, ControlGroupRuntimeState, ControlGroupSerializedState } from './types'; import { ControlGroup } from './components/control_group'; import { initSelectionsManager } from './selections_manager'; import { initializeControlGroupUnsavedChanges } from './control_group_unsaved_changes_api'; import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor'; -const DEFAULT_CHAINING_SYSTEM = 'HIERARCHICAL'; - export const getControlGroupEmbeddableFactory = (services: { core: CoreStart; dataViews: DataViewsPublicPluginStart; @@ -67,6 +60,7 @@ export const getControlGroupEmbeddableFactory = (services: { lastSavedRuntimeState ) => { const { + initialChildControlState, labelPosition: initialLabelPosition, chainingSystem, autoApplySelections, @@ -74,22 +68,19 @@ export const getControlGroupEmbeddableFactory = (services: { } = initialRuntimeState; const autoApplySelections$ = new BehaviorSubject(autoApplySelections); - const defaultDataViewId = await services.dataViews.getDefaultId(); - const lastSavedControlsState$ = new BehaviorSubject( - lastSavedRuntimeState.initialChildControlState - ); + const parentDataViewId = apiPublishesDataViews(parentApi) + ? parentApi.dataViews.value?.[0]?.id + : undefined; const controlsManager = initControlsManager( - initialRuntimeState.initialChildControlState, - lastSavedControlsState$ + initialChildControlState, + parentDataViewId ?? (await services.dataViews.getDefaultId()) ); const selectionsManager = initSelectionsManager({ ...controlsManager.api, autoApplySelections$, }); const dataViews = new BehaviorSubject(undefined); - const chainingSystem$ = new BehaviorSubject( - chainingSystem ?? DEFAULT_CHAINING_SYSTEM - ); + const chainingSystem$ = new BehaviorSubject(chainingSystem); const ignoreParentSettings$ = new BehaviorSubject( ignoreParentSettings ); @@ -113,7 +104,6 @@ export const getControlGroupEmbeddableFactory = (services: { chainingSystem: [ chainingSystem$, (next: ControlGroupChainingSystem) => chainingSystem$.next(next), - (a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM), ], ignoreParentSettings: [ ignoreParentSettings$, @@ -123,7 +113,6 @@ export const getControlGroupEmbeddableFactory = (services: { labelPosition: [labelPosition$, (next: ControlStyle) => labelPosition$.next(next)], }, controlsManager.snapshotControlsRuntimeState, - controlsManager.resetControlsUnsavedChanges, parentApi, lastSavedRuntimeState ); @@ -170,28 +159,20 @@ export const getControlGroupEmbeddableFactory = (services: { i18n.translate('controls.controlGroup.displayName', { defaultMessage: 'Controls', }), - openAddDataControlFlyout: (options) => { - const parentDataViewId = apiPublishesDataViews(parentApi) - ? parentApi.dataViews.value?.[0]?.id - : undefined; - const newControlState = controlsManager.getNewControlState(); + openAddDataControlFlyout: (settings) => { + const { controlInputTransform } = settings ?? { + controlInputTransform: (state) => state, + }; openDataControlEditor({ - initialState: { - ...newControlState, - dataViewId: - newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined, - }, + initialState: controlsManager.getNewControlState(), onSave: ({ type: controlType, state: initialState }) => { controlsManager.api.addNewPanel({ panelType: controlType, - initialState: options?.controlInputTransform - ? options.controlInputTransform( - initialState as Partial, - controlType - ) - : initialState, + initialState: controlInputTransform!( + initialState as Partial, + controlType + ), }); - options?.onSave?.(); }, controlGroupApi: api, services, @@ -226,20 +207,6 @@ export const getControlGroupEmbeddableFactory = (services: { dataViews.next(newDataViews) ); - const saveNotificationSubscription = apiHasSaveNotification(parentApi) - ? parentApi.saveNotification$.subscribe(() => { - lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState()); - - if ( - typeof autoApplySelections$.value === 'boolean' && - !autoApplySelections$.value && - selectionsManager.hasUnappliedSelections$.value - ) { - selectionsManager.applySelections(); - } - }) - : undefined; - /** Fetch the allowExpensiveQuries setting for the children to use if necessary */ try { const { allowExpensiveQueries } = await services.core.http.get<{ @@ -268,7 +235,6 @@ export const getControlGroupEmbeddableFactory = (services: { return () => { selectionsManager.cleanup(); childrenDataViewsSubscription.unsubscribe(); - saveNotificationSubscription?.unsubscribe(); }; }, []); diff --git a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts index fc729478ec770..3e381123ecd9a 100644 --- a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts +++ b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.test.ts @@ -6,26 +6,27 @@ * Side Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; import { DefaultDataControlState } from '../controls/data_controls/types'; import { DefaultControlApi } from '../controls/types'; import { initControlsManager, getLastUsedDataViewId } from './init_controls_manager'; -import { ControlPanelState, ControlPanelsState } from './types'; +import { ControlPanelState } from './types'; jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('delta'), })); -describe('PresentationContainer api', () => { - const intialControlsState = { - alpha: { type: 'testControl', order: 0 }, - bravo: { type: 'testControl', order: 1 }, - charlie: { type: 'testControl', order: 2 }, - }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); +const DEFAULT_DATA_VIEW_ID = 'myDataView'; +describe('PresentationContainer api', () => { test('addNewPanel should add control at end of controls', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager( + { + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + charlie: { type: 'testControl', order: 2 }, + }, + DEFAULT_DATA_VIEW_ID + ); const addNewPanelPromise = controlsManager.api.addNewPanel({ panelType: 'testControl', initialState: {}, @@ -41,7 +42,14 @@ describe('PresentationContainer api', () => { }); test('removePanel should remove control', () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager( + { + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + charlie: { type: 'testControl', order: 2 }, + }, + DEFAULT_DATA_VIEW_ID + ); controlsManager.api.removePanel('bravo'); expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([ 'alpha', @@ -50,7 +58,14 @@ describe('PresentationContainer api', () => { }); test('replacePanel should replace control', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager( + { + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + charlie: { type: 'testControl', order: 2 }, + }, + DEFAULT_DATA_VIEW_ID + ); const replacePanelPromise = controlsManager.api.replacePanel('bravo', { panelType: 'testControl', initialState: {}, @@ -66,7 +81,13 @@ describe('PresentationContainer api', () => { describe('untilInitialized', () => { test('should not resolve until all controls are initialized', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager( + { + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + }, + DEFAULT_DATA_VIEW_ID + ); let isDone = false; controlsManager.api.untilInitialized().then(() => { isDone = true; @@ -80,18 +101,19 @@ describe('PresentationContainer api', () => { controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(isDone).toBe(false); - - controlsManager.setControlApi('charlie', {} as unknown as DefaultControlApi); - await new Promise((resolve) => setTimeout(resolve, 0)); expect(isDone).toBe(true); }); test('should resolve when all control already initialized ', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager( + { + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + }, + DEFAULT_DATA_VIEW_ID + ); controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi); - controlsManager.setControlApi('charlie', {} as unknown as DefaultControlApi); let isDone = false; controlsManager.api.untilInitialized().then(() => { @@ -105,14 +127,14 @@ describe('PresentationContainer api', () => { }); describe('snapshotControlsRuntimeState', () => { - const intialControlsState = { - alpha: { type: 'testControl', order: 1 }, - bravo: { type: 'testControl', order: 0 }, - }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - test('should snapshot runtime state for all controls', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager( + { + alpha: { type: 'testControl', order: 1 }, + bravo: { type: 'testControl', order: 0 }, + }, + DEFAULT_DATA_VIEW_ID + ); controlsManager.setControlApi('alpha', { snapshotRuntimeState: () => { return { key1: 'alpha value' }; @@ -168,120 +190,28 @@ describe('getLastUsedDataViewId', () => { }); }); -describe('resetControlsUnsavedChanges', () => { - test(`should remove previous sessions's unsaved changes on reset`, () => { - // last session's unsaved changes added 1 control - const intialControlsState = { - alpha: { type: 'testControl', order: 0 }, - }; - // last saved state is empty control group - const lastSavedControlsState$ = new BehaviorSubject({}); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); - controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); - - expect(controlsManager.controlsInOrder$.value).toEqual([ - { - id: 'alpha', - type: 'testControl', - }, - ]); - - controlsManager.resetControlsUnsavedChanges(); - expect(controlsManager.controlsInOrder$.value).toEqual([]); - }); - - test('should restore deleted control on reset', () => { - const intialControlsState = { - alpha: { type: 'testControl', order: 0 }, - }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); - controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); - - // delete control - controlsManager.api.removePanel('alpha'); - - // deleted control should exist on reset - controlsManager.resetControlsUnsavedChanges(); - expect(controlsManager.controlsInOrder$.value).toEqual([ - { - id: 'alpha', - type: 'testControl', - }, - ]); - }); - - test('should restore controls to last saved state', () => { - const intialControlsState = {}; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); - - // add control - controlsManager.api.addNewPanel({ panelType: 'testControl' }); - controlsManager.setControlApi('delta', { - snapshotRuntimeState: () => { - return {}; - }, - } as unknown as DefaultControlApi); - - // simulate save - lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState()); - - // saved control should exist on reset - controlsManager.resetControlsUnsavedChanges(); - expect(controlsManager.controlsInOrder$.value).toEqual([ - { - id: 'delta', - type: 'testControl', - }, - ]); - }); - - // Test edge case where adding a panel and resetting left orphaned control in children$ - test('should remove orphaned children on reset', () => { - // baseline last saved state contains a single control - const intialControlsState = { - alpha: { type: 'testControl', order: 0 }, - }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); - controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); - - // add another control - controlsManager.api.addNewPanel({ panelType: 'testControl' }); - controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); - expect(Object.keys(controlsManager.api.children$.value).length).toBe(2); - - // reset to lastSavedControlsState - controlsManager.resetControlsUnsavedChanges(); - // children$ should no longer contain control removed by resetting back to original control baseline - expect(Object.keys(controlsManager.api.children$.value).length).toBe(1); - }); -}); - describe('getNewControlState', () => { test('should contain defaults when there are no existing controls', () => { - const controlsManager = initControlsManager({}, new BehaviorSubject({})); + const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID); expect(controlsManager.getNewControlState()).toEqual({ grow: true, width: 'medium', - dataViewId: undefined, + dataViewId: DEFAULT_DATA_VIEW_ID, }); }); test('should start with defaults if there are existing controls', () => { - const intialControlsState = { - alpha: { - type: 'testControl', - order: 1, - dataViewId: 'myOtherDataViewId', - width: 'small', - grow: false, - } as ControlPanelState & Pick, - }; const controlsManager = initControlsManager( - intialControlsState, - new BehaviorSubject(intialControlsState) + { + alpha: { + type: 'testControl', + order: 1, + dataViewId: 'myOtherDataViewId', + width: 'small', + grow: false, + } as ControlPanelState & Pick, + }, + DEFAULT_DATA_VIEW_ID ); expect(controlsManager.getNewControlState()).toEqual({ grow: true, @@ -291,7 +221,7 @@ describe('getNewControlState', () => { }); test('should contain values of last added control', () => { - const controlsManager = initControlsManager({}, new BehaviorSubject({})); + const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID); controlsManager.api.addNewPanel({ panelType: 'testControl', initialState: { diff --git a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts index aaa5d41e492ae..07b533f329631 100644 --- a/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts +++ b/src/plugins/controls/public/react_controls/control_group/init_controls_manager.ts @@ -38,25 +38,22 @@ export function getControlsInOrder(initialControlPanelsState: ControlPanelsState } export function initControlsManager( - /** - * Composed from last saved controls state and previous sessions's unsaved changes to controls state - */ - initialControlsState: ControlPanelsState, - /** - * Observable that publishes last saved controls state only - */ - lastSavedControlsState$: PublishingSubject + initialControlPanelsState: ControlPanelsState, + defaultDataViewId: string | null ) { - const initialControlIds = Object.keys(initialControlsState); + const lastSavedControlsPanelState$ = new BehaviorSubject(initialControlPanelsState); + const initialControlIds = Object.keys(initialControlPanelsState); const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); - let currentControlsState: { [panelId: string]: DefaultControlState } = { - ...initialControlsState, + let controlsPanelState: { [panelId: string]: DefaultControlState } = { + ...initialControlPanelsState, }; const controlsInOrder$ = new BehaviorSubject( - getControlsInOrder(initialControlsState) + getControlsInOrder(initialControlPanelsState) ); const lastUsedDataViewId$ = new BehaviorSubject( - getLastUsedDataViewId(controlsInOrder$.value, initialControlsState) + getLastUsedDataViewId(controlsInOrder$.value, initialControlPanelsState) ?? + defaultDataViewId ?? + undefined ); const lastUsedWidth$ = new BehaviorSubject(DEFAULT_CONTROL_WIDTH); const lastUsedGrow$ = new BehaviorSubject(DEFAULT_CONTROL_GROW); @@ -111,12 +108,12 @@ export function initControlsManager( type: panelType, }); controlsInOrder$.next(nextControlsInOrder); - currentControlsState[id] = initialState ?? {}; + controlsPanelState[id] = initialState ?? {}; return await untilControlLoaded(id); } function removePanel(panelId: string) { - delete currentControlsState[panelId]; + delete controlsPanelState[panelId]; controlsInOrder$.next(controlsInOrder$.value.filter(({ id }) => id !== panelId)); children$.next(omit(children$.value, panelId)); } @@ -164,7 +161,7 @@ export function initControlsManager( type: controlApi.type, width, /** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */ - explicitInput: { id, ...rest }, + explicitInput: rest, }; }); @@ -187,30 +184,9 @@ export function initControlsManager( }); return controlsRuntimeState; }, - resetControlsUnsavedChanges: () => { - currentControlsState = { - ...lastSavedControlsState$.value, - }; - const nextControlsInOrder = getControlsInOrder(currentControlsState as ControlPanelsState); - controlsInOrder$.next(nextControlsInOrder); - - const nextControlIds = nextControlsInOrder.map(({ id }) => id); - const children = { ...children$.value }; - let modifiedChildren = false; - Object.keys(children).forEach((controlId) => { - if (!nextControlIds.includes(controlId)) { - // remove children that no longer exist after reset - delete children[controlId]; - modifiedChildren = true; - } - }); - if (modifiedChildren) { - children$.next(children); - } - }, api: { getSerializedStateForChild: (childId: string) => { - const controlPanelState = currentControlsState[childId]; + const controlPanelState = controlsPanelState[childId]; return controlPanelState ? { rawState: controlPanelState } : undefined; }, children$: children$ as PublishingSubject<{ @@ -254,10 +230,26 @@ export function initControlsManager( comparators: { controlsInOrder: [ controlsInOrder$, - (next: ControlsInOrder) => {}, // setter does nothing, controlsInOrder$ reset by resetControlsRuntimeState + (next: ControlsInOrder) => controlsInOrder$.next(next), fastIsEqual, ], - } as StateComparators>, + // Control state differences tracked by controlApi comparators + // Control ordering differences tracked by controlsInOrder comparator + // initialChildControlState comparatator exists to reset controls manager to last saved state + initialChildControlState: [ + lastSavedControlsPanelState$, + (lastSavedControlPanelsState: ControlPanelsState) => { + lastSavedControlsPanelState$.next(lastSavedControlPanelsState); + controlsPanelState = { + ...lastSavedControlPanelsState, + }; + controlsInOrder$.next(getControlsInOrder(lastSavedControlPanelsState)); + }, + () => true, + ], + } as StateComparators< + Pick + >, }; } diff --git a/src/plugins/controls/public/react_controls/control_group/open_edit_control_group_flyout.tsx b/src/plugins/controls/public/react_controls/control_group/open_edit_control_group_flyout.tsx index 98784f826090b..c636d37ade6b2 100644 --- a/src/plugins/controls/public/react_controls/control_group/open_edit_control_group_flyout.tsx +++ b/src/plugins/controls/public/react_controls/control_group/open_edit_control_group_flyout.tsx @@ -72,7 +72,7 @@ export const openEditControlGroupFlyout = ( Object.keys(controlGroupApi.children$.getValue()).forEach((childId) => { controlGroupApi.removePanel(childId); }); - closeOverlay(ref); + ref.close(); }); }; diff --git a/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts b/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts index 031dababa5ca1..eb3706c3913a1 100644 --- a/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts +++ b/src/plugins/controls/public/react_controls/control_group/serialization_utils.ts @@ -9,7 +9,6 @@ import { SerializedPanelState } from '@kbn/presentation-containers'; import { omit } from 'lodash'; import { ControlGroupRuntimeState, ControlGroupSerializedState } from './types'; -import { parseReferenceName } from '../controls/data_controls/reference_name_utils'; export const deserializeControlGroup = ( state: SerializedPanelState @@ -21,9 +20,9 @@ export const deserializeControlGroup = ( const references = state.references ?? []; references.forEach((reference) => { const referenceName = reference.name; - const { controlId } = parseReferenceName(referenceName); - if (panels[controlId]) { - panels[controlId].dataViewId = reference.id; + const panelId = referenceName.substring('controlGroup_'.length, referenceName.lastIndexOf(':')); + if (panels[panelId]) { + panels[panelId].dataViewId = reference.id; } }); diff --git a/src/plugins/controls/public/react_controls/control_group/types.ts b/src/plugins/controls/public/react_controls/control_group/types.ts index 826a5fde393b1..d009712e52a5b 100644 --- a/src/plugins/controls/public/react_controls/control_group/types.ts +++ b/src/plugins/controls/public/react_controls/control_group/types.ts @@ -65,9 +65,8 @@ export type ControlGroupApi = PresentationContainer & ignoreParentSettings$: PublishingSubject; allowExpensiveQueries$: PublishingSubject; untilInitialized: () => Promise; - openAddDataControlFlyout: (options?: { + openAddDataControlFlyout: (settings?: { controlInputTransform?: ControlInputTransform; - onSave?: () => void; }) => void; labelPosition: PublishingSubject; }; diff --git a/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.test.tsx b/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.test.tsx index 5baca7edfdaab..5dd6bf745feca 100644 --- a/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.test.tsx +++ b/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.test.tsx @@ -51,7 +51,6 @@ describe('initializeDataControl', () => { dataControl = initializeDataControl( 'myControlId', 'myControlType', - 'referenceNameSuffix', dataControlState, editorStateManager, controlGroupApi, @@ -83,7 +82,6 @@ describe('initializeDataControl', () => { dataControl = initializeDataControl( 'myControlId', 'myControlType', - 'referenceNameSuffix', { ...dataControlState, dataViewId: 'notGonnaFindMeDataViewId', @@ -122,7 +120,6 @@ describe('initializeDataControl', () => { dataControl = initializeDataControl( 'myControlId', 'myControlType', - 'referenceNameSuffix', { ...dataControlState, fieldName: 'notGonnaFindMeFieldName', diff --git a/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.ts b/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.ts index d3b90e72bb7fa..312701dd22c32 100644 --- a/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.ts +++ b/src/plugins/controls/public/react_controls/controls/data_controls/initialize_data_control.ts @@ -26,12 +26,10 @@ import { initializeDefaultControlApi } from '../initialize_default_control_api'; import { ControlApiInitialization, ControlStateManager, DefaultControlState } from '../types'; import { openDataControlEditor } from './open_data_control_editor'; import { DataControlApi, DataControlFieldFormatter, DefaultDataControlState } from './types'; -import { getReferenceName } from './reference_name_utils'; export const initializeDataControl = ( controlId: string, controlType: string, - referenceNameSuffix: string, state: DefaultDataControlState, /** * `This state manager` should only include the state that the data control editor is @@ -244,7 +242,7 @@ export const initializeDataControl = ( }, references: [ { - name: getReferenceName(controlId, referenceNameSuffix), + name: `controlGroup_${controlId}:${controlType}DataView`, type: DATA_VIEW_SAVED_OBJECT_TYPE, id: dataViewId.getValue(), }, diff --git a/src/plugins/controls/public/react_controls/controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/src/plugins/controls/public/react_controls/controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 4a16fcfe29b31..12d0de5a3d7d3 100644 --- a/src/plugins/controls/public/react_controls/controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/src/plugins/controls/public/react_controls/controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -8,7 +8,6 @@ import React, { useEffect } from 'react'; import { BehaviorSubject, combineLatest, debounceTime, filter, skip } from 'rxjs'; -import fastIsEqual from 'fast-deep-equal'; import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter, Filter } from '@kbn/es-query'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; @@ -88,7 +87,6 @@ export const getOptionsListControlFactory = ( >( uuid, OPTIONS_LIST_CONTROL, - 'optionsListDataView', initialState, { searchTechnique: searchTechnique$, singleSelect: singleSelect$ }, controlGroupApi, @@ -245,7 +243,7 @@ export const getOptionsListControlFactory = ( searchTechnique: searchTechnique$.getValue(), runPastTimeout: runPastTimeout$.getValue(), singleSelect: singleSelect$.getValue(), - selectedOptions: selections.selectedOptions$.getValue(), + selections: selections.selectedOptions$.getValue(), sort: sort$.getValue(), existsSelected: selections.existsSelected$.getValue(), exclude: selections.exclude$.getValue(), @@ -279,7 +277,7 @@ export const getOptionsListControlFactory = ( sort: [ sort$, (sort) => sort$.next(sort), - (a, b) => fastIsEqual(a ?? OPTIONS_LIST_DEFAULT_SORT, b ?? OPTIONS_LIST_DEFAULT_SORT), + (a, b) => (a ?? OPTIONS_LIST_DEFAULT_SORT) === (b ?? OPTIONS_LIST_DEFAULT_SORT), ], /** This state cannot currently be changed after the control is created */ diff --git a/src/plugins/controls/public/react_controls/controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/src/plugins/controls/public/react_controls/controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 88f0497ac5cba..a2819460d05c9 100644 --- a/src/plugins/controls/public/react_controls/controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/src/plugins/controls/public/react_controls/controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -63,7 +63,6 @@ export const getRangesliderControlFactory = ( const dataControl = initializeDataControl>( uuid, RANGE_SLIDER_CONTROL, - 'rangeSliderDataView', initialState, { step: step$, @@ -159,8 +158,8 @@ export const getRangesliderControlFactory = ( if (error) { dataControl.api.setBlockingError(error); } - max$.next(max !== undefined ? Math.ceil(max) : undefined); - min$.next(min !== undefined ? Math.floor(min) : undefined); + max$.next(max); + min$.next(min); } ); diff --git a/src/plugins/controls/public/react_controls/controls/data_controls/reference_name_utils.ts b/src/plugins/controls/public/react_controls/controls/data_controls/reference_name_utils.ts deleted file mode 100644 index 1a8a1e65f72de..0000000000000 --- a/src/plugins/controls/public/react_controls/controls/data_controls/reference_name_utils.ts +++ /dev/null @@ -1,22 +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. - */ - -const REFERENCE_NAME_PREFIX = 'controlGroup_'; - -export function getReferenceName(controlId: string, referenceNameSuffix: string) { - return `${REFERENCE_NAME_PREFIX}${controlId}:${referenceNameSuffix}`; -} - -export function parseReferenceName(referenceName: string) { - return { - controlId: referenceName.substring( - REFERENCE_NAME_PREFIX.length, - referenceName.lastIndexOf(':') - ), - }; -} diff --git a/src/plugins/controls/public/react_controls/controls/timeslider_control/get_timeslider_control_factory.tsx b/src/plugins/controls/public/react_controls/controls/timeslider_control/get_timeslider_control_factory.tsx index ef8ea463a9f63..f3d1b43de8fa2 100644 --- a/src/plugins/controls/public/react_controls/controls/timeslider_control/get_timeslider_control_factory.tsx +++ b/src/plugins/controls/public/react_controls/controls/timeslider_control/get_timeslider_control_factory.tsx @@ -35,17 +35,16 @@ import './components/index.scss'; import { TimeSliderPrepend } from './components/time_slider_prepend'; import { TIME_SLIDER_CONTROL } from '../../../../common'; -const displayName = i18n.translate('controls.timesliderControl.displayName', { - defaultMessage: 'Time slider', -}); - export const getTimesliderControlFactory = ( services: Services ): ControlFactory => { return { type: TIME_SLIDER_CONTROL, getIconType: () => 'search', - getDisplayName: () => displayName, + getDisplayName: () => + i18n.translate('controls.timesliderControl.displayName', { + defaultMessage: 'Time slider', + }), buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } = initTimeRangeSubscription(controlGroupApi, services); @@ -204,7 +203,6 @@ export const getTimesliderControlFactory = ( const api = buildApi( { ...defaultControl.api, - defaultPanelTitle: new BehaviorSubject(displayName), timeslice$, serializeState: () => { const { rawState: defaultControlState } = defaultControl.serialize(); diff --git a/src/plugins/controls/public/react_controls/controls/timeslider_control/types.ts b/src/plugins/controls/public/react_controls/controls/timeslider_control/types.ts index d7c837732cce4..bc5fcd67829c2 100644 --- a/src/plugins/controls/public/react_controls/controls/timeslider_control/types.ts +++ b/src/plugins/controls/public/react_controls/controls/timeslider_control/types.ts @@ -8,7 +8,7 @@ import { CoreStart } from '@kbn/core/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { PublishesPanelTitle, PublishesTimeslice } from '@kbn/presentation-publishing'; +import type { PublishesTimeslice } from '@kbn/presentation-publishing'; import type { DefaultControlApi, DefaultControlState } from '../types'; export type Timeslice = [number, number]; @@ -20,9 +20,7 @@ export interface TimesliderControlState extends DefaultControlState { timesliceEndAsPercentageOfTimeRange?: number; } -export type TimesliderControlApi = DefaultControlApi & - Pick & - PublishesTimeslice; +export type TimesliderControlApi = DefaultControlApi & PublishesTimeslice; export interface Services { core: CoreStart; diff --git a/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts index 169af0ca27da4..f1f6efb0d6678 100644 --- a/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts +++ b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts @@ -7,6 +7,7 @@ */ import type { Reference } from '@kbn/content-management-utils'; +import { CONTROL_GROUP_TYPE, PersistableControlGroupInput } from '@kbn/controls-plugin/common'; import { EmbeddableInput, EmbeddablePersistableStateService, @@ -22,10 +23,6 @@ export const getReferencesForPanelId = (id: string, references: Reference[]): Re return filteredReferences; }; -export const getReferencesForControls = (references: Reference[]): Reference[] => { - return references.filter((reference) => reference.name.startsWith(controlGroupReferencePrefix)); -}; - export const prefixReferencesFromPanel = (id: string, references: Reference[]): Reference[] => { const prefix = `${id}:`; return references @@ -37,6 +34,7 @@ export const prefixReferencesFromPanel = (id: string, references: Reference[]): }; const controlGroupReferencePrefix = 'controlGroup_'; +const controlGroupId = 'dashboard_control_group'; export const createInject = ( persistableStateService: EmbeddablePersistableStateService @@ -92,6 +90,27 @@ 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, + id: controlGroupId, + }, + controlGroupReferences + ); + workingState.controlGroupInput = + injectedControlGroupState as unknown as PersistableControlGroupInput; + } + return workingState as EmbeddableStateWithType; }; }; @@ -141,6 +160,23 @@ 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, + id: controlGroupId, + }); + workingState.controlGroupInput = + extractedControlGroupState as unknown as PersistableControlGroupInput; + 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/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts index d6a852807bea3..94e8582ebecae 100644 --- a/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts +++ b/src/plugins/dashboard/common/dashboard_saved_object/persistable_state/dashboard_saved_object_references.ts @@ -8,6 +8,7 @@ import type { Reference } from '@kbn/content-management-utils'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types'; +import { rawControlGroupAttributesToControlGroupInput } from '@kbn/controls-plugin/common'; import { convertPanelMapToSavedPanels, @@ -32,6 +33,9 @@ function parseDashboardAttributesWithType( } return { + controlGroupInput: + attributes.controlGroupInput && + rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput), type: 'dashboard', panels: convertSavedPanelsToPanelMap(parsedPanels), } as ParsedDashboardAttributesWithType; @@ -55,6 +59,13 @@ export function injectReferences( panelsJSON: JSON.stringify(injectedPanels), } as DashboardAttributes; + if (attributes.controlGroupInput && injectedState.controlGroupInput) { + newAttributes.controlGroupInput = { + ...attributes.controlGroupInput, + panelsJSON: JSON.stringify(injectedState.controlGroupInput.panels), + }; + } + return newAttributes; } @@ -85,6 +96,13 @@ export function extractReferences( panelsJSON: JSON.stringify(extractedPanels), } as DashboardAttributes; + if (attributes.controlGroupInput && extractedState.controlGroupInput) { + newAttributes.controlGroupInput = { + ...attributes.controlGroupInput, + panelsJSON: JSON.stringify(extractedState.controlGroupInput.panels), + }; + } + return { references: [...references, ...extractedReferences], attributes: newAttributes, diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index fd434085b397b..b5492d62ea220 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -8,6 +8,8 @@ import type { Reference } from '@kbn/content-management-utils'; import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; + import { DashboardAttributes, SavedDashboardPanel } from './content_management'; import { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types'; @@ -38,6 +40,7 @@ export type SharedDashboardState = Partial< * A partially parsed version of the Dashboard Attributes used for inject and extract logic for both the Dashboard Container and the Dashboard Saved Object. */ export type ParsedDashboardAttributesWithType = EmbeddableStateWithType & { + controlGroupInput?: PersistableControlGroupInput; panels: DashboardPanelMap; type: 'dashboard'; }; diff --git a/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts b/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts index fd2f64828899f..2b56acc719158 100644 --- a/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts +++ b/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts @@ -9,7 +9,9 @@ import { DashboardAppLocatorDefinition } from './locator'; import { hashedItemStore } from '@kbn/kibana-utils-plugin/public'; import { mockStorage } from '@kbn/kibana-utils-plugin/public/storage/hashed_item_store/mock'; +import { mockControlGroupInput } from '@kbn/controls-plugin/common/mocks'; import { FilterStateStore } from '@kbn/es-query'; +import { SerializableControlGroupInput } from '@kbn/controls-plugin/common'; describe('dashboard locator', () => { beforeEach(() => { @@ -191,18 +193,16 @@ describe('dashboard locator', () => { useHashedUrl: false, getDashboardFilterFields: async (dashboardId: string) => [], }); - const controlGroupState = { - autoApplySelections: false, - }; + const controlGroupInput = mockControlGroupInput() as unknown as SerializableControlGroupInput; const location = await definition.getLocation({ - controlGroupState, + controlGroupInput, }); expect(location).toMatchObject({ app: 'dashboards', path: `#/create?_g=()`, state: { - controlGroupState, + controlGroupInput, }, }); }); diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx index b96f450e19bdc..e7c7daa2bcc27 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_data_control_button.tsx @@ -8,16 +8,16 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { ControlGroupContainer } from '@kbn/controls-plugin/public'; import { getAddControlButtonTitle } from '../../_dashboard_app_strings'; import { useDashboardAPI } from '../../dashboard_app'; interface Props { closePopover: () => void; - controlGroupApi?: ControlGroupApi; + controlGroup: ControlGroupContainer; } -export const AddDataControlButton = ({ closePopover, controlGroupApi, ...rest }: Props) => { +export const AddDataControlButton = ({ closePopover, controlGroup, ...rest }: Props) => { const dashboard = useDashboardAPI(); const onSave = () => { dashboard.scrollToTop(); @@ -28,10 +28,9 @@ export const AddDataControlButton = ({ closePopover, controlGroupApi, ...rest }: {...rest} icon="plusInCircle" data-test-subj="controls-create-button" - disabled={!controlGroupApi} aria-label={getAddControlButtonTitle()} onClick={() => { - controlGroupApi?.openAddDataControlFlyout({ onSave }); + controlGroup.openAddDataControlFlyout({ onSave }); closePopover(); }} > diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx index 9cb9b1b82f9da..a3a9cf7ce73d8 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx @@ -7,12 +7,8 @@ */ import React, { useEffect, useState } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import { EuiContextMenuItem } from '@elastic/eui'; -import type { ControlGroupApi } from '@kbn/controls-plugin/public'; -import { TIME_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; - -import { apiHasType } from '@kbn/presentation-publishing'; +import { ControlGroupContainer, TIME_SLIDER_CONTROL } from '@kbn/controls-plugin/public'; import { getAddTimeSliderControlButtonTitle, getOnlyOneTimeSliderControlMsg, @@ -21,47 +17,40 @@ import { useDashboardAPI } from '../../dashboard_app'; interface Props { closePopover: () => void; - controlGroupApi?: ControlGroupApi; + controlGroup: ControlGroupContainer; } -export const AddTimeSliderControlButton = ({ closePopover, controlGroupApi, ...rest }: Props) => { +export const AddTimeSliderControlButton = ({ closePopover, controlGroup, ...rest }: Props) => { const [hasTimeSliderControl, setHasTimeSliderControl] = useState(false); const dashboard = useDashboardAPI(); useEffect(() => { - if (!controlGroupApi) { - return; - } - - const subscription = controlGroupApi.children$.subscribe((children) => { - const nextHasTimeSliderControl = Object.values(children).some((controlApi) => { - return apiHasType(controlApi) && controlApi.type === TIME_SLIDER_CONTROL; + const subscription = controlGroup.getInput$().subscribe(() => { + const childIds = controlGroup.getChildIds(); + const nextHasTimeSliderControl = childIds.some((id: string) => { + const child = controlGroup.getChild(id); + return child.type === TIME_SLIDER_CONTROL; }); - setHasTimeSliderControl(nextHasTimeSliderControl); + if (nextHasTimeSliderControl !== hasTimeSliderControl) { + setHasTimeSliderControl(nextHasTimeSliderControl); + } }); return () => { subscription.unsubscribe(); }; - }, [controlGroupApi]); + }, [controlGroup, hasTimeSliderControl, setHasTimeSliderControl]); return ( { - controlGroupApi?.addNewPanel({ - panelType: TIME_SLIDER_CONTROL, - initialState: { - grow: true, - width: 'large', - id: uuidv4(), - }, - }); + await controlGroup.addTimeSliderControl(); dashboard.scrollToTop(); closePopover(); }} data-test-subj="controls-create-timeslider-button" - disabled={!controlGroupApi || hasTimeSliderControl} + disabled={hasTimeSliderControl} toolTipContent={hasTimeSliderControl ? getOnlyOneTimeSliderControlMsg() : null} > {getAddTimeSliderControlButtonTitle()} diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/controls_toolbar_button.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/controls_toolbar_button.tsx index 6c6459266f7c3..ba90513a44c1d 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/controls_toolbar_button.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/controls_toolbar_button.tsx @@ -10,18 +10,18 @@ import React from 'react'; import { EuiContextMenuPanel, useEuiTheme } from '@elastic/eui'; import { ToolbarPopover } from '@kbn/shared-ux-button-toolbar'; +import type { ControlGroupContainer } from '@kbn/controls-plugin/public'; -import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { getControlButtonTitle } from '../../_dashboard_app_strings'; import { AddDataControlButton } from './add_data_control_button'; import { AddTimeSliderControlButton } from './add_time_slider_control_button'; import { EditControlGroupButton } from './edit_control_group_button'; export function ControlsToolbarButton({ - controlGroupApi, + controlGroup, isDisabled, }: { - controlGroupApi?: ControlGroupApi; + controlGroup: ControlGroupContainer; isDisabled?: boolean; }) { const { euiTheme } = useEuiTheme(); @@ -43,17 +43,17 @@ export function ControlsToolbarButton({ items={[ , , , ]} diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/edit_control_group_button.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/edit_control_group_button.tsx index 5f093b2967d39..3563d87f5cf81 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/edit_control_group_button.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/edit_control_group_button.tsx @@ -8,24 +8,23 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { ControlGroupContainer } from '@kbn/controls-plugin/public'; import { getEditControlGroupButtonTitle } from '../../_dashboard_app_strings'; interface Props { closePopover: () => void; - controlGroupApi?: ControlGroupApi; + controlGroup: ControlGroupContainer; } -export const EditControlGroupButton = ({ closePopover, controlGroupApi, ...rest }: Props) => { +export const EditControlGroupButton = ({ closePopover, controlGroup, ...rest }: Props) => { return ( { - controlGroupApi?.onEdit(); + controlGroup.openEditControlGroupFlyout(); closePopover(); }} > diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 17d8ced554948..579d6d17d3a94 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -13,7 +13,6 @@ import { useEuiTheme } from '@elastic/eui'; import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar'; import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public'; -import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings'; import { EditorMenu } from './editor_menu'; import { useDashboardAPI } from '../dashboard_app'; @@ -83,7 +82,6 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean } * dismissNotification: Optional, if not passed a toast will appear in the dashboard */ - const controlGroupApi = useStateFromPublishingSubject(dashboard.controlGroupApi$); const extraButtons = [ , , - , ]; + if (dashboard.controlGroup) { + extraButtons.push( + + ); + } return (
void; showResetChange?: boolean; }) => { - const isMounted = useMountedState(); - const [isSaveInProgress, setIsSaveInProgress] = useState(false); /** @@ -102,7 +99,6 @@ export const useDashboardMenuItems = ({ * (1) reset the dashboard to the last saved state, and * (2) if `switchToViewMode` is `true`, set the dashboard to view mode. */ - const [isResetting, setIsResetting] = useState(false); const resetChanges = useCallback( (switchToViewMode: boolean = false) => { dashboard.clearOverlays(); @@ -117,17 +113,13 @@ export const useDashboardMenuItems = ({ return; } confirmDiscardUnsavedChanges(() => { - batch(async () => { - setIsResetting(true); - await dashboard.asyncResetToLastSavedState(); - if (isMounted()) { - setIsResetting(false); - switchModes?.(); - } + batch(() => { + dashboard.resetToLastSavedState(); + switchModes?.(); }); }, viewMode); }, - [dashboard, dashboardBackup, hasUnsavedChanges, viewMode, isMounted] + [dashboard, dashboardBackup, hasUnsavedChanges, viewMode] ); /** @@ -198,8 +190,7 @@ export const useDashboardMenuItems = ({ switchToViewMode: { ...topNavStrings.switchToViewMode, id: 'cancel', - disableButton: disableTopNav || !lastSavedId || isResetting, - isLoading: isResetting, + disableButton: disableTopNav || !lastSavedId, testId: 'dashboardViewOnlyMode', run: () => resetChanges(true), } as TopNavMenuData, @@ -235,7 +226,6 @@ export const useDashboardMenuItems = ({ dashboardBackup, quickSaveDashboard, resetChanges, - isResetting, ]); const resetChangesMenuItem = useMemo(() => { @@ -244,22 +234,12 @@ export const useDashboardMenuItems = ({ id: 'reset', testId: 'dashboardDiscardChangesMenuItem', disableButton: - isResetting || !hasUnsavedChanges || hasOverlays || (viewMode === ViewMode.EDIT && (isSaveInProgress || !lastSavedId)), - isLoading: isResetting, run: () => resetChanges(), }; - }, [ - hasOverlays, - lastSavedId, - resetChanges, - viewMode, - isSaveInProgress, - hasUnsavedChanges, - isResetting, - ]); + }, [hasOverlays, lastSavedId, resetChanges, viewMode, isSaveInProgress, hasUnsavedChanges]); /** * Build ordered menus for view and edit mode. diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx index 91fa453e7c5f9..93f25962a0916 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx @@ -44,7 +44,7 @@ jest.mock('./dashboard_grid_item', () => { }; }); -const createAndMountDashboardGrid = async () => { +const createAndMountDashboardGrid = () => { const dashboardContainer = buildMockDashboard({ overrides: { panels: { @@ -61,7 +61,6 @@ const createAndMountDashboardGrid = async () => { }, }, }); - await dashboardContainer.untilContainerInitialized(); const component = mountWithIntl( @@ -71,20 +70,20 @@ const createAndMountDashboardGrid = async () => { }; test('renders DashboardGrid', async () => { - const { component } = await createAndMountDashboardGrid(); + const { component } = createAndMountDashboardGrid(); const panelElements = component.find('GridItem'); expect(panelElements.length).toBe(2); }); test('renders DashboardGrid with no visualizations', async () => { - const { dashboardContainer, component } = await createAndMountDashboardGrid(); + const { dashboardContainer, component } = createAndMountDashboardGrid(); dashboardContainer.updateInput({ panels: {} }); component.update(); expect(component.find('GridItem').length).toBe(0); }); test('DashboardGrid removes panel when removed from container', async () => { - const { dashboardContainer, component } = await createAndMountDashboardGrid(); + const { dashboardContainer, component } = createAndMountDashboardGrid(); const originalPanels = dashboardContainer.getInput().panels; const filteredPanels = { ...originalPanels }; delete filteredPanels['1']; @@ -95,7 +94,7 @@ test('DashboardGrid removes panel when removed from container', async () => { }); test('DashboardGrid renders expanded panel', async () => { - const { dashboardContainer, component } = await createAndMountDashboardGrid(); + const { dashboardContainer, component } = createAndMountDashboardGrid(); dashboardContainer.setExpandedPanelId('1'); component.update(); // Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized. @@ -113,7 +112,7 @@ test('DashboardGrid renders expanded panel', async () => { }); test('DashboardGrid renders focused panel', async () => { - const { dashboardContainer, component } = await createAndMountDashboardGrid(); + const { dashboardContainer, component } = createAndMountDashboardGrid(); dashboardContainer.setFocusedPanelId('2'); component.update(); // Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized. diff --git a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx index a3ffb5bfcdd38..cc0397a5af1e3 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx @@ -9,19 +9,12 @@ import { debounce } from 'lodash'; import classNames from 'classnames'; import useResizeObserver from 'use-resize-observer/polyfilled'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { EuiPortal } from '@elastic/eui'; -import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen'; -import { - ControlGroupApi, - ControlGroupRuntimeState, - ControlGroupSerializedState, -} from '@kbn/controls-plugin/public'; -import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; -import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; import { DashboardGrid } from '../grid'; import { useDashboardContainer } from '../../embeddable/dashboard_container'; import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; @@ -41,11 +34,23 @@ export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => { }; export const DashboardViewportComponent = () => { + const controlsRoot = useRef(null); + const dashboard = useDashboardContainer(); - const controlGroupApi = useStateFromPublishingSubject(dashboard.controlGroupApi$); + /** + * Render Control group + */ + const controlGroup = dashboard.controlGroup; + useEffect(() => { + if (controlGroup && controlsRoot.current) controlGroup.render(controlsRoot.current); + }, [controlGroup]); + const panelCount = Object.keys(dashboard.select((state) => state.explicitInput.panels)).length; - const [hasControls, setHasControls] = useState(false); + const controlCount = Object.keys( + controlGroup?.select((state) => state.explicitInput.panels) ?? {} + ).length; + const viewMode = dashboard.select((state) => state.explicitInput.viewMode); const dashboardTitle = dashboard.select((state) => state.explicitInput.title); const useMargins = dashboard.select((state) => state.explicitInput.useMargins); @@ -60,59 +65,17 @@ export const DashboardViewportComponent = () => { 'dshDashboardViewport--panelExpanded': Boolean(expandedPanelId), }); - useEffect(() => { - if (!controlGroupApi) { - return; - } - const subscription = controlGroupApi.children$.subscribe((children) => { - setHasControls(Object.keys(children).length > 0); - }); - return () => { - subscription.unsubscribe(); - }; - }, [controlGroupApi]); - - const [dashboardInitialized, setDashboardInitialized] = useState(false); - useEffect(() => { - let ignore = false; - dashboard.untilContainerInitialized().then(() => { - if (!ignore) { - setDashboardInitialized(true); - } - }); - return () => { - ignore = true; - }; - }, [dashboard]); - return (
- {viewMode !== ViewMode.PRINT ? ( -
- - key={dashboard.getInput().id} - hidePanelChrome={true} - panelProps={{ hideLoader: true }} - type={CONTROL_GROUP_TYPE} - maybeId={'control_group'} - getParentApi={() => { - return { - ...dashboard, - getSerializedStateForChild: dashboard.getSerializedStateForControlGroup, - getRuntimeStateForChild: dashboard.getRuntimeStateForControlGroup, - }; - }} - onApiAvailable={(api) => dashboard.setControlGroupApi(api)} - /> -
+ {controlGroup && viewMode !== ViewMode.PRINT ? ( +
0 ? 'dshDashboardViewport-controls' : ''} + ref={controlsRoot} + /> ) : null} {panelCount === 0 && }
{ > {/* Wait for `viewportWidth` to actually be set before rendering the dashboard grid - otherwise, there is a race condition where the panels can end up being squashed */} - {viewportWidth !== 0 && dashboardInitialized && ( - - )} + {viewportWidth !== 0 && }
); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx index b8ee1cca82156..215c3e7b99e7d 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx @@ -7,6 +7,7 @@ */ import type { Reference } from '@kbn/content-management-utils'; +import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { EmbeddableInput, @@ -88,17 +89,13 @@ export async function runQuickSave(this: DashboardContainer) { const { panels: nextPanels, references } = await serializeAllPanelState(this); const dashboardStateToSave: DashboardContainerInput = { ...currentState, panels: nextPanels }; let stateToSave: SavedDashboardInput = dashboardStateToSave; - const controlGroupApi = this.controlGroupApi$.value; - let controlGroupReferences: Reference[] | undefined; - if (controlGroupApi) { - const { rawState: controlGroupSerializedState, references: extractedReferences } = - await controlGroupApi.serializeState(); - controlGroupReferences = extractedReferences; - stateToSave = { ...stateToSave, controlGroupInput: controlGroupSerializedState }; + let persistableControlGroupInput: PersistableControlGroupInput | undefined; + if (this.controlGroup) { + persistableControlGroupInput = this.controlGroup.getPersistableInput(); + stateToSave = { ...stateToSave, controlGroupInput: persistableControlGroupInput }; } const saveResult = await saveDashboardState({ - controlGroupReferences, panelReferences: references, currentState: stateToSave, saveOptions: {}, @@ -108,6 +105,9 @@ export async function runQuickSave(this: DashboardContainer) { this.savedObjectReferences = saveResult.references ?? []; this.dispatch.setLastSavedInput(dashboardStateToSave); this.saveNotification$.next(); + if (this.controlGroup && persistableControlGroupInput) { + this.controlGroup.setSavedState(persistableControlGroupInput); + } return saveResult; } @@ -180,20 +180,19 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo stateFromSaveModal.tags = newTags; } - let dashboardStateToSave: SavedDashboardInput = { + let dashboardStateToSave: DashboardContainerInput & { + controlGroupInput?: PersistableControlGroupInput; + } = { ...currentState, ...stateFromSaveModal, }; - const controlGroupApi = this.controlGroupApi$.value; - let controlGroupReferences: Reference[] | undefined; - if (controlGroupApi) { - const { rawState: controlGroupSerializedState, references } = - await controlGroupApi.serializeState(); - controlGroupReferences = references; + let persistableControlGroupInput: PersistableControlGroupInput | undefined; + if (this.controlGroup) { + persistableControlGroupInput = this.controlGroup.getPersistableInput(); dashboardStateToSave = { ...dashboardStateToSave, - controlGroupInput: controlGroupSerializedState, + controlGroupInput: persistableControlGroupInput, }; } @@ -226,7 +225,6 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo const beforeAddTime = window.performance.now(); const saveResult = await saveDashboardState({ - controlGroupReferences, panelReferences: references, saveOptions, currentState: { @@ -253,6 +251,9 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo batch(() => { this.dispatch.setStateFromSaveModal(stateFromSaveModal); this.dispatch.setLastSavedInput(dashboardStateToSave); + if (this.controlGroup && persistableControlGroupInput) { + this.controlGroup.setSavedState(persistableControlGroupInput); + } }); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts index 84b9d8dbea7b0..148c409e8d702 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.test.ts @@ -6,9 +6,11 @@ * Side Public License, v 1. */ +import { mockControlGroupInput } from '@kbn/controls-plugin/common/mocks'; +import { ControlGroupContainer } from '@kbn/controls-plugin/public/control_group/embeddable/control_group_container'; import { Filter } from '@kbn/es-query'; +import { ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group_integration'; -import { BehaviorSubject } from 'rxjs'; jest.mock('@kbn/controls-plugin/public/control_group/embeddable/control_group_container'); @@ -49,41 +51,46 @@ const testFilter3: Filter = { }, }; -describe('combineDashboardFiltersWithControlGroupFilters', () => { - it('Combined filter pills do not get overwritten', async () => { - const dashboardFilterPills = [testFilter1, testFilter2]; - const mockControlGroupApi = { - filters$: new BehaviorSubject([]), - }; - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - dashboardFilterPills, - mockControlGroupApi - ); - expect(combinedFilters).toEqual(dashboardFilterPills); - }); +const mockControlGroupContainer = new ControlGroupContainer( + { getTools: () => {} } as unknown as ReduxToolsPackage, + mockControlGroupInput() +); - it('Combined control filters do not get overwritten', async () => { - const controlGroupFilters = [testFilter1, testFilter2]; - const mockControlGroupApi = { - filters$: new BehaviorSubject(controlGroupFilters), - }; - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - [] as Filter[], - mockControlGroupApi - ); - expect(combinedFilters).toEqual(controlGroupFilters); - }); +describe('Test dashboard control group', () => { + describe('Combine dashboard filters with control group filters test', () => { + it('Combined filter pills do not get overwritten', async () => { + const dashboardFilterPills = [testFilter1, testFilter2]; + mockControlGroupContainer.getOutput = jest.fn().mockReturnValue({ filters: [] }); + const combinedFilters = combineDashboardFiltersWithControlGroupFilters( + dashboardFilterPills, + mockControlGroupContainer + ); + expect(combinedFilters).toEqual(dashboardFilterPills); + }); + + it('Combined control filters do not get overwritten', async () => { + const controlGroupFilters = [testFilter1, testFilter2]; + mockControlGroupContainer.getOutput = jest + .fn() + .mockReturnValue({ filters: controlGroupFilters }); + const combinedFilters = combineDashboardFiltersWithControlGroupFilters( + [] as Filter[], + mockControlGroupContainer + ); + expect(combinedFilters).toEqual(controlGroupFilters); + }); - it('Combined dashboard filter pills and control filters do not get overwritten', async () => { - const dashboardFilterPills = [testFilter1, testFilter2]; - const controlGroupFilters = [testFilter3]; - const mockControlGroupApi = { - filters$: new BehaviorSubject(controlGroupFilters), - }; - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - dashboardFilterPills, - mockControlGroupApi - ); - expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters)); + it('Combined dashboard filter pills and control filters do not get overwritten', async () => { + const dashboardFilterPills = [testFilter1, testFilter2]; + const controlGroupFilters = [testFilter3]; + mockControlGroupContainer.getOutput = jest + .fn() + .mockReturnValue({ filters: controlGroupFilters }); + const combinedFilters = combineDashboardFiltersWithControlGroupFilters( + dashboardFilterPills, + mockControlGroupContainer + ); + expect(combinedFilters).toEqual(dashboardFilterPills.concat(controlGroupFilters)); + }); }); }); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts index 6267f6a27a2cc..675ea42634506 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/controls/dashboard_control_group_integration.ts @@ -6,95 +6,114 @@ * Side Public License, v 1. */ -import { COMPARE_ALL_OPTIONS, compareFilters, type Filter } from '@kbn/es-query'; -import { - BehaviorSubject, - combineLatest, - distinctUntilChanged, - map, - of, - skip, - startWith, - switchMap, -} from 'rxjs'; -import { PublishesFilters, PublishingSubject } from '@kbn/presentation-publishing'; +import { ControlGroupInput } from '@kbn/controls-plugin/common'; +import { ControlGroupContainer } from '@kbn/controls-plugin/public'; +import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query'; +import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; +import { apiPublishesDataLoading, PublishesDataLoading } from '@kbn/presentation-publishing'; +import deepEqual from 'fast-deep-equal'; +import { isEqual } from 'lodash'; +import { distinctUntilChanged, Observable, skip } from 'rxjs'; +import { DashboardContainerInput } from '../../../../../common'; import { DashboardContainer } from '../../dashboard_container'; -export function startSyncingDashboardControlGroup(dashboard: DashboardContainer) { - const controlGroupFilters$ = dashboard.controlGroupApi$.pipe( - switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.filters$ : of(undefined))) - ); - const controlGroupTimeslice$ = dashboard.controlGroupApi$.pipe( - switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.timeslice$ : of(undefined))) - ); +interface DiffChecks { + [key: string]: (a?: unknown, b?: unknown) => boolean; +} - // -------------------------------------------------------------------------------------- - // dashboard.unifiedSearchFilters$ - // -------------------------------------------------------------------------------------- - const unifiedSearchFilters$ = new BehaviorSubject( - dashboard.getInput().filters - ); - dashboard.unifiedSearchFilters$ = unifiedSearchFilters$ as PublishingSubject< - Filter[] | undefined - >; - dashboard.publishingSubscription.add( - dashboard - .getInput$() - .pipe( - startWith(dashboard.getInput()), - map((input) => input.filters), - distinctUntilChanged((previous, current) => { - return compareFilters(previous ?? [], current ?? [], COMPARE_ALL_OPTIONS); - }) - ) - .subscribe((unifiedSearchFilters) => { - unifiedSearchFilters$.next(unifiedSearchFilters); - }) - ); +const distinctUntilDiffCheck = (a: T, b: T, diffChecks: DiffChecks) => + !(Object.keys(diffChecks) as Array) + .map((key) => deepEqual(a[key], b[key])) + .includes(false); + +type DashboardControlGroupCommonKeys = keyof Pick< + DashboardContainerInput | ControlGroupInput, + 'filters' | 'lastReloadRequestTime' | 'timeRange' | 'query' +>; + +export function startSyncingDashboardControlGroup(this: DashboardContainer) { + if (!this.controlGroup) return; - // -------------------------------------------------------------------------------------- - // Set dashboard.filters$ to include unified search filters and control group filters - // -------------------------------------------------------------------------------------- - function getCombinedFilters() { - return combineDashboardFiltersWithControlGroupFilters( - dashboard.getInput().filters ?? [], - dashboard.controlGroupApi$.value - ); - } + const compareAllFilters = (a?: Filter[], b?: Filter[]) => + compareFilters(a ?? [], b ?? [], COMPARE_ALL_OPTIONS); - const filters$ = new BehaviorSubject(getCombinedFilters()); - dashboard.filters$ = filters$; + const dashboardRefetchDiff: DiffChecks = { + filters: (a, b) => compareAllFilters(a as Filter[], b as Filter[]), + timeRange: deepEqual, + query: deepEqual, + viewMode: deepEqual, + }; - dashboard.publishingSubscription.add( - combineLatest([dashboard.unifiedSearchFilters$, controlGroupFilters$]).subscribe(() => { - filters$.next(getCombinedFilters()); - }) + // pass down any pieces of input needed to refetch or force refetch data for the controls + this.integrationSubscriptions.add( + (this.getInput$() as Readonly>) + .pipe( + distinctUntilChanged((a, b) => + distinctUntilDiffCheck(a, b, dashboardRefetchDiff) + ) + ) + .subscribe(() => { + const newInput: { [key: string]: unknown } = {}; + (Object.keys(dashboardRefetchDiff) as DashboardControlGroupCommonKeys[]).forEach((key) => { + if ( + !dashboardRefetchDiff[key]?.(this.getInput()[key], this.controlGroup!.getInput()[key]) + ) { + newInput[key] = this.getInput()[key]; + } + }); + if (Object.keys(newInput).length > 0) { + this.controlGroup!.updateInput(newInput); + } + }) ); - // -------------------------------------------------------------------------------------- // when control group outputs filters, force a refresh! - // -------------------------------------------------------------------------------------- - dashboard.publishingSubscription.add( - controlGroupFilters$ + this.integrationSubscriptions.add( + this.controlGroup + .getOutput$() .pipe( + distinctUntilChanged(({ filters: filtersA }, { filters: filtersB }) => + compareAllFilters(filtersA, filtersB) + ), skip(1) // skip first filter output because it will have been applied in initialize ) - .subscribe(() => dashboard.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted + .subscribe(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted ); - // -------------------------------------------------------------------------------------- - // when control group outputs timeslice, dispatch timeslice - // -------------------------------------------------------------------------------------- - dashboard.publishingSubscription.add( - controlGroupTimeslice$.subscribe((timeslice) => { - dashboard.dispatch.setTimeslice(timeslice); - }) + this.integrationSubscriptions.add( + this.controlGroup + .getOutput$() + .pipe( + distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) => + isEqual(timesliceA, timesliceB) + ) + ) + .subscribe(({ timeslice }) => { + if (!isEqual(timeslice, this.getInput().timeslice)) { + this.dispatch.setTimeslice(timeslice); + } + }) + ); + + // the Control Group needs to know when any dashboard children are loading in order to know when to move on to the next time slice when playing. + this.integrationSubscriptions.add( + combineCompatibleChildrenApis( + this, + 'dataLoading', + apiPublishesDataLoading, + false, + (childrenLoading) => childrenLoading.some(Boolean) + ) + .pipe(skip(1)) // skip the initial output of "false" + .subscribe((anyChildLoading) => + this.controlGroup?.anyControlOutputConsumerLoading$.next(anyChildLoading) + ) ); } export const combineDashboardFiltersWithControlGroupFilters = ( dashboardFilters: Filter[], - controlGroupApi?: PublishesFilters + controlGroup: ControlGroupContainer ): Filter[] => { - return [...dashboardFilters, ...(controlGroupApi?.filters$.value ?? [])]; + return [...dashboardFilters, ...(controlGroup.getOutput().filters ?? [])]; }; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts index 12f513c1f417f..b9d2ff286023d 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { BehaviorSubject, Observable } from 'rxjs'; + import { ContactCardEmbeddable, ContactCardEmbeddableFactory, @@ -13,6 +15,11 @@ import { ContactCardEmbeddableOutput, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples'; +import { + ControlGroupInput, + ControlGroupContainer, + ControlGroupContainerFactory, +} from '@kbn/controls-plugin/public'; import { Filter } from '@kbn/es-query'; import { EmbeddablePackageState, ViewMode } from '@kbn/embeddable-plugin/public'; import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; @@ -22,7 +29,6 @@ import { getSampleDashboardPanel } from '../../../mocks'; import { pluginServices } from '../../../services/plugin_services'; import { DashboardCreationOptions } from '../dashboard_container_factory'; import { DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants'; -import { mockControlGroupApi } from '../../../mocks'; test("doesn't throw error when no data views are available", async () => { pluginServices.getServices().data.dataViews.defaultDataViewExists = jest @@ -410,7 +416,6 @@ test('creates new embeddable with incoming embeddable if id does not match exist }, }), }); - dashboard?.setControlGroupApi(mockControlGroupApi); // flush promises await new Promise((r) => setTimeout(r, 1)); @@ -471,7 +476,6 @@ test('creates new embeddable with specified size if size is provided', async () }, }), }); - dashboard?.setControlGroupApi(mockControlGroupApi); // flush promises await new Promise((r) => setTimeout(r, 1)); @@ -493,6 +497,42 @@ test('creates new embeddable with specified size if size is provided', async () expect(dashboard!.getState().explicitInput.panels.new_panel.gridData.h).toBe(1); }); +test('creates a control group from the control group factory', async () => { + const mockControlGroupContainer = { + destroy: jest.fn(), + render: jest.fn(), + updateInput: jest.fn(), + getInput: jest.fn().mockReturnValue({}), + getInput$: jest.fn().mockReturnValue(new Observable()), + getOutput: jest.fn().mockReturnValue({}), + getOutput$: jest.fn().mockReturnValue(new Observable()), + onFiltersPublished$: new Observable(), + unsavedChanges: new BehaviorSubject(undefined), + } as unknown as ControlGroupContainer; + const mockControlGroupFactory = { + create: jest.fn().mockReturnValue(mockControlGroupContainer), + } as unknown as ControlGroupContainerFactory; + pluginServices.getServices().embeddable.getEmbeddableFactory = jest + .fn() + .mockReturnValue(mockControlGroupFactory); + await createDashboard({ + useControlGroupIntegration: true, + getInitialInput: () => ({ + controlGroupInput: { controlStyle: 'twoLine' } as unknown as ControlGroupInput, + }), + }); + // flush promises + await new Promise((r) => setTimeout(r, 1)); + expect(pluginServices.getServices().embeddable.getEmbeddableFactory).toHaveBeenCalledWith( + 'control_group' + ); + expect(mockControlGroupFactory.create).toHaveBeenCalledWith( + expect.objectContaining({ controlStyle: 'twoLine' }), + undefined, + { lastSavedInput: expect.objectContaining({ controlStyle: 'oneLine' }) } + ); +}); + /* * dashboard.getInput$() subscriptions are used to update: * 1) dashboard instance searchSessionId state @@ -527,7 +567,6 @@ test('searchSessionId is updated prior to child embeddable parent subscription e createSessionRestorationDataProvider: () => {}, } as unknown as DashboardCreationOptions['searchSessionSettings'], }); - dashboard?.setControlGroupApi(mockControlGroupApi); expect(dashboard).toBeDefined(); const embeddable = await dashboard!.addNewEmbeddable< ContactCardEmbeddableInput, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 8e23540479535..158fc638adc3d 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -5,13 +5,38 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { + ControlGroupInput, + CONTROL_GROUP_TYPE, + getDefaultControlGroupInput, + getDefaultControlGroupPersistableInput, +} from '@kbn/controls-plugin/common'; +import { + ControlGroupContainerFactory, + ControlGroupOutput, + type ControlGroupContainer, +} from '@kbn/controls-plugin/public'; import { GlobalQueryStateFromUrl, syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { TimeRange } from '@kbn/es-query'; +import { EmbeddableFactory, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; +import { + AggregateQuery, + compareFilters, + COMPARE_ALL_OPTIONS, + Filter, + Query, + TimeRange, +} from '@kbn/es-query'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; -import { cloneDeep, omit } from 'lodash'; -import { Subject } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; +import { cloneDeep, identity, omit, pickBy } from 'lodash'; +import { + BehaviorSubject, + combineLatest, + distinctUntilChanged, + map, + startWith, + Subject, +} from 'rxjs'; import { v4 } from 'uuid'; import { DashboardContainerInput, @@ -35,11 +60,14 @@ import { startDiffingDashboardState } from '../../state/diffing/dashboard_diffin import { DashboardPublicState, UnsavedPanelState } from '../../types'; import { DashboardContainer } from '../dashboard_container'; import { DashboardCreationOptions } from '../dashboard_container_factory'; +import { + combineDashboardFiltersWithControlGroupFilters, + startSyncingDashboardControlGroup, +} from './controls/dashboard_control_group_integration'; import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views'; import { startQueryPerformanceTracking } from './performance/query_performance_tracking'; import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration'; import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state'; -import { PANELS_CONTROL_GROUP_KEY } from '../../../services/dashboard_backup/dashboard_backup_service'; /** * Builds a new Dashboard from scratch. @@ -134,13 +162,16 @@ export const initializeDashboard = async ({ loadDashboardReturn, untilDashboardReady, creationOptions, + controlGroup, }: { loadDashboardReturn: LoadDashboardReturn; untilDashboardReady: () => Promise; creationOptions?: DashboardCreationOptions; + controlGroup?: ControlGroupContainer; }) => { const { dashboardBackup, + embeddable: { getEmbeddableFactory }, dashboardCapabilities: { showWriteControls }, embeddable: { reactEmbeddableRegistryHasKey }, data: { @@ -160,6 +191,7 @@ export const initializeDashboard = async ({ searchSessionSettings, unifiedSearchSettings, validateLoadedSavedObject, + useControlGroupIntegration, useUnifiedSearchIntegration, useSessionStorageIntegration, } = creationOptions ?? {}; @@ -259,6 +291,11 @@ export const initializeDashboard = async ({ cloneDeep(combinedOverrideInput), 'controlGroupInput' ); + const initialControlGroupInput: ControlGroupInput | {} = { + ...(loadDashboardReturn?.dashboardInput?.controlGroupInput ?? {}), + ...(sessionStorageInput?.controlGroupInput ?? {}), + ...(overrideInput?.controlGroupInput ?? {}), + }; // Back up any view mode passed in explicitly. if (overrideInput?.viewMode) { @@ -275,7 +312,6 @@ export const initializeDashboard = async ({ // -------------------------------------------------------------------------------------- untilDashboardReady().then((dashboard) => { dashboard.savedObjectReferences = loadDashboardReturn?.references; - dashboard.controlGroupInput = loadDashboardReturn?.dashboardInput?.controlGroupInput; }); // -------------------------------------------------------------------------------------- @@ -438,13 +474,6 @@ export const initializeDashboard = async ({ // Set restored runtime state for react embeddables. // -------------------------------------------------------------------------------------- untilDashboardReady().then((dashboardContainer) => { - if (overrideInput?.controlGroupState) { - dashboardContainer.setRuntimeStateForChild( - PANELS_CONTROL_GROUP_KEY, - overrideInput.controlGroupState - ); - } - for (const idWithRuntimeState of Object.keys(runtimePanelsToRestore)) { const restoredRuntimeStateForChild = runtimePanelsToRestore[idWithRuntimeState]; if (!restoredRuntimeStateForChild) continue; @@ -452,6 +481,52 @@ export const initializeDashboard = async ({ } }); + // -------------------------------------------------------------------------------------- + // Start the control group integration. + // -------------------------------------------------------------------------------------- + if (useControlGroupIntegration) { + const controlsGroupFactory = getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + ControlGroupContainer + >(CONTROL_GROUP_TYPE) as EmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + ControlGroupContainer + > & { + create: ControlGroupContainerFactory['create']; + }; + const { filters, query, timeRange, viewMode, id } = initialDashboardInput; + const fullControlGroupInput = { + id: `control_group_${id ?? 'new_dashboard'}`, + ...getDefaultControlGroupInput(), + ...pickBy(initialControlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults + timeRange, + viewMode, + filters, + query, + }; + + if (controlGroup) { + controlGroup.updateInputAndReinitialize(fullControlGroupInput); + } else { + const newControlGroup = await controlsGroupFactory?.create(fullControlGroupInput, this, { + lastSavedInput: + loadDashboardReturn?.dashboardInput?.controlGroupInput ?? + getDefaultControlGroupPersistableInput(), + }); + if (!newControlGroup || isErrorEmbeddable(newControlGroup)) { + throw new Error('Error in control group startup'); + } + controlGroup = newControlGroup; + } + + untilDashboardReady().then((dashboardContainer) => { + dashboardContainer.controlGroup = controlGroup; + startSyncingDashboardControlGroup.bind(dashboardContainer)(); + }); + } + // -------------------------------------------------------------------------------------- // Start the data views integration. // -------------------------------------------------------------------------------------- @@ -477,6 +552,63 @@ export const initializeDashboard = async ({ setTimeout(() => dashboard.dispatch.setAnimatePanelTransforms(true), 500) ); + // -------------------------------------------------------------------------------------- + // Set parentApi.filters$ to include dashboardContainer filters and control group filters + // -------------------------------------------------------------------------------------- + untilDashboardReady().then((dashboardContainer) => { + if (!dashboardContainer.controlGroup) { + return; + } + + function getCombinedFilters() { + return combineDashboardFiltersWithControlGroupFilters( + dashboardContainer.getInput().filters ?? [], + dashboardContainer.controlGroup! + ); + } + + const filters$ = new BehaviorSubject(getCombinedFilters()); + dashboardContainer.filters$ = filters$; + + const inputFilters$ = dashboardContainer.getInput$().pipe( + startWith(dashboardContainer.getInput()), + map((input) => input.filters), + distinctUntilChanged((previous, current) => { + return compareFilters(previous ?? [], current ?? [], COMPARE_ALL_OPTIONS); + }) + ); + + // Can not use onFiltersPublished$ directly since it does not have an intial value and + // combineLatest will not emit until each observable emits at least one value + const controlGroupFilters$ = dashboardContainer.controlGroup.onFiltersPublished$.pipe( + startWith(dashboardContainer.controlGroup.getOutput().filters) + ); + + dashboardContainer.integrationSubscriptions.add( + combineLatest([inputFilters$, controlGroupFilters$]).subscribe(() => { + filters$.next(getCombinedFilters()); + }) + ); + }); + + // -------------------------------------------------------------------------------------- + // Set up parentApi.query$ + // Can not use legacyEmbeddableToApi since query$ setting is delayed + // -------------------------------------------------------------------------------------- + untilDashboardReady().then((dashboardContainer) => { + const query$ = new BehaviorSubject( + dashboardContainer.getInput().query + ); + dashboardContainer.query$ = query$; + dashboardContainer.integrationSubscriptions.add( + dashboardContainer.getInput$().subscribe((input) => { + if (!deepEqual(query$.getValue() ?? [], input.query)) { + query$.next(input.query); + } + }) + ); + }); + // -------------------------------------------------------------------------------------- // Set up search sessions integration. // -------------------------------------------------------------------------------------- @@ -497,8 +629,7 @@ export const initializeDashboard = async ({ sessionIdToRestore ?? (existingSession && incomingEmbeddable ? existingSession : session.start()); - untilDashboardReady().then(async (container) => { - await container.untilContainerInitialized(); + untilDashboardReady().then((container) => { startDashboardSearchSessionIntegration.bind(container)( creationOptions?.searchSessionSettings ); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts index 9de483bfb0376..3fd4c0df233cf 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/data_views/sync_dashboard_data_views.ts @@ -10,7 +10,7 @@ import { DataView } from '@kbn/data-views-plugin/common'; import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { apiPublishesDataViews, PublishesDataViews } from '@kbn/presentation-publishing'; import { uniqBy } from 'lodash'; -import { combineLatest, Observable, of, switchMap } from 'rxjs'; +import { combineLatest, map, Observable, of, switchMap } from 'rxjs'; import { pluginServices } from '../../../../services/plugin_services'; import { DashboardContainer } from '../../dashboard_container'; @@ -19,11 +19,19 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) { data: { dataViews }, } = pluginServices.getServices(); - const controlGroupDataViewsPipe: Observable = this.controlGroupApi$.pipe( - switchMap((controlGroupApi) => { - return controlGroupApi ? controlGroupApi.dataViews : of([]); - }) - ); + const controlGroupDataViewsPipe: Observable = this.controlGroup + ? this.controlGroup.getOutput$().pipe( + map((output) => output.dataViewIds ?? []), + switchMap( + (dataViewIds) => + new Promise((resolve) => + Promise.all(dataViewIds.map((id) => dataViews.get(id))).then((nextDataViews) => + resolve(nextDataViews) + ) + ) + ) + ) + : of([]); const childDataViewsPipe = combineCompatibleChildrenApis( this, @@ -35,10 +43,7 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) { return combineLatest([controlGroupDataViewsPipe, childDataViewsPipe]) .pipe( switchMap(([controlGroupDataViews, childDataViews]) => { - const allDataViews = [ - ...(controlGroupDataViews ? controlGroupDataViews : []), - ...childDataViews, - ]; + const allDataViews = controlGroupDataViews.concat(childDataViews); if (allDataViews.length === 0) { return (async () => { const defaultDataViewId = await dataViews.getDefaultId(); @@ -49,6 +54,7 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) { }) ) .subscribe((newDataViews) => { + if (newDataViews[0].id) this.controlGroup?.setRelevantDataViewId(newDataViews[0].id); this.setAllDataViews(newDataViews); }); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx index b6d77ac9b7822..ee2cc0dd961fd 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx @@ -18,12 +18,7 @@ import { import type { TimeRange } from '@kbn/es-query'; import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks'; -import { - buildMockDashboard, - getSampleDashboardInput, - getSampleDashboardPanel, - mockControlGroupApi, -} from '../../mocks'; +import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks'; import { pluginServices } from '../../services/plugin_services'; import { DashboardContainer } from './dashboard_container'; @@ -175,7 +170,6 @@ test('searchSessionId propagates to children', async () => { undefined, { lastSavedInput: sampleInput } ); - container?.setControlGroupApi(mockControlGroupApi); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, @@ -195,10 +189,11 @@ describe('getInheritedInput', () => { const dashboardTimeslice = [1688061910000, 1688062209000] as [number, number]; test('Should pass dashboard timeRange and timeslice to panel when panel does not have custom time range', async () => { - const container = buildMockDashboard(); - container.updateInput({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, + const container = buildMockDashboard({ + overrides: { + timeRange: dashboardTimeRange, + timeslice: dashboardTimeslice, + }, }); const embeddable = await container.addNewEmbeddable( CONTACT_CARD_EMBEDDABLE, @@ -219,10 +214,11 @@ describe('getInheritedInput', () => { }); test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => { - const container = buildMockDashboard(); - container.updateInput({ - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, + const container = buildMockDashboard({ + overrides: { + timeRange: dashboardTimeRange, + timeslice: dashboardTimeslice, + }, }); const embeddableTimeRange = { to: 'now', diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index d1409a3a4b02e..585e0ff0b1ff6 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -8,6 +8,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; import type { Reference } from '@kbn/content-management-utils'; +import type { ControlGroupContainer } from '@kbn/controls-plugin/public'; import type { I18nStart, KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; import { type PublishingSubject, @@ -15,8 +16,6 @@ import { apiPublishesUnsavedChanges, getPanelTitle, PublishesViewMode, - PublishesDataLoading, - apiPublishesDataLoading, } from '@kbn/presentation-publishing'; import { RefreshInterval } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -33,7 +32,7 @@ import { type EmbeddableOutput, type IEmbeddable, } from '@kbn/embeddable-plugin/public'; -import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { Filter, Query, TimeRange } from '@kbn/es-query'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { HasRuntimeChildState, @@ -41,7 +40,6 @@ import { HasSerializedChildState, TrackContentfulRender, TracksQueryPerformance, - combineCompatibleChildrenApis, } from '@kbn/presentation-containers'; import { PanelPackage } from '@kbn/presentation-containers'; import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; @@ -52,18 +50,14 @@ import { omit } from 'lodash'; import React, { createContext, useContext } from 'react'; import ReactDOM from 'react-dom'; import { batch } from 'react-redux'; -import { BehaviorSubject, Subject, Subscription, first, skipWhile, switchMap } from 'rxjs'; +import { BehaviorSubject, Subject, Subscription } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs'; import { v4 } from 'uuid'; import { PublishesSettings } from '@kbn/presentation-containers/interfaces/publishes_settings'; import { apiHasSerializableState } from '@kbn/presentation-containers/interfaces/serialized_state'; -import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..'; -import { DashboardAttributes, DashboardContainerInput, DashboardPanelState } from '../../../common'; -import { - getReferencesForControls, - getReferencesForPanelId, -} from '../../../common/dashboard_container/persistable_state/dashboard_container_references'; +import { DashboardContainerInput, DashboardPanelState } from '../../../common'; +import { getReferencesForPanelId } from '../../../common/dashboard_container/persistable_state/dashboard_container_references'; import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID, @@ -90,10 +84,7 @@ import { showSettings, } from './api'; import { duplicateDashboardPanel } from './api/duplicate_dashboard_panel'; -import { - combineDashboardFiltersWithControlGroupFilters, - startSyncingDashboardControlGroup, -} from './create/controls/dashboard_control_group_integration'; +import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration'; import { initializeDashboard } from './create/create_dashboard'; import { DashboardCreationOptions, @@ -101,7 +92,6 @@ import { dashboardTypeDisplayName, } from './dashboard_container_factory'; import { getPanelAddedSuccessString } from '../../dashboard_app/_dashboard_app_strings'; -import { PANELS_CONTROL_GROUP_KEY } from '../../services/dashboard_backup/dashboard_backup_service'; export interface InheritedChildInput { filters: Filter[]; @@ -157,7 +147,7 @@ export class DashboardContainer public integrationSubscriptions: Subscription = new Subscription(); public publishingSubscription: Subscription = new Subscription(); public diffingSubscription: Subscription = new Subscription(); - public controlGroupApi$: PublishingSubject; + public controlGroup?: ControlGroupContainer; public settings: Record>; public searchSessionId?: string; @@ -166,7 +156,6 @@ export class DashboardContainer public reload$ = new Subject(); public timeRestore$: BehaviorSubject; public timeslice$: BehaviorSubject<[number, number] | undefined>; - public unifiedSearchFilters$?: PublishingSubject; public locator?: Pick, 'navigate' | 'getRedirectUrl'>; public readonly executionContext: KibanaExecutionContext; @@ -183,9 +172,6 @@ export class DashboardContainer private hadContentfulRender = false; private scrollPosition?: number; - // setup - public untilContainerInitialized: () => Promise; - // cleanup public stopSyncingWithUnifiedSearch?: () => void; private cleanupStateTools: () => void; @@ -211,7 +197,6 @@ export class DashboardContainer | undefined; // new embeddable framework public savedObjectReferences: Reference[] = []; - public controlGroupInput: DashboardAttributes['controlGroupInput'] | undefined; constructor( initialInput: DashboardContainerInput, @@ -222,43 +207,19 @@ export class DashboardContainer creationOptions?: DashboardCreationOptions, initialComponentState?: DashboardPublicState ) { - const controlGroupApi$ = new BehaviorSubject(undefined); - async function untilContainerInitialized(): Promise { - return new Promise((resolve) => { - controlGroupApi$ - .pipe( - skipWhile((controlGroupApi) => !controlGroupApi), - switchMap(async (controlGroupApi) => { - await controlGroupApi?.untilInitialized(); - }), - first() - ) - .subscribe(() => { - resolve(); - }); - }); - } - const { usageCollection, embeddable: { getEmbeddableFactory }, } = pluginServices.getServices(); - super( { ...initialInput, }, { embeddableLoaded: {} }, getEmbeddableFactory, - parent, - { - untilContainerInitialized, - } + parent ); - this.controlGroupApi$ = controlGroupApi$; - this.untilContainerInitialized = untilContainerInitialized; - this.trackPanelAddMetric = usageCollection.reportUiCounter?.bind( usageCollection, DASHBOARD_UI_METRIC_ID @@ -350,41 +311,7 @@ export class DashboardContainer DashboardContainerInput >(this.publishingSubscription, this, 'lastReloadRequestTime'); - startSyncingDashboardControlGroup(this); - this.executionContext = initialInput.executionContext; - - this.dataLoading = new BehaviorSubject(false); - this.publishingSubscription.add( - combineCompatibleChildrenApis( - this, - 'dataLoading', - apiPublishesDataLoading, - undefined, - // flatten method - (values) => { - return values.some((isLoading) => isLoading); - } - ).subscribe((isAtLeastOneChildLoading) => { - (this.dataLoading as BehaviorSubject).next(isAtLeastOneChildLoading); - }) - ); - - this.dataViews = new BehaviorSubject(this.getAllDataViews()); - - const query$ = new BehaviorSubject(this.getInput().query); - this.query$ = query$; - this.publishingSubscription.add( - this.getInput$().subscribe((input) => { - if (!deepEqual(query$.getValue() ?? [], input.query)) { - query$.next(input.query); - } - }) - ); - } - - public setControlGroupApi(controlGroupApi: ControlGroupApi) { - (this.controlGroupApi$ as BehaviorSubject).next(controlGroupApi); } public getAppContext() { @@ -470,10 +397,10 @@ export class DashboardContainer panels, } = this.input; - const combinedFilters = combineDashboardFiltersWithControlGroupFilters( - filters, - this.controlGroupApi$?.value - ); + let combinedFilters = filters; + if (this.controlGroup) { + combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup); + } const hasCustomTimeRange = Boolean( (panels[id]?.explicitInput as Partial)?.timeRange ); @@ -502,6 +429,7 @@ export class DashboardContainer public destroy() { super.destroy(); this.cleanupStateTools(); + this.controlGroup?.destroy(); this.diffingSubscription.unsubscribe(); this.publishingSubscription.unsubscribe(); this.integrationSubscriptions.unsubscribe(); @@ -687,12 +615,16 @@ export class DashboardContainer public forceRefresh(refreshControlGroup: boolean = true) { this.dispatch.setLastReloadRequestTimeToNow({}); if (refreshControlGroup) { + this.controlGroup?.reload(); + // only reload all panels if this refresh does not come from the control group. this.reload$.next(); } } - public async asyncResetToLastSavedState() { + public onDataViewsUpdate$ = new Subject(); + + public resetToLastSavedState() { this.dispatch.resetToLastSavedInput({}); const { explicitInput: { timeRange, refreshInterval }, @@ -701,8 +633,8 @@ export class DashboardContainer }, } = this.getState(); - if (this.controlGroupApi$.value) { - await this.controlGroupApi$.value.asyncResetUnsavedChanges(); + if (this.controlGroup) { + this.controlGroup.resetToLastSavedState(); } // if we are using the unified search integration, we need to force reset the time picker. @@ -747,6 +679,7 @@ export class DashboardContainer const initializeResult = await initializeDashboard({ creationOptions: this.creationOptions, + controlGroup: this.controlGroup, untilDashboardReady, loadDashboardReturn, }); @@ -761,6 +694,9 @@ export class DashboardContainer omit(loadDashboardReturn?.dashboardInput, 'controlGroupInput') ); this.dispatch.setManaged(loadDashboardReturn?.managed); + if (this.controlGroup) { + this.controlGroup.setSavedState(loadDashboardReturn.dashboardInput?.controlGroupInput); + } this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate. this.dispatch.setLastSavedId(newSavedObjectId); this.setExpandedPanelId(undefined); @@ -784,7 +720,7 @@ export class DashboardContainer */ public setAllDataViews = (newDataViews: DataView[]) => { this.allDataViews = newDataViews; - (this.dataViews as BehaviorSubject).next(newDataViews); + this.onDataViewsUpdate$.next(newDataViews); }; public getExpandedPanelId = () => { @@ -807,6 +743,7 @@ export class DashboardContainer public clearOverlays = () => { this.dispatch.setHasOverlays(false); this.dispatch.setFocusedPanelId(undefined); + this.controlGroup?.closeAllFlyouts(); this.overlayRef?.close(); }; @@ -911,22 +848,6 @@ export class DashboardContainer }; }; - public getSerializedStateForControlGroup = () => { - return { - rawState: this.controlGroupInput - ? (this.controlGroupInput as ControlGroupSerializedState) - : ({ - controlStyle: 'oneLine', - chainingSystem: 'HIERARCHICAL', - showApplySelections: false, - panelsJSON: '{}', - ignoreParentSettingsJSON: - '{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}', - } as ControlGroupSerializedState), - references: getReferencesForControls(this.savedObjectReferences), - }; - }; - private restoredRuntimeState: UnsavedPanelState | undefined = undefined; public setRuntimeStateForChild = (childId: string, state: object) => { const runtimeState = this.restoredRuntimeState ?? {}; @@ -937,10 +858,6 @@ export class DashboardContainer return this.restoredRuntimeState?.[childId]; }; - public getRuntimeStateForControlGroup = () => { - return this.getRuntimeStateForChild(PANELS_CONTROL_GROUP_KEY); - }; - public removePanel(id: string) { const { embeddable: { reactEmbeddableRegistryHasKey }, diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts index 6d73e59856e28..89f71c074d9fd 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts @@ -5,10 +5,11 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; import { childrenUnsavedChanges$ } from '@kbn/presentation-containers'; import { omit } from 'lodash'; import { AnyAction, Middleware } from 'redux'; -import { combineLatest, debounceTime, skipWhile, startWith, switchMap } from 'rxjs'; +import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs'; import { DashboardContainer, DashboardCreationOptions } from '../..'; import { DashboardContainerInput } from '../../../../common'; import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; @@ -16,7 +17,6 @@ import { pluginServices } from '../../../services/plugin_services'; import { UnsavedPanelState } from '../../types'; import { dashboardContainerReducers } from '../dashboard_container_reducers'; import { isKeyEqualAsync, unsavedChangesDiffingFunctions } from './dashboard_diffing_functions'; -import { PANELS_CONTROL_GROUP_KEY } from '../../../services/dashboard_backup/dashboard_backup_service'; /** * An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input @@ -111,12 +111,8 @@ export function startDiffingDashboardState( combineLatest([ dashboardUnsavedChanges, childrenUnsavedChanges$(this.children$), - this.controlGroupApi$.pipe( - skipWhile((controlGroupApi) => !controlGroupApi), - switchMap((controlGroupApi) => { - return controlGroupApi!.unsavedChanges; - }) - ), + this.controlGroup?.unsavedChanges ?? + (of(undefined) as Observable), ]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => { // calculate unsaved changes const hasUnsavedChanges = @@ -129,11 +125,11 @@ export function startDiffingDashboardState( // backup unsaved changes if configured to do so if (creationOptions?.useSessionStorageIntegration) { - const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {}; - if (controlGroupChanges) { - reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges; - } - backupUnsavedChanges.bind(this)(dashboardChanges, reactEmbeddableChanges); + backupUnsavedChanges.bind(this)( + dashboardChanges, + unsavedPanelState ? unsavedPanelState : {}, + controlGroupChanges + ); } }) ); @@ -185,7 +181,8 @@ export async function getDashboardUnsavedChanges( function backupUnsavedChanges( this: DashboardContainer, dashboardChanges: Partial, - reactEmbeddableChanges: UnsavedPanelState + reactEmbeddableChanges: UnsavedPanelState, + controlGroupChanges: PersistableControlGroupInput | undefined ) { const { dashboardBackup } = pluginServices.getServices(); const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage); @@ -195,6 +192,7 @@ function backupUnsavedChanges( { ...dashboardStateToBackup, panels: dashboardChanges.panels, + controlGroupInput: controlGroupChanges, }, reactEmbeddableChanges ); diff --git a/src/plugins/dashboard/public/dashboard_container/types.ts b/src/plugins/dashboard/public/dashboard_container/types.ts index f3ca588aa20b1..c2c7cfb8aa083 100644 --- a/src/plugins/dashboard/public/dashboard_container/types.ts +++ b/src/plugins/dashboard/public/dashboard_container/types.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ +import { SerializableControlGroupInput } from '@kbn/controls-plugin/common'; import type { ContainerOutput } from '@kbn/embeddable-plugin/public'; import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; import { SerializableRecord } from '@kbn/utility-types'; -import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public'; import type { DashboardContainerInput, DashboardOptions } from '../../common'; import { SavedDashboardPanel } from '../../common/content_management'; @@ -125,7 +125,7 @@ export type DashboardLocatorParams = Partial< panels?: Array; // used SerializableRecord here to force the GridData type to be read as serializable /** - * Control group changes + * Control group input */ - controlGroupState?: Partial & SerializableRecord; // used SerializableRecord here to force the GridData type to be read as serializable + controlGroupInput?: SerializableControlGroupInput; }; diff --git a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index 86682acb4287f..5f6edc138aa13 100644 --- a/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -15,6 +15,7 @@ import { getContextProvider as getPresentationUtilContextProvider, } from '@kbn/presentation-util-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; import { TopNavMenuBadgeProps, TopNavMenuProps } from '@kbn/navigation-plugin/public'; import { EuiBreadcrumb, @@ -28,7 +29,6 @@ import { import { MountPoint } from '@kbn/core/public'; import { getManagedContentBadge } from '@kbn/managed-content-badge'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; import { getDashboardTitle, leaveConfirmStrings, @@ -113,8 +113,16 @@ export function InternalDashboardTopNav({ const query = dashboard.select((state) => state.explicitInput.query); const title = dashboard.select((state) => state.explicitInput.title); + // store data views in state & subscribe to dashboard data view changes. + const [allDataViews, setAllDataViews] = useState([]); const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const allDataViews = useStateFromPublishingSubject(dashboard.dataViews); + useEffect(() => { + setAllDataViews(dashboard.getAllDataViews()); + const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) => + setAllDataViews(dataViews) + ); + return () => subscription.unsubscribe(); + }, [dashboard]); const dashboardTitle = useMemo(() => { return getDashboardTitle(title, viewMode, !lastSavedId); @@ -403,7 +411,7 @@ export function InternalDashboardTopNav({ screenTitle={title} useDefaultBehaviors={true} savedQueryId={savedQueryId} - indexPatterns={allDataViews ?? []} + indexPatterns={allDataViews} saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'} appName={LEGACY_DASHBOARD_APP_ID} visible={viewMode !== ViewMode.PRINT} diff --git a/src/plugins/dashboard/public/mocks.tsx b/src/plugins/dashboard/public/mocks.tsx index 4a75d1a08b996..d447015b2b1a6 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -9,8 +9,6 @@ import { EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/public'; import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks'; -import { ControlGroupApi } from '@kbn/controls-plugin/public'; -import { BehaviorSubject } from 'rxjs'; import { DashboardContainerInput, DashboardPanelState } from '../common'; import { DashboardContainer } from './dashboard_container/embeddable/dashboard_container'; import { DashboardStart } from './plugin'; @@ -74,15 +72,6 @@ export function setupIntersectionObserverMock({ }); } -export const mockControlGroupApi = { - untilInitialized: async () => {}, - filters$: new BehaviorSubject(undefined), - query$: new BehaviorSubject(undefined), - timeslice$: new BehaviorSubject(undefined), - dataViews: new BehaviorSubject(undefined), - unsavedChanges: new BehaviorSubject(undefined), -} as unknown as ControlGroupApi; - export function buildMockDashboard({ overrides, savedObjectId, @@ -100,7 +89,6 @@ export function buildMockDashboard({ undefined, { lastSavedInput: initialInput, lastSavedId: savedObjectId } ); - dashboardContainer?.setControlGroupApi(mockControlGroupApi); return dashboardContainer; } diff --git a/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts b/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts index 54486ece0970a..f97d88fd1c4fe 100644 --- a/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_backup/dashboard_backup_service.ts @@ -23,7 +23,6 @@ import { backupServiceStrings } from '../../dashboard_container/_dashboard_conta import { UnsavedPanelState } from '../../dashboard_container/types'; export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard'; -export const PANELS_CONTROL_GROUP_KEY = 'controlGroup'; const DASHBOARD_PANELS_SESSION_KEY = 'dashboardPanels'; const DASHBOARD_VIEWMODE_LOCAL_KEY = 'dashboardViewMode'; @@ -113,7 +112,6 @@ class DashboardBackupService implements DashboardBackupServiceType { const panels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[ id ] as UnsavedPanelState | undefined; - return { dashboardState, panels }; } catch (e) { this.notifications.toasts.addDanger({ diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts index eccd68b6952c0..2b2835e2a2420 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts @@ -56,15 +56,8 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen contentManagement, savedObjectsTagging, }), - saveDashboardState: ({ - controlGroupReferences, - currentState, - saveOptions, - lastSavedId, - panelReferences, - }) => + saveDashboardState: ({ currentState, saveOptions, lastSavedId, panelReferences }) => saveDashboardState({ - controlGroupReferences, data, embeddable, saveOptions, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts index 55ee72c5abbef..cabd1542efbb2 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts @@ -12,6 +12,7 @@ import { Filter, Query } from '@kbn/es-query'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public'; +import { rawControlGroupAttributesToControlGroupInput } from '@kbn/controls-plugin/common'; import { parseSearchSourceJSON, injectSearchSourceReferences } from '@kbn/data-plugin/public'; import { @@ -186,7 +187,9 @@ export const loadDashboardState = async ({ viewMode: ViewMode.VIEW, // dashboards loaded from saved object default to view mode. If it was edited recently, the view mode from session storage will override this. tags: savedObjectsTagging.getTagIdsFromReferences?.(references) ?? [], - controlGroupInput: attributes.controlGroupInput, + controlGroupInput: + attributes.controlGroupInput && + rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput), version: convertNumberToDashboardVersion(version), }, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.test.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.test.ts index 0487f14e699c6..1878344b630fc 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.test.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.test.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +import { ControlGroupInput } from '@kbn/controls-plugin/common'; +import { controlGroupInputBuilder } from '@kbn/controls-plugin/public'; + import { getSampleDashboardInput, getSampleDashboardPanel } from '../../../mocks'; import { DashboardEmbeddableService } from '../../embeddable/types'; import { SavedDashboardInput } from '../types'; @@ -29,6 +32,23 @@ describe('Migrate dashboard input', () => { panel3: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel3' } }), panel4: getSampleDashboardPanel({ type: 'ultraDiscover', explicitInput: { id: 'panel4' } }), }; + const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput; + controlGroupInputBuilder.addOptionsListControl(controlGroupInput, { + dataViewId: 'positions-remain-fixed', + title: 'Results can be mixed', + fieldName: 'theres-a-stasis', + width: 'medium', + grow: false, + }); + controlGroupInputBuilder.addRangeSliderControl(controlGroupInput, { + dataViewId: 'an-object-set-in-motion', + title: 'The arbiter of time', + fieldName: 'unexpressed-emotion', + width: 'medium', + grow: false, + }); + controlGroupInputBuilder.addTimeSliderControl(controlGroupInput); + dashboardInput.controlGroupInput = controlGroupInput; const embeddableService: DashboardEmbeddableService = { getEmbeddableFactory: jest.fn(() => ({ @@ -42,8 +62,11 @@ describe('Migrate dashboard input', () => { // migration run should be true because the runEmbeddableFactoryMigrations mock above returns true. expect(result.anyMigrationRun).toBe(true); - expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(4); // should be called 4 times for the panels, and 3 times for the controls + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(7); // should be called 4 times for the panels, and 3 times for the controls expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('superLens'); expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('ultraDiscover'); + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('optionsListControl'); + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('rangeSliderControl'); + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledWith('timeSlider'); }); }); diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts index 70a6df30303dd..46e57588a2c95 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { ControlGroupInput } from '@kbn/controls-plugin/common'; import { EmbeddableFactoryNotFoundError, runEmbeddableFactoryMigrations, @@ -30,7 +31,28 @@ export const migrateDashboardInput = ( } = pluginServices.getServices(); let anyMigrationRun = false; if (!dashboardInput) return dashboardInput; + if (dashboardInput.controlGroupInput) { + /** + * If any Control Group migrations are required, we will need to start storing a Control Group Input version + * string in Dashboard Saved Objects and then running the whole Control Group input through the embeddable + * factory migrations here. + */ + // Migrate all of the Control children as well. + const migratedControls: ControlGroupInput['panels'] = {}; + + Object.entries(dashboardInput.controlGroupInput.panels).forEach(([id, panel]) => { + const factory = embeddable.getEmbeddableFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + const { input: newInput, migrationRun: controlMigrationRun } = runEmbeddableFactoryMigrations( + panel.explicitInput, + factory + ); + if (controlMigrationRun) anyMigrationRun = true; + panel.explicitInput = newInput as DashboardPanelState['explicitInput']; + migratedControls[id] = panel; + }); + } const migratedPanels: DashboardContainerInput['panels'] = {}; for (const [id, panel] of Object.entries(dashboardInput.panels)) { // if the panel type is registered in the new embeddable system, we do not need to run migrations for it. diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts index 94ebcd0702f2c..c69f7fa065a7b 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts @@ -9,6 +9,12 @@ import { pick } from 'lodash'; import moment, { Moment } from 'moment'; +import { + controlGroupInputToRawControlGroupAttributes, + generateNewControlIds, + getDefaultControlGroupInput, + persistableControlGroupInputIsEqual, +} from '@kbn/controls-plugin/common'; import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public'; import { isFilterPinned } from '@kbn/es-query'; @@ -23,10 +29,24 @@ import { DashboardContentManagementRequiredServices, SaveDashboardProps, SaveDashboardReturn, + SavedDashboardInput, } from '../types'; import { convertDashboardVersionToNumber } from './dashboard_versioning'; import { generateNewPanelIds } from '../../../../common/lib/dashboard_panel_converters'; +export const serializeControlGroupInput = ( + controlGroupInput: SavedDashboardInput['controlGroupInput'] +) => { + // only save to saved object if control group is not default + if ( + !controlGroupInput || + persistableControlGroupInputIsEqual(controlGroupInput, getDefaultControlGroupInput()) + ) { + return undefined; + } + return controlGroupInputToRawControlGroupAttributes(controlGroupInput); +}; + export const convertTimeToUTCString = (time?: string | Moment): undefined | string => { if (moment(time).isValid()) { return moment(time).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); @@ -48,7 +68,6 @@ type SaveDashboardStateProps = SaveDashboardProps & { }; export const saveDashboardState = async ({ - controlGroupReferences, data, embeddable, lastSavedId, @@ -81,10 +100,9 @@ export const saveDashboardState = async ({ syncCursor, syncTooltips, hidePanelTitles, - controlGroupInput, } = currentState; - let { panels } = currentState; + let { panels, controlGroupInput } = currentState; let prefixedPanelReferences = panelReferences; if (saveOptions.saveAsCopy) { const { panels: newPanels, references: newPanelReferences } = generateNewPanelIds( @@ -93,10 +111,7 @@ export const saveDashboardState = async ({ ); panels = newPanels; prefixedPanelReferences = newPanelReferences; - // - // do not need to generate new ids for controls. - // ControlGroup Component is keyed on dashboard id so changing dashboard id mounts new ControlGroup Component. - // + controlGroupInput = generateNewControlIds(controlGroupInput); } /** @@ -144,7 +159,7 @@ export const saveDashboardState = async ({ const rawDashboardAttributes: DashboardAttributes = { version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION), - controlGroupInput, + controlGroupInput: serializeControlGroupInput(controlGroupInput), kibanaSavedObjectMeta: { searchSourceJSON }, description: description ?? '', refreshInterval, @@ -171,11 +186,7 @@ export const saveDashboardState = async ({ ? savedObjectsTagging.updateTagsReferences(dashboardReferences, tags) : dashboardReferences; - const allReferences = [ - ...references, - ...(prefixedPanelReferences ?? []), - ...(controlGroupReferences ?? []), - ]; + const allReferences = [...references, ...(prefixedPanelReferences ?? [])]; /** * Save the saved object using the content management diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts index 3caa5f73e65b2..ac8b921672e2d 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts @@ -7,11 +7,11 @@ */ import type { Reference } from '@kbn/content-management-utils'; +import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public'; -import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public'; import { DashboardContainerInput } from '../../../common'; -import { DashboardAttributes, DashboardCrudTypes } from '../../../common/content_management'; +import { DashboardCrudTypes } from '../../../common/content_management'; import { DashboardStartDependencies } from '../../plugin'; import { DashboardBackupServiceType } from '../dashboard_backup/types'; import { DashboardDataService } from '../data/types'; @@ -64,17 +64,7 @@ export interface LoadDashboardFromSavedObjectProps { type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta']; export type SavedDashboardInput = DashboardContainerInput & { - /** - * Serialized control group state. - * Contains state loaded from dashboard saved object - */ - controlGroupInput?: DashboardAttributes['controlGroupInput'] | undefined; - /** - * Runtime control group state. - * Contains state passed from dashboard locator - * Use runtime state when building input for portable dashboards - */ - controlGroupState?: Partial; + controlGroupInput?: PersistableControlGroupInput; }; export interface LoadDashboardReturn { @@ -99,7 +89,6 @@ export interface LoadDashboardReturn { export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { saveAsCopy?: boolean }; export interface SaveDashboardProps { - controlGroupReferences?: Reference[]; currentState: SavedDashboardInput; saveOptions: SavedDashboardSaveOpts; panelReferences?: Reference[]; diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index e4ce579104bb5..cac385dd2c86d 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -82,9 +82,6 @@ export abstract class Container< const init$ = this.getInput$().pipe( take(1), mergeMap(async (currentInput) => { - if (settings?.untilContainerInitialized) { - await settings.untilContainerInitialized(); - } const initPromise = this.initializeChildEmbeddables(currentInput, settings); if (awaitingInitialize) await initPromise; }) diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 5ee9b0a250adc..53226e7d15146 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -37,8 +37,6 @@ export interface EmbeddableContainerSettings { * Initialise children in the order specified. If an ID does not match it will be skipped and if a child is not included it will be initialized in the default order after the list of provided IDs. */ childIdInitializeOrder?: string[]; - - untilContainerInitialized?: () => Promise; } export interface IContainer< diff --git a/src/plugins/embeddable/public/lib/embeddables/compatibility/embeddable_compatibility_utils.ts b/src/plugins/embeddable/public/lib/embeddables/compatibility/embeddable_compatibility_utils.ts index e34fc02acd6be..57cfe3350420a 100644 --- a/src/plugins/embeddable/public/lib/embeddables/compatibility/embeddable_compatibility_utils.ts +++ b/src/plugins/embeddable/public/lib/embeddables/compatibility/embeddable_compatibility_utils.ts @@ -51,11 +51,7 @@ export const embeddableInputToSubject = < subscription.add( embeddable .getInput$() - .pipe( - distinctUntilKeyChanged(key, (prev, current) => { - return deepEqual(prev, current); - }) - ) + .pipe(distinctUntilKeyChanged(key)) .subscribe(() => subject.next(embeddable.getInput()?.[key] as ValueType)) ); } diff --git a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts index 79e7b5b99bc60..16b41ec9cc23c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts @@ -58,19 +58,16 @@ export const genericEmbeddableInputIsEqual = ( const { title: currentTitle, hidePanelTitles: currentHidePanelTitles, - enhancements: currentEnhancements, ...current } = pick(currentInput as GenericEmbedableInputToCompare, genericInputKeysToCompare); const { title: lastTitle, hidePanelTitles: lastHidePanelTitles, - enhancements: lastEnhancements, ...last } = pick(lastInput as GenericEmbedableInputToCompare, genericInputKeysToCompare); if (currentTitle !== lastTitle) return false; if (Boolean(currentHidePanelTitles) !== Boolean(lastHidePanelTitles)) return false; - if (!fastIsEqual(currentEnhancements ?? {}, lastEnhancements ?? {})) return false; if (!fastIsEqual(current, last)) return false; return true; }; diff --git a/test/functional/apps/dashboard_elements/controls/common/control_group_apply_button.ts b/test/functional/apps/dashboard_elements/controls/common/control_group_apply_button.ts index f0e4cce0c8adb..683d6a6e7cc22 100644 --- a/test/functional/apps/dashboard_elements/controls/common/control_group_apply_button.ts +++ b/test/functional/apps/dashboard_elements/controls/common/control_group_apply_button.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -13,25 +14,77 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const pieChart = getService('pieChart'); const elasticChart = getService('elasticChart'); + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); - const { dashboard, header, dashboardControls } = getPageObjects([ + const { dashboard, header, dashboardControls, timePicker } = getPageObjects([ 'dashboardControls', + 'timePicker', 'dashboard', 'header', ]); describe('Dashboard control group apply button', () => { - const optionsListId = '41827e70-5285-4d44-8375-4c498449b9a7'; - const rangeSliderId = '515e7b9f-4f1b-4a06-beec-763810e4951a'; + let controlIds: string[]; before(async () => { await dashboard.navigateToApp(); - await dashboard.loadSavedDashboard('Test Control Group Apply Button'); - await dashboard.switchToEditMode(); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + await elasticChart.setNewChartUiDebugFlag(); + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + + // save the dashboard before adding controls + await dashboard.saveDashboard('Test Control Group Apply Button', { + exitFromEditMode: false, + saveAsNew: true, + }); + await header.waitUntilLoadingHasFinished(); + await dashboard.waitForRenderComplete(); + await dashboard.expectMissingUnsavedChangesBadge(); + + // populate an initial set of controls and get their ids. + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + await dashboardControls.createControl({ + controlType: RANGE_SLIDER_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'weightLbs', + title: 'Animal Name', + }); + await dashboardControls.createTimeSliderControl(); + + // wait for all controls to finish loading before saving + controlIds = await dashboardControls.getAllControlIds(); + await dashboardControls.optionsListWaitForLoading(controlIds[0]); + await dashboardControls.rangeSliderWaitForLoading(controlIds[1]); + + // re-save the dashboard + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + await dashboard.waitForRenderComplete(); + await dashboard.expectMissingUnsavedChangesBadge(); + }); + + it('able to set apply button setting', async () => { + await dashboardControls.updateShowApplyButtonSetting(true); + await testSubjects.existOrFail('controlGroup--applyFiltersButton'); + await dashboard.expectUnsavedChangesBadge(); + + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + await dashboard.expectMissingUnsavedChangesBadge(); }); it('renabling auto-apply forces filters to be published', async () => { + const optionsListId = controlIds[0]; + await dashboardControls.verifyApplyButtonEnabled(false); await dashboardControls.optionsListOpenPopover(optionsListId); await dashboardControls.optionsListPopoverSelectOption('cat'); await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId); @@ -48,7 +101,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('options list selections', () => { + let optionsListId: string; + + before(async () => { + optionsListId = controlIds[0]; + }); + it('making selection enables apply button', async () => { + await dashboardControls.verifyApplyButtonEnabled(false); await dashboardControls.optionsListOpenPopover(optionsListId); await dashboardControls.optionsListPopoverSelectOption('cat'); await dashboardControls.optionsListEnsurePopoverIsClosed(optionsListId); @@ -57,6 +117,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('waits to apply filters until button is pressed', async () => { + await dashboard.expectMissingUnsavedChangesBadge(); expect(await pieChart.getPieSliceCount()).to.be(5); await dashboardControls.clickApplyButton(); @@ -78,19 +139,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await dashboard.waitForRenderComplete(); - await dashboard.expectMissingUnsavedChangesBadge(); expect(await pieChart.getPieSliceCount()).to.be(5); + await dashboardControls.verifyApplyButtonEnabled(false); expect(await dashboardControls.optionsListGetSelectionsString(optionsListId)).to.be('Any'); }); }); describe('range slider selections', () => { + let rangeSliderId: string; + + before(async () => { + rangeSliderId = controlIds[1]; + }); + it('making selection enables apply button', async () => { + await dashboardControls.verifyApplyButtonEnabled(false); await dashboardControls.rangeSliderSetUpperBound(rangeSliderId, '30'); await dashboardControls.verifyApplyButtonEnabled(); }); it('waits to apply filters until apply button is pressed', async () => { + await dashboard.expectMissingUnsavedChangesBadge(); expect(await pieChart.getPieSliceCount()).to.be(5); await dashboardControls.clickApplyButton(); @@ -111,8 +180,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await dashboard.waitForRenderComplete(); - await dashboard.expectMissingUnsavedChangesBadge(); expect(await pieChart.getPieSliceCount()).to.be(5); + await dashboardControls.verifyApplyButtonEnabled(false); expect( await dashboardControls.rangeSliderGetLowerBoundAttribute(rangeSliderId, 'value') ).to.be(''); @@ -130,6 +199,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('making selection enables apply button', async () => { + await dashboardControls.verifyApplyButtonEnabled(false); await dashboardControls.gotoNextTimeSlice(); await dashboardControls.gotoNextTimeSlice(); // go to an empty timeslice await header.waitUntilLoadingHasFinished(); @@ -137,6 +207,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('waits to apply timeslice until apply button is pressed', async () => { + await dashboard.expectMissingUnsavedChangesBadge(); expect(await pieChart.getPieSliceCount()).to.be(5); await dashboardControls.clickApplyButton(); @@ -155,8 +226,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await dashboard.waitForRenderComplete(); - await dashboard.expectMissingUnsavedChangesBadge(); expect(await pieChart.getPieSliceCount()).to.be(5); + await dashboardControls.verifyApplyButtonEnabled(false); const valueNow = await dashboardControls.getTimeSliceFromTimeSlider(); expect(valueNow).to.equal(valueBefore); }); diff --git a/test/functional/apps/dashboard_elements/controls/common/multiple_data_views.ts b/test/functional/apps/dashboard_elements/controls/common/multiple_data_views.ts index f20052add7243..5a07e60d45695 100644 --- a/test/functional/apps/dashboard_elements/controls/common/multiple_data_views.ts +++ b/test/functional/apps/dashboard_elements/controls/common/multiple_data_views.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -16,29 +17,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const filterBar = getService('filterBar'); const testSubjects = getService('testSubjects'); - const { dashboard, dashboardControls } = getPageObjects([ + const dashboardAddPanel = getService('dashboardAddPanel'); + const { common, dashboard, dashboardControls } = getPageObjects([ 'dashboardControls', 'dashboard', 'console', + 'common', 'header', ]); describe('Dashboard control group with multiple data views', () => { - // Controls from flights data view - const carrierControlId = '265b6a28-9ccb-44ae-83c9-3d7a7cac1961'; - const ticketPriceControlId = 'ed2b93e2-da37-482b-ae43-586a41cc2399'; - // Controls from logstash-* data view - const osControlId = '5e1b146b-8a8b-4117-9218-c4aeaee7bc9a'; - const bytesControlId = 'c4760951-e793-45d5-a6b7-c72c145af7f9'; - - async function waitForAllConrolsLoading() { - await Promise.all([ - dashboardControls.optionsListWaitForLoading(carrierControlId), - dashboardControls.rangeSliderWaitForLoading(ticketPriceControlId), - dashboardControls.optionsListWaitForLoading(osControlId), - dashboardControls.rangeSliderWaitForLoading(bytesControlId), - ]); - } + let controlIds: string[]; before(async () => { await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']); @@ -50,12 +39,50 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.importExport.load( 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' ); - await kibanaServer.importExport.load( - 'test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana' - ); await kibanaServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + 'courier:ignoreFilterIfFieldNotInIndex': true, + }); + + await common.setTime({ + from: 'Apr 10, 2018 @ 00:00:00.000', + to: 'Nov 15, 2018 @ 00:00:00.000', + }); + + await dashboard.navigateToApp(); + await dashboard.clickNewDashboard(); + + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'kibana_sample_data_flights', + fieldName: 'Carrier', + title: 'Carrier', + }); + + await dashboardControls.createControl({ + controlType: RANGE_SLIDER_CONTROL, + dataViewTitle: 'kibana_sample_data_flights', + fieldName: 'AvgTicketPrice', + title: 'Average Ticket Price', }); + + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'logstash-*', + fieldName: 'machine.os.raw', + title: 'Operating System', + }); + + await dashboardControls.createControl({ + controlType: RANGE_SLIDER_CONTROL, + dataViewTitle: 'logstash-*', + fieldName: 'bytes', + title: 'Bytes', + }); + + await dashboardAddPanel.addSavedSearch('logstash hits'); + + controlIds = await dashboardControls.getAllControlIds(); }); after(async () => { @@ -66,169 +93,96 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.importExport.unload( 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' ); - await kibanaServer.importExport.unload( - 'test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana' - ); await security.testUser.restoreDefaults(); + await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex'); await kibanaServer.uiSettings.unset('defaultIndex'); }); - describe('courier:ignoreFilterIfFieldNotInIndex enabled', () => { - before(async () => { - await kibanaServer.uiSettings.replace({ - 'courier:ignoreFilterIfFieldNotInIndex': true, - }); + it('ignores global filters on controls using a data view without the filter field', async () => { + await filterBar.addFilter({ field: 'Carrier', operation: 'exists' }); - await dashboard.navigateToApp(); - await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views'); - }); + await dashboardControls.optionsListOpenPopover(controlIds[0]); + expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - after(async () => { - await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex'); - }); + await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200'); - describe('global filters', () => { - before(async () => { - await filterBar.addFilter({ - field: 'Carrier', - operation: 'is', - value: 'Kibana Airlines', - }); - await waitForAllConrolsLoading(); - }); - - after(async () => { - await dashboard.clickDiscardChanges(); - }); - - it('applies global filters to controls with data view of filter field', async () => { - await dashboardControls.optionsListOpenPopover(carrierControlId); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('1'); - await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId); - - await dashboardControls.validateRange('placeholder', ticketPriceControlId, '100', '1196'); - }); - - it('ignores global filters to controls without data view of filter field', async () => { - await dashboardControls.optionsListOpenPopover(osControlId); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5'); - await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId); - - await dashboardControls.validateRange('placeholder', bytesControlId, '0', '19979'); - }); - }); + await dashboardControls.optionsListOpenPopover(controlIds[2]); + expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); - describe('control filters', () => { - before(async () => { - await dashboardControls.optionsListOpenPopover(carrierControlId); - await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines'); - await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId); - await waitForAllConrolsLoading(); - }); - - after(async () => { - await dashboard.clickDiscardChanges(); - }); - - it('applies control filters to controls with data view of control filter', async () => { - await dashboardControls.validateRange('placeholder', ticketPriceControlId, '100', '1196'); - }); - - it('ignores control filters on controls without data view of control filter', async () => { - await dashboardControls.optionsListOpenPopover(osControlId); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5'); - await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId); - - await dashboardControls.validateRange('placeholder', bytesControlId, '0', '19979'); - }); - - it('ignores control filters on panels without data view of control filter', async () => { - const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable'); - expect( - await ( - await logstashSavedSearchPanel.findByCssSelector('[data-document-number]') - ).getAttribute('data-document-number') - ).to.not.be('0'); - }); - }); + await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979'); }); - describe('courier:ignoreFilterIfFieldNotInIndex disabled', () => { - before(async () => { - await kibanaServer.uiSettings.replace({ - 'courier:ignoreFilterIfFieldNotInIndex': false, - }); + it('ignores controls on other controls and panels using a data view without the control field by default', async () => { + await filterBar.removeFilter('Carrier'); + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - await dashboard.navigateToApp(); - await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views'); - }); + await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196'); - after(async () => { - await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex'); - }); + await dashboardControls.optionsListOpenPopover(controlIds[2]); + expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); - describe('global filters', () => { - before(async () => { - await filterBar.addFilter({ - field: 'Carrier', - operation: 'is', - value: 'Kibana Airlines', - }); - await waitForAllConrolsLoading(); - }); - - after(async () => { - await dashboard.clickDiscardChanges(); - }); - - it('applies global filters to controls without data view of filter field', async () => { - await dashboardControls.optionsListOpenPopover(osControlId); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0'); - await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId); - - await dashboardControls.validateRange( - 'placeholder', - bytesControlId, - '-Infinity', - 'Infinity' - ); - }); - }); + await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979'); - describe('control filters', () => { - before(async () => { - await dashboardControls.optionsListOpenPopover(carrierControlId); - await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines'); - await dashboardControls.optionsListEnsurePopoverIsClosed(carrierControlId); - await waitForAllConrolsLoading(); - }); - - after(async () => { - await dashboard.clickDiscardChanges(); - }); - - it('applies control filters on controls without data view of control filter', async () => { - await dashboardControls.optionsListOpenPopover(osControlId); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0'); - await dashboardControls.optionsListEnsurePopoverIsClosed(osControlId); - - await dashboardControls.validateRange( - 'placeholder', - bytesControlId, - '-Infinity', - 'Infinity' - ); - }); - - it('applies control filters on panels without data view of control filter', async () => { - const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable'); - expect( - await ( - await logstashSavedSearchPanel.findByCssSelector('[data-document-number]') - ).getAttribute('data-document-number') - ).to.be('0'); - }); - }); + const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable'); + expect( + await ( + await logstashSavedSearchPanel.findByCssSelector('[data-document-number]') + ).getAttribute('data-document-number') + ).to.not.be('0'); + }); + + it('applies global filters on controls using data view a without the filter field', async () => { + await kibanaServer.uiSettings.update({ 'courier:ignoreFilterIfFieldNotInIndex': false }); + await common.navigateToApp('dashboard'); + await testSubjects.click('edit-unsaved-New-Dashboard'); + await filterBar.addFilter({ field: 'Carrier', operation: 'exists' }); + + await Promise.all([ + dashboardControls.optionsListWaitForLoading(controlIds[0]), + dashboardControls.rangeSliderWaitForLoading(controlIds[1]), + dashboardControls.optionsListWaitForLoading(controlIds[2]), + dashboardControls.rangeSliderWaitForLoading(controlIds[3]), + ]); + + await dashboardControls.clearControlSelections(controlIds[0]); + await dashboardControls.optionsListOpenPopover(controlIds[0]); + expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('4'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200'); + + await dashboardControls.optionsListOpenPopover(controlIds[2]); + expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + + await dashboardControls.validateRange('placeholder', controlIds[3], '0', '0'); + }); + + it('applies global filters on controls using a data view without the filter field', async () => { + await filterBar.removeFilter('Carrier'); + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('Kibana Airlines'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196'); + + await dashboardControls.optionsListOpenPopover(controlIds[2]); + expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + + await dashboardControls.validateRange('placeholder', controlIds[3], '0', '0'); + + const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable'); + expect( + await ( + await logstashSavedSearchPanel.findByCssSelector('[data-document-number]') + ).getAttribute('data-document-number') + ).to.be('0'); }); }); } diff --git a/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts b/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts index 5d0199fc248e4..22980eb6423a2 100644 --- a/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts +++ b/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts @@ -35,17 +35,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const replaceWithOptionsList = async (controlId: string, field: string) => { await changeFieldType(controlId, field, OPTIONS_LIST_CONTROL); - const newControlId: string = (await dashboardControls.getAllControlIds())[0]; - await testSubjects.waitForEnabled(`optionsList-control-${newControlId}`); - await dashboardControls.verifyControlType(newControlId, 'optionsList-control'); + await testSubjects.waitForEnabled(`optionsList-control-${controlId}`); + await dashboardControls.verifyControlType(controlId, 'optionsList-control'); }; const replaceWithRangeSlider = async (controlId: string, field: string) => { await changeFieldType(controlId, field, RANGE_SLIDER_CONTROL); await retry.try(async () => { - const newControlId: string = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.rangeSliderWaitForLoading(newControlId); - await dashboardControls.verifyControlType(newControlId, 'range-slider-control'); + await dashboardControls.rangeSliderWaitForLoading(controlId); + await dashboardControls.verifyControlType(controlId, 'range-slider-control'); }); }; @@ -70,6 +68,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Replace options list', () => { beforeEach(async () => { + await dashboardControls.clearAllControls(); await dashboardControls.createControl({ controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', @@ -79,7 +78,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await dashboardControls.clearAllControls(); + await dashboard.clearUnsavedChanges(); }); it('with range slider - default title', async () => { @@ -101,6 +100,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Replace range slider', () => { beforeEach(async () => { + await dashboardControls.clearAllControls(); await dashboardControls.createControl({ controlType: RANGE_SLIDER_CONTROL, dataViewTitle: 'animals-*', @@ -111,7 +111,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await dashboardControls.clearAllControls(); + await dashboard.clearUnsavedChanges(); }); it('with options list - default title', async () => { diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts index 87d754b053301..220b9819f4466 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_suggestions.ts @@ -17,7 +17,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const { dashboardControls, dashboard, header } = getPageObjects([ 'dashboardControls', + 'timePicker', 'dashboard', + 'settings', + 'console', + 'common', 'header', ]); @@ -48,7 +52,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - await dashboard.clickDiscardChanges(); }); it('sort alphabetically - descending', async () => { @@ -130,6 +133,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { for (let i = 0; i < sortedSuggestions.length - 1; i++) { expect(sortedSuggestions[i]).to.be.lessThan(sortedSuggestions[i + 1]); } + + // revert to the old field name to keep state consistent for other tests + await dashboardControls.editExistingControl(controlId); + await dashboardControls.controlsEditorSetfield('sound.keyword'); + await dashboardControls.optionsListSetAdditionalSettings({ searchTechnique: 'prefix' }); + await dashboardControls.controlEditorSave(); }); }); diff --git a/test/functional/apps/dashboard_elements/controls/options_list/options_list_validation.ts b/test/functional/apps/dashboard_elements/controls/options_list/options_list_validation.ts index bff1e069b2ff0..fa4322963381c 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list/options_list_validation.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list/options_list_validation.ts @@ -9,6 +9,7 @@ import { pick } from 'lodash'; import expect from '@kbn/expect'; +import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; import { FtrProviderContext } from '../../../../ftr_provider_context'; import { OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS } from '../../../../page_objects/dashboard_page_controls'; @@ -17,6 +18,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const queryBar = getService('queryBar'); const pieChart = getService('pieChart'); const filterBar = getService('filterBar'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); const { dashboardControls, dashboard, header } = getPageObjects([ 'dashboardControls', @@ -29,18 +32,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); describe('Dashboard options list validation', () => { - const controlId = 'cd881630-fd28-4e9c-aec5-ae9711d48369'; + let controlId: string; before(async () => { - await dashboard.loadSavedDashboard('Test Options List Validation'); await dashboard.ensureDashboardIsInEditMode(); + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sounds', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await filterBar.removeAllFilters(); + await dashboardControls.deleteAllControls(); + await dashboardPanelActions.removePanelByTitle('Rendering Test: animal sounds pie'); + await dashboard.clickQuickSave(); }); describe('Options List dashboard validation', () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + after(async () => { - // Instead of reset, filter must be manually deleted to avoid - // https://github.com/elastic/kibana/issues/191675 + await dashboardControls.clearControlSelections(controlId); await filterBar.removeAllFilters(); + await queryBar.clickQuerySubmitButton(); }); it('Can mark selections invalid with Query', async () => { @@ -92,13 +118,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Options List dashboard no validation', () => { before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); await dashboardControls.updateValidationSetting(false); }); - after(async () => { - await dashboard.clickDiscardChanges(); - }); - it('Does not mark selections invalid with Query', async () => { await queryBar.setQuery('NOT animal.keyword : "dog" '); await queryBar.submitQuery(); diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json index c4c2b4ab2d025..a89dcf714dfc3 100644 --- a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json @@ -3225,108 +3225,3 @@ "coreMigrationVersion": "8.8.0", "typeMigrationVersion": "10.2.0" } - -{ - "id": "55bc0b4b-a50f-46bf-b154-dd156067eea5", - "type": "dashboard", - "namespaces": [ - "default" - ], - "updated_at": "2024-08-26T13:30:47.442Z", - "created_at": "2024-08-26T13:29:23.580Z", - "version": "WzEwNiwxXQ==", - "attributes": { - "version": 2, - "controlGroupInput": { - "chainingSystem": "HIERARCHICAL", - "controlStyle": "oneLine", - "showApplySelections": true, - "ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}", - "panelsJSON": "{\"41827e70-5285-4d44-8375-4c498449b9a7\":{\"grow\":true,\"order\":0,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"a0f483a0-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"animal.keyword\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}},\"515e7b9f-4f1b-4a06-beec-763810e4951a\":{\"grow\":true,\"order\":1,\"type\":\"rangeSliderControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"a0f483a0-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"weightLbs\",\"step\":1}},\"b33b103a-84e2-4c2f-b4bd-be143dbd7e8a\":{\"grow\":true,\"order\":2,\"type\":\"timeSlider\",\"width\":\"large\",\"explicitInput\":{}}}" - }, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" - }, - "description": "", - "refreshInterval": { - "pause": true, - "value": 60000 - }, - "timeRestore": true, - "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", - "panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"ffc13252-56b4-4e3f-847e-61373fa0be86\"},\"panelIndex\":\"ffc13252-56b4-4e3f-847e-61373fa0be86\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_ffc13252-56b4-4e3f-847e-61373fa0be86\"}]", - "timeFrom": "2018-01-01T00:00:00.000Z", - "title": "Test Control Group Apply Button", - "timeTo": "2018-04-13T00:00:00.000Z" - }, - "references": [ - { - "name": "ffc13252-56b4-4e3f-847e-61373fa0be86:panel_ffc13252-56b4-4e3f-847e-61373fa0be86", - "type": "visualization", - "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159" - }, - { - "name": "controlGroup_41827e70-5285-4d44-8375-4c498449b9a7:optionsListControlDataView", - "type": "index-pattern", - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c" - }, - { - "name": "controlGroup_515e7b9f-4f1b-4a06-beec-763810e4951a:rangeSliderControlDataView", - "type": "index-pattern", - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c" - } - ], - "managed": false, - "coreMigrationVersion": "8.8.0", - "typeMigrationVersion": "10.2.0" -} - -{ - "id": "0b61857d-b7d3-4b4b-aa6b-773808361cd6", - "type": "dashboard", - "namespaces": [ - "default" - ], - "updated_at": "2024-08-26T15:23:33.053Z", - "created_at": "2024-08-26T15:22:39.194Z", - "version": "WzE1MTksMV0=", - "attributes": { - "version": 2, - "controlGroupInput": { - "chainingSystem": "HIERARCHICAL", - "controlStyle": "oneLine", - "showApplySelections": false, - "ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}", - "panelsJSON": "{\"cd881630-fd28-4e9c-aec5-ae9711d48369\":{\"grow\":true,\"order\":0,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"a0f483a0-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"sound.keyword\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[\"meow\",\"bark\"],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}}}" - }, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" - }, - "description": "", - "refreshInterval": { - "pause": true, - "value": 60000 - }, - "timeRestore": true, - "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", - "panelsJSON": "[{\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"12415efc-008a-4f02-bad4-5c1f0d9ba1c6\"},\"panelIndex\":\"12415efc-008a-4f02-bad4-5c1f0d9ba1c6\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_12415efc-008a-4f02-bad4-5c1f0d9ba1c6\"}]", - "timeFrom": "2018-01-01T00:00:00.000Z", - "title": "Test Options List Validation", - "timeTo": "2018-04-13T00:00:00.000Z" - }, - "references": [ - { - "name": "12415efc-008a-4f02-bad4-5c1f0d9ba1c6:panel_12415efc-008a-4f02-bad4-5c1f0d9ba1c6", - "type": "visualization", - "id": "50643b60-3dd3-11e8-b2b9-5d5dc1715159" - }, - { - "name": "controlGroup_cd881630-fd28-4e9c-aec5-ae9711d48369:optionsListControlDataView", - "type": "index-pattern", - "id": "a0f483a0-3dc9-11e8-8660-4d65aa086b3c" - } - ], - "managed": false, - "coreMigrationVersion": "8.8.0", - "typeMigrationVersion": "10.2.0" -} \ No newline at end of file diff --git a/test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana.json b/test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana.json deleted file mode 100644 index 7a5de78d372aa..0000000000000 --- a/test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "id": "2af8906f-143b-4152-9f74-4994fb9c7b3e", - "type": "dashboard", - "namespaces": [ - "default" - ], - "updated_at": "2024-08-27T16:43:33.847Z", - "created_at": "2024-08-27T16:43:33.847Z", - "version": "WzIwNSwxXQ==", - "attributes": { - "version": 2, - "controlGroupInput": { - "chainingSystem": "HIERARCHICAL", - "controlStyle": "oneLine", - "showApplySelections": false, - "ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}", - "panelsJSON": "{\"265b6a28-9ccb-44ae-83c9-3d7a7cac1961\":{\"grow\":true,\"order\":0,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"fieldName\":\"Carrier\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}},\"ed2b93e2-da37-482b-ae43-586a41cc2399\":{\"grow\":true,\"order\":1,\"type\":\"rangeSliderControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"fieldName\":\"AvgTicketPrice\",\"title\":\"Average Ticket Price\",\"step\":1}},\"5e1b146b-8a8b-4117-9218-c4aeaee7bc9a\":{\"grow\":true,\"order\":2,\"type\":\"optionsListControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"0bf35f60-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"machine.os.raw\",\"title\":\"Operating System\",\"searchTechnique\":\"prefix\",\"selectedOptions\":[],\"sort\":{\"by\":\"_count\",\"direction\":\"desc\"}}},\"c4760951-e793-45d5-a6b7-c72c145af7f9\":{\"grow\":true,\"order\":3,\"type\":\"rangeSliderControl\",\"width\":\"medium\",\"explicitInput\":{\"dataViewId\":\"0bf35f60-3dc9-11e8-8660-4d65aa086b3c\",\"fieldName\":\"bytes\",\"title\":\"Bytes\",\"step\":1}}}" - }, - "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" - }, - "description": "", - "refreshInterval": { - "pause": true, - "value": 60000 - }, - "timeRestore": true, - "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", - "panelsJSON": "[{\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"d75a68e9-67d9-4bed-9dba-85490d3eec37\"},\"panelIndex\":\"d75a68e9-67d9-4bed-9dba-85490d3eec37\",\"embeddableConfig\":{\"description\":\"\",\"enhancements\":{}},\"title\":\"logstash hits\",\"panelRefName\":\"panel_d75a68e9-67d9-4bed-9dba-85490d3eec37\"}]", - "timeFrom": "2018-04-10T00:00:00.000Z", - "title": "Test Control Group With Multiple Data Views", - "timeTo": "2018-11-15T00:00:00.000Z" - }, - "references": [ - { - "name": "d75a68e9-67d9-4bed-9dba-85490d3eec37:panel_d75a68e9-67d9-4bed-9dba-85490d3eec37", - "type": "search", - "id": "2b9247e0-6458-11ed-9957-e76caeeb9f75" - }, - { - "name": "controlGroup_265b6a28-9ccb-44ae-83c9-3d7a7cac1961:optionsListControlDataView", - "type": "index-pattern", - "id": "d3d7af60-4c81-11e8-b3d7-01146121b73d" - }, - { - "name": "controlGroup_ed2b93e2-da37-482b-ae43-586a41cc2399:rangeSliderControlDataView", - "type": "index-pattern", - "id": "d3d7af60-4c81-11e8-b3d7-01146121b73d" - }, - { - "name": "controlGroup_5e1b146b-8a8b-4117-9218-c4aeaee7bc9a:optionsListControlDataView", - "type": "index-pattern", - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" - }, - { - "name": "controlGroup_c4760951-e793-45d5-a6b7-c72c145af7f9:rangeSliderControlDataView", - "type": "index-pattern", - "id": "0bf35f60-3dc9-11e8-8660-4d65aa086b3c" - } - ], - "managed": false, - "coreMigrationVersion": "8.8.0", - "typeMigrationVersion": "10.2.0" -} \ No newline at end of file diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index dcc43432dab28..a3573438124e5 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -475,11 +475,7 @@ export class DashboardPageControls extends FtrService { await this.optionsListWaitForLoading(controlId); if (!skipOpen) await this.optionsListOpenPopover(controlId); await this.retry.try(async () => { - const availableOptions = await this.optionsListPopoverGetAvailableOptions(); - expect(availableOptions.suggestions).to.eql(expectation.suggestions); - expect(availableOptions.invalidSelections.sort()).to.eql( - expectation.invalidSelections.sort() - ); + expect(await this.optionsListPopoverGetAvailableOptions()).to.eql(expectation); }); if (await this.testSubjects.exists('optionsList-cardinality-label')) { expect(await this.optionsListGetCardinalityValue()).to.be( @@ -500,9 +496,7 @@ export class DashboardPageControls extends FtrService { public async optionsListPopoverSearchForOption(search: string) { this.log.debug(`searching for ${search} in options list`); await this.optionsListPopoverAssertOpen(); - await this.testSubjects.setValue(`optionsList-control-search-input`, search, { - typeCharByChar: true, - }); + await this.testSubjects.setValue(`optionsList-control-search-input`, search); await this.optionsListPopoverWaitForLoading(); } diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/index.tsx index 6f2c91dec9bfe..752e52aa27c4a 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/metrics/static_dashboard/index.tsx @@ -16,7 +16,8 @@ import { import { DataView } from '@kbn/data-views-plugin/common'; import { buildExistsFilter, buildPhraseFilter, Filter } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; -import { controlGroupStateBuilder } from '@kbn/controls-plugin/public'; +import { controlGroupInputBuilder } from '@kbn/controls-plugin/public'; +import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common'; import { NotificationsStart } from '@kbn/core/public'; import { ENVIRONMENT_ALL, @@ -70,9 +71,10 @@ async function getCreationOptions( dataView: DataView ): Promise { try { - const controlGroupState = {}; + const builder = controlGroupInputBuilder; + const controlGroupInput = getDefaultControlGroupInput(); - await controlGroupStateBuilder.addDataControlFromField(controlGroupState, { + await builder.addDataControlFromField(controlGroupInput, { dataViewId: dataView.id ?? '', title: 'Node name', fieldName: 'service.node.name', @@ -90,7 +92,7 @@ async function getCreationOptions( getInitialInput: () => ({ viewMode: ViewMode.VIEW, panels, - controlGroupState, + controlGroupInput, }), }; } catch (error) {