Skip to content
Closed
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 @@ -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<void>;

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

/** Public getters */
getEditorConfig: () => ControlGroupEditorConfig | undefined;
getLastSavedControlState: (controlUuid: string) => object;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -180,7 +178,7 @@ export const initializeDataControl = <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 { getMockedBuildApi, getMockedControlGroupApi } from '../../mocks/control_mocks';
import * as initializeControl from '../initialize_data_control';
Expand All @@ -23,7 +21,7 @@ describe('Options List Control Api', () => {
const uuid = 'myControl1';
const controlGroupApi = getMockedControlGroupApi();

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 @@ -53,11 +51,77 @@ 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;
});
});

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(
{
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ControlGroupApi | undefined>(undefined);

async function untilControlsInitialized(): Promise<void> {
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,
},
};
}
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 @@ -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),
Expand Down