diff --git a/src/platform/plugins/shared/controls/public/control_group/selections_manager.test.ts b/src/platform/plugins/shared/controls/public/control_group/selections_manager.test.ts index af40c4b4103bf..a6523565dc81a 100644 --- a/src/platform/plugins/shared/controls/public/control_group/selections_manager.test.ts +++ b/src/platform/plugins/shared/controls/public/control_group/selections_manager.test.ts @@ -28,28 +28,29 @@ describe('selections manager', () => { timeslice$?: BehaviorSubject<[number, number] | undefined>; }; }>({}); + const setupControlChildren = () => { + control1Api.filters$.next(undefined); + control2Api.filters$.next([ + { + meta: { + alias: 'control2 original filter', + }, + }, + ]); + control3Api.timeslice$.next([ + Date.parse('2024-06-09T06:00:00.000Z'), + Date.parse('2024-06-09T12:00:00.000Z'), + ]); + controlGroupApi.children$.next({ + control1: control1Api, + control2: control2Api, + control3: control3Api, + }); + }; const controlGroupApi = { autoApplySelections$: new BehaviorSubject(false), children$, - untilInitialized: async () => { - control1Api.filters$.next(undefined); - control2Api.filters$.next([ - { - meta: { - alias: 'control2 original filter', - }, - }, - ]); - control3Api.timeslice$.next([ - Date.parse('2024-06-09T06:00:00.000Z'), - Date.parse('2024-06-09T12:00:00.000Z'), - ]); - controlGroupApi.children$.next({ - control1: control1Api, - control2: control2Api, - control3: control3Api, - }); - }, + untilInitialized: async () => setupControlChildren(), }; const onFireMock = jest.fn(); @@ -58,6 +59,51 @@ describe('selections manager', () => { controlGroupApi.children$.next({}); }); + describe('initialization', () => { + it('should wait for all child apis to be available before publishing initial filters', async () => { + let resolveChildren: (() => void) | undefined; + const childrenResolvePromise = new Promise((r) => (resolveChildren = r)); + + const slowControlGroupApi = { + ...controlGroupApi, + untilInitialized: async () => { + await childrenResolvePromise; + return setupControlChildren(); + }, + }; + const selectionsManager = initSelectionsManager( + slowControlGroupApi as unknown as Pick< + ControlGroupApi, + 'autoApplySelections$' | 'children$' | 'untilInitialized' + > + ); + + // instrumentation to tell when filters are published. + let filtersPublished = false; + (async () => { + await selectionsManager.api.untilFiltersPublished(); + filtersPublished = true; + })(); + + // filters should not have been published yet even after waiting 5 ms. + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(selectionsManager.api.filters$.value).toEqual([]); // default empty state for filters + expect(filtersPublished).toBe(false); + + // resolve children and ensure initial filters are reported + resolveChildren?.(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(filtersPublished).toBe(true); + expect(selectionsManager.api.filters$.value).toEqual([ + { + meta: { + alias: 'control2 original filter', + }, + }, + ]); + }); + }); + describe('auto apply selections disabled', () => { beforeEach(() => { controlGroupApi.autoApplySelections$.next(false); diff --git a/src/platform/plugins/shared/controls/public/control_group/selections_manager.ts b/src/platform/plugins/shared/controls/public/control_group/selections_manager.ts index 66335142c5b5e..26966fb56ad40 100644 --- a/src/platform/plugins/shared/controls/public/control_group/selections_manager.ts +++ b/src/platform/plugins/shared/controls/public/control_group/selections_manager.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; +import type { Subscription } from 'rxjs'; +import { BehaviorSubject, combineLatest, first } from 'rxjs'; import deepEqual from 'fast-deep-equal'; import { Filter } from '@kbn/es-query'; import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; @@ -28,6 +29,15 @@ export function initSelectionsManager( const unpublishedTimeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined); const hasUnappliedSelections$ = new BehaviorSubject(false); + const filtersPublished = new BehaviorSubject(false); + const untilFiltersPublished = () => + new Promise((resolve) => { + filtersPublished.pipe(first((isComplete) => isComplete)).subscribe(() => { + resolve(); + filtersPublished.complete(); + }); + }); + const subscriptions: Subscription[] = []; controlGroupApi.untilInitialized().then(() => { const initialFilters: Filter[] = []; @@ -96,6 +106,7 @@ export function initSelectionsManager( } }) ); + filtersPublished.next(true); }); function applySelections() { @@ -111,6 +122,7 @@ export function initSelectionsManager( api: { filters$, timeslice$, + untilFiltersPublished, }, applySelections, cleanup: () => { diff --git a/src/platform/plugins/shared/controls/public/control_group/types.ts b/src/platform/plugins/shared/controls/public/control_group/types.ts index 391314b285f13..d46aa3c52201e 100644 --- a/src/platform/plugins/shared/controls/public/control_group/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/types.ts @@ -70,8 +70,16 @@ export type ControlGroupApi = PresentationContainer & controlStateTransform?: ControlStateTransform; onSave?: () => void; }) => void; + /** + * @returns a promise which is resolved when all controls children have finished initializing. + */ untilInitialized: () => Promise; + /** + * @returns a promise which is resolved when all initial selections have been initialized and published. + */ + untilFiltersPublished: () => Promise; + /** Public getters */ getEditorConfig: () => ControlGroupEditorConfig | undefined; getLastSavedControlState: (controlUuid: string) => object; diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts index 9234f321b80b6..a62885aa7f485 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts @@ -7,9 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { isEqual } from 'lodash'; -import { BehaviorSubject, combineLatest, debounceTime, first, skip, switchMap, tap } from 'rxjs'; - +import { BehaviorSubject, combineLatest, first, skip, switchMap, tap } from 'rxjs'; import { DATA_VIEW_SAVED_OBJECT_TYPE, DataView, @@ -180,7 +178,7 @@ export const initializeDataControl = ( }); }; - const filtersReadySubscription = filters$.pipe(skip(1), debounceTime(0)).subscribe(() => { + const filtersReadySubscription = filters$.pipe(skip(1)).subscribe(() => { // Set filtersReady$.next(true); in filters$ subscription instead of setOutputFilter // to avoid signaling filters ready until after filters have been emitted // to avoid timing issues diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx index cf5539a0a2680..f205d53b97c06 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx @@ -7,13 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; - -import { DataView } from '@kbn/data-views-plugin/common'; +import type { DataView } from '@kbn/data-views-plugin/common'; import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - +import React from 'react'; import { coreServices, dataViewsService } from '../../../services/kibana_services'; import { getMockedBuildApi, getMockedControlGroupApi } from '../../mocks/control_mocks'; import * as initializeControl from '../initialize_data_control'; @@ -23,7 +21,7 @@ describe('Options List Control Api', () => { const uuid = 'myControl1'; const controlGroupApi = getMockedControlGroupApi(); - dataViewsService.get = jest.fn().mockImplementation(async (id: string): Promise => { + const getDataView = async (id: string): Promise => { if (id !== 'myDataViewId') { throw new Error(`Simulated error: no data view found for id ${id}`); } @@ -53,11 +51,77 @@ describe('Options List Control Api', () => { }; }); return stubDataView; + }; + + describe('initialization', () => { + let dataviewDelayPromise: Promise | undefined; + + beforeAll(() => { + dataViewsService.get = jest.fn().mockImplementation(async (id: string) => { + if (dataviewDelayPromise) await dataviewDelayPromise; + return getDataView(id); + }); + }); + + it('returns api immediately when no initial selections are configured', async () => { + let resolveDataView: (() => void) | undefined; + let apiReturned = false; + dataviewDelayPromise = new Promise((res) => (resolveDataView = res)); + (async () => { + await factory.buildControl({ + initialState: { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + }, + finalizeApi, + uuid, + controlGroupApi, + }); + apiReturned = true; + })(); + await new Promise((r) => setTimeout(r, 1)); + expect(apiReturned).toBe(true); + resolveDataView?.(); + dataviewDelayPromise = undefined; + }); + + it('waits until data view is available before returning api when initial selections are configured', async () => { + let resolveDataView: (() => void) | undefined; + let apiReturned = false; + dataviewDelayPromise = new Promise((res) => (resolveDataView = res)); + (async () => { + await factory.buildControl({ + initialState: { + dataViewId: 'myDataViewId', + fieldName: 'myFieldName', + selectedOptions: ['cool', 'test'], + }, + finalizeApi, + uuid, + controlGroupApi, + }); + apiReturned = true; + })(); + + // even after 10ms the API should not have returned yet because the data view was not available + await new Promise((r) => setTimeout(r, 10)); + expect(apiReturned).toBe(false); + + // resolve the data view and ensure the api returns + resolveDataView?.(); + await new Promise((r) => setTimeout(r, 10)); + expect(apiReturned).toBe(true); + dataviewDelayPromise = undefined; + }); }); const factory = getOptionsListControlFactory(); describe('filters$', () => { + beforeAll(() => { + dataViewsService.get = jest.fn().mockImplementation(getDataView); + }); + test('should not set filters$ when selectedOptions is not provided', async () => { const { api } = await factory.buildControl( { @@ -168,6 +232,7 @@ describe('Options List Control Api', () => { describe('make selection', () => { beforeAll(() => { + dataViewsService.get = jest.fn().mockImplementation(getDataView); coreServices.http.fetch = jest.fn().mockResolvedValue({ suggestions: [ { value: 'woof', docCount: 10 }, diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 8a6326203c3b3..1df1d46abaafb 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -248,15 +248,14 @@ export const getOptionsListControlFactory = (): DataControlFactory< const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; let newFilter: Filter | undefined; - if (dataView && field) { - if (existsSelected) { - newFilter = buildExistsFilter(field, dataView); - } else if (selectedOptions && selectedOptions.length > 0) { - newFilter = - selectedOptions.length === 1 - ? buildPhraseFilter(field, selectedOptions[0], dataView) - : buildPhrasesFilter(field, selectedOptions, dataView); - } + if (!dataView || !field) return; + if (existsSelected) { + newFilter = buildExistsFilter(field, dataView); + } else if (selectedOptions && selectedOptions.length > 0) { + newFilter = + selectedOptions.length === 1 + ? buildPhraseFilter(field, selectedOptions[0], dataView) + : buildPhrasesFilter(field, selectedOptions, dataView); } if (newFilter) { newFilter.meta.key = field?.name; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/control_group_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/control_group_manager.ts new file mode 100644 index 0000000000000..27c7eeadb8a89 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/control_group_manager.ts @@ -0,0 +1,75 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Reference } from '@kbn/content-management-utils'; +import type { ControlsGroupState } from '@kbn/controls-schemas'; +import type { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { BehaviorSubject, first, skipWhile, switchMap } from 'rxjs'; + +export const CONTROL_GROUP_EMBEDDABLE_ID = 'CONTROL_GROUP_EMBEDDABLE_ID'; + +export function initializeControlGroupManager( + initialState: ControlsGroupState | undefined, + getReferences: (id: string) => Reference[] +) { + const controlGroupApi$ = new BehaviorSubject(undefined); + + async function untilControlsInitialized(): Promise { + return new Promise((resolve) => { + controlGroupApi$ + .pipe( + skipWhile((controlGroupApi) => !controlGroupApi), + switchMap(async (controlGroupApi) => { + await controlGroupApi?.untilFiltersPublished(); + }), + first() + ) + .subscribe(() => { + resolve(); + }); + }); + } + + return { + api: { + controlGroupApi$, + }, + internalApi: { + getStateForControlGroup: () => { + return { + rawState: initialState + ? initialState + : ({ + autoApplySelections: true, + chainingSystem: 'HIERARCHICAL', + controls: [], + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + labelPosition: 'oneLine', + } as ControlsGroupState), + references: getReferences(CONTROL_GROUP_EMBEDDABLE_ID), + }; + }, + serializeControlGroup: () => { + const serializedState = controlGroupApi$.value?.serializeState(); + return { + controlGroupInput: serializedState?.rawState, + controlGroupReferences: serializedState?.references ?? [], + }; + }, + setControlGroupApi: (controlGroupApi: ControlGroupApi) => + controlGroupApi$.next(controlGroupApi), + untilControlsInitialized, + }, + }; +} diff --git a/src/platform/plugins/shared/dashboard/public/mocks.tsx b/src/platform/plugins/shared/dashboard/public/mocks.tsx index 1b54ecf8af273..03c27918168de 100644 --- a/src/platform/plugins/shared/dashboard/public/mocks.tsx +++ b/src/platform/plugins/shared/dashboard/public/mocks.tsx @@ -70,6 +70,7 @@ export function setupIntersectionObserverMock({ export const mockControlGroupApi = { untilInitialized: async () => {}, + untilFiltersPublished: async () => {}, filters$: new BehaviorSubject(undefined), query$: new BehaviorSubject(undefined), timeslice$: new BehaviorSubject(undefined),