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 77054a5f6c1cf..4b4dd02dd4a4b 100644 --- a/src/platform/plugins/shared/controls/public/control_group/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/types.ts @@ -69,8 +69,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; diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/data_control_manager.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/data_control_manager.ts index 55bf49b7580d1..6f2917a60ea37 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/data_control_manager.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/data_control_manager.ts @@ -7,16 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { - BehaviorSubject, - Observable, - combineLatest, - debounceTime, - first, - skip, - switchMap, - tap, -} from 'rxjs'; +import type { Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, first, skip, switchMap, tap } from 'rxjs'; import { DATA_VIEW_SAVED_OBJECT_TYPE, @@ -186,7 +178,7 @@ export const initializeDataControlManager = ( }); }; - 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 3d8bf464396f1..9e06a60d365d0 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 { getMockedControlGroupApi, getMockedFinalizeApi } from '../../mocks/control_mocks'; import { getOptionsListControlFactory } from './get_options_list_control_factory'; @@ -24,7 +22,7 @@ describe('Options List Control Api', () => { const factory = getOptionsListControlFactory(); const finalizeApi = getMockedFinalizeApi(uuid, factory, controlGroupApi); - 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}`); } @@ -54,9 +52,75 @@ 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; + }); }); 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({ initialState: { @@ -167,6 +231,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 9fd595436fb75..fa8f610416256 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 @@ -232,15 +232,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 index 721758ec05fbf..bee0b85706f93 100644 --- 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 @@ -25,7 +25,7 @@ export function initializeControlGroupManager( .pipe( skipWhile((controlGroupApi) => !controlGroupApi), switchMap(async (controlGroupApi) => { - await controlGroupApi?.untilInitialized(); + await controlGroupApi?.untilFiltersPublished(); }), first() ) diff --git a/src/platform/plugins/shared/dashboard/public/mocks.tsx b/src/platform/plugins/shared/dashboard/public/mocks.tsx index c30a0f544699c..07178a1af5a10 100644 --- a/src/platform/plugins/shared/dashboard/public/mocks.tsx +++ b/src/platform/plugins/shared/dashboard/public/mocks.tsx @@ -69,6 +69,7 @@ export function setupIntersectionObserverMock({ export const mockControlGroupApi = { untilInitialized: async () => {}, + untilFiltersPublished: async () => {}, filters$: new BehaviorSubject(undefined), query$: new BehaviorSubject(undefined), timeslice$: new BehaviorSubject(undefined),