From 3412ec18041d8a45c05b40e26b3f11c701697abd Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 2 Aug 2024 12:01:57 -0600 Subject: [PATCH 01/14] [control group] apply selections on reset --- .../react_control_example.tsx | 16 ++- .../control_group_unsaved_changes_api.ts | 16 ++- .../get_control_group_factory.tsx | 1 + .../react_controls/control_group/types.ts | 3 +- .../data_controls/initialize_data_control.ts | 65 ++++++----- .../fetch_and_validate.tsx | 8 +- .../get_options_list_control_factory.tsx | 108 +++++++++--------- .../options_list_context_provider.tsx | 10 +- .../options_list_control_selections.ts | 48 ++++++++ .../data_controls/publishes_async_filters.ts | 28 +++++ .../get_range_slider_control_factory.tsx | 37 +++--- .../range_slider/range_control_selections.ts | 27 +++++ .../get_search_control_factory.tsx | 73 +++++++----- .../search_control_selections.ts | 27 +++++ .../react_controls/data_controls/types.ts | 7 +- 15 files changed, 327 insertions(+), 147 deletions(-) create mode 100644 examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts create mode 100644 examples/controls_example/public/react_controls/data_controls/publishes_async_filters.ts create mode 100644 examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts create mode 100644 examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts diff --git a/examples/controls_example/public/app/react_control_example/react_control_example.tsx b/examples/controls_example/public/app/react_control_example/react_control_example.tsx index c3420cf22b609..76adcfb7e3c72 100644 --- a/examples/controls_example/public/app/react_control_example/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example/react_control_example.tsx @@ -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(false); }, []); @@ -112,6 +113,7 @@ export const ReactControlExample = ({ const [controlGroupApi, setControlGroupApi] = useState(undefined); const [isControlGroupInitialized, setIsControlGroupInitialized] = useState(false); const [dataViewNotFound, setDataViewNotFound] = useState(false); + const [isResetting, setIsResetting] = useState(false); const dashboardApi = useMemo(() => { const query$ = new BehaviorSubject(undefined); @@ -361,9 +363,15 @@ export const ReactControlExample = ({ { - controlGroupApi?.resetUnsavedChanges(); + isDisabled={!controlGroupApi || isResetting} + isLoading={isResetting} + onClick={async () => { + if (!controlGroupApi) { + return; + } + setIsResetting(true); + await controlGroupApi.asyncResetUnsavedChanges(); + if (isMounted()) setIsResetting(false); }} > Reset diff --git a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts index 399fbf6c463cd..a988517ab0504 100644 --- a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts +++ b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts @@ -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, snapshotControlsRuntimeState: () => ControlPanelsState, @@ -68,12 +70,22 @@ export function initializeControlGroupUnsavedChanges( return Object.keys(unsavedChanges).length ? unsavedChanges : undefined; }) ), - resetUnsavedChanges: () => { + asyncResetUnsavedChanges: async () => { controlGroupUnsavedChanges.api.resetUnsavedChanges(); + + const filtersReadyPromises: Array> = []; Object.values(children$.value).forEach((controlApi) => { if (apiPublishesUnsavedChanges(controlApi)) controlApi.resetUnsavedChanges(); + if (apiPublishesAsyncFilters(controlApi)) { + filtersReadyPromises.push(controlApi.untilFiltersReady()); + } }); + + await Promise.all(filtersReadyPromises); + applySelections(); }, - } as PublishesUnsavedChanges, + } as Pick & { + asyncResetUnsavedChanges: () => Promise; + }, }; } diff --git a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx index 13e6456071a47..896c9cebc641e 100644 --- a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx +++ b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx @@ -96,6 +96,7 @@ export const getControlGroupEmbeddableFactory = (services: { const dataLoading$ = new BehaviorSubject(false); const unsavedChanges = initializeControlGroupUnsavedChanges( + selectionsManager.applySelections, controlsManager.api.children$, { ...controlsManager.comparators, diff --git a/examples/controls_example/public/react_controls/control_group/types.ts b/examples/controls_example/public/react_controls/control_group/types.ts index 65db5b8121b1b..17ce4f1890751 100644 --- a/examples/controls_example/public/react_controls/control_group/types.ts +++ b/examples/controls_example/public/react_controls/control_group/types.ts @@ -55,10 +55,11 @@ export type ControlGroupApi = PresentationContainer & HasSerializedChildState & HasEditCapabilities & PublishesDataLoading & - PublishesUnsavedChanges & + Pick & PublishesControlGroupDisplaySettings & PublishesTimeslice & Partial & HasSaveNotification> & { + asyncResetUnsavedChanges: () => Promise; autoApplySelections$: PublishingSubject; controlFetch$: (controlUuid: string) => Observable; getLastSavedControlState: (controlUuid: string) => object; diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts index 42119456bc0ef..b653c3345b55d 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts @@ -7,7 +7,7 @@ */ import { isEqual } from 'lodash'; -import { BehaviorSubject, combineLatest, first, switchMap } from 'rxjs'; +import { BehaviorSubject, combineLatest, first, switchMap, tap } from 'rxjs'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { @@ -45,9 +45,12 @@ export const initializeDataControl = ( api: ControlApiInitialization; cleanup: () => void; comparators: StateComparators; + setters: { + onSelectionChange: () => void; + setOutputFilter: (filter: Filter | undefined) => void; + }; stateManager: ControlStateManager; serialize: () => SerializedPanelState; - untilFiltersInitialized: () => Promise; } => { const defaultControl = initializeDefaultControlApi(state); @@ -57,6 +60,7 @@ export const initializeDataControl = ( const fieldName = new BehaviorSubject(state.fieldName); const dataViews = new BehaviorSubject(undefined); const filters$ = new BehaviorSubject(undefined); + const filtersReady$ = new BehaviorSubject(false); const field$ = new BehaviorSubject(undefined); const fieldFormatter = new BehaviorSubject((toFormat: any) => String(toFormat) @@ -69,14 +73,13 @@ export const initializeDataControl = ( title: panelTitle, }; - function clearBlockingError() { - if (defaultControl.api.blockingError.value) { - defaultControl.api.setBlockingError(undefined); - } - } - const dataViewIdSubscription = dataViewId .pipe( + tap(() => { + if (defaultControl.api.blockingError.value) { + defaultControl.api.setBlockingError(undefined); + } + }), switchMap(async (currentDataViewId) => { let dataView: DataView | undefined; try { @@ -90,8 +93,6 @@ export const initializeDataControl = ( .subscribe(({ dataView, error }) => { if (error) { defaultControl.api.setBlockingError(error); - } else { - clearBlockingError(); } dataViews.next(dataView ? [dataView] : undefined); }); @@ -115,8 +116,8 @@ export const initializeDataControl = ( }) ) ); - } else { - clearBlockingError(); + } else if (defaultControl.api.blockingError.value) { + defaultControl.api.setBlockingError(undefined); } field$.next(field); @@ -181,10 +182,21 @@ export const initializeDataControl = ( fieldFormatter, onEdit, filters$, - setOutputFilter: (newFilter: Filter | undefined) => { - filters$.next(newFilter ? [newFilter] : undefined); - }, isEditingEnabled: () => true, + untilFiltersReady: async () => { + return new Promise((resolve) => { + combineLatest([defaultControl.api.blockingError, filters$]) + .pipe( + first( + ([blockingError, filters]) => + blockingError !== undefined || (filters?.length ?? 0) > 0 + ) + ) + .subscribe(() => { + resolve(); + }); + }); + }, }; return { @@ -199,6 +211,15 @@ export const initializeDataControl = ( 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); + filtersReady$.next(true); + }, + }, stateManager, serialize: () => { return { @@ -217,19 +238,5 @@ export const initializeDataControl = ( ], }; }, - untilFiltersInitialized: async () => { - return new Promise((resolve) => { - combineLatest([defaultControl.api.blockingError, filters$]) - .pipe( - first( - ([blockingError, filters]) => - blockingError !== undefined || (filters?.length ?? 0) > 0 - ) - ) - .subscribe(() => { - resolve(); - }); - }); - }, }; }; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx index 5ed0d00623706..6f02e26864184 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/fetch_and_validate.tsx @@ -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; }; services: DataControlServices; - stateManager: ControlStateManager; + stateManager: ControlStateManager< + Pick + > & { + selectedOptions: PublishingSubject; + }; }): Observable { const requestCache = new OptionsListFetchCache(); let abortController: AbortController | undefined; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 22927cadf3cb1..6583091f40e7a 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -38,6 +38,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 @@ -61,14 +62,9 @@ export const getOptionsListControlFactory = ( ); const runPastTimeout$ = new BehaviorSubject(initialState.runPastTimeout); const singleSelect$ = new BehaviorSubject(initialState.singleSelect); - const selections$ = new BehaviorSubject( - initialState.selectedOptions ?? [] - ); const sort$ = new BehaviorSubject( initialState.sort ?? OPTIONS_LIST_DEFAULT_SORT ); - const existsSelected$ = new BehaviorSubject(initialState.existsSelected); - const excludeSelected$ = new BehaviorSubject(initialState.exclude); /** Creation options state - cannot currently be changed after creation, but need subjects for comparators */ const placeholder$ = new BehaviorSubject(initialState.placeholder); @@ -97,12 +93,17 @@ export const getOptionsListControlFactory = ( services ); + const optionsListControlSelections = initializeOptionsListSelections( + initialState, + dataControl.setters.onSelectionChange + ); + const stateManager = { ...dataControl.stateManager, - exclude: excludeSelected$, - existsSelected: existsSelected$, + exclude: optionsListControlSelections.exclude$, + existsSelected: optionsListControlSelections.existsSelected$, searchTechnique: searchTechnique$, - selectedOptions: selections$, + selectedOptions: optionsListControlSelections.selectedOptions$, singleSelect: singleSelect$, sort: sort$, searchString: searchString$, @@ -149,9 +150,7 @@ export const getOptionsListControlFactory = ( ) .subscribe(() => { searchString$.next(''); - selections$.next(undefined); - existsSelected$.next(false); - excludeSelected$.next(false); + optionsListControlSelections.clearSelections(); requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE); sort$.next(OPTIONS_LIST_DEFAULT_SORT); }); @@ -195,37 +194,38 @@ 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 = optionsListControlSelections.selectedOptions$.getValue() ?? []; + if (currentSelections.length > 1) + optionsListControlSelections.setSelectedOptions([currentSelections[0]]); }); /** Output filters when selections change */ const outputFilterSubscription = combineLatest([ dataControl.api.dataViews, dataControl.stateManager.fieldName, - selections$, - existsSelected$, - excludeSelected$, + optionsListControlSelections.selectedOptions$, + optionsListControlSelections.existsSelected$, + optionsListControlSelections.exclude$, ]).subscribe(([dataViews, fieldName, selections, 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 (dataView && field) { + 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); + dataControl.setters.setOutputFilter(newFilter); }); const api = buildApi( @@ -240,10 +240,10 @@ export const getOptionsListControlFactory = ( searchTechnique: searchTechnique$.getValue(), runPastTimeout: runPastTimeout$.getValue(), singleSelect: singleSelect$.getValue(), - selections: selections$.getValue(), + selections: optionsListControlSelections.selectedOptions$.getValue(), sort: sort$.getValue(), - existsSelected: existsSelected$.getValue(), - exclude: excludeSelected$.getValue(), + existsSelected: optionsListControlSelections.existsSelected$.getValue(), + exclude: optionsListControlSelections.exclude$.getValue(), // serialize state that cannot be changed to keep it consistent placeholder: placeholder$.getValue(), @@ -255,16 +255,15 @@ export const getOptionsListControlFactory = ( references, // does not have any references other than those provided by the data control serializer }; }, - clearSelections: () => { - if (selections$.getValue()?.length) selections$.next([]); - if (existsSelected$.getValue()) existsSelected$.next(false); - if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([])); - }, + clearSelections: optionsListControlSelections.clearSelections, }, { ...dataControl.comparators, - exclude: [excludeSelected$, (selected) => excludeSelected$.next(selected)], - existsSelected: [existsSelected$, (selected) => existsSelected$.next(selected)], + exclude: [optionsListControlSelections.exclude$, optionsListControlSelections.setExclude], + existsSelected: [ + optionsListControlSelections.existsSelected$, + optionsListControlSelections.setExistsSelected, + ], runPastTimeout: [runPastTimeout$, (runPast) => runPastTimeout$.next(runPast)], searchTechnique: [ searchTechnique$, @@ -272,8 +271,8 @@ export const getOptionsListControlFactory = ( (a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE), ], selectedOptions: [ - selections$, - (selections) => selections$.next(selections), + optionsListControlSelections.selectedOptions$, + optionsListControlSelections.setSelectedOptions, (a, b) => deepEqual(a ?? [], b ?? []), ], singleSelect: [singleSelect$, (selected) => singleSelect$.next(selected)], @@ -294,7 +293,7 @@ export const getOptionsListControlFactory = ( const componentApi = { ...api, - selections$, + selections$: optionsListControlSelections.selectedOptions$, loadMoreSubject, totalCardinality$, availableOptions$, @@ -311,12 +310,14 @@ export const getOptionsListControlFactory = ( const keyAsType = getSelectionAsFieldType(field, key); // delete from selections - const selectedOptions = selections$.getValue() ?? []; - const itemIndex = (selections$.getValue() ?? []).indexOf(keyAsType); + const selectedOptions = optionsListControlSelections.selectedOptions$.getValue() ?? []; + const itemIndex = ( + optionsListControlSelections.selectedOptions$.getValue() ?? [] + ).indexOf(keyAsType); if (itemIndex !== -1) { const newSelections = [...selectedOptions]; newSelections.splice(itemIndex, 1); - selections$.next(newSelections); + optionsListControlSelections.setSelectedOptions(newSelections); } // delete from invalid selections const currentInvalid = invalidSelections$.getValue(); @@ -334,37 +335,38 @@ export const getOptionsListControlFactory = ( return; } - const existsSelected = Boolean(existsSelected$.getValue()); - const selectedOptions = selections$.getValue() ?? []; + const existsSelected = Boolean(optionsListControlSelections.existsSelected$.getValue()); + const selectedOptions = optionsListControlSelections.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); + optionsListControlSelections.setExistsSelected(!existsSelected); if (!existsSelected) { - selections$.next([]); + optionsListControlSelections.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); + optionsListControlSelections.setSelectedOptions([keyAsType]); + if (existsSelected) optionsListControlSelections.setExistsSelected(false); } else { // select option - if (!selectedOptions) selections$.next([]); - if (existsSelected) existsSelected$.next(false); - selections$.next([...selectedOptions, keyAsType]); + if (existsSelected) optionsListControlSelections.setExistsSelected(false); + optionsListControlSelections.setSelectedOptions( + selectedOptions ? [...selectedOptions, keyAsType] : [] + ); } }, }; - if (initialState.selectedOptions?.length || initialState.existsSelected) { + if (optionsListControlSelections.hasInitialSelections) { // has selections, so wait for initialization of filters - await dataControl.untilFiltersInitialized(); + await dataControl.api.untilFiltersReady(); } return { diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx index 71783210bddfb..9dccf87ea3e1f 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx @@ -8,17 +8,25 @@ 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 const OptionsListControlContext = React.createContext< | { api: OptionsListComponentApi; - stateManager: ControlStateManager; + stateManager: ControlStateManager< + Omit + > & { + selectedOptions: PublishingSubject; + existsSelected: PublishingSubject; + exclude: PublishingSubject; + }; displaySettings: OptionsListDisplaySettings; } | undefined diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts new file mode 100644 index 0000000000000..143686b1b8c5e --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts @@ -0,0 +1,48 @@ +/* + * 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 } 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( + initialState.selectedOptions ?? [] + ); + const existsSelected$ = new BehaviorSubject(initialState.existsSelected); + const exclude$ = new BehaviorSubject(initialState.exclude); + + return { + clearSelections: () => { + selectedOptions$.next(undefined); + existsSelected$.next(false); + exclude$.next(false); + onSelectionChange(); + }, + hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected, + selectedOptions$: selectedOptions$ as PublishingSubject, + setSelectedOptions: (next: OptionsListSelection[] | undefined) => { + selectedOptions$.next(next); + onSelectionChange(); + }, + existsSelected$: existsSelected$ as PublishingSubject, + setExistsSelected: (next: boolean | undefined) => { + existsSelected$.next(next); + onSelectionChange(); + }, + exclude$: exclude$ as PublishingSubject, + setExclude: (next: boolean | undefined) => { + exclude$.next(next); + onSelectionChange(); + }, + }; +} diff --git a/examples/controls_example/public/react_controls/data_controls/publishes_async_filters.ts b/examples/controls_example/public/react_controls/data_controls/publishes_async_filters.ts new file mode 100644 index 0000000000000..18e94d048f98f --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/publishes_async_filters.ts @@ -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; +}; + +export const apiPublishesAsyncFilters = ( + unknownApi: unknown +): unknownApi is PublishesAsyncFilters => { + return Boolean( + unknownApi && + apiPublishesFilters(unknownApi) && + (unknownApi as PublishesAsyncFilters)?.untilFiltersReady !== undefined + ); +}; diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 385a93cf7e1d5..9a5207e4034ab 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -17,12 +17,8 @@ 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(false); const dataLoading$ = new BehaviorSubject(undefined); const step$ = new BehaviorSubject(initialState.step ?? 1); - const value$ = new BehaviorSubject(initialState.value); - function setValue(nextValue: RangeValue | undefined) { - value$.next(nextValue); - } const dataControl = initializeDataControl>( uuid, @@ -78,6 +70,11 @@ export const getRangesliderControlFactory = ( services ); + const rangeControlSelections = initializeRangeControlSelections( + initialState, + dataControl.setters.onSelectionChange + ); + const api = buildApi( { ...dataControl.api, @@ -89,13 +86,13 @@ export const getRangesliderControlFactory = ( rawState: { ...dataControlState, step: step$.getValue(), - value: value$.getValue(), + value: rangeControlSelections.value$.getValue(), }, references, // does not have any references other than those provided by the data control serializer }; }, clearSelections: () => { - value$.next(undefined); + rangeControlSelections.setValue(undefined); }, }, { @@ -105,7 +102,7 @@ export const getRangesliderControlFactory = ( (nextStep: number | undefined) => step$.next(nextStep), (a, b) => (a ?? 1) === (b ?? 1), ], - value: [value$, setValue], + value: [rangeControlSelections.value$, rangeControlSelections.setValue], } ); @@ -129,7 +126,7 @@ export const getRangesliderControlFactory = ( .pipe(skip(1)) .subscribe(() => { step$.next(1); - value$.next(undefined); + rangeControlSelections.setValue(undefined); }); const max$ = new BehaviorSubject(undefined); @@ -167,7 +164,7 @@ export const getRangesliderControlFactory = ( const outputFilterSubscription = combineLatest([ dataControl.api.dataViews, dataControl.stateManager.fieldName, - value$, + rangeControlSelections.value$, ]).subscribe(([dataViews, fieldName, value]) => { const dataView = dataViews?.[0]; const dataViewField = @@ -187,7 +184,7 @@ export const getRangesliderControlFactory = ( rangeFilter.meta.type = 'range'; rangeFilter.meta.params = params; } - api.setOutputFilter(rangeFilter); + dataControl.setters.setOutputFilter(rangeFilter); }); const selectionHasNoResults$ = new BehaviorSubject(false); @@ -204,8 +201,8 @@ export const getRangesliderControlFactory = ( selectionHasNoResults$.next(hasNoResults); }); - if (initialState.value !== undefined) { - await dataControl.untilFiltersInitialized(); + if (rangeControlSelections.hasInitialSelections) { + await dataControl.api.untilFiltersReady(); } return { @@ -219,7 +216,7 @@ export const getRangesliderControlFactory = ( min$, selectionHasNoResults$, step$, - value$ + rangeControlSelections.value$ ); useEffect(() => { @@ -240,7 +237,7 @@ export const getRangesliderControlFactory = ( isLoading={typeof dataLoading === 'boolean' ? dataLoading : false} max={max} min={min} - onChange={setValue} + onChange={rangeControlSelections.setValue} step={step ?? 1} value={value} uuid={uuid} diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts new file mode 100644 index 0000000000000..ff00469c65d7f --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts @@ -0,0 +1,27 @@ +/* + * 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 } from '@kbn/presentation-publishing'; +import { RangeValue, RangesliderControlState } from './types'; + +export function initializeRangeControlSelections( + initialState: RangesliderControlState, + onSelectionChange: () => void +) { + const value$ = new BehaviorSubject(initialState.value); + + return { + hasInitialSelections: initialState.value !== undefined, + value$: value$ as PublishingSubject, + setValue: (nextValue: RangeValue | undefined) => { + value$.next(nextValue); + onSelectionChange(); + }, + }; +} diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx index 6eed314ccdaa2..f07d111899fa7 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx @@ -16,6 +16,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 +25,7 @@ import { SearchControlTechniques, SEARCH_CONTROL_TYPE, } from './types'; +import { initializeSearchControlSelections } from './search_control_selections'; const allSearchOptions = [ { @@ -79,7 +81,6 @@ export const getSearchControlFactory = ( ); }, buildControl: async (initialState, buildApi, uuid, parentApi) => { - const searchString = new BehaviorSubject(initialState.searchString); const searchTechnique = new BehaviorSubject( initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE ); @@ -94,6 +95,11 @@ export const getSearchControlFactory = ( services ); + const searchControlSelections = initializeSearchControlSelections( + initialState, + dataControl.setters.onSelectionChange + ); + const api = buildApi( { ...dataControl.api, @@ -106,14 +112,14 @@ export const getSearchControlFactory = ( return { rawState: { ...dataControlState, - searchString: searchString.getValue(), + searchString: searchControlSelections.searchString$.getValue(), searchTechnique: searchTechnique.getValue(), }, references, // does not have any references other than those provided by the data control serializer }; }, clearSelections: () => { - searchString.next(undefined); + searchControlSelections.setSearchString(undefined); }, }, { @@ -125,9 +131,11 @@ export const getSearchControlFactory = ( (a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE), ], searchString: [ - searchString, + searchControlSelections.searchString$, (newString: string | undefined) => - searchString.next(newString?.length === 0 ? undefined : newString), + searchControlSelections.setSearchString( + newString?.length === 0 ? undefined : newString + ), ], } ); @@ -135,35 +143,36 @@ export const getSearchControlFactory = ( /** * If either the search string or the search technique changes, recalulate the output filter */ - const onSearchStringChanged = combineLatest([searchString, searchTechnique]) + const onSearchStringChanged = combineLatest([ + searchControlSelections.searchString$, + searchTechnique, + ]) .pipe(debounceTime(200), distinctUntilChanged(deepEqual)) .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 +185,11 @@ export const getSearchControlFactory = ( ]) .pipe(skip(1)) .subscribe(() => { - searchString.next(undefined); + searchControlSelections.setSearchString(undefined); }); if (initialState.searchString?.length) { - await dataControl.untilFiltersInitialized(); + await dataControl.api.untilFiltersReady(); } return { @@ -190,7 +199,9 @@ export const getSearchControlFactory = ( * ControlPanel that are necessary for styling */ Component: ({ className: controlPanelClassName }) => { - const currentSearch = useStateFromPublishingSubject(searchString); + const currentSearch = useStateFromPublishingSubject( + searchControlSelections.searchString$ + ); useEffect(() => { return () => { @@ -211,7 +222,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); + searchControlSelections.setSearchString(event.target.value); }} placeholder={i18n.translate('controls.searchControl.placeholder', { defaultMessage: 'Search...', diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts new file mode 100644 index 0000000000000..091cf9fa30abe --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts @@ -0,0 +1,27 @@ +/* + * 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 } from '@kbn/presentation-publishing'; +import { SearchControlState } from './types'; + +export function initializeSearchControlSelections( + initialState: SearchControlState, + onSelectionChange: () => void +) { + const searchString$ = new BehaviorSubject(initialState.searchString); + + return { + hasInitialSelections: initialState.searchString?.length, + searchString$: searchString$ as PublishingSubject, + setSearchString: (next: string | undefined) => { + searchString$.next(next); + onSelectionChange(); + }, + }; +} diff --git a/examples/controls_example/public/react_controls/data_controls/types.ts b/examples/controls_example/public/react_controls/data_controls/types.ts index db4cba8773232..39effdb015184 100644 --- a/examples/controls_example/public/react_controls/data_controls/types.ts +++ b/examples/controls_example/public/react_controls/data_controls/types.ts @@ -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 From 635540c4e90adea2b19cc80a05fdd392d194bc50 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 2 Aug 2024 13:27:26 -0600 Subject: [PATCH 02/14] fix untilFiltersReady --- .../control_group_unsaved_changes_api.ts | 2 + .../data_controls/initialize_data_control.ts | 7 +-- .../get_options_list_control_factory.tsx | 45 +++++++++--------- .../options_list_control_selections.ts | 25 +++++++--- .../get_range_slider_control_factory.tsx | 46 ++++++++++--------- .../range_slider/range_control_selections.ts | 8 ++-- .../search_control_selections.ts | 6 ++- 7 files changed, 79 insertions(+), 60 deletions(-) diff --git a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts index a988517ab0504..b44bc1059c3a2 100644 --- a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts +++ b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts @@ -82,6 +82,8 @@ export function initializeControlGroupUnsavedChanges( }); await Promise.all(filtersReadyPromises); + // wait to allow controlGroup controlApi.filters$ subscriptions to fire + await new Promise((resolve) => setTimeout(resolve, 10)); applySelections(); }, } as Pick & { diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts index b653c3345b55d..d56d250fdde3a 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts @@ -185,12 +185,9 @@ export const initializeDataControl = ( isEditingEnabled: () => true, untilFiltersReady: async () => { return new Promise((resolve) => { - combineLatest([defaultControl.api.blockingError, filters$]) + combineLatest([defaultControl.api.blockingError, filtersReady$]) .pipe( - first( - ([blockingError, filters]) => - blockingError !== undefined || (filters?.length ?? 0) > 0 - ) + first(([blockingError, filtersReady]) => filtersReady || blockingError !== undefined) ) .subscribe(() => { resolve(); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 6583091f40e7a..a273a7f862cc9 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -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'; @@ -206,27 +205,29 @@ export const getOptionsListControlFactory = ( optionsListControlSelections.selectedOptions$, optionsListControlSelections.existsSelected$, optionsListControlSelections.exclude$, - ]).subscribe(([dataViews, fieldName, selections, existsSelected, exclude]) => { - const dataView = dataViews?.[0]; - const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; - - let newFilter: Filter | undefined; - if (dataView && field) { - 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); + ]) + .pipe(debounceTime(0)) + .subscribe(([dataViews, fieldName, selections, existsSelected, exclude]) => { + const dataView = dataViews?.[0]; + const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; + + let newFilter: Filter | undefined; + if (dataView && field) { + 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; - } - dataControl.setters.setOutputFilter(newFilter); - }); + if (newFilter) { + newFilter.meta.key = field?.name; + if (exclude) newFilter.meta.negate = true; + } + dataControl.setters.setOutputFilter(newFilter); + }); const api = buildApi( { @@ -273,7 +274,7 @@ export const getOptionsListControlFactory = ( selectedOptions: [ optionsListControlSelections.selectedOptions$, optionsListControlSelections.setSelectedOptions, - (a, b) => deepEqual(a ?? [], b ?? []), + optionsListControlSelections.selectedOptionsComparatorFunction, ], singleSelect: [singleSelect$, (selected) => singleSelect$.next(selected)], sort: [ diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts index 143686b1b8c5e..c70ce0038b498 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts @@ -7,6 +7,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import deepEqual from 'react-fast-compare'; import { PublishingSubject } from '@kbn/presentation-publishing'; import { OptionsListControlState } from './types'; import { OptionsListSelection } from '../../../../common/options_list/options_list_selections'; @@ -21,6 +22,11 @@ export function initializeOptionsListSelections( const existsSelected$ = new BehaviorSubject(initialState.existsSelected); const exclude$ = new BehaviorSubject(initialState.exclude); + const selectedOptionsComparatorFunction = ( + a: OptionsListSelection[] | undefined, + b: OptionsListSelection[] | undefined + ) => deepEqual(a ?? [], b ?? []); + return { clearSelections: () => { selectedOptions$.next(undefined); @@ -30,19 +36,26 @@ export function initializeOptionsListSelections( }, hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected, selectedOptions$: selectedOptions$ as PublishingSubject, + selectedOptionsComparatorFunction, setSelectedOptions: (next: OptionsListSelection[] | undefined) => { - selectedOptions$.next(next); - onSelectionChange(); + if (selectedOptionsComparatorFunction(selectedOptions$.value, next)) { + selectedOptions$.next(next); + onSelectionChange(); + } }, existsSelected$: existsSelected$ as PublishingSubject, setExistsSelected: (next: boolean | undefined) => { - existsSelected$.next(next); - onSelectionChange(); + if (existsSelected$.value !== next) { + existsSelected$.next(next); + onSelectionChange(); + } }, exclude$: exclude$ as PublishingSubject, setExclude: (next: boolean | undefined) => { - exclude$.next(next); - onSelectionChange(); + if (exclude$.value !== next) { + exclude$.next(next); + onSelectionChange(); + } }, }; } diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 9a5207e4034ab..ae4e21ff1e77e 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -10,7 +10,7 @@ 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'; @@ -165,27 +165,29 @@ export const getRangesliderControlFactory = ( dataControl.api.dataViews, dataControl.stateManager.fieldName, rangeControlSelections.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] ?? ''); - - 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; - } - dataControl.setters.setOutputFilter(rangeFilter); - }); + ]) + .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; + + 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$({ diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts index ff00469c65d7f..7922784a5468b 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts @@ -19,9 +19,11 @@ export function initializeRangeControlSelections( return { hasInitialSelections: initialState.value !== undefined, value$: value$ as PublishingSubject, - setValue: (nextValue: RangeValue | undefined) => { - value$.next(nextValue); - onSelectionChange(); + setValue: (next: RangeValue | undefined) => { + if (value$.value !== next) { + value$.next(next); + onSelectionChange(); + } }, }; } diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts index 091cf9fa30abe..d896824fc12df 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts +++ b/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts @@ -20,8 +20,10 @@ export function initializeSearchControlSelections( hasInitialSelections: initialState.searchString?.length, searchString$: searchString$ as PublishingSubject, setSearchString: (next: string | undefined) => { - searchString$.next(next); - onSelectionChange(); + if (searchString$.value !== next) { + searchString$.next(next); + onSelectionChange(); + } }, }; } From 110b029311b312055b9f08a12aab064be49a760e Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 2 Aug 2024 15:22:34 -0600 Subject: [PATCH 03/14] avoid static timer --- .../control_group_unsaved_changes_api.ts | 2 -- .../data_controls/initialize_data_control.ts | 11 +++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts index b44bc1059c3a2..a988517ab0504 100644 --- a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts +++ b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts @@ -82,8 +82,6 @@ export function initializeControlGroupUnsavedChanges( }); await Promise.all(filtersReadyPromises); - // wait to allow controlGroup controlApi.filters$ subscriptions to fire - await new Promise((resolve) => setTimeout(resolve, 10)); applySelections(); }, } as Pick & { diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts index d56d250fdde3a..ee9c6645655df 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts @@ -7,7 +7,7 @@ */ import { isEqual } from 'lodash'; -import { BehaviorSubject, combineLatest, first, switchMap, tap } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, first, switchMap, tap } from 'rxjs'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { @@ -173,6 +173,13 @@ export const initializeDataControl = ( }); }; + const filtersReadySubscription = filters$.pipe(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 = { ...defaultControl.api, panelTitle, @@ -201,6 +208,7 @@ export const initializeDataControl = ( cleanup: () => { dataViewIdSubscription.unsubscribe(); fieldNameSubscription.unsubscribe(); + filtersReadySubscription.unsubscribe(); }, comparators: { ...defaultControl.comparators, @@ -214,7 +222,6 @@ export const initializeDataControl = ( }, setOutputFilter: (newFilter: Filter | undefined) => { filters$.next(newFilter ? [newFilter] : undefined); - filtersReady$.next(true); }, }, stateManager, From fa376f767ed0dd8211d5cdc56e0d5bc9df3aa2ae Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 2 Aug 2024 15:41:25 -0600 Subject: [PATCH 04/14] tslint --- .../data_controls/initialize_data_control.ts | 4 ++-- .../data_controls/mocks/api_mocks.tsx | 13 +++++++++--- .../components/options_list_control.test.tsx | 17 ++++++++------- .../components/options_list_popover.test.tsx | 21 +++++++++++-------- .../options_list_popover_footer.tsx | 6 ++---- ...tions_list_popover_sorting_button.test.tsx | 3 +++ .../get_options_list_control_factory.tsx | 1 + .../options_list_context_provider.tsx | 1 + .../public/react_embeddable_system/types.ts | 2 +- 9 files changed, 42 insertions(+), 26 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts index ee9c6645655df..d7baf3657a877 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts @@ -175,9 +175,9 @@ export const initializeDataControl = ( const filtersReadySubscription = filters$.pipe(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 signaling filters ready until after filters have been emitted // to avoid timing issues - filtersReady$.next(true) + filtersReady$.next(true); }); const api: ControlApiInitialization = { diff --git a/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx b/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx index 7f08a96e0ad71..775441768a0a5 100644 --- a/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx +++ b/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx @@ -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(undefined); + const exclude$ = new BehaviorSubject(undefined); + const existsSelected$ = new BehaviorSubject(undefined); return { api: { uuid: 'testControl', @@ -35,12 +39,15 @@ export const getOptionsListMocks = () => { searchString: new BehaviorSubject(''), searchStringValid: new BehaviorSubject(true), fieldName: new BehaviorSubject('field'), - exclude: new BehaviorSubject(undefined), - existsSelected: new BehaviorSubject(undefined), + exclude: exclude$ as PublishingSubject, + existsSelected: existsSelected$ as PublishingSubject, sort: new BehaviorSubject(undefined), - selectedOptions: new BehaviorSubject(undefined), + selectedOptions: selectedOptions$ as PublishingSubject, searchTechnique: new BehaviorSubject(undefined), }, + setExclude: (next: boolean | undefined) => exclude$.next(next), + setSelectedOptions: (next: OptionsListSelection[] | undefined) => selectedOptions$.next(next), + setExistsSelected: (next: boolean | undefined) => existsSelected$.next(next), displaySettings: {} as OptionsListDisplaySettings, }; }; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx index c18233d85fc62..184ca9981d098 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx @@ -21,10 +21,12 @@ describe('Options list control', () => { api, displaySettings, stateManager, + setExclude, }: { api: any; displaySettings: any; stateManager: any; + setExclude: (next: boolean | undefined) => void; }) => { return render( { api: api as unknown as OptionsListComponentApi, displaySettings, stateManager: stateManager as unknown as ControlStateManager, + setExclude, }} > @@ -42,8 +45,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.setExclude(false); + mocks.setExistsSelected(true); const control = mountComponent(mocks); const existsOption = control.getByTestId('optionsList-control-testExists'); expect(existsOption).toHaveTextContent('Exists'); @@ -52,8 +55,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.setExclude(true); + mocks.setExistsSelected(true); const control = mountComponent(mocks); const existsOption = control.getByTestId('optionsList-control-testDoesNotExist'); expect(existsOption).toHaveTextContent('DOES NOT Exist'); @@ -68,7 +71,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 +90,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 +108,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', diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx index 05d601e093a4e..38df6b3880e56 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx @@ -30,10 +30,12 @@ describe('Options list popover', () => { api, displaySettings, stateManager, + setExclude, }: { api: any; displaySettings: any; stateManager: any; + setExclude: (next: boolean | undefined) => void; }) => { return render( { api: api as unknown as OptionsListComponentApi, displaySettings, stateManager: stateManager as unknown as ControlStateManager, + setExclude, }} > @@ -83,7 +86,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 +105,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 +124,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 +142,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 +166,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 +188,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 +210,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.setExclude(true); await waitOneTick(); const includeButton = popover.getByTestId('optionsList__includeResults'); @@ -223,7 +226,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 +241,7 @@ describe('Options list popover', () => { ]); const popover = mountComponent(mocks); - mocks.stateManager.existsSelected.next(true); + mocks.setExistsSelected(true); await waitOneTick(); clickShowOnlySelections(popover); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx index aa38330908762..2aa8aac423c9d 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx @@ -38,7 +38,7 @@ const aggregationToggleButtons = [ ]; export const OptionsListPopoverFooter = () => { - const { api, stateManager } = useOptionsListContext(); + const { api, stateManager, setExclude } = useOptionsListContext(); const [exclude, loading, allowExpensiveQueries] = useBatchedPublishingSubjects( stateManager.exclude, @@ -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) => setExclude(optionId === 'optionsList__excludeResults')} buttonSize="compressed" data-test-subj="optionsList__includeExcludeButtonGroup" /> diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx index c86aa85b9116e..81f3f3749dafe 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx @@ -23,10 +23,12 @@ describe('Options list sorting button', () => { api, displaySettings, stateManager, + setExclude, }: { api: any; displaySettings: any; stateManager: any; + setExclude: (next: boolean | undefined) => void; }) => { const component = render( { api: api as unknown as OptionsListComponentApi, displaySettings, stateManager: stateManager as unknown as ControlStateManager, + setExclude, }} > diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index a273a7f862cc9..2fb73e0d998d1 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -399,6 +399,7 @@ export const getOptionsListControlFactory = ( ; exclude: PublishingSubject; }; + setExclude: (next: boolean | undefined) => void; displaySettings: OptionsListDisplaySettings; } | undefined diff --git a/src/plugins/embeddable/public/react_embeddable_system/types.ts b/src/plugins/embeddable/public/react_embeddable_system/types.ts index 8973cef9ce109..5860737b13fc8 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/types.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/types.ts @@ -31,7 +31,7 @@ export interface DefaultEmbeddableApi< > extends DefaultPresentationPanelApi, HasType, PublishesPhaseEvents, - PublishesUnsavedChanges, + Partial, HasSerializableState, HasSnapshottableState {} From 787d71fa7c52e7088c8dad581ee3b8ad3795cae7 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 2 Aug 2024 15:57:41 -0600 Subject: [PATCH 05/14] ContextStateManager --- .../components/options_list_control.test.tsx | 7 +++---- .../components/options_list_popover.test.tsx | 11 +++-------- .../options_list_popover_sorting_button.test.tsx | 7 +++---- .../options_list_context_provider.tsx | 16 +++++++++------- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx index 184ca9981d098..a1bfcc6925dbe 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx @@ -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', () => { @@ -33,7 +32,7 @@ describe('Options list control', () => { value={{ api: api as unknown as OptionsListComponentApi, displaySettings, - stateManager: stateManager as unknown as ControlStateManager, + stateManager: stateManager as unknown as ContextStateManager, setExclude, }} > diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx index 38df6b3880e56..b6e9851924f1d 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx @@ -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', () => { @@ -42,7 +37,7 @@ describe('Options list popover', () => { value={{ api: api as unknown as OptionsListComponentApi, displaySettings, - stateManager: stateManager as unknown as ControlStateManager, + stateManager: stateManager as unknown as ContextStateManager, setExclude, }} > diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx index 81f3f3749dafe..d925147c952a3 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx @@ -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', () => { @@ -35,7 +34,7 @@ describe('Options list sorting button', () => { value={{ api: api as unknown as OptionsListComponentApi, displaySettings, - stateManager: stateManager as unknown as ControlStateManager, + stateManager: stateManager as unknown as ContextStateManager, setExclude, }} > diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx index 69f7b4ed7ef09..08f1ac04d5faf 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_context_provider.tsx @@ -17,16 +17,18 @@ import { } from './types'; import { OptionsListSelection } from '../../../../common/options_list/options_list_selections'; +export type ContextStateManager = ControlStateManager< + Omit +> & { + selectedOptions: PublishingSubject; + existsSelected: PublishingSubject; + exclude: PublishingSubject; +}; + export const OptionsListControlContext = React.createContext< | { api: OptionsListComponentApi; - stateManager: ControlStateManager< - Omit - > & { - selectedOptions: PublishingSubject; - existsSelected: PublishingSubject; - exclude: PublishingSubject; - }; + stateManager: ContextStateManager; setExclude: (next: boolean | undefined) => void; displaySettings: OptionsListDisplaySettings; } From 18fdffa214338a22a16e2acc8e052a25e337d804 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 5 Aug 2024 09:31:48 -0600 Subject: [PATCH 06/14] move comparators into selections files --- .../data_controls/initialize_data_control.ts | 4 +- .../get_options_list_control_factory.tsx | 83 ++++++++----------- .../options_list_control_selections.ts | 48 +++++++---- .../get_range_slider_control_factory.tsx | 18 ++-- .../range_slider/range_control_selections.ts | 18 ++-- .../get_search_control_factory.tsx | 27 ++---- .../search_control_selections.ts | 18 ++-- 7 files changed, 107 insertions(+), 109 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts index d7baf3657a877..f8551ae35db88 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts @@ -7,7 +7,7 @@ */ import { isEqual } from 'lodash'; -import { BehaviorSubject, combineLatest, debounceTime, first, switchMap, tap } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, first, skip, switchMap, tap } from 'rxjs'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { @@ -173,7 +173,7 @@ export const initializeDataControl = ( }); }; - const filtersReadySubscription = filters$.pipe(debounceTime(0)).subscribe(() => { + 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 diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 0d42de44ea80d..009d78c6f88bf 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -93,17 +93,17 @@ export const getOptionsListControlFactory = ( services ); - const optionsListControlSelections = initializeOptionsListSelections( + const selections = initializeOptionsListSelections( initialState, dataControl.setters.onSelectionChange ); const stateManager = { ...dataControl.stateManager, - exclude: optionsListControlSelections.exclude$, - existsSelected: optionsListControlSelections.existsSelected$, + exclude: selections.exclude$, + existsSelected: selections.existsSelected$, searchTechnique: searchTechnique$, - selectedOptions: optionsListControlSelections.selectedOptions$, + selectedOptions: selections.selectedOptions$, singleSelect: singleSelect$, sort: sort$, searchString: searchString$, @@ -150,7 +150,7 @@ export const getOptionsListControlFactory = ( ) .subscribe(() => { searchString$.next(''); - optionsListControlSelections.clearSelections(); + selections.clearSelections(); requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE); sort$.next(OPTIONS_LIST_DEFAULT_SORT); }); @@ -194,21 +194,20 @@ export const getOptionsListControlFactory = ( const singleSelectSubscription = singleSelect$ .pipe(filter((singleSelect) => Boolean(singleSelect))) .subscribe(() => { - const currentSelections = optionsListControlSelections.selectedOptions$.getValue() ?? []; - if (currentSelections.length > 1) - optionsListControlSelections.setSelectedOptions([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, - optionsListControlSelections.selectedOptions$, - optionsListControlSelections.existsSelected$, - optionsListControlSelections.exclude$, + selections.selectedOptions$, + selections.existsSelected$, + selections.exclude$, ]) .pipe(debounceTime(0)) - .subscribe(([dataViews, fieldName, selections, existsSelected, exclude]) => { + .subscribe(([dataViews, fieldName, selectedOptions, existsSelected, exclude]) => { const dataView = dataViews?.[0]; const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; @@ -216,11 +215,11 @@ export const getOptionsListControlFactory = ( if (dataView && field) { if (existsSelected) { newFilter = buildExistsFilter(field, dataView); - } else if (selections && selections.length > 0) { + } else if (selectedOptions && selectedOptions.length > 0) { newFilter = - selections.length === 1 - ? buildPhraseFilter(field, selections[0], dataView) - : buildPhrasesFilter(field, selections, dataView); + selectedOptions.length === 1 + ? buildPhraseFilter(field, selectedOptions[0], dataView) + : buildPhrasesFilter(field, selectedOptions, dataView); } } if (newFilter) { @@ -242,10 +241,10 @@ export const getOptionsListControlFactory = ( searchTechnique: searchTechnique$.getValue(), runPastTimeout: runPastTimeout$.getValue(), singleSelect: singleSelect$.getValue(), - selections: optionsListControlSelections.selectedOptions$.getValue(), + selections: selections.selectedOptions$.getValue(), sort: sort$.getValue(), - existsSelected: optionsListControlSelections.existsSelected$.getValue(), - exclude: optionsListControlSelections.exclude$.getValue(), + existsSelected: selections.existsSelected$.getValue(), + exclude: selections.exclude$.getValue(), // serialize state that cannot be changed to keep it consistent placeholder: placeholder$.getValue(), @@ -257,26 +256,17 @@ export const getOptionsListControlFactory = ( references, // does not have any references other than those provided by the data control serializer }; }, - clearSelections: optionsListControlSelections.clearSelections, + clearSelections: selections.clearSelections, }, { ...dataControl.comparators, - exclude: [optionsListControlSelections.exclude$, optionsListControlSelections.setExclude], - existsSelected: [ - optionsListControlSelections.existsSelected$, - optionsListControlSelections.setExistsSelected, - ], + ...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: [ - optionsListControlSelections.selectedOptions$, - optionsListControlSelections.setSelectedOptions, - optionsListControlSelections.selectedOptionsComparatorFunction, - ], singleSelect: [singleSelect$, (selected) => singleSelect$.next(selected)], sort: [ sort$, @@ -295,7 +285,7 @@ export const getOptionsListControlFactory = ( const componentApi = { ...api, - selections$: optionsListControlSelections.selectedOptions$, + selections$: selections.selectedOptions$, loadMoreSubject, totalCardinality$, availableOptions$, @@ -312,14 +302,12 @@ export const getOptionsListControlFactory = ( const keyAsType = getSelectionAsFieldType(field, key); // delete from selections - const selectedOptions = optionsListControlSelections.selectedOptions$.getValue() ?? []; - const itemIndex = ( - optionsListControlSelections.selectedOptions$.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); - optionsListControlSelections.setSelectedOptions(newSelections); + selections.setSelectedOptions(newSelections); } // delete from invalid selections const currentInvalid = invalidSelections$.getValue(); @@ -337,37 +325,34 @@ export const getOptionsListControlFactory = ( return; } - const existsSelected = Boolean(optionsListControlSelections.existsSelected$.getValue()); - const selectedOptions = optionsListControlSelections.selectedOptions$.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 - optionsListControlSelections.setExistsSelected(!existsSelected); + selections.setExistsSelected(!existsSelected); if (!existsSelected) { - optionsListControlSelections.setSelectedOptions([]); + selections.setSelectedOptions([]); invalidSelections$.next(new Set([])); } } else if (showOnlySelected || selectedOptions.includes(keyAsType)) { componentApi.deselectOption(key); } else if (singleSelect) { // replace selection - optionsListControlSelections.setSelectedOptions([keyAsType]); - if (existsSelected) optionsListControlSelections.setExistsSelected(false); + selections.setSelectedOptions([keyAsType]); + if (existsSelected) selections.setExistsSelected(false); } else { // select option - if (existsSelected) optionsListControlSelections.setExistsSelected(false); - optionsListControlSelections.setSelectedOptions( - selectedOptions ? [...selectedOptions, keyAsType] : [] - ); + if (existsSelected) selections.setExistsSelected(false); + selections.setSelectedOptions(selectedOptions ? [...selectedOptions, keyAsType] : []); } }, }; - if (optionsListControlSelections.hasInitialSelections) { - // has selections, so wait for initialization of filters + if (selections.hasInitialSelections) { await dataControl.api.untilFiltersReady(); } @@ -400,7 +385,7 @@ export const getOptionsListControlFactory = ( ( initialState.selectedOptions ?? [] ); - const existsSelected$ = new BehaviorSubject(initialState.existsSelected); - const exclude$ = new BehaviorSubject(initialState.exclude); - 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(initialState.existsSelected); + function setExistsSelected(next: boolean | undefined) { + if (existsSelected$.value !== next) { + existsSelected$.next(next); + onSelectionChange(); + } + } + + const exclude$ = new BehaviorSubject(initialState.exclude); + function setExclude(next: boolean | undefined) { + if (exclude$.value !== next) { + exclude$.next(next); + onSelectionChange(); + } + } return { clearSelections: () => { @@ -34,6 +53,13 @@ export function initializeOptionsListSelections( exclude$.next(false); onSelectionChange(); }, + comparators: { + exclude: [exclude$, setExclude], + existsSelected: [existsSelected$, setExistsSelected], + selectedOptions: [selectedOptions$, setSelectedOptions, selectedOptionsComparatorFunction], + } as StateComparators< + Pick + >, hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected, selectedOptions$: selectedOptions$ as PublishingSubject, selectedOptionsComparatorFunction, @@ -44,18 +70,8 @@ export function initializeOptionsListSelections( } }, existsSelected$: existsSelected$ as PublishingSubject, - setExistsSelected: (next: boolean | undefined) => { - if (existsSelected$.value !== next) { - existsSelected$.next(next); - onSelectionChange(); - } - }, + setExistsSelected, exclude$: exclude$ as PublishingSubject, - setExclude: (next: boolean | undefined) => { - if (exclude$.value !== next) { - exclude$.next(next); - onSelectionChange(); - } - }, + setExclude, }; } diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx index ae4e21ff1e77e..a9b3e31a2c706 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -70,7 +70,7 @@ export const getRangesliderControlFactory = ( services ); - const rangeControlSelections = initializeRangeControlSelections( + const selections = initializeRangeControlSelections( initialState, dataControl.setters.onSelectionChange ); @@ -86,23 +86,23 @@ export const getRangesliderControlFactory = ( rawState: { ...dataControlState, step: step$.getValue(), - value: rangeControlSelections.value$.getValue(), + value: selections.value$.getValue(), }, references, // does not have any references other than those provided by the data control serializer }; }, clearSelections: () => { - rangeControlSelections.setValue(undefined); + selections.setValue(undefined); }, }, { ...dataControl.comparators, + ...selections.comparators, step: [ step$, (nextStep: number | undefined) => step$.next(nextStep), (a, b) => (a ?? 1) === (b ?? 1), ], - value: [rangeControlSelections.value$, rangeControlSelections.setValue], } ); @@ -126,7 +126,7 @@ export const getRangesliderControlFactory = ( .pipe(skip(1)) .subscribe(() => { step$.next(1); - rangeControlSelections.setValue(undefined); + selections.setValue(undefined); }); const max$ = new BehaviorSubject(undefined); @@ -164,7 +164,7 @@ export const getRangesliderControlFactory = ( const outputFilterSubscription = combineLatest([ dataControl.api.dataViews, dataControl.stateManager.fieldName, - rangeControlSelections.value$, + selections.value$, ]) .pipe(debounceTime(0)) .subscribe(([dataViews, fieldName, value]) => { @@ -203,7 +203,7 @@ export const getRangesliderControlFactory = ( selectionHasNoResults$.next(hasNoResults); }); - if (rangeControlSelections.hasInitialSelections) { + if (selections.hasInitialSelections) { await dataControl.api.untilFiltersReady(); } @@ -218,7 +218,7 @@ export const getRangesliderControlFactory = ( min$, selectionHasNoResults$, step$, - rangeControlSelections.value$ + selections.value$ ); useEffect(() => { @@ -239,7 +239,7 @@ export const getRangesliderControlFactory = ( isLoading={typeof dataLoading === 'boolean' ? dataLoading : false} max={max} min={min} - onChange={rangeControlSelections.setValue} + onChange={selections.setValue} step={step ?? 1} value={value} uuid={uuid} diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts index 7922784a5468b..b8c88b249c799 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts @@ -7,7 +7,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { PublishingSubject } from '@kbn/presentation-publishing'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; import { RangeValue, RangesliderControlState } from './types'; export function initializeRangeControlSelections( @@ -15,15 +15,19 @@ export function initializeRangeControlSelections( onSelectionChange: () => void ) { const value$ = new BehaviorSubject(initialState.value); + function setValue(next: RangeValue | undefined) { + if (value$.value !== next) { + value$.next(next); + onSelectionChange(); + } + } return { + comparators: { + value: [value$, setValue], + } as StateComparators>, hasInitialSelections: initialState.value !== undefined, value$: value$ as PublishingSubject, - setValue: (next: RangeValue | undefined) => { - if (value$.value !== next) { - value$.next(next); - onSelectionChange(); - } - }, + setValue, }; } diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx index f07d111899fa7..cf1e3a338f20a 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx @@ -95,7 +95,7 @@ export const getSearchControlFactory = ( services ); - const searchControlSelections = initializeSearchControlSelections( + const selections = initializeSearchControlSelections( initialState, dataControl.setters.onSelectionChange ); @@ -112,41 +112,32 @@ export const getSearchControlFactory = ( return { rawState: { ...dataControlState, - searchString: searchControlSelections.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: () => { - searchControlSelections.setSearchString(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: [ - searchControlSelections.searchString$, - (newString: string | undefined) => - searchControlSelections.setSearchString( - newString?.length === 0 ? undefined : newString - ), - ], } ); /** * If either the search string or the search technique changes, recalulate the output filter */ - const onSearchStringChanged = combineLatest([ - searchControlSelections.searchString$, - searchTechnique, - ]) + const onSearchStringChanged = combineLatest([selections.searchString$, searchTechnique]) .pipe(debounceTime(200), distinctUntilChanged(deepEqual)) .subscribe(([newSearchString, currentSearchTechnnique]) => { const currentDataView = dataControl.api.dataViews.getValue()?.[0]; @@ -185,7 +176,7 @@ export const getSearchControlFactory = ( ]) .pipe(skip(1)) .subscribe(() => { - searchControlSelections.setSearchString(undefined); + selections.setSearchString(undefined); }); if (initialState.searchString?.length) { @@ -199,9 +190,7 @@ export const getSearchControlFactory = ( * ControlPanel that are necessary for styling */ Component: ({ className: controlPanelClassName }) => { - const currentSearch = useStateFromPublishingSubject( - searchControlSelections.searchString$ - ); + const currentSearch = useStateFromPublishingSubject(selections.searchString$); useEffect(() => { return () => { @@ -222,7 +211,7 @@ export const getSearchControlFactory = ( isClearable={false} // this will be handled by the clear floating action instead value={currentSearch ?? ''} onChange={(event) => { - searchControlSelections.setSearchString(event.target.value); + selections.setSearchString(event.target.value); }} placeholder={i18n.translate('controls.searchControl.placeholder', { defaultMessage: 'Search...', diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts index d896824fc12df..b36ac8aa4304c 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts +++ b/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts @@ -7,7 +7,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { PublishingSubject } from '@kbn/presentation-publishing'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; import { SearchControlState } from './types'; export function initializeSearchControlSelections( @@ -15,15 +15,19 @@ export function initializeSearchControlSelections( onSelectionChange: () => void ) { const searchString$ = new BehaviorSubject(initialState.searchString); + function setSearchString(next: string | undefined) { + if (searchString$.value !== next) { + searchString$.next(next); + onSelectionChange(); + } + } return { + comparators: { + searchString: [searchString$, setSearchString], + } as StateComparators>, hasInitialSelections: initialState.searchString?.length, searchString$: searchString$ as PublishingSubject, - setSearchString: (next: string | undefined) => { - if (searchString$.value !== next) { - searchString$.next(next); - onSelectionChange(); - } - }, + setSearchString, }; } From 5742e929c1afa9778bea1444b1c8f1950945daba Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 5 Aug 2024 09:45:58 -0600 Subject: [PATCH 07/14] revert changes to options list clear selections --- .../get_options_list_control_factory.tsx | 12 ++++++++---- .../options_list_control_selections.ts | 6 ------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 009d78c6f88bf..23ce002d34ab5 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -150,9 +150,9 @@ export const getOptionsListControlFactory = ( ) .subscribe(() => { searchString$.next(''); - selections.clearSelections(); - requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE); - sort$.next(OPTIONS_LIST_DEFAULT_SORT); + selections.setSelectedOptions(undefined); + selections.setExistsSelected(false); + selections.setExclude(false); }); /** Fetch the suggestions and perform validation */ @@ -256,7 +256,11 @@ export const getOptionsListControlFactory = ( references, // does not have any references other than those provided by the data control serializer }; }, - clearSelections: selections.clearSelections, + clearSelections: () => { + if (selections.selectedOptions$.getValue()?.length) selections.setSelectedOptions([]); + if (selections.existsSelected$.getValue()) selections.setExistsSelected(false); + if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([])); + }, }, { ...dataControl.comparators, diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts index 5cccd1cee083b..1808eb136bc69 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts @@ -47,12 +47,6 @@ export function initializeOptionsListSelections( } return { - clearSelections: () => { - selectedOptions$.next(undefined); - existsSelected$.next(false); - exclude$.next(false); - onSelectionChange(); - }, comparators: { exclude: [exclude$, setExclude], existsSelected: [existsSelected$, setExistsSelected], From b9b8226259dd5ab1263928dcc4726fab1df714f1 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 5 Aug 2024 09:46:55 -0600 Subject: [PATCH 08/14] restore --- .../options_list_control/get_options_list_control_factory.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 23ce002d34ab5..35d2fc92d5b4b 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -153,6 +153,8 @@ export const getOptionsListControlFactory = ( selections.setSelectedOptions(undefined); selections.setExistsSelected(false); selections.setExclude(false); + requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE); + sort$.next(OPTIONS_LIST_DEFAULT_SORT); }); /** Fetch the suggestions and perform validation */ From 8dda053771d79e0d23d94c313cfa79c390846000 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 5 Aug 2024 12:20:49 -0600 Subject: [PATCH 09/14] move setExclude to component api --- .../react_controls/data_controls/mocks/api_mocks.tsx | 7 +++++-- .../components/options_list_control.test.tsx | 7 ++----- .../components/options_list_control.tsx | 1 + .../components/options_list_popover.test.tsx | 5 +---- .../components/options_list_popover_footer.tsx | 4 ++-- .../options_list_popover_sorting_button.test.tsx | 3 --- .../components/options_list_popover_suggestions.tsx | 1 - .../get_options_list_control_factory.tsx | 7 +++---- .../options_list_context_provider.tsx | 1 - .../options_list_control_selections.ts | 10 ++-------- .../data_controls/options_list_control/types.ts | 1 + 11 files changed, 17 insertions(+), 30 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx b/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx index 775441768a0a5..a21e253f57628 100644 --- a/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx +++ b/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx @@ -34,6 +34,7 @@ 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(''), @@ -45,9 +46,11 @@ export const getOptionsListMocks = () => { selectedOptions: selectedOptions$ as PublishingSubject, searchTechnique: new BehaviorSubject(undefined), }, - setExclude: (next: boolean | undefined) => exclude$.next(next), + 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), - displaySettings: {} as OptionsListDisplaySettings, }; }; diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx index a1bfcc6925dbe..9cdf976150da7 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.test.tsx @@ -20,12 +20,10 @@ describe('Options list control', () => { api, displaySettings, stateManager, - setExclude, }: { api: any; displaySettings: any; stateManager: any; - setExclude: (next: boolean | undefined) => void; }) => { return render( { api: api as unknown as OptionsListComponentApi, displaySettings, stateManager: stateManager as unknown as ContextStateManager, - setExclude, }} > @@ -44,7 +41,7 @@ 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.setExclude(false); + mocks.api.setExclude(false); mocks.setExistsSelected(true); const control = mountComponent(mocks); const existsOption = control.getByTestId('optionsList-control-testExists'); @@ -54,7 +51,7 @@ 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.setExclude(true); + mocks.api.setExclude(true); mocks.setExistsSelected(true); const control = mountComponent(mocks); const existsOption = control.getByTestId('optionsList-control-testDoesNotExist'); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx index 998ca612a34fb..b3ccafe050ece 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx @@ -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]); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx index b6e9851924f1d..5e22b22077e52 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover.test.tsx @@ -25,12 +25,10 @@ describe('Options list popover', () => { api, displaySettings, stateManager, - setExclude, }: { api: any; displaySettings: any; stateManager: any; - setExclude: (next: boolean | undefined) => void; }) => { return render( { api: api as unknown as OptionsListComponentApi, displaySettings, stateManager: stateManager as unknown as ContextStateManager, - setExclude, }} > @@ -205,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.setExclude(true); + mocks.api.setExclude(true); await waitOneTick(); const includeButton = popover.getByTestId('optionsList__includeResults'); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx index 2aa8aac423c9d..67f984cd69905 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_footer.tsx @@ -38,7 +38,7 @@ const aggregationToggleButtons = [ ]; export const OptionsListPopoverFooter = () => { - const { api, stateManager, setExclude } = useOptionsListContext(); + const { api, stateManager } = useOptionsListContext(); const [exclude, loading, allowExpensiveQueries] = useBatchedPublishingSubjects( stateManager.exclude, @@ -78,7 +78,7 @@ export const OptionsListPopoverFooter = () => { legend={OptionsListStrings.popover.getIncludeExcludeLegend()} options={aggregationToggleButtons} idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'} - onChange={(optionId) => setExclude(optionId === 'optionsList__excludeResults')} + onChange={(optionId) => api.setExclude(optionId === 'optionsList__excludeResults')} buttonSize="compressed" data-test-subj="optionsList__includeExcludeButtonGroup" /> diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx index d925147c952a3..b7d59a1e91923 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_sorting_button.test.tsx @@ -22,12 +22,10 @@ describe('Options list sorting button', () => { api, displaySettings, stateManager, - setExclude, }: { api: any; displaySettings: any; stateManager: any; - setExclude: (next: boolean | undefined) => void; }) => { const component = render( { api: api as unknown as OptionsListComponentApi, displaySettings, stateManager: stateManager as unknown as ContextStateManager, - setExclude, }} > diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx index 3fdce47271873..a8cd84252e0d9 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_popover_suggestions.tsx @@ -196,7 +196,6 @@ export const OptionsListPopoverSuggestions = ({ )} emptyMessage={} onChange={(newSuggestions, _, changedOption) => { - setSelectableOptions(newSuggestions); api.makeSelection(changedOption.key, showOnlySelected); }} > diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 35d2fc92d5b4b..4cd1c9a95f4d2 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -291,11 +291,11 @@ export const getOptionsListControlFactory = ( const componentApi = { ...api, - selections$: selections.selectedOptions$, loadMoreSubject, totalCardinality$, availableOptions$, invalidSelections$, + setExclude: selections.setExclude, deselectOption: (key: string | undefined) => { const field = api.field$.getValue(); if (!key || !field) { @@ -353,7 +353,7 @@ export const getOptionsListControlFactory = ( } else { // select option if (existsSelected) selections.setExistsSelected(false); - selections.setSelectedOptions(selectedOptions ? [...selectedOptions, keyAsType] : []); + selections.setSelectedOptions(selectedOptions ? [...selectedOptions, keyAsType] : [keyAsType]); } }, }; @@ -390,8 +390,7 @@ export const getOptionsListControlFactory = ( return ( void; displaySettings: OptionsListDisplaySettings; } | undefined diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts index 1808eb136bc69..632afd07808dd 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts @@ -29,7 +29,7 @@ export function initializeOptionsListSelections( onSelectionChange(); } } - + const existsSelected$ = new BehaviorSubject(initialState.existsSelected); function setExistsSelected(next: boolean | undefined) { if (existsSelected$.value !== next) { @@ -56,13 +56,7 @@ export function initializeOptionsListSelections( >, hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected, selectedOptions$: selectedOptions$ as PublishingSubject, - selectedOptionsComparatorFunction, - setSelectedOptions: (next: OptionsListSelection[] | undefined) => { - if (selectedOptionsComparatorFunction(selectedOptions$.value, next)) { - selectedOptions$.next(next); - onSelectionChange(); - } - }, + setSelectedOptions, existsSelected$: existsSelected$ as PublishingSubject, setExistsSelected, exclude$: exclude$ as PublishingSubject, diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts b/examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts index 3fba2f6908d0c..b2e2f9c4c75b1 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/types.ts @@ -56,4 +56,5 @@ export type OptionsListComponentApi = OptionsListControlApi & deselectOption: (key: string | undefined) => void; makeSelection: (key: string | undefined, showOnlySelected: boolean) => void; loadMoreSubject: BehaviorSubject; + setExclude: (next: boolean | undefined) => void; }; From 757a6f1090c7fcb7e5391c0b99f693ce02d47a71 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 5 Aug 2024 12:26:32 -0600 Subject: [PATCH 10/14] fix test --- .../components/options_list_control.tsx | 2 +- .../get_options_list_control_factory.tsx | 6 ++++-- .../options_list_control/options_list_control_selections.ts | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx index b3ccafe050ece..c102f7822b804 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/components/options_list_control.tsx @@ -60,7 +60,7 @@ export const OptionsListControl = ({ api.panelTitle, api.fieldFormatter ); - + const [defaultPanelTitle] = useBatchedOptionalPublishingSubjects(api.defaultPanelTitle); const delimiter = useMemo(() => OptionsListStrings.control.getSeparator(field?.type), [field]); diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 4cd1c9a95f4d2..82190ac47cff6 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -353,7 +353,9 @@ export const getOptionsListControlFactory = ( } else { // select option if (existsSelected) selections.setExistsSelected(false); - selections.setSelectedOptions(selectedOptions ? [...selectedOptions, keyAsType] : [keyAsType]); + selections.setSelectedOptions( + selectedOptions ? [...selectedOptions, keyAsType] : [keyAsType] + ); } }, }; @@ -390,7 +392,7 @@ export const getOptionsListControlFactory = ( return ( deepEqual(a ?? [], b ?? []); function setSelectedOptions(next: OptionsListSelection[] | undefined) { - if (selectedOptionsComparatorFunction(selectedOptions$.value, next)) { + if (!selectedOptionsComparatorFunction(selectedOptions$.value, next)) { selectedOptions$.next(next); onSelectionChange(); } } - + const existsSelected$ = new BehaviorSubject(initialState.existsSelected); function setExistsSelected(next: boolean | undefined) { if (existsSelected$.value !== next) { From c013489c5415e9320d92b643ef65636af58c527b Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 5 Aug 2024 12:30:00 -0600 Subject: [PATCH 11/14] clean up --- .../options_list_control/get_options_list_control_factory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 82190ac47cff6..e71bcd9002c36 100644 --- a/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -392,7 +392,7 @@ export const getOptionsListControlFactory = ( return ( Date: Mon, 5 Aug 2024 13:53:05 -0600 Subject: [PATCH 12/14] reset filters ready on dataViewId and field name change --- .../data_controls/initialize_data_control.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts index f8551ae35db88..c376890b232f5 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.ts @@ -76,6 +76,7 @@ export const initializeDataControl = ( const dataViewIdSubscription = dataViewId .pipe( tap(() => { + filtersReady$.next(false); if (defaultControl.api.blockingError.value) { defaultControl.api.setBlockingError(undefined); } @@ -97,8 +98,13 @@ export const initializeDataControl = ( 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; @@ -126,8 +132,7 @@ export const initializeDataControl = ( if (spec) { fieldFormatter.next(dataView.getFormatterForField(spec).getConverterFor('text')); } - } - ); + }); const onEdit = async () => { // get the initial state from the state manager From b78a3014e8edd12a39b31c85300fe4f8beac8729 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 7 Aug 2024 15:19:06 -0600 Subject: [PATCH 13/14] fix issue where reset was not finishing --- .../search_control/get_search_control_factory.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx index cf1e3a338f20a..4d00e9834c349 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx @@ -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'; @@ -138,7 +137,7 @@ export const getSearchControlFactory = ( * If either the search string or the search technique changes, recalulate the output filter */ const onSearchStringChanged = combineLatest([selections.searchString$, searchTechnique]) - .pipe(debounceTime(200), distinctUntilChanged(deepEqual)) + .pipe(debounceTime(200)) .subscribe(([newSearchString, currentSearchTechnnique]) => { const currentDataView = dataControl.api.dataViews.getValue()?.[0]; const currentField = dataControl.stateManager.fieldName.getValue(); From f1a4c63ed64c0034fbb3bc3dc0c3146502eea390 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 7 Aug 2024 15:30:19 -0600 Subject: [PATCH 14/14] only applySelections when not auto apply --- .../control_group/control_group_unsaved_changes_api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts index a988517ab0504..a0c5927872f10 100644 --- a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts +++ b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts @@ -82,7 +82,10 @@ export function initializeControlGroupUnsavedChanges( }); await Promise.all(filtersReadyPromises); - applySelections(); + + if (!comparators.autoApplySelections[0].value) { + applySelections(); + } }, } as Pick & { asyncResetUnsavedChanges: () => Promise;