mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
88144cc45c
commit
b87e967f46
24 changed files with 437 additions and 228 deletions
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -196,7 +196,6 @@ export const OptionsListPopoverSuggestions = ({
|
|||
)}
|
||||
emptyMessage={<OptionsListPopoverEmptyMessage showOnlySelected={showOnlySelected} />}
|
||||
onChange={(newSuggestions, _, changedOption) => {
|
||||
setSelectableOptions(newSuggestions);
|
||||
api.makeSelection(changedOption.key, showOnlySelected);
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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...',
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -31,7 +31,7 @@ export interface DefaultEmbeddableApi<
|
|||
> extends DefaultPresentationPanelApi,
|
||||
HasType,
|
||||
PublishesPhaseEvents,
|
||||
PublishesUnsavedChanges,
|
||||
Partial<PublishesUnsavedChanges>,
|
||||
HasSerializableState<SerializedState>,
|
||||
HasSnapshottableState<RuntimeState> {}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue