Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<void>((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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +29,15 @@ export function initSelectionsManager(
const unpublishedTimeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined);
const hasUnappliedSelections$ = new BehaviorSubject(false);

const filtersPublished = new BehaviorSubject<boolean>(false);
const untilFiltersPublished = () =>
new Promise<void>((resolve) => {
filtersPublished.pipe(first((isComplete) => isComplete)).subscribe(() => {
resolve();
filtersPublished.complete();
});
});

const subscriptions: Subscription[] = [];
controlGroupApi.untilInitialized().then(() => {
const initialFilters: Filter[] = [];
Expand Down Expand Up @@ -96,6 +106,7 @@ export function initSelectionsManager(
}
})
);
filtersPublished.next(true);
});

function applySelections() {
Expand All @@ -111,6 +122,7 @@ export function initSelectionsManager(
api: {
filters$,
timeslice$,
untilFiltersPublished,
},
applySelections,
cleanup: () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

/**
* @returns a promise which is resolved when all initial selections have been initialized and published.
*/
untilFiltersPublished: () => Promise<void>;

/** Public getters */
getEditorConfig: () => ControlGroupEditorConfig | undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -186,7 +178,7 @@ export const initializeDataControlManager = <EditorState extends object = {}>(
});
};

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<DataView> => {
const getDataView = async (id: string): Promise<DataView> => {
if (id !== 'myDataViewId') {
throw new Error(`Simulated error: no data view found for id ${id}`);
}
Expand Down Expand Up @@ -54,9 +52,75 @@ describe('Options List Control Api', () => {
};
});
return stubDataView;
};

describe('initialization', () => {
let dataviewDelayPromise: Promise<void> | 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: {
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function initializeControlGroupManager(
.pipe(
skipWhile((controlGroupApi) => !controlGroupApi),
switchMap(async (controlGroupApi) => {
await controlGroupApi?.untilInitialized();
await controlGroupApi?.untilFiltersPublished();
}),
first()
)
Expand Down
1 change: 1 addition & 0 deletions src/platform/plugins/shared/dashboard/public/mocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down