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 6cd6e1788aa29..2cdcf12996c61 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 98a850335f2fd..04b0fc6d781b1 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 @@ -8,7 +8,7 @@ */ import type { Subscription } from 'rxjs'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest, first } from 'rxjs'; import deepEqual from 'fast-deep-equal'; import type { Filter } from '@kbn/es-query'; import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; @@ -25,6 +25,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[] = []; @@ -93,6 +102,7 @@ export function initSelectionsManager( } }) ); + filtersPublished.next(true); }); function applySelections() { @@ -108,6 +118,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 e1269ac439dcb..76813743bfacf 100644 --- a/src/platform/plugins/shared/controls/public/control_group/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/types.ts @@ -71,8 +71,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 3cdc810c8b6ec..1b4c17677ca31 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 @@ -8,7 +8,7 @@ */ import type { Observable } from 'rxjs'; -import { BehaviorSubject, combineLatest, debounceTime, first, skip, switchMap, tap } from 'rxjs'; +import { BehaviorSubject, combineLatest, first, skip, switchMap, tap } from 'rxjs'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; @@ -175,7 +175,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 2de7cc8acec91..aa426133c0760 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,14 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; - +import { EuiThemeProvider } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/common'; import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; import { render as rtlRender, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { EuiThemeProvider } from '@elastic/eui'; - +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'; @@ -29,7 +27,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}`); } @@ -59,9 +57,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: { @@ -172,6 +236,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 5092b9e1283da..b845db16c497b 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 @@ -235,15 +235,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 6d032c9c1bbcc..27c7eeadb8a89 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 @@ -26,7 +26,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 124487489d0c4..0884bce3622c6 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),