From c272d9715edbd7a780236437d9728abf8f8cd60a Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 30 Aug 2024 09:10:59 -0600 Subject: [PATCH] Integrate react control group embeddable into dashboard container (#190273) closes https://github.com/elastic/kibana/issues/191137, https://github.com/elastic/kibana/issues/190988, https://github.com/elastic/kibana/issues/191155 PR replaces legacy embeddable control group implementation with react control group implementation in DashboardContainer. ### Test instructions 1. Open dashboard via dashboard application or portable dashboard 2. Mess around with controls. There should be no changes in behavior --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Hannah Mudge --- .../serialized_control_group_state.ts | 4 +- .../dashboard_with_controls_example.tsx | 12 +- .../control_group_persistence.ts | 28 -- src/plugins/controls/common/index.ts | 2 - .../components/control_group.tsx | 1 + .../control_group_unsaved_changes_api.ts | 9 +- .../get_control_group_factory.tsx | 68 +++- .../init_controls_manager.test.ts | 192 ++++++++---- .../control_group/init_controls_manager.ts | 72 +++-- .../open_edit_control_group_flyout.tsx | 2 +- .../control_group/serialization_utils.ts | 7 +- .../react_controls/control_group/types.ts | 3 +- .../initialize_data_control.test.tsx | 3 + .../data_controls/initialize_data_control.ts | 4 +- .../get_options_list_control_factory.tsx | 6 +- .../get_range_slider_control_factory.tsx | 5 +- .../data_controls/reference_name_utils.ts | 22 ++ .../get_timeslider_control_factory.tsx | 10 +- .../controls/timeslider_control/types.ts | 6 +- .../dashboard_container_references.ts | 44 +-- .../dashboard_saved_object_references.ts | 18 -- src/plugins/dashboard/common/types.ts | 3 - .../dashboard_app/locator/locator.test.ts | 10 +- .../add_data_control_button.tsx | 9 +- .../add_time_slider_control_button.tsx | 39 ++- .../controls_toolbar_button.tsx | 12 +- .../edit_control_group_button.tsx | 9 +- .../top_nav/dashboard_editing_toolbar.tsx | 8 +- .../top_nav/share/show_share_modal.tsx | 6 +- .../top_nav/use_dashboard_menu_items.tsx | 32 +- .../component/grid/dashboard_grid.test.tsx | 13 +- .../component/viewport/dashboard_viewport.tsx | 83 +++-- .../embeddable/api/run_save_functions.tsx | 35 +-- ...ashboard_control_group_integration.test.ts | 77 +++-- .../dashboard_control_group_integration.ts | 161 +++++----- .../create/create_dashboard.test.ts | 47 +-- .../embeddable/create/create_dashboard.ts | 163 +--------- .../data_views/sync_dashboard_data_views.ts | 26 +- .../embeddable/dashboard_container.test.tsx | 26 +- .../embeddable/dashboard_container.tsx | 135 ++++++-- .../diffing/dashboard_diffing_integration.ts | 26 +- .../public/dashboard_container/types.ts | 6 +- .../internal_dashboard_top_nav.tsx | 14 +- src/plugins/dashboard/public/mocks.tsx | 12 + .../dashboard_backup_service.ts | 2 + .../dashboard_content_management_service.ts | 9 +- .../lib/load_dashboard_state.ts | 5 +- .../lib/migrate_dashboard_input.test.ts | 25 +- .../lib/migrate_dashboard_input.ts | 22 -- .../lib/save_dashboard_state.ts | 37 +-- .../dashboard_content_management/types.ts | 17 +- .../public/lib/containers/container.ts | 3 + .../public/lib/containers/i_container.ts | 2 + .../embeddable_compatibility_utils.ts | 6 +- .../lib/embeddables/diff_embeddable_input.ts | 3 + .../common/control_group_apply_button.ts | 87 +----- .../controls/common/multiple_data_views.ts | 290 ++++++++++-------- .../controls/common/replace_controls.ts | 16 +- .../options_list/options_list_suggestions.ts | 11 +- .../options_list/options_list_validation.ts | 42 +-- .../dashboard/current/kibana.json | 105 +++++++ .../current/multi_data_view_kibana.json | 64 ++++ .../page_objects/dashboard_page_controls.ts | 10 +- .../app/metrics/static_dashboard/index.tsx | 10 +- 64 files changed, 1171 insertions(+), 1065 deletions(-) create mode 100644 src/plugins/controls/public/react_controls/controls/data_controls/reference_name_utils.ts create mode 100644 test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana.json 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 b02cf450cdd73..9e4b18aaffa26 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}:${RANGE_SLIDER_CONTROL}DataView`, + name: `controlGroup_${rangeSliderControlId}:rangeSliderDataView`, type: 'index-pattern', id: WEB_LOGS_DATA_VIEW_ID, }, { - name: `controlGroup_${optionsListId}:${OPTIONS_LIST_CONTROL}DataView`, + name: `controlGroup_${optionsListId}:optionsListDataView`, 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 ac902c72e851f..f63df505f5d85 100644 --- a/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx +++ b/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx @@ -11,8 +11,7 @@ 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 { controlGroupInputBuilder } from '@kbn/controls-plugin/public'; -import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common'; +import { controlGroupStateBuilder } from '@kbn/controls-plugin/public'; import { AwaitingDashboardAPI, DashboardRenderer, @@ -63,16 +62,15 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView => { - const builder = controlGroupInputBuilder; - const controlGroupInput = getDefaultControlGroupInput(); - await builder.addDataControlFromField(controlGroupInput, { + const controlGroupState = {}; + await controlGroupStateBuilder.addDataControlFromField(controlGroupState, { dataViewId: dataView.id ?? '', title: 'Destintion country', fieldName: 'geo.dest', width: 'medium', grow: false, }); - await builder.addDataControlFromField(controlGroupInput, { + await controlGroupStateBuilder.addDataControlFromField(controlGroupState, { dataViewId: dataView.id ?? '', fieldName: 'bytes', width: 'medium', @@ -85,7 +83,7 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView getInitialInput: () => ({ timeRange: { from: 'now-30d', to: 'now' }, viewMode: ViewMode.VIEW, - controlGroupInput, + controlGroupState, }), }; }} 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 8e9a795c2ec4c..0de1238b9575c 100644 --- a/src/plugins/controls/common/control_group/control_group_persistence.ts +++ b/src/plugins/controls/common/control_group/control_group_persistence.ts @@ -9,7 +9,6 @@ import deepEqual from 'fast-deep-equal'; import { SerializableRecord } from '@kbn/utility-types'; -import { v4 } from 'uuid'; import { pick, omit, xor } from 'lodash'; import { @@ -23,7 +22,6 @@ import { } from './control_group_panel_diff_system'; import { ControlGroupInput } from '..'; import { - ControlsPanels, PersistableControlGroupInput, persistableControlGroupInputKeys, RawControlGroupAttributes, @@ -103,32 +101,6 @@ 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 69af581fc5fad..6be0bc5818f57 100644 --- a/src/plugins/controls/common/index.ts +++ b/src/plugins/controls/common/index.ts @@ -22,14 +22,12 @@ 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 c825e9021b48d..4a2a4c802272d 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,6 +121,7 @@ export function ControlGroup({ paddingSize="none" color={draggingId ? 'success' : 'transparent'} className="controlsWrapper" + data-test-subj="controls-group-wrapper" > & { controlsInOrder: ControlsInOrder; }; @@ -38,6 +34,7 @@ export function initializeControlGroupUnsavedChanges( children$: PresentationContainer['children$'], comparators: StateComparators, snapshotControlsRuntimeState: () => ControlPanelsState, + resetControlsUnsavedChanges: () => void, parentApi: unknown, lastSavedRuntimeState: ControlGroupRuntimeState ) { @@ -47,7 +44,6 @@ export function initializeControlGroupUnsavedChanges( chainingSystem: lastSavedRuntimeState.chainingSystem, controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState), ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings, - initialChildControlState: lastSavedRuntimeState.initialChildControlState, labelPosition: lastSavedRuntimeState.labelPosition, }, parentApi, @@ -72,6 +68,7 @@ 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 45802689e81a1..2e6519b69343f 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,12 +34,19 @@ 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 } from './types'; +import { + ControlGroupApi, + ControlGroupRuntimeState, + ControlGroupSerializedState, + ControlPanelsState, +} 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; @@ -60,7 +67,6 @@ export const getControlGroupEmbeddableFactory = (services: { lastSavedRuntimeState ) => { const { - initialChildControlState, labelPosition: initialLabelPosition, chainingSystem, autoApplySelections, @@ -68,19 +74,22 @@ export const getControlGroupEmbeddableFactory = (services: { } = initialRuntimeState; const autoApplySelections$ = new BehaviorSubject(autoApplySelections); - const parentDataViewId = apiPublishesDataViews(parentApi) - ? parentApi.dataViews.value?.[0]?.id - : undefined; + const defaultDataViewId = await services.dataViews.getDefaultId(); + const lastSavedControlsState$ = new BehaviorSubject( + lastSavedRuntimeState.initialChildControlState + ); const controlsManager = initControlsManager( - initialChildControlState, - parentDataViewId ?? (await services.dataViews.getDefaultId()) + initialRuntimeState.initialChildControlState, + lastSavedControlsState$ ); const selectionsManager = initSelectionsManager({ ...controlsManager.api, autoApplySelections$, }); const dataViews = new BehaviorSubject(undefined); - const chainingSystem$ = new BehaviorSubject(chainingSystem); + const chainingSystem$ = new BehaviorSubject( + chainingSystem ?? DEFAULT_CHAINING_SYSTEM + ); const ignoreParentSettings$ = new BehaviorSubject( ignoreParentSettings ); @@ -104,6 +113,7 @@ export const getControlGroupEmbeddableFactory = (services: { chainingSystem: [ chainingSystem$, (next: ControlGroupChainingSystem) => chainingSystem$.next(next), + (a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM), ], ignoreParentSettings: [ ignoreParentSettings$, @@ -113,6 +123,7 @@ export const getControlGroupEmbeddableFactory = (services: { labelPosition: [labelPosition$, (next: ControlStyle) => labelPosition$.next(next)], }, controlsManager.snapshotControlsRuntimeState, + controlsManager.resetControlsUnsavedChanges, parentApi, lastSavedRuntimeState ); @@ -159,20 +170,28 @@ export const getControlGroupEmbeddableFactory = (services: { i18n.translate('controls.controlGroup.displayName', { defaultMessage: 'Controls', }), - openAddDataControlFlyout: (settings) => { - const { controlInputTransform } = settings ?? { - controlInputTransform: (state) => state, - }; + openAddDataControlFlyout: (options) => { + const parentDataViewId = apiPublishesDataViews(parentApi) + ? parentApi.dataViews.value?.[0]?.id + : undefined; + const newControlState = controlsManager.getNewControlState(); openDataControlEditor({ - initialState: controlsManager.getNewControlState(), + initialState: { + ...newControlState, + dataViewId: + newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined, + }, onSave: ({ type: controlType, state: initialState }) => { controlsManager.api.addNewPanel({ panelType: controlType, - initialState: controlInputTransform!( - initialState as Partial, - controlType - ), + initialState: options?.controlInputTransform + ? options.controlInputTransform( + initialState as Partial, + controlType + ) + : initialState, }); + options?.onSave?.(); }, controlGroupApi: api, services, @@ -207,6 +226,20 @@ 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<{ @@ -235,6 +268,7 @@ 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 3e381123ecd9a..fc729478ec770 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,27 +6,26 @@ * 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 } from './types'; +import { ControlPanelState, ControlPanelsState } from './types'; jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('delta'), })); -const DEFAULT_DATA_VIEW_ID = 'myDataView'; - 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); + test('addNewPanel should add control at end of controls', async () => { - const controlsManager = initControlsManager( - { - alpha: { type: 'testControl', order: 0 }, - bravo: { type: 'testControl', order: 1 }, - charlie: { type: 'testControl', order: 2 }, - }, - DEFAULT_DATA_VIEW_ID - ); + const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); const addNewPanelPromise = controlsManager.api.addNewPanel({ panelType: 'testControl', initialState: {}, @@ -42,14 +41,7 @@ describe('PresentationContainer api', () => { }); test('removePanel should remove control', () => { - const controlsManager = initControlsManager( - { - alpha: { type: 'testControl', order: 0 }, - bravo: { type: 'testControl', order: 1 }, - charlie: { type: 'testControl', order: 2 }, - }, - DEFAULT_DATA_VIEW_ID - ); + const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); controlsManager.api.removePanel('bravo'); expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([ 'alpha', @@ -58,14 +50,7 @@ describe('PresentationContainer api', () => { }); test('replacePanel should replace control', async () => { - const controlsManager = initControlsManager( - { - alpha: { type: 'testControl', order: 0 }, - bravo: { type: 'testControl', order: 1 }, - charlie: { type: 'testControl', order: 2 }, - }, - DEFAULT_DATA_VIEW_ID - ); + const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); const replacePanelPromise = controlsManager.api.replacePanel('bravo', { panelType: 'testControl', initialState: {}, @@ -81,13 +66,7 @@ describe('PresentationContainer api', () => { describe('untilInitialized', () => { test('should not resolve until all controls are initialized', async () => { - const controlsManager = initControlsManager( - { - alpha: { type: 'testControl', order: 0 }, - bravo: { type: 'testControl', order: 1 }, - }, - DEFAULT_DATA_VIEW_ID - ); + const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); let isDone = false; controlsManager.api.untilInitialized().then(() => { isDone = true; @@ -101,19 +80,18 @@ 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( - { - alpha: { type: 'testControl', order: 0 }, - bravo: { type: 'testControl', order: 1 }, - }, - DEFAULT_DATA_VIEW_ID - ); + const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); 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(() => { @@ -127,14 +105,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( - { - alpha: { type: 'testControl', order: 1 }, - bravo: { type: 'testControl', order: 0 }, - }, - DEFAULT_DATA_VIEW_ID - ); + const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); controlsManager.setControlApi('alpha', { snapshotRuntimeState: () => { return { key1: 'alpha value' }; @@ -190,28 +168,120 @@ 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({}, DEFAULT_DATA_VIEW_ID); + const controlsManager = initControlsManager({}, new BehaviorSubject({})); expect(controlsManager.getNewControlState()).toEqual({ grow: true, width: 'medium', - dataViewId: DEFAULT_DATA_VIEW_ID, + dataViewId: undefined, }); }); 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( - { - alpha: { - type: 'testControl', - order: 1, - dataViewId: 'myOtherDataViewId', - width: 'small', - grow: false, - } as ControlPanelState & Pick, - }, - DEFAULT_DATA_VIEW_ID + intialControlsState, + new BehaviorSubject(intialControlsState) ); expect(controlsManager.getNewControlState()).toEqual({ grow: true, @@ -221,7 +291,7 @@ describe('getNewControlState', () => { }); test('should contain values of last added control', () => { - const controlsManager = initControlsManager({}, DEFAULT_DATA_VIEW_ID); + const controlsManager = initControlsManager({}, new BehaviorSubject({})); 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 07b533f329631..aaa5d41e492ae 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,22 +38,25 @@ export function getControlsInOrder(initialControlPanelsState: ControlPanelsState } export function initControlsManager( - initialControlPanelsState: ControlPanelsState, - defaultDataViewId: string | null + /** + * 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 ) { - const lastSavedControlsPanelState$ = new BehaviorSubject(initialControlPanelsState); - const initialControlIds = Object.keys(initialControlPanelsState); + const initialControlIds = Object.keys(initialControlsState); const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); - let controlsPanelState: { [panelId: string]: DefaultControlState } = { - ...initialControlPanelsState, + let currentControlsState: { [panelId: string]: DefaultControlState } = { + ...initialControlsState, }; const controlsInOrder$ = new BehaviorSubject( - getControlsInOrder(initialControlPanelsState) + getControlsInOrder(initialControlsState) ); const lastUsedDataViewId$ = new BehaviorSubject( - getLastUsedDataViewId(controlsInOrder$.value, initialControlPanelsState) ?? - defaultDataViewId ?? - undefined + getLastUsedDataViewId(controlsInOrder$.value, initialControlsState) ); const lastUsedWidth$ = new BehaviorSubject(DEFAULT_CONTROL_WIDTH); const lastUsedGrow$ = new BehaviorSubject(DEFAULT_CONTROL_GROW); @@ -108,12 +111,12 @@ export function initControlsManager( type: panelType, }); controlsInOrder$.next(nextControlsInOrder); - controlsPanelState[id] = initialState ?? {}; + currentControlsState[id] = initialState ?? {}; return await untilControlLoaded(id); } function removePanel(panelId: string) { - delete controlsPanelState[panelId]; + delete currentControlsState[panelId]; controlsInOrder$.next(controlsInOrder$.value.filter(({ id }) => id !== panelId)); children$.next(omit(children$.value, panelId)); } @@ -161,7 +164,7 @@ export function initControlsManager( type: controlApi.type, width, /** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */ - explicitInput: rest, + explicitInput: { id, ...rest }, }; }); @@ -184,9 +187,30 @@ 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 = controlsPanelState[childId]; + const controlPanelState = currentControlsState[childId]; return controlPanelState ? { rawState: controlPanelState } : undefined; }, children$: children$ as PublishingSubject<{ @@ -230,26 +254,10 @@ export function initControlsManager( comparators: { controlsInOrder: [ controlsInOrder$, - (next: ControlsInOrder) => controlsInOrder$.next(next), + (next: ControlsInOrder) => {}, // setter does nothing, controlsInOrder$ reset by resetControlsRuntimeState fastIsEqual, ], - // 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 - >, + } as StateComparators>, }; } 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 c636d37ade6b2..98784f826090b 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); }); - ref.close(); + closeOverlay(ref); }); }; 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 eb3706c3913a1..031dababa5ca1 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,6 +9,7 @@ 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 @@ -20,9 +21,9 @@ export const deserializeControlGroup = ( const references = state.references ?? []; references.forEach((reference) => { const referenceName = reference.name; - const panelId = referenceName.substring('controlGroup_'.length, referenceName.lastIndexOf(':')); - if (panels[panelId]) { - panels[panelId].dataViewId = reference.id; + const { controlId } = parseReferenceName(referenceName); + if (panels[controlId]) { + panels[controlId].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 d009712e52a5b..826a5fde393b1 100644 --- a/src/plugins/controls/public/react_controls/control_group/types.ts +++ b/src/plugins/controls/public/react_controls/control_group/types.ts @@ -65,8 +65,9 @@ export type ControlGroupApi = PresentationContainer & ignoreParentSettings$: PublishingSubject; allowExpensiveQueries$: PublishingSubject; untilInitialized: () => Promise; - openAddDataControlFlyout: (settings?: { + openAddDataControlFlyout: (options?: { 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 5dd6bf745feca..5baca7edfdaab 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,6 +51,7 @@ describe('initializeDataControl', () => { dataControl = initializeDataControl( 'myControlId', 'myControlType', + 'referenceNameSuffix', dataControlState, editorStateManager, controlGroupApi, @@ -82,6 +83,7 @@ describe('initializeDataControl', () => { dataControl = initializeDataControl( 'myControlId', 'myControlType', + 'referenceNameSuffix', { ...dataControlState, dataViewId: 'notGonnaFindMeDataViewId', @@ -120,6 +122,7 @@ 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 312701dd22c32..d3b90e72bb7fa 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,10 +26,12 @@ 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 @@ -242,7 +244,7 @@ export const initializeDataControl = ( }, references: [ { - name: `controlGroup_${controlId}:${controlType}DataView`, + name: getReferenceName(controlId, referenceNameSuffix), 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 12d0de5a3d7d3..4a16fcfe29b31 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,6 +8,7 @@ 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'; @@ -87,6 +88,7 @@ export const getOptionsListControlFactory = ( >( uuid, OPTIONS_LIST_CONTROL, + 'optionsListDataView', initialState, { searchTechnique: searchTechnique$, singleSelect: singleSelect$ }, controlGroupApi, @@ -243,7 +245,7 @@ export const getOptionsListControlFactory = ( searchTechnique: searchTechnique$.getValue(), runPastTimeout: runPastTimeout$.getValue(), singleSelect: singleSelect$.getValue(), - selections: selections.selectedOptions$.getValue(), + selectedOptions: selections.selectedOptions$.getValue(), sort: sort$.getValue(), existsSelected: selections.existsSelected$.getValue(), exclude: selections.exclude$.getValue(), @@ -277,7 +279,7 @@ export const getOptionsListControlFactory = ( sort: [ sort$, (sort) => sort$.next(sort), - (a, b) => (a ?? OPTIONS_LIST_DEFAULT_SORT) === (b ?? OPTIONS_LIST_DEFAULT_SORT), + (a, b) => fastIsEqual(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 a2819460d05c9..88f0497ac5cba 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,6 +63,7 @@ export const getRangesliderControlFactory = ( const dataControl = initializeDataControl>( uuid, RANGE_SLIDER_CONTROL, + 'rangeSliderDataView', initialState, { step: step$, @@ -158,8 +159,8 @@ export const getRangesliderControlFactory = ( if (error) { dataControl.api.setBlockingError(error); } - max$.next(max); - min$.next(min); + max$.next(max !== undefined ? Math.ceil(max) : undefined); + min$.next(min !== undefined ? Math.floor(min) : undefined); } ); 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 new file mode 100644 index 0000000000000..1a8a1e65f72de --- /dev/null +++ b/src/plugins/controls/public/react_controls/controls/data_controls/reference_name_utils.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +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 f3d1b43de8fa2..ef8ea463a9f63 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,16 +35,17 @@ 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: () => - i18n.translate('controls.timesliderControl.displayName', { - defaultMessage: 'Time slider', - }), + getDisplayName: () => displayName, buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } = initTimeRangeSubscription(controlGroupApi, services); @@ -203,6 +204,7 @@ 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 bc5fcd67829c2..d7c837732cce4 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 { PublishesTimeslice } from '@kbn/presentation-publishing'; +import type { PublishesPanelTitle, PublishesTimeslice } from '@kbn/presentation-publishing'; import type { DefaultControlApi, DefaultControlState } from '../types'; export type Timeslice = [number, number]; @@ -20,7 +20,9 @@ export interface TimesliderControlState extends DefaultControlState { timesliceEndAsPercentageOfTimeRange?: number; } -export type TimesliderControlApi = DefaultControlApi & PublishesTimeslice; +export type TimesliderControlApi = DefaultControlApi & + Pick & + 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 f1f6efb0d6678..169af0ca27da4 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,7 +7,6 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import { CONTROL_GROUP_TYPE, PersistableControlGroupInput } from '@kbn/controls-plugin/common'; import { EmbeddableInput, EmbeddablePersistableStateService, @@ -23,6 +22,10 @@ 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 @@ -34,7 +37,6 @@ export const prefixReferencesFromPanel = (id: string, references: Reference[]): }; const controlGroupReferencePrefix = 'controlGroup_'; -const controlGroupId = 'dashboard_control_group'; export const createInject = ( persistableStateService: EmbeddablePersistableStateService @@ -90,27 +92,6 @@ 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; }; }; @@ -160,23 +141,6 @@ 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 94e8582ebecae..d6a852807bea3 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,7 +8,6 @@ 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, @@ -33,9 +32,6 @@ function parseDashboardAttributesWithType( } return { - controlGroupInput: - attributes.controlGroupInput && - rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput), type: 'dashboard', panels: convertSavedPanelsToPanelMap(parsedPanels), } as ParsedDashboardAttributesWithType; @@ -59,13 +55,6 @@ 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; } @@ -96,13 +85,6 @@ 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 b5492d62ea220..fd434085b397b 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -8,8 +8,6 @@ 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'; @@ -40,7 +38,6 @@ 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 2b56acc719158..fd2f64828899f 100644 --- a/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts +++ b/src/plugins/dashboard/public/dashboard_app/locator/locator.test.ts @@ -9,9 +9,7 @@ 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(() => { @@ -193,16 +191,18 @@ describe('dashboard locator', () => { useHashedUrl: false, getDashboardFilterFields: async (dashboardId: string) => [], }); - const controlGroupInput = mockControlGroupInput() as unknown as SerializableControlGroupInput; + const controlGroupState = { + autoApplySelections: false, + }; const location = await definition.getLocation({ - controlGroupInput, + controlGroupState, }); expect(location).toMatchObject({ app: 'dashboards', path: `#/create?_g=()`, state: { - controlGroupInput, + controlGroupState, }, }); }); 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 e7c7daa2bcc27..b96f450e19bdc 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 { ControlGroupContainer } from '@kbn/controls-plugin/public'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { getAddControlButtonTitle } from '../../_dashboard_app_strings'; import { useDashboardAPI } from '../../dashboard_app'; interface Props { closePopover: () => void; - controlGroup: ControlGroupContainer; + controlGroupApi?: ControlGroupApi; } -export const AddDataControlButton = ({ closePopover, controlGroup, ...rest }: Props) => { +export const AddDataControlButton = ({ closePopover, controlGroupApi, ...rest }: Props) => { const dashboard = useDashboardAPI(); const onSave = () => { dashboard.scrollToTop(); @@ -28,9 +28,10 @@ export const AddDataControlButton = ({ closePopover, controlGroup, ...rest }: Pr {...rest} icon="plusInCircle" data-test-subj="controls-create-button" + disabled={!controlGroupApi} aria-label={getAddControlButtonTitle()} onClick={() => { - controlGroup.openAddDataControlFlyout({ onSave }); + controlGroupApi?.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 a3a9cf7ce73d8..9cb9b1b82f9da 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,8 +7,12 @@ */ import React, { useEffect, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { EuiContextMenuItem } from '@elastic/eui'; -import { ControlGroupContainer, TIME_SLIDER_CONTROL } from '@kbn/controls-plugin/public'; +import type { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { TIME_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; + +import { apiHasType } from '@kbn/presentation-publishing'; import { getAddTimeSliderControlButtonTitle, getOnlyOneTimeSliderControlMsg, @@ -17,40 +21,47 @@ import { useDashboardAPI } from '../../dashboard_app'; interface Props { closePopover: () => void; - controlGroup: ControlGroupContainer; + controlGroupApi?: ControlGroupApi; } -export const AddTimeSliderControlButton = ({ closePopover, controlGroup, ...rest }: Props) => { +export const AddTimeSliderControlButton = ({ closePopover, controlGroupApi, ...rest }: Props) => { const [hasTimeSliderControl, setHasTimeSliderControl] = useState(false); const dashboard = useDashboardAPI(); useEffect(() => { - 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; + if (!controlGroupApi) { + return; + } + + const subscription = controlGroupApi.children$.subscribe((children) => { + const nextHasTimeSliderControl = Object.values(children).some((controlApi) => { + return apiHasType(controlApi) && controlApi.type === TIME_SLIDER_CONTROL; }); - if (nextHasTimeSliderControl !== hasTimeSliderControl) { - setHasTimeSliderControl(nextHasTimeSliderControl); - } + setHasTimeSliderControl(nextHasTimeSliderControl); }); return () => { subscription.unsubscribe(); }; - }, [controlGroup, hasTimeSliderControl, setHasTimeSliderControl]); + }, [controlGroupApi]); return ( { - await controlGroup.addTimeSliderControl(); + controlGroupApi?.addNewPanel({ + panelType: TIME_SLIDER_CONTROL, + initialState: { + grow: true, + width: 'large', + id: uuidv4(), + }, + }); dashboard.scrollToTop(); closePopover(); }} data-test-subj="controls-create-timeslider-button" - disabled={hasTimeSliderControl} + disabled={!controlGroupApi || 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 ba90513a44c1d..6c6459266f7c3 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({ - controlGroup, + controlGroupApi, isDisabled, }: { - controlGroup: ControlGroupContainer; + controlGroupApi?: ControlGroupApi; 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 3563d87f5cf81..5f093b2967d39 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,23 +8,24 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import { ControlGroupContainer } from '@kbn/controls-plugin/public'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { getEditControlGroupButtonTitle } from '../../_dashboard_app_strings'; interface Props { closePopover: () => void; - controlGroup: ControlGroupContainer; + controlGroupApi?: ControlGroupApi; } -export const EditControlGroupButton = ({ closePopover, controlGroup, ...rest }: Props) => { +export const EditControlGroupButton = ({ closePopover, controlGroupApi, ...rest }: Props) => { return ( { - controlGroup.openEditControlGroupFlyout(); + controlGroupApi?.onEdit(); 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 579d6d17d3a94..17d8ced554948 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,6 +13,7 @@ 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'; @@ -82,6 +83,7 @@ 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); /** @@ -99,6 +102,7 @@ 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(); @@ -113,13 +117,17 @@ export const useDashboardMenuItems = ({ return; } confirmDiscardUnsavedChanges(() => { - batch(() => { - dashboard.resetToLastSavedState(); - switchModes?.(); + batch(async () => { + setIsResetting(true); + await dashboard.asyncResetToLastSavedState(); + if (isMounted()) { + setIsResetting(false); + switchModes?.(); + } }); }, viewMode); }, - [dashboard, dashboardBackup, hasUnsavedChanges, viewMode] + [dashboard, dashboardBackup, hasUnsavedChanges, viewMode, isMounted] ); /** @@ -190,7 +198,8 @@ export const useDashboardMenuItems = ({ switchToViewMode: { ...topNavStrings.switchToViewMode, id: 'cancel', - disableButton: disableTopNav || !lastSavedId, + disableButton: disableTopNav || !lastSavedId || isResetting, + isLoading: isResetting, testId: 'dashboardViewOnlyMode', run: () => resetChanges(true), } as TopNavMenuData, @@ -226,6 +235,7 @@ export const useDashboardMenuItems = ({ dashboardBackup, quickSaveDashboard, resetChanges, + isResetting, ]); const resetChangesMenuItem = useMemo(() => { @@ -234,12 +244,22 @@ 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]); + }, [ + hasOverlays, + lastSavedId, + resetChanges, + viewMode, + isSaveInProgress, + hasUnsavedChanges, + isResetting, + ]); /** * 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 93f25962a0916..91fa453e7c5f9 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 = () => { +const createAndMountDashboardGrid = async () => { const dashboardContainer = buildMockDashboard({ overrides: { panels: { @@ -61,6 +61,7 @@ const createAndMountDashboardGrid = () => { }, }, }); + await dashboardContainer.untilContainerInitialized(); const component = mountWithIntl( @@ -70,20 +71,20 @@ const createAndMountDashboardGrid = () => { }; test('renders DashboardGrid', async () => { - const { component } = createAndMountDashboardGrid(); + const { component } = await createAndMountDashboardGrid(); const panelElements = component.find('GridItem'); expect(panelElements.length).toBe(2); }); test('renders DashboardGrid with no visualizations', async () => { - const { dashboardContainer, component } = createAndMountDashboardGrid(); + const { dashboardContainer, component } = await 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 } = createAndMountDashboardGrid(); + const { dashboardContainer, component } = await createAndMountDashboardGrid(); const originalPanels = dashboardContainer.getInput().panels; const filteredPanels = { ...originalPanels }; delete filteredPanels['1']; @@ -94,7 +95,7 @@ test('DashboardGrid removes panel when removed from container', async () => { }); test('DashboardGrid renders expanded panel', async () => { - const { dashboardContainer, component } = createAndMountDashboardGrid(); + const { dashboardContainer, component } = await createAndMountDashboardGrid(); dashboardContainer.setExpandedPanelId('1'); component.update(); // Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized. @@ -112,7 +113,7 @@ test('DashboardGrid renders expanded panel', async () => { }); test('DashboardGrid renders focused panel', async () => { - const { dashboardContainer, component } = createAndMountDashboardGrid(); + const { dashboardContainer, component } = await 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 cc0397a5af1e3..a3ffb5bfcdd38 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,12 +9,19 @@ import { debounce } from 'lodash'; import classNames from 'classnames'; import useResizeObserver from 'use-resize-observer/polyfilled'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { EuiPortal } from '@elastic/eui'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { ReactEmbeddableRenderer, 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'; @@ -34,23 +41,11 @@ export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => { }; export const DashboardViewportComponent = () => { - const controlsRoot = useRef(null); - const dashboard = useDashboardContainer(); - /** - * Render Control group - */ - const controlGroup = dashboard.controlGroup; - useEffect(() => { - if (controlGroup && controlsRoot.current) controlGroup.render(controlsRoot.current); - }, [controlGroup]); - + const controlGroupApi = useStateFromPublishingSubject(dashboard.controlGroupApi$); const panelCount = Object.keys(dashboard.select((state) => state.explicitInput.panels)).length; - const controlCount = Object.keys( - controlGroup?.select((state) => state.explicitInput.panels) ?? {} - ).length; - + const [hasControls, setHasControls] = useState(false); const viewMode = dashboard.select((state) => state.explicitInput.viewMode); const dashboardTitle = dashboard.select((state) => state.explicitInput.title); const useMargins = dashboard.select((state) => state.explicitInput.useMargins); @@ -65,17 +60,59 @@ 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 (
- {controlGroup && viewMode !== ViewMode.PRINT ? ( -
0 ? 'dshDashboardViewport-controls' : ''} - ref={controlsRoot} - /> + {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)} + /> +
) : 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 && } + {viewportWidth !== 0 && dashboardInitialized && ( + + )}
); 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 215c3e7b99e7d..b8ee1cca82156 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,7 +7,6 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { EmbeddableInput, @@ -89,13 +88,17 @@ export async function runQuickSave(this: DashboardContainer) { const { panels: nextPanels, references } = await serializeAllPanelState(this); const dashboardStateToSave: DashboardContainerInput = { ...currentState, panels: nextPanels }; let stateToSave: SavedDashboardInput = dashboardStateToSave; - let persistableControlGroupInput: PersistableControlGroupInput | undefined; - if (this.controlGroup) { - persistableControlGroupInput = this.controlGroup.getPersistableInput(); - stateToSave = { ...stateToSave, controlGroupInput: persistableControlGroupInput }; + 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 }; } const saveResult = await saveDashboardState({ + controlGroupReferences, panelReferences: references, currentState: stateToSave, saveOptions: {}, @@ -105,9 +108,6 @@ 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,19 +180,20 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo stateFromSaveModal.tags = newTags; } - let dashboardStateToSave: DashboardContainerInput & { - controlGroupInput?: PersistableControlGroupInput; - } = { + let dashboardStateToSave: SavedDashboardInput = { ...currentState, ...stateFromSaveModal, }; - let persistableControlGroupInput: PersistableControlGroupInput | undefined; - if (this.controlGroup) { - persistableControlGroupInput = this.controlGroup.getPersistableInput(); + const controlGroupApi = this.controlGroupApi$.value; + let controlGroupReferences: Reference[] | undefined; + if (controlGroupApi) { + const { rawState: controlGroupSerializedState, references } = + await controlGroupApi.serializeState(); + controlGroupReferences = references; dashboardStateToSave = { ...dashboardStateToSave, - controlGroupInput: persistableControlGroupInput, + controlGroupInput: controlGroupSerializedState, }; } @@ -225,6 +226,7 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo const beforeAddTime = window.performance.now(); const saveResult = await saveDashboardState({ + controlGroupReferences, panelReferences: references, saveOptions, currentState: { @@ -251,9 +253,6 @@ 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 148c409e8d702..84b9d8dbea7b0 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,11 +6,9 @@ * 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'); @@ -51,46 +49,41 @@ const testFilter3: Filter = { }, }; -const mockControlGroupContainer = new ControlGroupContainer( - { getTools: () => {} } as unknown as ReduxToolsPackage, - mockControlGroupInput() -); - -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); - }); +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); + }); - 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 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); + }); - 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)); - }); + 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)); }); }); 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 675ea42634506..6267f6a27a2cc 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,114 +6,95 @@ * Side Public License, v 1. */ -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 { 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 { DashboardContainer } from '../../dashboard_container'; -interface DiffChecks { - [key: string]: (a?: unknown, b?: unknown) => boolean; -} - -const distinctUntilDiffCheck = (a: T, b: T, diffChecks: DiffChecks) => - !(Object.keys(diffChecks) as Array) - .map((key) => deepEqual(a[key], b[key])) - .includes(false); - -type DashboardControlGroupCommonKeys = keyof Pick< - DashboardContainerInput | ControlGroupInput, - 'filters' | 'lastReloadRequestTime' | 'timeRange' | 'query' ->; - -export function startSyncingDashboardControlGroup(this: DashboardContainer) { - if (!this.controlGroup) return; - - const compareAllFilters = (a?: Filter[], b?: Filter[]) => - compareFilters(a ?? [], b ?? [], COMPARE_ALL_OPTIONS); - - const dashboardRefetchDiff: DiffChecks = { - filters: (a, b) => compareAllFilters(a as Filter[], b as Filter[]), - timeRange: deepEqual, - query: deepEqual, - viewMode: deepEqual, - }; +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))) + ); - // pass down any pieces of input needed to refetch or force refetch data for the controls - this.integrationSubscriptions.add( - (this.getInput$() as Readonly>) + // -------------------------------------------------------------------------------------- + // dashboard.unifiedSearchFilters$ + // -------------------------------------------------------------------------------------- + const unifiedSearchFilters$ = new BehaviorSubject( + dashboard.getInput().filters + ); + dashboard.unifiedSearchFilters$ = unifiedSearchFilters$ as PublishingSubject< + Filter[] | undefined + >; + dashboard.publishingSubscription.add( + dashboard + .getInput$() .pipe( - distinctUntilChanged((a, b) => - distinctUntilDiffCheck(a, b, dashboardRefetchDiff) - ) + startWith(dashboard.getInput()), + map((input) => input.filters), + distinctUntilChanged((previous, current) => { + return compareFilters(previous ?? [], current ?? [], COMPARE_ALL_OPTIONS); + }) ) - .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); - } + .subscribe((unifiedSearchFilters) => { + unifiedSearchFilters$.next(unifiedSearchFilters); }) ); - // when control group outputs filters, force a refresh! - 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(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted + // -------------------------------------------------------------------------------------- + // Set dashboard.filters$ to include unified search filters and control group filters + // -------------------------------------------------------------------------------------- + function getCombinedFilters() { + return combineDashboardFiltersWithControlGroupFilters( + dashboard.getInput().filters ?? [], + dashboard.controlGroupApi$.value + ); + } + + const filters$ = new BehaviorSubject(getCombinedFilters()); + dashboard.filters$ = filters$; + + dashboard.publishingSubscription.add( + combineLatest([dashboard.unifiedSearchFilters$, controlGroupFilters$]).subscribe(() => { + filters$.next(getCombinedFilters()); + }) ); - this.integrationSubscriptions.add( - this.controlGroup - .getOutput$() + // -------------------------------------------------------------------------------------- + // when control group outputs filters, force a refresh! + // -------------------------------------------------------------------------------------- + dashboard.publishingSubscription.add( + controlGroupFilters$ .pipe( - distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) => - isEqual(timesliceA, timesliceB) - ) + skip(1) // skip first filter output because it will have been applied in initialize ) - .subscribe(({ timeslice }) => { - if (!isEqual(timeslice, this.getInput().timeslice)) { - this.dispatch.setTimeslice(timeslice); - } - }) + .subscribe(() => dashboard.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted ); - // 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) - ) + // -------------------------------------------------------------------------------------- + // when control group outputs timeslice, dispatch timeslice + // -------------------------------------------------------------------------------------- + dashboard.publishingSubscription.add( + controlGroupTimeslice$.subscribe((timeslice) => { + dashboard.dispatch.setTimeslice(timeslice); + }) ); } export const combineDashboardFiltersWithControlGroupFilters = ( dashboardFilters: Filter[], - controlGroup: ControlGroupContainer + controlGroupApi?: PublishesFilters ): Filter[] => { - return [...dashboardFilters, ...(controlGroup.getOutput().filters ?? [])]; + return [...dashboardFilters, ...(controlGroupApi?.filters$.value ?? [])]; }; 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 b9d2ff286023d..12f513c1f417f 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,8 +6,6 @@ * Side Public License, v 1. */ -import { BehaviorSubject, Observable } from 'rxjs'; - import { ContactCardEmbeddable, ContactCardEmbeddableFactory, @@ -15,11 +13,6 @@ 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'; @@ -29,6 +22,7 @@ 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 @@ -416,6 +410,7 @@ 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)); @@ -476,6 +471,7 @@ test('creates new embeddable with specified size if size is provided', async () }, }), }); + dashboard?.setControlGroupApi(mockControlGroupApi); // flush promises await new Promise((r) => setTimeout(r, 1)); @@ -497,42 +493,6 @@ 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 @@ -567,6 +527,7 @@ 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 158fc638adc3d..8e23540479535 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,38 +5,13 @@ * 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 { EmbeddableFactory, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; -import { - AggregateQuery, - compareFilters, - COMPARE_ALL_OPTIONS, - Filter, - Query, - TimeRange, -} from '@kbn/es-query'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { TimeRange } from '@kbn/es-query'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; -import deepEqual from 'fast-deep-equal'; -import { cloneDeep, identity, omit, pickBy } from 'lodash'; -import { - BehaviorSubject, - combineLatest, - distinctUntilChanged, - map, - startWith, - Subject, -} from 'rxjs'; +import { cloneDeep, omit } from 'lodash'; +import { Subject } from 'rxjs'; import { v4 } from 'uuid'; import { DashboardContainerInput, @@ -60,14 +35,11 @@ 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. @@ -162,16 +134,13 @@ 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: { @@ -191,7 +160,6 @@ export const initializeDashboard = async ({ searchSessionSettings, unifiedSearchSettings, validateLoadedSavedObject, - useControlGroupIntegration, useUnifiedSearchIntegration, useSessionStorageIntegration, } = creationOptions ?? {}; @@ -291,11 +259,6 @@ 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) { @@ -312,6 +275,7 @@ export const initializeDashboard = async ({ // -------------------------------------------------------------------------------------- untilDashboardReady().then((dashboard) => { dashboard.savedObjectReferences = loadDashboardReturn?.references; + dashboard.controlGroupInput = loadDashboardReturn?.dashboardInput?.controlGroupInput; }); // -------------------------------------------------------------------------------------- @@ -474,6 +438,13 @@ 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; @@ -481,52 +452,6 @@ 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. // -------------------------------------------------------------------------------------- @@ -552,63 +477,6 @@ 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. // -------------------------------------------------------------------------------------- @@ -629,7 +497,8 @@ export const initializeDashboard = async ({ sessionIdToRestore ?? (existingSession && incomingEmbeddable ? existingSession : session.start()); - untilDashboardReady().then((container) => { + untilDashboardReady().then(async (container) => { + await container.untilContainerInitialized(); 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 3fd4c0df233cf..9de483bfb0376 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, map, Observable, of, switchMap } from 'rxjs'; +import { combineLatest, Observable, of, switchMap } from 'rxjs'; import { pluginServices } from '../../../../services/plugin_services'; import { DashboardContainer } from '../../dashboard_container'; @@ -19,19 +19,11 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) { data: { dataViews }, } = pluginServices.getServices(); - 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 controlGroupDataViewsPipe: Observable = this.controlGroupApi$.pipe( + switchMap((controlGroupApi) => { + return controlGroupApi ? controlGroupApi.dataViews : of([]); + }) + ); const childDataViewsPipe = combineCompatibleChildrenApis( this, @@ -43,7 +35,10 @@ export function startSyncingDashboardDataViews(this: DashboardContainer) { return combineLatest([controlGroupDataViewsPipe, childDataViewsPipe]) .pipe( switchMap(([controlGroupDataViews, childDataViews]) => { - const allDataViews = controlGroupDataViews.concat(childDataViews); + const allDataViews = [ + ...(controlGroupDataViews ? controlGroupDataViews : []), + ...childDataViews, + ]; if (allDataViews.length === 0) { return (async () => { const defaultDataViewId = await dataViews.getDefaultId(); @@ -54,7 +49,6 @@ 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 ee2cc0dd961fd..b6d77ac9b7822 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,7 +18,12 @@ import { import type { TimeRange } from '@kbn/es-query'; import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks'; -import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks'; +import { + buildMockDashboard, + getSampleDashboardInput, + getSampleDashboardPanel, + mockControlGroupApi, +} from '../../mocks'; import { pluginServices } from '../../services/plugin_services'; import { DashboardContainer } from './dashboard_container'; @@ -170,6 +175,7 @@ test('searchSessionId propagates to children', async () => { undefined, { lastSavedInput: sampleInput } ); + container?.setControlGroupApi(mockControlGroupApi); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, @@ -189,11 +195,10 @@ 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({ - overrides: { - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, - }, + const container = buildMockDashboard(); + container.updateInput({ + timeRange: dashboardTimeRange, + timeslice: dashboardTimeslice, }); const embeddable = await container.addNewEmbeddable( CONTACT_CARD_EMBEDDABLE, @@ -214,11 +219,10 @@ describe('getInheritedInput', () => { }); test('Should not pass dashboard timeRange and timeslice to panel when panel has custom time range', async () => { - const container = buildMockDashboard({ - overrides: { - timeRange: dashboardTimeRange, - timeslice: dashboardTimeslice, - }, + const container = buildMockDashboard(); + container.updateInput({ + 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 585e0ff0b1ff6..d1409a3a4b02e 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -8,7 +8,6 @@ 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, @@ -16,6 +15,8 @@ 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'; @@ -32,7 +33,7 @@ import { type EmbeddableOutput, type IEmbeddable, } from '@kbn/embeddable-plugin/public'; -import type { Filter, Query, TimeRange } from '@kbn/es-query'; +import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { HasRuntimeChildState, @@ -40,6 +41,7 @@ import { HasSerializedChildState, TrackContentfulRender, TracksQueryPerformance, + combineCompatibleChildrenApis, } from '@kbn/presentation-containers'; import { PanelPackage } from '@kbn/presentation-containers'; import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; @@ -50,14 +52,18 @@ import { omit } from 'lodash'; import React, { createContext, useContext } from 'react'; import ReactDOM from 'react-dom'; import { batch } from 'react-redux'; -import { BehaviorSubject, Subject, Subscription } from 'rxjs'; +import { BehaviorSubject, Subject, Subscription, first, skipWhile, switchMap } 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 { DashboardContainerInput, DashboardPanelState } from '../../../common'; -import { getReferencesForPanelId } from '../../../common/dashboard_container/persistable_state/dashboard_container_references'; +import { DashboardAttributes, DashboardContainerInput, DashboardPanelState } from '../../../common'; +import { + getReferencesForControls, + getReferencesForPanelId, +} from '../../../common/dashboard_container/persistable_state/dashboard_container_references'; import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID, @@ -84,7 +90,10 @@ import { showSettings, } from './api'; import { duplicateDashboardPanel } from './api/duplicate_dashboard_panel'; -import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration'; +import { + combineDashboardFiltersWithControlGroupFilters, + startSyncingDashboardControlGroup, +} from './create/controls/dashboard_control_group_integration'; import { initializeDashboard } from './create/create_dashboard'; import { DashboardCreationOptions, @@ -92,6 +101,7 @@ 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[]; @@ -147,7 +157,7 @@ export class DashboardContainer public integrationSubscriptions: Subscription = new Subscription(); public publishingSubscription: Subscription = new Subscription(); public diffingSubscription: Subscription = new Subscription(); - public controlGroup?: ControlGroupContainer; + public controlGroupApi$: PublishingSubject; public settings: Record>; public searchSessionId?: string; @@ -156,6 +166,7 @@ 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; @@ -172,6 +183,9 @@ export class DashboardContainer private hadContentfulRender = false; private scrollPosition?: number; + // setup + public untilContainerInitialized: () => Promise; + // cleanup public stopSyncingWithUnifiedSearch?: () => void; private cleanupStateTools: () => void; @@ -197,6 +211,7 @@ export class DashboardContainer | undefined; // new embeddable framework public savedObjectReferences: Reference[] = []; + public controlGroupInput: DashboardAttributes['controlGroupInput'] | undefined; constructor( initialInput: DashboardContainerInput, @@ -207,19 +222,43 @@ 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 + parent, + { + untilContainerInitialized, + } ); + this.controlGroupApi$ = controlGroupApi$; + this.untilContainerInitialized = untilContainerInitialized; + this.trackPanelAddMetric = usageCollection.reportUiCounter?.bind( usageCollection, DASHBOARD_UI_METRIC_ID @@ -311,7 +350,41 @@ 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() { @@ -397,10 +470,10 @@ export class DashboardContainer panels, } = this.input; - let combinedFilters = filters; - if (this.controlGroup) { - combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup); - } + const combinedFilters = combineDashboardFiltersWithControlGroupFilters( + filters, + this.controlGroupApi$?.value + ); const hasCustomTimeRange = Boolean( (panels[id]?.explicitInput as Partial)?.timeRange ); @@ -429,7 +502,6 @@ export class DashboardContainer public destroy() { super.destroy(); this.cleanupStateTools(); - this.controlGroup?.destroy(); this.diffingSubscription.unsubscribe(); this.publishingSubscription.unsubscribe(); this.integrationSubscriptions.unsubscribe(); @@ -615,16 +687,12 @@ 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 onDataViewsUpdate$ = new Subject(); - - public resetToLastSavedState() { + public async asyncResetToLastSavedState() { this.dispatch.resetToLastSavedInput({}); const { explicitInput: { timeRange, refreshInterval }, @@ -633,8 +701,8 @@ export class DashboardContainer }, } = this.getState(); - if (this.controlGroup) { - this.controlGroup.resetToLastSavedState(); + if (this.controlGroupApi$.value) { + await this.controlGroupApi$.value.asyncResetUnsavedChanges(); } // if we are using the unified search integration, we need to force reset the time picker. @@ -679,7 +747,6 @@ export class DashboardContainer const initializeResult = await initializeDashboard({ creationOptions: this.creationOptions, - controlGroup: this.controlGroup, untilDashboardReady, loadDashboardReturn, }); @@ -694,9 +761,6 @@ 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); @@ -720,7 +784,7 @@ export class DashboardContainer */ public setAllDataViews = (newDataViews: DataView[]) => { this.allDataViews = newDataViews; - this.onDataViewsUpdate$.next(newDataViews); + (this.dataViews as BehaviorSubject).next(newDataViews); }; public getExpandedPanelId = () => { @@ -743,7 +807,6 @@ export class DashboardContainer public clearOverlays = () => { this.dispatch.setHasOverlays(false); this.dispatch.setFocusedPanelId(undefined); - this.controlGroup?.closeAllFlyouts(); this.overlayRef?.close(); }; @@ -848,6 +911,22 @@ 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 ?? {}; @@ -858,6 +937,10 @@ 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 89f71c074d9fd..6d73e59856e28 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,11 +5,10 @@ * 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, Observable, of, startWith, switchMap } from 'rxjs'; +import { combineLatest, debounceTime, skipWhile, startWith, switchMap } from 'rxjs'; import { DashboardContainer, DashboardCreationOptions } from '../..'; import { DashboardContainerInput } from '../../../../common'; import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; @@ -17,6 +16,7 @@ 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,8 +111,12 @@ export function startDiffingDashboardState( combineLatest([ dashboardUnsavedChanges, childrenUnsavedChanges$(this.children$), - this.controlGroup?.unsavedChanges ?? - (of(undefined) as Observable), + this.controlGroupApi$.pipe( + skipWhile((controlGroupApi) => !controlGroupApi), + switchMap((controlGroupApi) => { + return controlGroupApi!.unsavedChanges; + }) + ), ]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => { // calculate unsaved changes const hasUnsavedChanges = @@ -125,11 +129,11 @@ export function startDiffingDashboardState( // backup unsaved changes if configured to do so if (creationOptions?.useSessionStorageIntegration) { - backupUnsavedChanges.bind(this)( - dashboardChanges, - unsavedPanelState ? unsavedPanelState : {}, - controlGroupChanges - ); + const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {}; + if (controlGroupChanges) { + reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges; + } + backupUnsavedChanges.bind(this)(dashboardChanges, reactEmbeddableChanges); } }) ); @@ -181,8 +185,7 @@ export async function getDashboardUnsavedChanges( function backupUnsavedChanges( this: DashboardContainer, dashboardChanges: Partial, - reactEmbeddableChanges: UnsavedPanelState, - controlGroupChanges: PersistableControlGroupInput | undefined + reactEmbeddableChanges: UnsavedPanelState ) { const { dashboardBackup } = pluginServices.getServices(); const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage); @@ -192,7 +195,6 @@ 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 c2c7cfb8aa083..f3ca588aa20b1 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 input + * Control group changes */ - controlGroupInput?: SerializableControlGroupInput; + controlGroupState?: Partial & SerializableRecord; // used SerializableRecord here to force the GridData type to be read as serializable }; 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 5f6edc138aa13..86682acb4287f 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,7 +15,6 @@ 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, @@ -29,6 +28,7 @@ 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,16 +113,8 @@ 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); - useEffect(() => { - setAllDataViews(dashboard.getAllDataViews()); - const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) => - setAllDataViews(dataViews) - ); - return () => subscription.unsubscribe(); - }, [dashboard]); + const allDataViews = useStateFromPublishingSubject(dashboard.dataViews); const dashboardTitle = useMemo(() => { return getDashboardTitle(title, viewMode, !lastSavedId); @@ -411,7 +403,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 d447015b2b1a6..4a75d1a08b996 100644 --- a/src/plugins/dashboard/public/mocks.tsx +++ b/src/plugins/dashboard/public/mocks.tsx @@ -9,6 +9,8 @@ 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'; @@ -72,6 +74,15 @@ 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, @@ -89,6 +100,7 @@ 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 f97d88fd1c4fe..54486ece0970a 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,6 +23,7 @@ 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'; @@ -112,6 +113,7 @@ 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 2b2835e2a2420..eccd68b6952c0 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,8 +56,15 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen contentManagement, savedObjectsTagging, }), - saveDashboardState: ({ currentState, saveOptions, lastSavedId, panelReferences }) => + saveDashboardState: ({ + controlGroupReferences, + 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 cabd1542efbb2..55ee72c5abbef 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,7 +12,6 @@ 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 { @@ -187,9 +186,7 @@ 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 && - rawControlGroupAttributesToControlGroupInput(attributes.controlGroupInput), + controlGroupInput: 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 1878344b630fc..0487f14e699c6 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,9 +6,6 @@ * 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'; @@ -32,23 +29,6 @@ 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(() => ({ @@ -62,11 +42,8 @@ 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(7); // should be called 4 times for the panels, and 3 times for the controls + expect(embeddableService.getEmbeddableFactory).toHaveBeenCalledTimes(4); // 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 46e57588a2c95..70a6df30303dd 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,7 +6,6 @@ * Side Public License, v 1. */ -import { ControlGroupInput } from '@kbn/controls-plugin/common'; import { EmbeddableFactoryNotFoundError, runEmbeddableFactoryMigrations, @@ -31,28 +30,7 @@ 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 c69f7fa065a7b..94ebcd0702f2c 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,12 +9,6 @@ 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'; @@ -29,24 +23,10 @@ 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]'); @@ -68,6 +48,7 @@ type SaveDashboardStateProps = SaveDashboardProps & { }; export const saveDashboardState = async ({ + controlGroupReferences, data, embeddable, lastSavedId, @@ -100,9 +81,10 @@ export const saveDashboardState = async ({ syncCursor, syncTooltips, hidePanelTitles, + controlGroupInput, } = currentState; - let { panels, controlGroupInput } = currentState; + let { panels } = currentState; let prefixedPanelReferences = panelReferences; if (saveOptions.saveAsCopy) { const { panels: newPanels, references: newPanelReferences } = generateNewPanelIds( @@ -111,7 +93,10 @@ export const saveDashboardState = async ({ ); panels = newPanels; prefixedPanelReferences = newPanelReferences; - controlGroupInput = generateNewControlIds(controlGroupInput); + // + // do not need to generate new ids for controls. + // ControlGroup Component is keyed on dashboard id so changing dashboard id mounts new ControlGroup Component. + // } /** @@ -159,7 +144,7 @@ export const saveDashboardState = async ({ const rawDashboardAttributes: DashboardAttributes = { version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION), - controlGroupInput: serializeControlGroupInput(controlGroupInput), + controlGroupInput, kibanaSavedObjectMeta: { searchSourceJSON }, description: description ?? '', refreshInterval, @@ -186,7 +171,11 @@ export const saveDashboardState = async ({ ? savedObjectsTagging.updateTagsReferences(dashboardReferences, tags) : dashboardReferences; - const allReferences = [...references, ...(prefixedPanelReferences ?? [])]; + const allReferences = [ + ...references, + ...(prefixedPanelReferences ?? []), + ...(controlGroupReferences ?? []), + ]; /** * 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 ac8b921672e2d..3caa5f73e65b2 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 { DashboardCrudTypes } from '../../../common/content_management'; +import { DashboardAttributes, DashboardCrudTypes } from '../../../common/content_management'; import { DashboardStartDependencies } from '../../plugin'; import { DashboardBackupServiceType } from '../dashboard_backup/types'; import { DashboardDataService } from '../data/types'; @@ -64,7 +64,17 @@ export interface LoadDashboardFromSavedObjectProps { type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta']; export type SavedDashboardInput = DashboardContainerInput & { - controlGroupInput?: PersistableControlGroupInput; + /** + * 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; }; export interface LoadDashboardReturn { @@ -89,6 +99,7 @@ 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 cac385dd2c86d..e4ce579104bb5 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -82,6 +82,9 @@ 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 53226e7d15146..5ee9b0a250adc 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -37,6 +37,8 @@ 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 57cfe3350420a..e34fc02acd6be 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,7 +51,11 @@ export const embeddableInputToSubject = < subscription.add( embeddable .getInput$() - .pipe(distinctUntilKeyChanged(key)) + .pipe( + distinctUntilKeyChanged(key, (prev, current) => { + return deepEqual(prev, current); + }) + ) .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 16b41ec9cc23c..79e7b5b99bc60 100644 --- a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts @@ -58,16 +58,19 @@ 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 683d6a6e7cc22..f0e4cce0c8adb 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,7 +6,6 @@ * 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'; @@ -14,77 +13,25 @@ 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, timePicker } = getPageObjects([ + const { dashboard, header, dashboardControls } = getPageObjects([ 'dashboardControls', - 'timePicker', 'dashboard', 'header', ]); describe('Dashboard control group apply button', () => { - let controlIds: string[]; + const optionsListId = '41827e70-5285-4d44-8375-4c498449b9a7'; + const rangeSliderId = '515e7b9f-4f1b-4a06-beec-763810e4951a'; before(async () => { await dashboard.navigateToApp(); - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - + await dashboard.loadSavedDashboard('Test Control Group Apply Button'); + await dashboard.switchToEditMode(); 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); @@ -101,14 +48,7 @@ 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); @@ -117,7 +57,6 @@ 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(); @@ -139,27 +78,19 @@ 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(); @@ -180,8 +111,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(''); @@ -199,7 +130,6 @@ 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(); @@ -207,7 +137,6 @@ 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(); @@ -226,8 +155,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 5a07e60d45695..f20052add7243 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,7 +6,6 @@ * 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'; @@ -17,17 +16,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const filterBar = getService('filterBar'); const testSubjects = getService('testSubjects'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const { common, dashboard, dashboardControls } = getPageObjects([ + const { dashboard, dashboardControls } = getPageObjects([ 'dashboardControls', 'dashboard', 'console', - 'common', 'header', ]); describe('Dashboard control group with multiple data views', () => { - let controlIds: string[]; + // 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), + ]); + } before(async () => { await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']); @@ -39,50 +50,12 @@ 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 () => { @@ -93,96 +66,169 @@ 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'); }); - it('ignores global filters on controls using a data view without the filter field', async () => { - await filterBar.addFilter({ field: 'Carrier', operation: 'exists' }); - - 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('5'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); - - await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979'); - }); - - 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 dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196'); - - await dashboardControls.optionsListOpenPopover(controlIds[2]); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('5'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + describe('courier:ignoreFilterIfFieldNotInIndex enabled', () => { + before(async () => { + await kibanaServer.uiSettings.replace({ + 'courier:ignoreFilterIfFieldNotInIndex': true, + }); - await dashboardControls.validateRange('placeholder', controlIds[3], '0', '19979'); - - 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 dashboard.navigateToApp(); + await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views'); + }); - await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1200'); + after(async () => { + await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex'); + }); - await dashboardControls.optionsListOpenPopover(controlIds[2]); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0'); - 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 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.validateRange('placeholder', controlIds[3], '0', '0'); + 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'); + }); + }); }); - 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]); + describe('courier:ignoreFilterIfFieldNotInIndex disabled', () => { + before(async () => { + await kibanaServer.uiSettings.replace({ + 'courier:ignoreFilterIfFieldNotInIndex': false, + }); - await dashboardControls.validateRange('placeholder', controlIds[1], '100', '1196'); + await dashboard.navigateToApp(); + await dashboard.loadSavedDashboard('Test Control Group With Multiple Data Views'); + }); - await dashboardControls.optionsListOpenPopover(controlIds[2]); - expect(await dashboardControls.optionsListGetCardinalityValue()).to.be('0'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + after(async () => { + await kibanaServer.uiSettings.unset('courier:ignoreFilterIfFieldNotInIndex'); + }); - await dashboardControls.validateRange('placeholder', controlIds[3], '0', '0'); + 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' + ); + }); + }); - const logstashSavedSearchPanel = await testSubjects.find('embeddedSavedSearchDocTable'); - expect( - await ( - await logstashSavedSearchPanel.findByCssSelector('[data-document-number]') - ).getAttribute('data-document-number') - ).to.be('0'); + 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'); + }); + }); }); }); } 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 22980eb6423a2..5d0199fc248e4 100644 --- a/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts +++ b/test/functional/apps/dashboard_elements/controls/common/replace_controls.ts @@ -35,15 +35,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const replaceWithOptionsList = async (controlId: string, field: string) => { await changeFieldType(controlId, field, OPTIONS_LIST_CONTROL); - await testSubjects.waitForEnabled(`optionsList-control-${controlId}`); - await dashboardControls.verifyControlType(controlId, 'optionsList-control'); + const newControlId: string = (await dashboardControls.getAllControlIds())[0]; + await testSubjects.waitForEnabled(`optionsList-control-${newControlId}`); + await dashboardControls.verifyControlType(newControlId, 'optionsList-control'); }; const replaceWithRangeSlider = async (controlId: string, field: string) => { await changeFieldType(controlId, field, RANGE_SLIDER_CONTROL); await retry.try(async () => { - await dashboardControls.rangeSliderWaitForLoading(controlId); - await dashboardControls.verifyControlType(controlId, 'range-slider-control'); + const newControlId: string = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.rangeSliderWaitForLoading(newControlId); + await dashboardControls.verifyControlType(newControlId, 'range-slider-control'); }); }; @@ -68,7 +70,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Replace options list', () => { beforeEach(async () => { - await dashboardControls.clearAllControls(); await dashboardControls.createControl({ controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', @@ -78,7 +79,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await dashboard.clearUnsavedChanges(); + await dashboardControls.clearAllControls(); }); it('with range slider - default title', async () => { @@ -100,7 +101,6 @@ 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 dashboard.clearUnsavedChanges(); + await dashboardControls.clearAllControls(); }); 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 220b9819f4466..87d754b053301 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,11 +17,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const { dashboardControls, dashboard, header } = getPageObjects([ 'dashboardControls', - 'timePicker', 'dashboard', - 'settings', - 'console', - 'common', 'header', ]); @@ -52,6 +48,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboard.clickDiscardChanges(); }); it('sort alphabetically - descending', async () => { @@ -133,12 +130,6 @@ 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 fa4322963381c..bff1e069b2ff0 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,7 +9,6 @@ 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'; @@ -18,8 +17,6 @@ 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', @@ -32,41 +29,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); describe('Dashboard options list validation', () => { - let controlId: string; + const controlId = 'cd881630-fd28-4e9c-aec5-ae9711d48369'; 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 () => { - await dashboardControls.clearControlSelections(controlId); + // Instead of reset, filter must be manually deleted to avoid + // https://github.com/elastic/kibana/issues/191675 await filterBar.removeAllFilters(); - await queryBar.clickQuerySubmitButton(); }); it('Can mark selections invalid with Query', async () => { @@ -118,13 +92,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 a89dcf714dfc3..c4c2b4ab2d025 100644 --- a/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/kibana.json @@ -3225,3 +3225,108 @@ "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 new file mode 100644 index 0000000000000..7a5de78d372aa --- /dev/null +++ b/test/functional/fixtures/kbn_archiver/dashboard/current/multi_data_view_kibana.json @@ -0,0 +1,64 @@ +{ + "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 a3573438124e5..dcc43432dab28 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -475,7 +475,11 @@ export class DashboardPageControls extends FtrService { await this.optionsListWaitForLoading(controlId); if (!skipOpen) await this.optionsListOpenPopover(controlId); await this.retry.try(async () => { - expect(await this.optionsListPopoverGetAvailableOptions()).to.eql(expectation); + const availableOptions = await this.optionsListPopoverGetAvailableOptions(); + expect(availableOptions.suggestions).to.eql(expectation.suggestions); + expect(availableOptions.invalidSelections.sort()).to.eql( + expectation.invalidSelections.sort() + ); }); if (await this.testSubjects.exists('optionsList-cardinality-label')) { expect(await this.optionsListGetCardinalityValue()).to.be( @@ -496,7 +500,9 @@ 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); + await this.testSubjects.setValue(`optionsList-control-search-input`, search, { + typeCharByChar: true, + }); 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 752e52aa27c4a..6f2c91dec9bfe 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,8 +16,7 @@ import { import { DataView } from '@kbn/data-views-plugin/common'; import { buildExistsFilter, buildPhraseFilter, Filter } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; -import { controlGroupInputBuilder } from '@kbn/controls-plugin/public'; -import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common'; +import { controlGroupStateBuilder } from '@kbn/controls-plugin/public'; import { NotificationsStart } from '@kbn/core/public'; import { ENVIRONMENT_ALL, @@ -71,10 +70,9 @@ async function getCreationOptions( dataView: DataView ): Promise { try { - const builder = controlGroupInputBuilder; - const controlGroupInput = getDefaultControlGroupInput(); + const controlGroupState = {}; - await builder.addDataControlFromField(controlGroupInput, { + await controlGroupStateBuilder.addDataControlFromField(controlGroupState, { dataViewId: dataView.id ?? '', title: 'Node name', fieldName: 'service.node.name', @@ -92,7 +90,7 @@ async function getCreationOptions( getInitialInput: () => ({ viewMode: ViewMode.VIEW, panels, - controlGroupInput, + controlGroupState, }), }; } catch (error) {