[control group] apply selections on reset (#189830)

Fixes https://github.com/elastic/kibana/issues/189580

PR awaits until all control filters are ready and then applies
selections during reset.

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-08-08 07:50:57 -06:00 committed by GitHub
parent 88144cc45c
commit b87e967f46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 437 additions and 228 deletions

View file

@ -8,7 +8,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import useMountedState from 'react-use/lib/useMountedState';
import {
EuiBadge,
EuiButton,
@ -76,6 +76,7 @@ export const ReactControlExample = ({
core: CoreStart;
dataViews: DataViewsPublicPluginStart;
}) => {
const isMounted = useMountedState();
const dataLoading$ = useMemo(() => {
return new BehaviorSubject<boolean | undefined>(false);
}, []);
@ -112,6 +113,7 @@ export const ReactControlExample = ({
const [controlGroupApi, setControlGroupApi] = useState<ControlGroupApi | undefined>(undefined);
const [isControlGroupInitialized, setIsControlGroupInitialized] = useState(false);
const [dataViewNotFound, setDataViewNotFound] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const dashboardApi = useMemo(() => {
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
@ -361,9 +363,15 @@ export const ReactControlExample = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
isDisabled={!controlGroupApi}
onClick={() => {
controlGroupApi?.resetUnsavedChanges();
isDisabled={!controlGroupApi || isResetting}
isLoading={isResetting}
onClick={async () => {
if (!controlGroupApi) {
return;
}
setIsResetting(true);
await controlGroupApi.asyncResetUnsavedChanges();
if (isMounted()) setIsResetting(false);
}}
>
Reset

View file

@ -20,6 +20,7 @@ import {
import { combineLatest, map } from 'rxjs';
import { ControlsInOrder, getControlsInOrder } from './init_controls_manager';
import { ControlGroupRuntimeState, ControlPanelsState } from './types';
import { apiPublishesAsyncFilters } from '../data_controls/publishes_async_filters';
export type ControlGroupComparatorState = Pick<
ControlGroupRuntimeState,
@ -33,6 +34,7 @@ export type ControlGroupComparatorState = Pick<
};
export function initializeControlGroupUnsavedChanges(
applySelections: () => void,
children$: PresentationContainer['children$'],
comparators: StateComparators<ControlGroupComparatorState>,
snapshotControlsRuntimeState: () => ControlPanelsState,
@ -68,12 +70,25 @@ export function initializeControlGroupUnsavedChanges(
return Object.keys(unsavedChanges).length ? unsavedChanges : undefined;
})
),
resetUnsavedChanges: () => {
asyncResetUnsavedChanges: async () => {
controlGroupUnsavedChanges.api.resetUnsavedChanges();
const filtersReadyPromises: Array<Promise<void>> = [];
Object.values(children$.value).forEach((controlApi) => {
if (apiPublishesUnsavedChanges(controlApi)) controlApi.resetUnsavedChanges();
if (apiPublishesAsyncFilters(controlApi)) {
filtersReadyPromises.push(controlApi.untilFiltersReady());
}
});
await Promise.all(filtersReadyPromises);
if (!comparators.autoApplySelections[0].value) {
applySelections();
}
},
} as PublishesUnsavedChanges<ControlGroupRuntimeState>,
} as Pick<PublishesUnsavedChanges, 'unsavedChanges'> & {
asyncResetUnsavedChanges: () => Promise<void>;
},
};
}

View file

@ -96,6 +96,7 @@ export const getControlGroupEmbeddableFactory = (services: {
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
const unsavedChanges = initializeControlGroupUnsavedChanges(
selectionsManager.applySelections,
controlsManager.api.children$,
{
...controlsManager.comparators,

View file

@ -55,10 +55,11 @@ export type ControlGroupApi = PresentationContainer &
HasSerializedChildState<ControlPanelState> &
HasEditCapabilities &
PublishesDataLoading &
PublishesUnsavedChanges &
Pick<PublishesUnsavedChanges, 'unsavedChanges'> &
PublishesControlGroupDisplaySettings &
PublishesTimeslice &
Partial<HasParentApi<PublishesUnifiedSearch> & HasSaveNotification> & {
asyncResetUnsavedChanges: () => Promise<void>;
autoApplySelections$: PublishingSubject<boolean>;
controlFetch$: (controlUuid: string) => Observable<ControlFetchContext>;
getLastSavedControlState: (controlUuid: string) => object;

View file

@ -7,7 +7,7 @@
*/
import { isEqual } from 'lodash';
import { BehaviorSubject, combineLatest, first, switchMap } from 'rxjs';
import { BehaviorSubject, combineLatest, debounceTime, first, skip, switchMap, tap } from 'rxjs';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import {
@ -45,9 +45,12 @@ export const initializeDataControl = <EditorState extends object = {}>(
api: ControlApiInitialization<DataControlApi>;
cleanup: () => void;
comparators: StateComparators<DefaultDataControlState>;
setters: {
onSelectionChange: () => void;
setOutputFilter: (filter: Filter | undefined) => void;
};
stateManager: ControlStateManager<DefaultDataControlState>;
serialize: () => SerializedPanelState<DefaultControlState>;
untilFiltersInitialized: () => Promise<void>;
} => {
const defaultControl = initializeDefaultControlApi(state);
@ -57,6 +60,7 @@ export const initializeDataControl = <EditorState extends object = {}>(
const fieldName = new BehaviorSubject<string>(state.fieldName);
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const filters$ = new BehaviorSubject<Filter[] | undefined>(undefined);
const filtersReady$ = new BehaviorSubject<boolean>(false);
const field$ = new BehaviorSubject<DataViewField | undefined>(undefined);
const fieldFormatter = new BehaviorSubject<DataControlFieldFormatter>((toFormat: any) =>
String(toFormat)
@ -69,14 +73,14 @@ export const initializeDataControl = <EditorState extends object = {}>(
title: panelTitle,
};
function clearBlockingError() {
if (defaultControl.api.blockingError.value) {
defaultControl.api.setBlockingError(undefined);
}
}
const dataViewIdSubscription = dataViewId
.pipe(
tap(() => {
filtersReady$.next(false);
if (defaultControl.api.blockingError.value) {
defaultControl.api.setBlockingError(undefined);
}
}),
switchMap(async (currentDataViewId) => {
let dataView: DataView | undefined;
try {
@ -90,14 +94,17 @@ export const initializeDataControl = <EditorState extends object = {}>(
.subscribe(({ dataView, error }) => {
if (error) {
defaultControl.api.setBlockingError(error);
} else {
clearBlockingError();
}
dataViews.next(dataView ? [dataView] : undefined);
});
const fieldNameSubscription = combineLatest([dataViews, fieldName]).subscribe(
([nextDataViews, nextFieldName]) => {
const fieldNameSubscription = combineLatest([dataViews, fieldName])
.pipe(
tap(() => {
filtersReady$.next(false);
})
)
.subscribe(([nextDataViews, nextFieldName]) => {
const dataView = nextDataViews
? nextDataViews.find(({ id }) => dataViewId.value === id)
: undefined;
@ -115,8 +122,8 @@ export const initializeDataControl = <EditorState extends object = {}>(
})
)
);
} else {
clearBlockingError();
} else if (defaultControl.api.blockingError.value) {
defaultControl.api.setBlockingError(undefined);
}
field$.next(field);
@ -125,8 +132,7 @@ export const initializeDataControl = <EditorState extends object = {}>(
if (spec) {
fieldFormatter.next(dataView.getFormatterForField(spec).getConverterFor('text'));
}
}
);
});
const onEdit = async () => {
// get the initial state from the state manager
@ -172,6 +178,13 @@ export const initializeDataControl = <EditorState extends object = {}>(
});
};
const filtersReadySubscription = filters$.pipe(skip(1), debounceTime(0)).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
filtersReady$.next(true);
});
const api: ControlApiInitialization<DataControlApi> = {
...defaultControl.api,
panelTitle,
@ -181,10 +194,18 @@ export const initializeDataControl = <EditorState extends object = {}>(
fieldFormatter,
onEdit,
filters$,
setOutputFilter: (newFilter: Filter | undefined) => {
filters$.next(newFilter ? [newFilter] : undefined);
},
isEditingEnabled: () => true,
untilFiltersReady: async () => {
return new Promise((resolve) => {
combineLatest([defaultControl.api.blockingError, filtersReady$])
.pipe(
first(([blockingError, filtersReady]) => filtersReady || blockingError !== undefined)
)
.subscribe(() => {
resolve();
});
});
},
};
return {
@ -192,6 +213,7 @@ export const initializeDataControl = <EditorState extends object = {}>(
cleanup: () => {
dataViewIdSubscription.unsubscribe();
fieldNameSubscription.unsubscribe();
filtersReadySubscription.unsubscribe();
},
comparators: {
...defaultControl.comparators,
@ -199,6 +221,14 @@ export const initializeDataControl = <EditorState extends object = {}>(
dataViewId: [dataViewId, (value: string) => dataViewId.next(value)],
fieldName: [fieldName, (value: string) => fieldName.next(value)],
},
setters: {
onSelectionChange: () => {
filtersReady$.next(false);
},
setOutputFilter: (newFilter: Filter | undefined) => {
filters$.next(newFilter ? [newFilter] : undefined);
},
},
stateManager,
serialize: () => {
return {
@ -217,19 +247,5 @@ export const initializeDataControl = <EditorState extends object = {}>(
],
};
},
untilFiltersInitialized: async () => {
return new Promise((resolve) => {
combineLatest([defaultControl.api.blockingError, filters$])
.pipe(
first(
([blockingError, filters]) =>
blockingError !== undefined || (filters?.length ?? 0) > 0
)
)
.subscribe(() => {
resolve();
});
});
},
};
};

View file

@ -11,12 +11,16 @@ import { BehaviorSubject } from 'rxjs';
import { OptionsListSuggestions } from '@kbn/controls-plugin/common/options_list/types';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { OptionsListSelection } from '../../../../common/options_list/options_list_selections';
import { OptionsListSearchTechnique } from '../../../../common/options_list/suggestions_searching';
import { OptionsListSortingType } from '../../../../common/options_list/suggestions_sorting';
import { OptionsListDisplaySettings } from '../options_list_control/types';
export const getOptionsListMocks = () => {
const selectedOptions$ = new BehaviorSubject<OptionsListSelection[] | undefined>(undefined);
const exclude$ = new BehaviorSubject<boolean | undefined>(undefined);
const existsSelected$ = new BehaviorSubject<boolean | undefined>(undefined);
return {
api: {
uuid: 'testControl',
@ -30,17 +34,23 @@ export const getOptionsListMocks = () => {
},
fieldFormatter: new BehaviorSubject((value: string | number) => String(value)),
makeSelection: jest.fn(),
setExclude: (next: boolean | undefined) => exclude$.next(next),
},
stateManager: {
searchString: new BehaviorSubject<string>(''),
searchStringValid: new BehaviorSubject<boolean>(true),
fieldName: new BehaviorSubject<string>('field'),
exclude: new BehaviorSubject<boolean | undefined>(undefined),
existsSelected: new BehaviorSubject<boolean | undefined>(undefined),
exclude: exclude$ as PublishingSubject<boolean | undefined>,
existsSelected: existsSelected$ as PublishingSubject<boolean | undefined>,
sort: new BehaviorSubject<OptionsListSortingType | undefined>(undefined),
selectedOptions: new BehaviorSubject<OptionsListSelection[] | undefined>(undefined),
selectedOptions: selectedOptions$ as PublishingSubject<OptionsListSelection[] | undefined>,
searchTechnique: new BehaviorSubject<OptionsListSearchTechnique | undefined>(undefined),
},
displaySettings: {} as OptionsListDisplaySettings,
// setSelectedOptions and setExistsSelected are not exposed via API because
// they are not used by components
// they are needed in tests however so expose them as top level keys
setSelectedOptions: (next: OptionsListSelection[] | undefined) => selectedOptions$.next(next),
setExistsSelected: (next: boolean | undefined) => existsSelected$.next(next),
};
};

View file

@ -10,10 +10,9 @@ import React from 'react';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { render } from '@testing-library/react';
import { ControlStateManager } from '../../../types';
import { getOptionsListMocks } from '../../mocks/api_mocks';
import { OptionsListControlContext } from '../options_list_context_provider';
import { OptionsListComponentApi, OptionsListComponentState } from '../types';
import { ContextStateManager, OptionsListControlContext } from '../options_list_context_provider';
import { OptionsListComponentApi } from '../types';
import { OptionsListControl } from './options_list_control';
describe('Options list control', () => {
@ -31,7 +30,7 @@ describe('Options list control', () => {
value={{
api: api as unknown as OptionsListComponentApi,
displaySettings,
stateManager: stateManager as unknown as ControlStateManager<OptionsListComponentState>,
stateManager: stateManager as unknown as ContextStateManager,
}}
>
<OptionsListControl controlPanelClassName="controlPanel" />
@ -42,8 +41,8 @@ describe('Options list control', () => {
test('if exclude = false and existsSelected = true, then the option should read "Exists"', async () => {
const mocks = getOptionsListMocks();
mocks.api.uuid = 'testExists';
mocks.stateManager.exclude.next(false);
mocks.stateManager.existsSelected.next(true);
mocks.api.setExclude(false);
mocks.setExistsSelected(true);
const control = mountComponent(mocks);
const existsOption = control.getByTestId('optionsList-control-testExists');
expect(existsOption).toHaveTextContent('Exists');
@ -52,8 +51,8 @@ describe('Options list control', () => {
test('if exclude = true and existsSelected = true, then the option should read "Does not exist"', async () => {
const mocks = getOptionsListMocks();
mocks.api.uuid = 'testDoesNotExist';
mocks.stateManager.exclude.next(true);
mocks.stateManager.existsSelected.next(true);
mocks.api.setExclude(true);
mocks.setExistsSelected(true);
const control = mountComponent(mocks);
const existsOption = control.getByTestId('optionsList-control-testDoesNotExist');
expect(existsOption).toHaveTextContent('DOES NOT Exist');
@ -68,7 +67,7 @@ describe('Options list control', () => {
{ value: 'bark', docCount: 10 },
{ value: 'meow', docCount: 12 },
]);
mocks.stateManager.selectedOptions.next(['woof', 'bark']);
mocks.setSelectedOptions(['woof', 'bark']);
mocks.api.field$.next({
name: 'Test keyword field',
type: 'keyword',
@ -87,7 +86,7 @@ describe('Options list control', () => {
{ value: 2, docCount: 10 },
{ value: 3, docCount: 12 },
]);
mocks.stateManager.selectedOptions.next([1, 2]);
mocks.setSelectedOptions([1, 2]);
mocks.api.field$.next({
name: 'Test keyword field',
type: 'number',
@ -105,7 +104,7 @@ describe('Options list control', () => {
{ value: 'bark', docCount: 10 },
{ value: 'meow', docCount: 12 },
]);
mocks.stateManager.selectedOptions.next(['woof', 'bark']);
mocks.setSelectedOptions(['woof', 'bark']);
mocks.api.invalidSelections$.next(new Set(['woof']));
mocks.api.field$.next({
name: 'Test keyword field',

View file

@ -60,6 +60,7 @@ export const OptionsListControl = ({
api.panelTitle,
api.fieldFormatter
);
const [defaultPanelTitle] = useBatchedOptionalPublishingSubjects(api.defaultPanelTitle);
const delimiter = useMemo(() => OptionsListStrings.control.getSeparator(field?.type), [field]);

View file

@ -13,14 +13,9 @@ import { act, render, RenderResult, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BehaviorSubject } from 'rxjs';
import { ControlStateManager } from '../../../types';
import { getOptionsListMocks } from '../../mocks/api_mocks';
import { OptionsListControlContext } from '../options_list_context_provider';
import {
OptionsListComponentApi,
OptionsListComponentState,
OptionsListDisplaySettings,
} from '../types';
import { ContextStateManager, OptionsListControlContext } from '../options_list_context_provider';
import { OptionsListComponentApi, OptionsListDisplaySettings } from '../types';
import { OptionsListPopover } from './options_list_popover';
describe('Options list popover', () => {
@ -40,7 +35,7 @@ describe('Options list popover', () => {
value={{
api: api as unknown as OptionsListComponentApi,
displaySettings,
stateManager: stateManager as unknown as ControlStateManager<OptionsListComponentState>,
stateManager: stateManager as unknown as ContextStateManager,
}}
>
<OptionsListPopover />
@ -83,7 +78,7 @@ describe('Options list popover', () => {
expect(mocks.api.makeSelection).toBeCalledWith('woof', false);
// simulate `makeSelection`
mocks.stateManager.selectedOptions.next(['woof']);
mocks.setSelectedOptions(['woof']);
await waitOneTick();
clickShowOnlySelections(popover);
@ -102,7 +97,7 @@ describe('Options list popover', () => {
{ value: 'meow', docCount: 12 },
]);
const popover = mountComponent(mocks);
mocks.stateManager.selectedOptions.next(selections);
mocks.setSelectedOptions(selections);
await waitOneTick();
clickShowOnlySelections(popover);
@ -121,7 +116,7 @@ describe('Options list popover', () => {
{ value: 'bark', docCount: 10 },
{ value: 'meow', docCount: 12 },
]);
mocks.stateManager.selectedOptions.next([]);
mocks.setSelectedOptions([]);
const popover = mountComponent(mocks);
clickShowOnlySelections(popover);
@ -139,7 +134,7 @@ describe('Options list popover', () => {
{ value: 'bark', docCount: 10 },
{ value: 'meow', docCount: 12 },
]);
mocks.stateManager.selectedOptions.next(['woof', 'bark']);
mocks.setSelectedOptions(['woof', 'bark']);
const popover = mountComponent(mocks);
let searchBox = popover.getByTestId('optionsList-control-search-input');
@ -163,7 +158,7 @@ describe('Options list popover', () => {
{ value: 'bark', docCount: 75 },
]);
const popover = mountComponent(mocks);
mocks.stateManager.selectedOptions.next(['woof', 'bark']);
mocks.setSelectedOptions(['woof', 'bark']);
mocks.api.invalidSelections$.next(new Set(['woof']));
await waitOneTick();
@ -185,7 +180,7 @@ describe('Options list popover', () => {
{ value: 'woof', docCount: 5 },
{ value: 'bark', docCount: 75 },
]);
mocks.stateManager.selectedOptions.next(['bark', 'woof', 'meow']);
mocks.setSelectedOptions(['bark', 'woof', 'meow']);
mocks.api.invalidSelections$.next(new Set(['woof', 'meow']));
const popover = mountComponent(mocks);
@ -207,7 +202,7 @@ describe('Options list popover', () => {
test('if exclude = true, select appropriate button in button group', async () => {
const mocks = getOptionsListMocks();
const popover = mountComponent(mocks);
mocks.stateManager.exclude.next(true);
mocks.api.setExclude(true);
await waitOneTick();
const includeButton = popover.getByTestId('optionsList__includeResults');
@ -223,7 +218,7 @@ describe('Options list popover', () => {
mocks.api.availableOptions$.next([]);
const popover = mountComponent(mocks);
mocks.stateManager.existsSelected.next(false);
mocks.setExistsSelected(false);
await waitOneTick();
const existsOption = popover.queryByTestId('optionsList-control-selection-exists');
@ -238,7 +233,7 @@ describe('Options list popover', () => {
]);
const popover = mountComponent(mocks);
mocks.stateManager.existsSelected.next(true);
mocks.setExistsSelected(true);
await waitOneTick();
clickShowOnlySelections(popover);

View file

@ -78,9 +78,7 @@ export const OptionsListPopoverFooter = () => {
legend={OptionsListStrings.popover.getIncludeExcludeLegend()}
options={aggregationToggleButtons}
idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'}
onChange={(optionId) =>
stateManager.exclude.next(optionId === 'optionsList__excludeResults')
}
onChange={(optionId) => api.setExclude(optionId === 'optionsList__excludeResults')}
buttonSize="compressed"
data-test-subj="optionsList__includeExcludeButtonGroup"
/>

View file

@ -12,10 +12,9 @@ import { DataViewField } from '@kbn/data-views-plugin/common';
import { render, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ControlStateManager } from '../../../types';
import { getOptionsListMocks } from '../../mocks/api_mocks';
import { OptionsListControlContext } from '../options_list_context_provider';
import { OptionsListComponentApi, OptionsListComponentState } from '../types';
import { ContextStateManager, OptionsListControlContext } from '../options_list_context_provider';
import { OptionsListComponentApi } from '../types';
import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button';
describe('Options list sorting button', () => {
@ -33,7 +32,7 @@ describe('Options list sorting button', () => {
value={{
api: api as unknown as OptionsListComponentApi,
displaySettings,
stateManager: stateManager as unknown as ControlStateManager<OptionsListComponentState>,
stateManager: stateManager as unknown as ContextStateManager,
}}
>
<OptionsListPopoverSortingButton showOnlySelected={false} />

View file

@ -196,7 +196,6 @@ export const OptionsListPopoverSuggestions = ({
)}
emptyMessage={<OptionsListPopoverEmptyMessage showOnlySelected={showOnlySelected} />}
onChange={(newSuggestions, _, changedOption) => {
setSelectableOptions(newSuggestions);
api.makeSelection(changedOption.key, showOnlySelected);
}}
>

View file

@ -18,7 +18,9 @@ import {
import { OptionsListSuccessResponse } from '@kbn/controls-plugin/common/options_list/types';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { isValidSearch } from '../../../../common/options_list/suggestions_searching';
import { OptionsListSelection } from '../../../../common/options_list/options_list_selections';
import { ControlFetchContext } from '../../control_group/control_fetch';
import { ControlStateManager } from '../../types';
import { DataControlServices } from '../types';
@ -37,7 +39,11 @@ export function fetchAndValidate$({
debouncedSearchString: Observable<string>;
};
services: DataControlServices;
stateManager: ControlStateManager<OptionsListComponentState>;
stateManager: ControlStateManager<
Pick<OptionsListComponentState, 'requestSize' | 'runPastTimeout' | 'searchTechnique' | 'sort'>
> & {
selectedOptions: PublishingSubject<OptionsListSelection[] | undefined>;
};
}): Observable<OptionsListSuccessResponse | { error: Error }> {
const requestCache = new OptionsListFetchCache();
let abortController: AbortController | undefined;

View file

@ -7,7 +7,6 @@
*/
import React, { useEffect } from 'react';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject, combineLatest, debounceTime, filter, skip } from 'rxjs';
import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/suggestions_searching';
@ -38,6 +37,7 @@ import { fetchAndValidate$ } from './fetch_and_validate';
import { OptionsListControlContext } from './options_list_context_provider';
import { OptionsListStrings } from './options_list_strings';
import { OptionsListControlApi, OptionsListControlState } from './types';
import { initializeOptionsListSelections } from './options_list_control_selections';
export const getOptionsListControlFactory = (
services: DataControlServices
@ -62,14 +62,9 @@ export const getOptionsListControlFactory = (
);
const runPastTimeout$ = new BehaviorSubject<boolean | undefined>(initialState.runPastTimeout);
const singleSelect$ = new BehaviorSubject<boolean | undefined>(initialState.singleSelect);
const selections$ = new BehaviorSubject<OptionsListSelection[] | undefined>(
initialState.selectedOptions ?? []
);
const sort$ = new BehaviorSubject<OptionsListSortingType | undefined>(
initialState.sort ?? OPTIONS_LIST_DEFAULT_SORT
);
const existsSelected$ = new BehaviorSubject<boolean | undefined>(initialState.existsSelected);
const excludeSelected$ = new BehaviorSubject<boolean | undefined>(initialState.exclude);
/** Creation options state - cannot currently be changed after creation, but need subjects for comparators */
const placeholder$ = new BehaviorSubject<string | undefined>(initialState.placeholder);
@ -98,12 +93,17 @@ export const getOptionsListControlFactory = (
services
);
const selections = initializeOptionsListSelections(
initialState,
dataControl.setters.onSelectionChange
);
const stateManager = {
...dataControl.stateManager,
exclude: excludeSelected$,
existsSelected: existsSelected$,
exclude: selections.exclude$,
existsSelected: selections.existsSelected$,
searchTechnique: searchTechnique$,
selectedOptions: selections$,
selectedOptions: selections.selectedOptions$,
singleSelect: singleSelect$,
sort: sort$,
searchString: searchString$,
@ -150,9 +150,9 @@ export const getOptionsListControlFactory = (
)
.subscribe(() => {
searchString$.next('');
selections$.next(undefined);
existsSelected$.next(false);
excludeSelected$.next(false);
selections.setSelectedOptions(undefined);
selections.setExistsSelected(false);
selections.setExclude(false);
requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE);
sort$.next(OPTIONS_LIST_DEFAULT_SORT);
});
@ -196,38 +196,40 @@ export const getOptionsListControlFactory = (
const singleSelectSubscription = singleSelect$
.pipe(filter((singleSelect) => Boolean(singleSelect)))
.subscribe(() => {
const currentSelections = selections$.getValue() ?? [];
if (currentSelections.length > 1) selections$.next([currentSelections[0]]);
const currentSelections = selections.selectedOptions$.getValue() ?? [];
if (currentSelections.length > 1) selections.setSelectedOptions([currentSelections[0]]);
});
/** Output filters when selections change */
const outputFilterSubscription = combineLatest([
dataControl.api.dataViews,
dataControl.stateManager.fieldName,
selections$,
existsSelected$,
excludeSelected$,
]).subscribe(([dataViews, fieldName, selections, existsSelected, exclude]) => {
const dataView = dataViews?.[0];
const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined;
selections.selectedOptions$,
selections.existsSelected$,
selections.exclude$,
])
.pipe(debounceTime(0))
.subscribe(([dataViews, fieldName, selectedOptions, existsSelected, exclude]) => {
const dataView = dataViews?.[0];
const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined;
if (!dataView || !field) return;
let newFilter: Filter | undefined;
if (existsSelected) {
newFilter = buildExistsFilter(field, dataView);
} else if (selections && selections.length > 0) {
newFilter =
selections.length === 1
? buildPhraseFilter(field, selections[0], dataView)
: buildPhrasesFilter(field, selections, dataView);
}
if (newFilter) {
newFilter.meta.key = field?.name;
if (exclude) newFilter.meta.negate = true;
}
api.setOutputFilter(newFilter);
});
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 (newFilter) {
newFilter.meta.key = field?.name;
if (exclude) newFilter.meta.negate = true;
}
dataControl.setters.setOutputFilter(newFilter);
});
const api = buildApi(
{
@ -241,10 +243,10 @@ export const getOptionsListControlFactory = (
searchTechnique: searchTechnique$.getValue(),
runPastTimeout: runPastTimeout$.getValue(),
singleSelect: singleSelect$.getValue(),
selections: selections$.getValue(),
selections: selections.selectedOptions$.getValue(),
sort: sort$.getValue(),
existsSelected: existsSelected$.getValue(),
exclude: excludeSelected$.getValue(),
existsSelected: selections.existsSelected$.getValue(),
exclude: selections.exclude$.getValue(),
// serialize state that cannot be changed to keep it consistent
placeholder: placeholder$.getValue(),
@ -257,26 +259,20 @@ export const getOptionsListControlFactory = (
};
},
clearSelections: () => {
if (selections$.getValue()?.length) selections$.next([]);
if (existsSelected$.getValue()) existsSelected$.next(false);
if (selections.selectedOptions$.getValue()?.length) selections.setSelectedOptions([]);
if (selections.existsSelected$.getValue()) selections.setExistsSelected(false);
if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([]));
},
},
{
...dataControl.comparators,
exclude: [excludeSelected$, (selected) => excludeSelected$.next(selected)],
existsSelected: [existsSelected$, (selected) => existsSelected$.next(selected)],
...selections.comparators,
runPastTimeout: [runPastTimeout$, (runPast) => runPastTimeout$.next(runPast)],
searchTechnique: [
searchTechnique$,
(technique) => searchTechnique$.next(technique),
(a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE),
],
selectedOptions: [
selections$,
(selections) => selections$.next(selections),
(a, b) => deepEqual(a ?? [], b ?? []),
],
singleSelect: [singleSelect$, (selected) => singleSelect$.next(selected)],
sort: [
sort$,
@ -295,11 +291,11 @@ export const getOptionsListControlFactory = (
const componentApi = {
...api,
selections$,
loadMoreSubject,
totalCardinality$,
availableOptions$,
invalidSelections$,
setExclude: selections.setExclude,
deselectOption: (key: string | undefined) => {
const field = api.field$.getValue();
if (!key || !field) {
@ -312,12 +308,12 @@ export const getOptionsListControlFactory = (
const keyAsType = getSelectionAsFieldType(field, key);
// delete from selections
const selectedOptions = selections$.getValue() ?? [];
const itemIndex = (selections$.getValue() ?? []).indexOf(keyAsType);
const selectedOptions = selections.selectedOptions$.getValue() ?? [];
const itemIndex = (selections.selectedOptions$.getValue() ?? []).indexOf(keyAsType);
if (itemIndex !== -1) {
const newSelections = [...selectedOptions];
newSelections.splice(itemIndex, 1);
selections$.next(newSelections);
selections.setSelectedOptions(newSelections);
}
// delete from invalid selections
const currentInvalid = invalidSelections$.getValue();
@ -335,37 +331,37 @@ export const getOptionsListControlFactory = (
return;
}
const existsSelected = Boolean(existsSelected$.getValue());
const selectedOptions = selections$.getValue() ?? [];
const existsSelected = Boolean(selections.existsSelected$.getValue());
const selectedOptions = selections.selectedOptions$.getValue() ?? [];
const singleSelect = singleSelect$.getValue();
// the order of these checks matters, so be careful if rearranging them
const keyAsType = getSelectionAsFieldType(field, key);
if (key === 'exists-option') {
// if selecting exists, then deselect everything else
existsSelected$.next(!existsSelected);
selections.setExistsSelected(!existsSelected);
if (!existsSelected) {
selections$.next([]);
selections.setSelectedOptions([]);
invalidSelections$.next(new Set([]));
}
} else if (showOnlySelected || selectedOptions.includes(keyAsType)) {
componentApi.deselectOption(key);
} else if (singleSelect) {
// replace selection
selections$.next([keyAsType]);
if (existsSelected) existsSelected$.next(false);
selections.setSelectedOptions([keyAsType]);
if (existsSelected) selections.setExistsSelected(false);
} else {
// select option
if (!selectedOptions) selections$.next([]);
if (existsSelected) existsSelected$.next(false);
selections$.next([...selectedOptions, keyAsType]);
if (existsSelected) selections.setExistsSelected(false);
selections.setSelectedOptions(
selectedOptions ? [...selectedOptions, keyAsType] : [keyAsType]
);
}
},
};
if (initialState.selectedOptions?.length || initialState.existsSelected) {
// has selections, so wait for initialization of filters
await dataControl.untilFiltersInitialized();
if (selections.hasInitialSelections) {
await dataControl.api.untilFiltersReady();
}
return {

View file

@ -8,17 +8,27 @@
import React, { useContext } from 'react';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { ControlStateManager } from '../../types';
import {
OptionsListComponentApi,
OptionsListComponentState,
OptionsListDisplaySettings,
} from './types';
import { OptionsListSelection } from '../../../../common/options_list/options_list_selections';
export type ContextStateManager = ControlStateManager<
Omit<OptionsListComponentState, 'exclude' | 'existsSelected' | 'selectedOptions'>
> & {
selectedOptions: PublishingSubject<OptionsListSelection[] | undefined>;
existsSelected: PublishingSubject<boolean | undefined>;
exclude: PublishingSubject<boolean | undefined>;
};
export const OptionsListControlContext = React.createContext<
| {
api: OptionsListComponentApi;
stateManager: ControlStateManager<OptionsListComponentState>;
stateManager: ContextStateManager;
displaySettings: OptionsListDisplaySettings;
}
| undefined

View file

@ -0,0 +1,65 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
import deepEqual from 'react-fast-compare';
import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing';
import { OptionsListControlState } from './types';
import { OptionsListSelection } from '../../../../common/options_list/options_list_selections';
export function initializeOptionsListSelections(
initialState: OptionsListControlState,
onSelectionChange: () => void
) {
const selectedOptions$ = new BehaviorSubject<OptionsListSelection[] | undefined>(
initialState.selectedOptions ?? []
);
const selectedOptionsComparatorFunction = (
a: OptionsListSelection[] | undefined,
b: OptionsListSelection[] | undefined
) => deepEqual(a ?? [], b ?? []);
function setSelectedOptions(next: OptionsListSelection[] | undefined) {
if (!selectedOptionsComparatorFunction(selectedOptions$.value, next)) {
selectedOptions$.next(next);
onSelectionChange();
}
}
const existsSelected$ = new BehaviorSubject<boolean | undefined>(initialState.existsSelected);
function setExistsSelected(next: boolean | undefined) {
if (existsSelected$.value !== next) {
existsSelected$.next(next);
onSelectionChange();
}
}
const exclude$ = new BehaviorSubject<boolean | undefined>(initialState.exclude);
function setExclude(next: boolean | undefined) {
if (exclude$.value !== next) {
exclude$.next(next);
onSelectionChange();
}
}
return {
comparators: {
exclude: [exclude$, setExclude],
existsSelected: [existsSelected$, setExistsSelected],
selectedOptions: [selectedOptions$, setSelectedOptions, selectedOptionsComparatorFunction],
} as StateComparators<
Pick<OptionsListControlState, 'exclude' | 'existsSelected' | 'selectedOptions'>
>,
hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected,
selectedOptions$: selectedOptions$ as PublishingSubject<OptionsListSelection[] | undefined>,
setSelectedOptions,
existsSelected$: existsSelected$ as PublishingSubject<boolean | undefined>,
setExistsSelected,
exclude$: exclude$ as PublishingSubject<boolean | undefined>,
setExclude,
};
}

View file

@ -56,4 +56,5 @@ export type OptionsListComponentApi = OptionsListControlApi &
deselectOption: (key: string | undefined) => void;
makeSelection: (key: string | undefined, showOnlySelected: boolean) => void;
loadMoreSubject: BehaviorSubject<null>;
setExclude: (next: boolean | undefined) => void;
};

View file

@ -0,0 +1,28 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { PublishesFilters, apiPublishesFilters } from '@kbn/presentation-publishing';
/**
* Data control filter generation is async because
* 1) filter generation requires a DataView
* 2) filter generation is a subscription
*/
export type PublishesAsyncFilters = PublishesFilters & {
untilFiltersReady: () => Promise<void>;
};
export const apiPublishesAsyncFilters = (
unknownApi: unknown
): unknownApi is PublishesAsyncFilters => {
return Boolean(
unknownApi &&
apiPublishesFilters(unknownApi) &&
(unknownApi as PublishesAsyncFilters)?.untilFiltersReady !== undefined
);
};

View file

@ -10,19 +10,15 @@ import React, { useEffect, useState } from 'react';
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import { buildRangeFilter, Filter, RangeFilterParams } from '@kbn/es-query';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { BehaviorSubject, combineLatest, map, skip } from 'rxjs';
import { BehaviorSubject, combineLatest, debounceTime, map, skip } from 'rxjs';
import { initializeDataControl } from '../initialize_data_control';
import { DataControlFactory, DataControlServices } from '../types';
import { RangeSliderControl } from './components/range_slider_control';
import { hasNoResults$ } from './has_no_results';
import { minMax$ } from './min_max';
import { RangeSliderStrings } from './range_slider_strings';
import {
RangesliderControlApi,
RangesliderControlState,
RangeValue,
RANGE_SLIDER_CONTROL_TYPE,
} from './types';
import { RangesliderControlApi, RangesliderControlState, RANGE_SLIDER_CONTROL_TYPE } from './types';
import { initializeRangeControlSelections } from './range_control_selections';
export const getRangesliderControlFactory = (
services: DataControlServices
@ -62,10 +58,6 @@ export const getRangesliderControlFactory = (
const loadingHasNoResults$ = new BehaviorSubject<boolean>(false);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(undefined);
const step$ = new BehaviorSubject<number | undefined>(initialState.step ?? 1);
const value$ = new BehaviorSubject<RangeValue | undefined>(initialState.value);
function setValue(nextValue: RangeValue | undefined) {
value$.next(nextValue);
}
const dataControl = initializeDataControl<Pick<RangesliderControlState, 'step'>>(
uuid,
@ -78,6 +70,11 @@ export const getRangesliderControlFactory = (
services
);
const selections = initializeRangeControlSelections(
initialState,
dataControl.setters.onSelectionChange
);
const api = buildApi(
{
...dataControl.api,
@ -89,23 +86,23 @@ export const getRangesliderControlFactory = (
rawState: {
...dataControlState,
step: step$.getValue(),
value: value$.getValue(),
value: selections.value$.getValue(),
},
references, // does not have any references other than those provided by the data control serializer
};
},
clearSelections: () => {
value$.next(undefined);
selections.setValue(undefined);
},
},
{
...dataControl.comparators,
...selections.comparators,
step: [
step$,
(nextStep: number | undefined) => step$.next(nextStep),
(a, b) => (a ?? 1) === (b ?? 1),
],
value: [value$, setValue],
}
);
@ -129,7 +126,7 @@ export const getRangesliderControlFactory = (
.pipe(skip(1))
.subscribe(() => {
step$.next(1);
value$.next(undefined);
selections.setValue(undefined);
});
const max$ = new BehaviorSubject<number | undefined>(undefined);
@ -167,28 +164,30 @@ export const getRangesliderControlFactory = (
const outputFilterSubscription = combineLatest([
dataControl.api.dataViews,
dataControl.stateManager.fieldName,
value$,
]).subscribe(([dataViews, fieldName, value]) => {
const dataView = dataViews?.[0];
const dataViewField =
dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined;
const gte = parseFloat(value?.[0] ?? '');
const lte = parseFloat(value?.[1] ?? '');
selections.value$,
])
.pipe(debounceTime(0))
.subscribe(([dataViews, fieldName, value]) => {
const dataView = dataViews?.[0];
const dataViewField =
dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined;
const gte = parseFloat(value?.[0] ?? '');
const lte = parseFloat(value?.[1] ?? '');
let rangeFilter: Filter | undefined;
if (value && dataView && dataViewField && !isNaN(gte) && !isNaN(lte)) {
const params = {
gte,
lte,
} as RangeFilterParams;
let rangeFilter: Filter | undefined;
if (value && dataView && dataViewField && !isNaN(gte) && !isNaN(lte)) {
const params = {
gte,
lte,
} as RangeFilterParams;
rangeFilter = buildRangeFilter(dataViewField, params, dataView);
rangeFilter.meta.key = fieldName;
rangeFilter.meta.type = 'range';
rangeFilter.meta.params = params;
}
api.setOutputFilter(rangeFilter);
});
rangeFilter = buildRangeFilter(dataViewField, params, dataView);
rangeFilter.meta.key = fieldName;
rangeFilter.meta.type = 'range';
rangeFilter.meta.params = params;
}
dataControl.setters.setOutputFilter(rangeFilter);
});
const selectionHasNoResults$ = new BehaviorSubject(false);
const hasNotResultsSubscription = hasNoResults$({
@ -204,8 +203,8 @@ export const getRangesliderControlFactory = (
selectionHasNoResults$.next(hasNoResults);
});
if (initialState.value !== undefined) {
await dataControl.untilFiltersInitialized();
if (selections.hasInitialSelections) {
await dataControl.api.untilFiltersReady();
}
return {
@ -219,7 +218,7 @@ export const getRangesliderControlFactory = (
min$,
selectionHasNoResults$,
step$,
value$
selections.value$
);
useEffect(() => {
@ -240,7 +239,7 @@ export const getRangesliderControlFactory = (
isLoading={typeof dataLoading === 'boolean' ? dataLoading : false}
max={max}
min={min}
onChange={setValue}
onChange={selections.setValue}
step={step ?? 1}
value={value}
uuid={uuid}

View file

@ -0,0 +1,33 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing';
import { RangeValue, RangesliderControlState } from './types';
export function initializeRangeControlSelections(
initialState: RangesliderControlState,
onSelectionChange: () => void
) {
const value$ = new BehaviorSubject<RangeValue | undefined>(initialState.value);
function setValue(next: RangeValue | undefined) {
if (value$.value !== next) {
value$.next(next);
onSelectionChange();
}
}
return {
comparators: {
value: [value$, setValue],
} as StateComparators<Pick<RangesliderControlState, 'value'>>,
hasInitialSelections: initialState.value !== undefined,
value$: value$ as PublishingSubject<RangeValue | undefined>,
setValue,
};
}

View file

@ -7,8 +7,7 @@
*/
import React, { useEffect, useState } from 'react';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, skip } from 'rxjs';
import { BehaviorSubject, combineLatest, debounceTime, skip } from 'rxjs';
import { EuiFieldSearch, EuiFormRow, EuiRadioGroup } from '@elastic/eui';
import { css } from '@emotion/react';
@ -16,6 +15,7 @@ import { i18n } from '@kbn/i18n';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { euiThemeVars } from '@kbn/ui-theme';
import { Filter } from '@kbn/es-query';
import { initializeDataControl } from '../initialize_data_control';
import { DataControlFactory, DataControlServices } from '../types';
import {
@ -24,6 +24,7 @@ import {
SearchControlTechniques,
SEARCH_CONTROL_TYPE,
} from './types';
import { initializeSearchControlSelections } from './search_control_selections';
const allSearchOptions = [
{
@ -79,7 +80,6 @@ export const getSearchControlFactory = (
);
},
buildControl: async (initialState, buildApi, uuid, parentApi) => {
const searchString = new BehaviorSubject<string | undefined>(initialState.searchString);
const searchTechnique = new BehaviorSubject<SearchControlTechniques | undefined>(
initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE
);
@ -94,6 +94,11 @@ export const getSearchControlFactory = (
services
);
const selections = initializeSearchControlSelections(
initialState,
dataControl.setters.onSelectionChange
);
const api = buildApi(
{
...dataControl.api,
@ -106,64 +111,58 @@ export const getSearchControlFactory = (
return {
rawState: {
...dataControlState,
searchString: searchString.getValue(),
searchString: selections.searchString$.getValue(),
searchTechnique: searchTechnique.getValue(),
},
references, // does not have any references other than those provided by the data control serializer
};
},
clearSelections: () => {
searchString.next(undefined);
selections.setSearchString(undefined);
},
},
{
...dataControl.comparators,
...selections.comparators,
searchTechnique: [
searchTechnique,
(newTechnique: SearchControlTechniques | undefined) =>
searchTechnique.next(newTechnique),
(a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE),
],
searchString: [
searchString,
(newString: string | undefined) =>
searchString.next(newString?.length === 0 ? undefined : newString),
],
}
);
/**
* If either the search string or the search technique changes, recalulate the output filter
*/
const onSearchStringChanged = combineLatest([searchString, searchTechnique])
.pipe(debounceTime(200), distinctUntilChanged(deepEqual))
const onSearchStringChanged = combineLatest([selections.searchString$, searchTechnique])
.pipe(debounceTime(200))
.subscribe(([newSearchString, currentSearchTechnnique]) => {
const currentDataView = dataControl.api.dataViews.getValue()?.[0];
const currentField = dataControl.stateManager.fieldName.getValue();
if (currentDataView && currentField) {
if (newSearchString) {
api.setOutputFilter(
currentSearchTechnnique === 'match'
? {
query: { match: { [currentField]: { query: newSearchString } } },
meta: { index: currentDataView.id },
}
: {
query: {
simple_query_string: {
query: newSearchString,
fields: [currentField],
default_operator: 'and',
},
let filter: Filter | undefined;
if (currentDataView && currentField && newSearchString) {
filter =
currentSearchTechnnique === 'match'
? {
query: { match: { [currentField]: { query: newSearchString } } },
meta: { index: currentDataView.id },
}
: {
query: {
simple_query_string: {
query: newSearchString,
fields: [currentField],
default_operator: 'and',
},
meta: { index: currentDataView.id },
}
);
} else {
api.setOutputFilter(undefined);
}
},
meta: { index: currentDataView.id },
};
}
dataControl.setters.setOutputFilter(filter);
});
/**
@ -176,11 +175,11 @@ export const getSearchControlFactory = (
])
.pipe(skip(1))
.subscribe(() => {
searchString.next(undefined);
selections.setSearchString(undefined);
});
if (initialState.searchString?.length) {
await dataControl.untilFiltersInitialized();
await dataControl.api.untilFiltersReady();
}
return {
@ -190,7 +189,7 @@ export const getSearchControlFactory = (
* ControlPanel that are necessary for styling
*/
Component: ({ className: controlPanelClassName }) => {
const currentSearch = useStateFromPublishingSubject(searchString);
const currentSearch = useStateFromPublishingSubject(selections.searchString$);
useEffect(() => {
return () => {
@ -211,7 +210,7 @@ export const getSearchControlFactory = (
isClearable={false} // this will be handled by the clear floating action instead
value={currentSearch ?? ''}
onChange={(event) => {
searchString.next(event.target.value);
selections.setSearchString(event.target.value);
}}
placeholder={i18n.translate('controls.searchControl.placeholder', {
defaultMessage: 'Search...',

View file

@ -0,0 +1,33 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing';
import { SearchControlState } from './types';
export function initializeSearchControlSelections(
initialState: SearchControlState,
onSelectionChange: () => void
) {
const searchString$ = new BehaviorSubject<string | undefined>(initialState.searchString);
function setSearchString(next: string | undefined) {
if (searchString$.value !== next) {
searchString$.next(next);
onSelectionChange();
}
}
return {
comparators: {
searchString: [searchString$, setSearchString],
} as StateComparators<Pick<SearchControlState, 'searchString'>>,
hasInitialSelections: initialState.searchString?.length,
searchString$: searchString$ as PublishingSubject<string | undefined>,
setSearchString,
};
}

View file

@ -10,17 +10,16 @@ import { CoreStart } from '@kbn/core/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { Filter } from '@kbn/es-query';
import { FieldFormatConvertFunction } from '@kbn/field-formats-plugin/common';
import {
HasEditCapabilities,
PublishesDataViews,
PublishesFilters,
PublishesPanelTitle,
PublishingSubject,
} from '@kbn/presentation-publishing';
import { ControlGroupApi } from '../control_group/types';
import { ControlFactory, DefaultControlApi, DefaultControlState } from '../types';
import { PublishesAsyncFilters } from './publishes_async_filters';
export type DataControlFieldFormatter = FieldFormatConvertFunction | ((toFormat: any) => string);
@ -34,9 +33,7 @@ export type DataControlApi = DefaultControlApi &
HasEditCapabilities &
PublishesDataViews &
PublishesField &
PublishesFilters & {
setOutputFilter: (filter: Filter | undefined) => void; // a control should only ever output a **single** filter
};
PublishesAsyncFilters;
export interface CustomOptionsComponentProps<
State extends DefaultDataControlState = DefaultDataControlState

View file

@ -31,7 +31,7 @@ export interface DefaultEmbeddableApi<
> extends DefaultPresentationPanelApi,
HasType,
PublishesPhaseEvents,
PublishesUnsavedChanges,
Partial<PublishesUnsavedChanges>,
HasSerializableState<SerializedState>,
HasSnapshottableState<RuntimeState> {}