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..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 @@ -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,25 @@ 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); + + if (!comparators.autoApplySelections[0].value) { + 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..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 @@ -7,7 +7,7 @@ */ import { isEqual } from 'lodash'; -import { BehaviorSubject, combineLatest, first, switchMap } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, first, skip, switchMap, tap } from 'rxjs'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { @@ -45,9 +45,12 @@ export const initializeDataControl = ( 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,14 @@ export const initializeDataControl = ( title: panelTitle, }; - function clearBlockingError() { - if (defaultControl.api.blockingError.value) { - defaultControl.api.setBlockingError(undefined); - } - } - const dataViewIdSubscription = dataViewId .pipe( + tap(() => { + filtersReady$.next(false); + if (defaultControl.api.blockingError.value) { + defaultControl.api.setBlockingError(undefined); + } + }), switchMap(async (currentDataViewId) => { let dataView: DataView | undefined; try { @@ -90,14 +94,17 @@ export const initializeDataControl = ( .subscribe(({ dataView, error }) => { if (error) { defaultControl.api.setBlockingError(error); - } else { - clearBlockingError(); } dataViews.next(dataView ? [dataView] : undefined); }); - const fieldNameSubscription = combineLatest([dataViews, fieldName]).subscribe( - ([nextDataViews, nextFieldName]) => { + const fieldNameSubscription = combineLatest([dataViews, fieldName]) + .pipe( + tap(() => { + filtersReady$.next(false); + }) + ) + .subscribe(([nextDataViews, nextFieldName]) => { const dataView = nextDataViews ? nextDataViews.find(({ id }) => dataViewId.value === id) : undefined; @@ -115,8 +122,8 @@ export const initializeDataControl = ( }) ) ); - } else { - clearBlockingError(); + } else if (defaultControl.api.blockingError.value) { + defaultControl.api.setBlockingError(undefined); } field$.next(field); @@ -125,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 @@ -172,6 +178,13 @@ export const initializeDataControl = ( }); }; + const filtersReadySubscription = filters$.pipe(skip(1), debounceTime(0)).subscribe(() => { + // Set filtersReady$.next(true); in filters$ subscription instead of setOutputFilter + // to avoid signaling filters ready until after filters have been emitted + // to avoid timing issues + filtersReady$.next(true); + }); + const api: ControlApiInitialization = { ...defaultControl.api, panelTitle, @@ -181,10 +194,18 @@ 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, filtersReady$]) + .pipe( + first(([blockingError, filtersReady]) => filtersReady || blockingError !== undefined) + ) + .subscribe(() => { + resolve(); + }); + }); + }, }; return { @@ -192,6 +213,7 @@ export const initializeDataControl = ( cleanup: () => { dataViewIdSubscription.unsubscribe(); fieldNameSubscription.unsubscribe(); + filtersReadySubscription.unsubscribe(); }, comparators: { ...defaultControl.comparators, @@ -199,6 +221,14 @@ 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); + }, + }, stateManager, serialize: () => { return { @@ -217,19 +247,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/mocks/api_mocks.tsx b/examples/controls_example/public/react_controls/data_controls/mocks/api_mocks.tsx index 7f08a96e0ad71..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 @@ -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', @@ -30,17 +34,23 @@ export const getOptionsListMocks = () => { }, fieldFormatter: new BehaviorSubject((value: string | number) => String(value)), makeSelection: jest.fn(), + setExclude: (next: boolean | undefined) => exclude$.next(next), }, stateManager: { searchString: new BehaviorSubject(''), 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), }, 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), }; }; 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..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 @@ -10,10 +10,9 @@ import React from 'react'; import { DataViewField } from '@kbn/data-views-plugin/common'; import { render } from '@testing-library/react'; -import { ControlStateManager } from '../../../types'; import { getOptionsListMocks } from '../../mocks/api_mocks'; -import { OptionsListControlContext } from '../options_list_context_provider'; -import { OptionsListComponentApi, OptionsListComponentState } from '../types'; +import { ContextStateManager, OptionsListControlContext } from '../options_list_context_provider'; +import { OptionsListComponentApi } from '../types'; import { OptionsListControl } from './options_list_control'; describe('Options list control', () => { @@ -31,7 +30,7 @@ describe('Options list control', () => { value={{ api: api as unknown as OptionsListComponentApi, displaySettings, - stateManager: stateManager as unknown as ControlStateManager, + stateManager: stateManager as unknown as ContextStateManager, }} > @@ -42,8 +41,8 @@ describe('Options list control', () => { test('if exclude = false and existsSelected = true, then the option should read "Exists"', async () => { const mocks = getOptionsListMocks(); mocks.api.uuid = 'testExists'; - mocks.stateManager.exclude.next(false); - mocks.stateManager.existsSelected.next(true); + mocks.api.setExclude(false); + mocks.setExistsSelected(true); const control = mountComponent(mocks); const existsOption = control.getByTestId('optionsList-control-testExists'); expect(existsOption).toHaveTextContent('Exists'); @@ -52,8 +51,8 @@ describe('Options list control', () => { test('if exclude = true and existsSelected = true, then the option should read "Does not exist"', async () => { const mocks = getOptionsListMocks(); mocks.api.uuid = 'testDoesNotExist'; - mocks.stateManager.exclude.next(true); - mocks.stateManager.existsSelected.next(true); + mocks.api.setExclude(true); + mocks.setExistsSelected(true); const control = mountComponent(mocks); const existsOption = control.getByTestId('optionsList-control-testDoesNotExist'); expect(existsOption).toHaveTextContent('DOES NOT Exist'); @@ -68,7 +67,7 @@ describe('Options list control', () => { { value: 'bark', docCount: 10 }, { value: 'meow', docCount: 12 }, ]); - mocks.stateManager.selectedOptions.next(['woof', 'bark']); + mocks.setSelectedOptions(['woof', 'bark']); mocks.api.field$.next({ name: 'Test keyword field', type: 'keyword', @@ -87,7 +86,7 @@ describe('Options list control', () => { { value: 2, docCount: 10 }, { value: 3, docCount: 12 }, ]); - mocks.stateManager.selectedOptions.next([1, 2]); + mocks.setSelectedOptions([1, 2]); mocks.api.field$.next({ name: 'Test keyword field', type: 'number', @@ -105,7 +104,7 @@ describe('Options list control', () => { { value: 'bark', docCount: 10 }, { value: 'meow', docCount: 12 }, ]); - mocks.stateManager.selectedOptions.next(['woof', 'bark']); + mocks.setSelectedOptions(['woof', 'bark']); mocks.api.invalidSelections$.next(new Set(['woof'])); mocks.api.field$.next({ name: 'Test keyword field', 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..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,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 05d601e093a4e..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 @@ -13,14 +13,9 @@ import { act, render, RenderResult, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BehaviorSubject } from 'rxjs'; -import { ControlStateManager } from '../../../types'; import { getOptionsListMocks } from '../../mocks/api_mocks'; -import { OptionsListControlContext } from '../options_list_context_provider'; -import { - OptionsListComponentApi, - OptionsListComponentState, - OptionsListDisplaySettings, -} from '../types'; +import { ContextStateManager, OptionsListControlContext } from '../options_list_context_provider'; +import { OptionsListComponentApi, OptionsListDisplaySettings } from '../types'; import { OptionsListPopover } from './options_list_popover'; describe('Options list popover', () => { @@ -40,7 +35,7 @@ describe('Options list popover', () => { value={{ api: api as unknown as OptionsListComponentApi, displaySettings, - stateManager: stateManager as unknown as ControlStateManager, + stateManager: stateManager as unknown as ContextStateManager, }} > @@ -83,7 +78,7 @@ describe('Options list popover', () => { expect(mocks.api.makeSelection).toBeCalledWith('woof', false); // simulate `makeSelection` - mocks.stateManager.selectedOptions.next(['woof']); + mocks.setSelectedOptions(['woof']); await waitOneTick(); clickShowOnlySelections(popover); @@ -102,7 +97,7 @@ describe('Options list popover', () => { { value: 'meow', docCount: 12 }, ]); const popover = mountComponent(mocks); - mocks.stateManager.selectedOptions.next(selections); + mocks.setSelectedOptions(selections); await waitOneTick(); clickShowOnlySelections(popover); @@ -121,7 +116,7 @@ describe('Options list popover', () => { { value: 'bark', docCount: 10 }, { value: 'meow', docCount: 12 }, ]); - mocks.stateManager.selectedOptions.next([]); + mocks.setSelectedOptions([]); const popover = mountComponent(mocks); clickShowOnlySelections(popover); @@ -139,7 +134,7 @@ describe('Options list popover', () => { { value: 'bark', docCount: 10 }, { value: 'meow', docCount: 12 }, ]); - mocks.stateManager.selectedOptions.next(['woof', 'bark']); + mocks.setSelectedOptions(['woof', 'bark']); const popover = mountComponent(mocks); let searchBox = popover.getByTestId('optionsList-control-search-input'); @@ -163,7 +158,7 @@ describe('Options list popover', () => { { value: 'bark', docCount: 75 }, ]); const popover = mountComponent(mocks); - mocks.stateManager.selectedOptions.next(['woof', 'bark']); + mocks.setSelectedOptions(['woof', 'bark']); mocks.api.invalidSelections$.next(new Set(['woof'])); await waitOneTick(); @@ -185,7 +180,7 @@ describe('Options list popover', () => { { value: 'woof', docCount: 5 }, { value: 'bark', docCount: 75 }, ]); - mocks.stateManager.selectedOptions.next(['bark', 'woof', 'meow']); + mocks.setSelectedOptions(['bark', 'woof', 'meow']); mocks.api.invalidSelections$.next(new Set(['woof', 'meow'])); const popover = mountComponent(mocks); @@ -207,7 +202,7 @@ describe('Options list popover', () => { test('if exclude = true, select appropriate button in button group', async () => { const mocks = getOptionsListMocks(); const popover = mountComponent(mocks); - mocks.stateManager.exclude.next(true); + mocks.api.setExclude(true); await waitOneTick(); const includeButton = popover.getByTestId('optionsList__includeResults'); @@ -223,7 +218,7 @@ describe('Options list popover', () => { mocks.api.availableOptions$.next([]); const popover = mountComponent(mocks); - mocks.stateManager.existsSelected.next(false); + mocks.setExistsSelected(false); await waitOneTick(); const existsOption = popover.queryByTestId('optionsList-control-selection-exists'); @@ -238,7 +233,7 @@ describe('Options list popover', () => { ]); const popover = mountComponent(mocks); - mocks.stateManager.existsSelected.next(true); + mocks.setExistsSelected(true); await waitOneTick(); clickShowOnlySelections(popover); 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..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 @@ -78,9 +78,7 @@ export const OptionsListPopoverFooter = () => { legend={OptionsListStrings.popover.getIncludeExcludeLegend()} options={aggregationToggleButtons} idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'} - onChange={(optionId) => - stateManager.exclude.next(optionId === 'optionsList__excludeResults') - } + onChange={(optionId) => api.setExclude(optionId === 'optionsList__excludeResults')} buttonSize="compressed" data-test-subj="optionsList__includeExcludeButtonGroup" /> 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..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 @@ -12,10 +12,9 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { render, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { ControlStateManager } from '../../../types'; import { getOptionsListMocks } from '../../mocks/api_mocks'; -import { OptionsListControlContext } from '../options_list_context_provider'; -import { OptionsListComponentApi, OptionsListComponentState } from '../types'; +import { ContextStateManager, OptionsListControlContext } from '../options_list_context_provider'; +import { OptionsListComponentApi } from '../types'; import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button'; describe('Options list sorting button', () => { @@ -33,7 +32,7 @@ describe('Options list sorting button', () => { value={{ api: api as unknown as OptionsListComponentApi, displaySettings, - stateManager: stateManager as unknown as ControlStateManager, + stateManager: stateManager as unknown as ContextStateManager, }} > 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/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 0e0d28cd96a33..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 @@ -7,7 +7,6 @@ */ import React, { useEffect } from 'react'; -import deepEqual from 'react-fast-compare'; import { BehaviorSubject, combineLatest, debounceTime, filter, skip } from 'rxjs'; import { OptionsListSearchTechnique } from '@kbn/controls-plugin/common/options_list/suggestions_searching'; @@ -38,6 +37,7 @@ import { fetchAndValidate$ } from './fetch_and_validate'; import { OptionsListControlContext } from './options_list_context_provider'; import { OptionsListStrings } from './options_list_strings'; import { OptionsListControlApi, OptionsListControlState } from './types'; +import { initializeOptionsListSelections } from './options_list_control_selections'; export const getOptionsListControlFactory = ( services: DataControlServices @@ -62,14 +62,9 @@ export const getOptionsListControlFactory = ( ); const runPastTimeout$ = new BehaviorSubject(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); @@ -98,12 +93,17 @@ export const getOptionsListControlFactory = ( services ); + const selections = initializeOptionsListSelections( + initialState, + dataControl.setters.onSelectionChange + ); + const stateManager = { ...dataControl.stateManager, - exclude: excludeSelected$, - existsSelected: existsSelected$, + exclude: selections.exclude$, + existsSelected: selections.existsSelected$, searchTechnique: searchTechnique$, - selectedOptions: selections$, + selectedOptions: selections.selectedOptions$, singleSelect: singleSelect$, sort: sort$, searchString: searchString$, @@ -150,9 +150,9 @@ export const getOptionsListControlFactory = ( ) .subscribe(() => { searchString$.next(''); - selections$.next(undefined); - existsSelected$.next(false); - excludeSelected$.next(false); + selections.setSelectedOptions(undefined); + selections.setExistsSelected(false); + selections.setExclude(false); requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE); sort$.next(OPTIONS_LIST_DEFAULT_SORT); }); @@ -196,38 +196,40 @@ export const getOptionsListControlFactory = ( const singleSelectSubscription = singleSelect$ .pipe(filter((singleSelect) => Boolean(singleSelect))) .subscribe(() => { - const currentSelections = selections$.getValue() ?? []; - if (currentSelections.length > 1) selections$.next([currentSelections[0]]); + const currentSelections = selections.selectedOptions$.getValue() ?? []; + if (currentSelections.length > 1) selections.setSelectedOptions([currentSelections[0]]); }); /** Output filters when selections change */ const outputFilterSubscription = combineLatest([ dataControl.api.dataViews, dataControl.stateManager.fieldName, - selections$, - existsSelected$, - excludeSelected$, - ]).subscribe(([dataViews, fieldName, selections, existsSelected, exclude]) => { - const dataView = dataViews?.[0]; - const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; - - if (!dataView || !field) return; - - let newFilter: Filter | undefined; - if (existsSelected) { - newFilter = buildExistsFilter(field, dataView); - } else if (selections && selections.length > 0) { - newFilter = - selections.length === 1 - ? buildPhraseFilter(field, selections[0], dataView) - : buildPhrasesFilter(field, selections, dataView); - } - if (newFilter) { - newFilter.meta.key = field?.name; - if (exclude) newFilter.meta.negate = true; - } - api.setOutputFilter(newFilter); - }); + selections.selectedOptions$, + selections.existsSelected$, + selections.exclude$, + ]) + .pipe(debounceTime(0)) + .subscribe(([dataViews, fieldName, selectedOptions, existsSelected, exclude]) => { + const dataView = dataViews?.[0]; + const field = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; + + let newFilter: Filter | undefined; + if (dataView && field) { + if (existsSelected) { + newFilter = buildExistsFilter(field, dataView); + } else if (selectedOptions && selectedOptions.length > 0) { + newFilter = + selectedOptions.length === 1 + ? buildPhraseFilter(field, selectedOptions[0], dataView) + : buildPhrasesFilter(field, selectedOptions, dataView); + } + } + if (newFilter) { + newFilter.meta.key = field?.name; + if (exclude) newFilter.meta.negate = true; + } + dataControl.setters.setOutputFilter(newFilter); + }); const api = buildApi( { @@ -241,10 +243,10 @@ export const getOptionsListControlFactory = ( searchTechnique: searchTechnique$.getValue(), runPastTimeout: runPastTimeout$.getValue(), singleSelect: singleSelect$.getValue(), - selections: selections$.getValue(), + selections: selections.selectedOptions$.getValue(), sort: sort$.getValue(), - existsSelected: existsSelected$.getValue(), - exclude: excludeSelected$.getValue(), + existsSelected: selections.existsSelected$.getValue(), + exclude: selections.exclude$.getValue(), // serialize state that cannot be changed to keep it consistent placeholder: placeholder$.getValue(), @@ -257,26 +259,20 @@ export const getOptionsListControlFactory = ( }; }, clearSelections: () => { - if (selections$.getValue()?.length) selections$.next([]); - if (existsSelected$.getValue()) existsSelected$.next(false); + if (selections.selectedOptions$.getValue()?.length) selections.setSelectedOptions([]); + if (selections.existsSelected$.getValue()) selections.setExistsSelected(false); if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([])); }, }, { ...dataControl.comparators, - exclude: [excludeSelected$, (selected) => excludeSelected$.next(selected)], - existsSelected: [existsSelected$, (selected) => existsSelected$.next(selected)], + ...selections.comparators, runPastTimeout: [runPastTimeout$, (runPast) => runPastTimeout$.next(runPast)], searchTechnique: [ searchTechnique$, (technique) => searchTechnique$.next(technique), (a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE), ], - selectedOptions: [ - selections$, - (selections) => selections$.next(selections), - (a, b) => deepEqual(a ?? [], b ?? []), - ], singleSelect: [singleSelect$, (selected) => singleSelect$.next(selected)], sort: [ sort$, @@ -295,11 +291,11 @@ export const getOptionsListControlFactory = ( const componentApi = { ...api, - selections$, loadMoreSubject, totalCardinality$, availableOptions$, invalidSelections$, + setExclude: selections.setExclude, deselectOption: (key: string | undefined) => { const field = api.field$.getValue(); if (!key || !field) { @@ -312,12 +308,12 @@ export const getOptionsListControlFactory = ( const keyAsType = getSelectionAsFieldType(field, key); // delete from selections - const selectedOptions = selections$.getValue() ?? []; - const itemIndex = (selections$.getValue() ?? []).indexOf(keyAsType); + const selectedOptions = selections.selectedOptions$.getValue() ?? []; + const itemIndex = (selections.selectedOptions$.getValue() ?? []).indexOf(keyAsType); if (itemIndex !== -1) { const newSelections = [...selectedOptions]; newSelections.splice(itemIndex, 1); - selections$.next(newSelections); + selections.setSelectedOptions(newSelections); } // delete from invalid selections const currentInvalid = invalidSelections$.getValue(); @@ -335,37 +331,37 @@ export const getOptionsListControlFactory = ( return; } - const existsSelected = Boolean(existsSelected$.getValue()); - const selectedOptions = selections$.getValue() ?? []; + const existsSelected = Boolean(selections.existsSelected$.getValue()); + const selectedOptions = selections.selectedOptions$.getValue() ?? []; const singleSelect = singleSelect$.getValue(); // the order of these checks matters, so be careful if rearranging them const keyAsType = getSelectionAsFieldType(field, key); if (key === 'exists-option') { // if selecting exists, then deselect everything else - existsSelected$.next(!existsSelected); + selections.setExistsSelected(!existsSelected); if (!existsSelected) { - selections$.next([]); + selections.setSelectedOptions([]); invalidSelections$.next(new Set([])); } } else if (showOnlySelected || selectedOptions.includes(keyAsType)) { componentApi.deselectOption(key); } else if (singleSelect) { // replace selection - selections$.next([keyAsType]); - if (existsSelected) existsSelected$.next(false); + selections.setSelectedOptions([keyAsType]); + if (existsSelected) selections.setExistsSelected(false); } else { // select option - if (!selectedOptions) selections$.next([]); - if (existsSelected) existsSelected$.next(false); - selections$.next([...selectedOptions, keyAsType]); + if (existsSelected) selections.setExistsSelected(false); + selections.setSelectedOptions( + selectedOptions ? [...selectedOptions, keyAsType] : [keyAsType] + ); } }, }; - if (initialState.selectedOptions?.length || initialState.existsSelected) { - // has selections, so wait for initialization of filters - await dataControl.untilFiltersInitialized(); + if (selections.hasInitialSelections) { + await dataControl.api.untilFiltersReady(); } return { 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..4c992331e6a5a 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,27 @@ import React, { useContext } from 'react'; +import { PublishingSubject } from '@kbn/presentation-publishing'; import { ControlStateManager } from '../../types'; import { OptionsListComponentApi, OptionsListComponentState, OptionsListDisplaySettings, } from './types'; +import { OptionsListSelection } from '../../../../common/options_list/options_list_selections'; + +export type ContextStateManager = ControlStateManager< + Omit +> & { + selectedOptions: PublishingSubject; + existsSelected: PublishingSubject; + exclude: PublishingSubject; +}; export const OptionsListControlContext = React.createContext< | { api: OptionsListComponentApi; - stateManager: ControlStateManager; + stateManager: ContextStateManager; 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..58efa05110844 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/options_list_control/options_list_control_selections.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +import deepEqual from 'react-fast-compare'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import { OptionsListControlState } from './types'; +import { OptionsListSelection } from '../../../../common/options_list/options_list_selections'; + +export function initializeOptionsListSelections( + initialState: OptionsListControlState, + onSelectionChange: () => void +) { + const selectedOptions$ = new BehaviorSubject( + initialState.selectedOptions ?? [] + ); + const selectedOptionsComparatorFunction = ( + a: OptionsListSelection[] | undefined, + b: OptionsListSelection[] | undefined + ) => deepEqual(a ?? [], b ?? []); + function setSelectedOptions(next: OptionsListSelection[] | undefined) { + if (!selectedOptionsComparatorFunction(selectedOptions$.value, next)) { + selectedOptions$.next(next); + onSelectionChange(); + } + } + + const existsSelected$ = new BehaviorSubject(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 { + comparators: { + exclude: [exclude$, setExclude], + existsSelected: [existsSelected$, setExistsSelected], + selectedOptions: [selectedOptions$, setSelectedOptions, selectedOptionsComparatorFunction], + } as StateComparators< + Pick + >, + hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected, + selectedOptions$: selectedOptions$ as PublishingSubject, + setSelectedOptions, + existsSelected$: existsSelected$ as PublishingSubject, + setExistsSelected, + exclude$: exclude$ as PublishingSubject, + setExclude, + }; +} 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; }; 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..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 @@ -10,19 +10,15 @@ import React, { useEffect, useState } from 'react'; import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; import { buildRangeFilter, Filter, RangeFilterParams } from '@kbn/es-query'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; -import { BehaviorSubject, combineLatest, map, skip } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, map, skip } from 'rxjs'; import { initializeDataControl } from '../initialize_data_control'; import { DataControlFactory, DataControlServices } from '../types'; import { RangeSliderControl } from './components/range_slider_control'; import { hasNoResults$ } from './has_no_results'; import { minMax$ } from './min_max'; import { RangeSliderStrings } from './range_slider_strings'; -import { - RangesliderControlApi, - RangesliderControlState, - RangeValue, - RANGE_SLIDER_CONTROL_TYPE, -} from './types'; +import { RangesliderControlApi, RangesliderControlState, RANGE_SLIDER_CONTROL_TYPE } from './types'; +import { initializeRangeControlSelections } from './range_control_selections'; export const getRangesliderControlFactory = ( services: DataControlServices @@ -62,10 +58,6 @@ export const getRangesliderControlFactory = ( const loadingHasNoResults$ = new BehaviorSubject(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 selections = initializeRangeControlSelections( + initialState, + dataControl.setters.onSelectionChange + ); + const api = buildApi( { ...dataControl.api, @@ -89,23 +86,23 @@ export const getRangesliderControlFactory = ( rawState: { ...dataControlState, step: step$.getValue(), - value: value$.getValue(), + value: selections.value$.getValue(), }, references, // does not have any references other than those provided by the data control serializer }; }, clearSelections: () => { - value$.next(undefined); + selections.setValue(undefined); }, }, { ...dataControl.comparators, + ...selections.comparators, step: [ step$, (nextStep: number | undefined) => step$.next(nextStep), (a, b) => (a ?? 1) === (b ?? 1), ], - value: [value$, setValue], } ); @@ -129,7 +126,7 @@ export const getRangesliderControlFactory = ( .pipe(skip(1)) .subscribe(() => { step$.next(1); - value$.next(undefined); + selections.setValue(undefined); }); const max$ = new BehaviorSubject(undefined); @@ -167,28 +164,30 @@ export const getRangesliderControlFactory = ( const outputFilterSubscription = combineLatest([ dataControl.api.dataViews, dataControl.stateManager.fieldName, - value$, - ]).subscribe(([dataViews, fieldName, value]) => { - const dataView = dataViews?.[0]; - const dataViewField = - dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; - const gte = parseFloat(value?.[0] ?? ''); - const lte = parseFloat(value?.[1] ?? ''); - - let rangeFilter: Filter | undefined; - if (value && dataView && dataViewField && !isNaN(gte) && !isNaN(lte)) { - const params = { - gte, - lte, - } as RangeFilterParams; - - rangeFilter = buildRangeFilter(dataViewField, params, dataView); - rangeFilter.meta.key = fieldName; - rangeFilter.meta.type = 'range'; - rangeFilter.meta.params = params; - } - api.setOutputFilter(rangeFilter); - }); + selections.value$, + ]) + .pipe(debounceTime(0)) + .subscribe(([dataViews, fieldName, value]) => { + const dataView = dataViews?.[0]; + const dataViewField = + dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined; + const gte = parseFloat(value?.[0] ?? ''); + const lte = parseFloat(value?.[1] ?? ''); + + let rangeFilter: Filter | undefined; + if (value && dataView && dataViewField && !isNaN(gte) && !isNaN(lte)) { + const params = { + gte, + lte, + } as RangeFilterParams; + + rangeFilter = buildRangeFilter(dataViewField, params, dataView); + rangeFilter.meta.key = fieldName; + rangeFilter.meta.type = 'range'; + rangeFilter.meta.params = params; + } + dataControl.setters.setOutputFilter(rangeFilter); + }); const selectionHasNoResults$ = new BehaviorSubject(false); const hasNotResultsSubscription = hasNoResults$({ @@ -204,8 +203,8 @@ export const getRangesliderControlFactory = ( selectionHasNoResults$.next(hasNoResults); }); - if (initialState.value !== undefined) { - await dataControl.untilFiltersInitialized(); + if (selections.hasInitialSelections) { + await dataControl.api.untilFiltersReady(); } return { @@ -219,7 +218,7 @@ export const getRangesliderControlFactory = ( min$, selectionHasNoResults$, step$, - value$ + selections.value$ ); useEffect(() => { @@ -240,7 +239,7 @@ export const getRangesliderControlFactory = ( isLoading={typeof dataLoading === 'boolean' ? dataLoading : false} max={max} min={min} - onChange={setValue} + onChange={selections.setValue} step={step ?? 1} value={value} uuid={uuid} 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..b8c88b249c799 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/range_control_selections.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import { RangeValue, RangesliderControlState } from './types'; + +export function initializeRangeControlSelections( + initialState: RangesliderControlState, + onSelectionChange: () => void +) { + const value$ = new BehaviorSubject(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, + }; +} 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..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'; @@ -16,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; import { euiThemeVars } from '@kbn/ui-theme'; +import { Filter } from '@kbn/es-query'; import { initializeDataControl } from '../initialize_data_control'; import { DataControlFactory, DataControlServices } from '../types'; import { @@ -24,6 +24,7 @@ import { SearchControlTechniques, SEARCH_CONTROL_TYPE, } from './types'; +import { initializeSearchControlSelections } from './search_control_selections'; const allSearchOptions = [ { @@ -79,7 +80,6 @@ export const getSearchControlFactory = ( ); }, buildControl: async (initialState, buildApi, uuid, parentApi) => { - const searchString = new BehaviorSubject(initialState.searchString); const searchTechnique = new BehaviorSubject( initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE ); @@ -94,6 +94,11 @@ export const getSearchControlFactory = ( services ); + const selections = initializeSearchControlSelections( + initialState, + dataControl.setters.onSelectionChange + ); + const api = buildApi( { ...dataControl.api, @@ -106,64 +111,58 @@ export const getSearchControlFactory = ( return { rawState: { ...dataControlState, - searchString: searchString.getValue(), + searchString: selections.searchString$.getValue(), searchTechnique: searchTechnique.getValue(), }, references, // does not have any references other than those provided by the data control serializer }; }, clearSelections: () => { - searchString.next(undefined); + selections.setSearchString(undefined); }, }, { ...dataControl.comparators, + ...selections.comparators, searchTechnique: [ searchTechnique, (newTechnique: SearchControlTechniques | undefined) => searchTechnique.next(newTechnique), (a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE), ], - searchString: [ - searchString, - (newString: string | undefined) => - searchString.next(newString?.length === 0 ? undefined : newString), - ], } ); /** * If either the search string or the search technique changes, recalulate the output filter */ - const onSearchStringChanged = combineLatest([searchString, searchTechnique]) - .pipe(debounceTime(200), distinctUntilChanged(deepEqual)) + const onSearchStringChanged = combineLatest([selections.searchString$, searchTechnique]) + .pipe(debounceTime(200)) .subscribe(([newSearchString, currentSearchTechnnique]) => { const currentDataView = dataControl.api.dataViews.getValue()?.[0]; const currentField = dataControl.stateManager.fieldName.getValue(); - if (currentDataView && currentField) { - if (newSearchString) { - api.setOutputFilter( - currentSearchTechnnique === 'match' - ? { - query: { match: { [currentField]: { query: newSearchString } } }, - meta: { index: currentDataView.id }, - } - : { - query: { - simple_query_string: { - query: newSearchString, - fields: [currentField], - default_operator: 'and', - }, + let filter: Filter | undefined; + if (currentDataView && currentField && newSearchString) { + filter = + currentSearchTechnnique === 'match' + ? { + query: { match: { [currentField]: { query: newSearchString } } }, + meta: { index: currentDataView.id }, + } + : { + query: { + simple_query_string: { + query: newSearchString, + fields: [currentField], + default_operator: 'and', }, - meta: { index: currentDataView.id }, - } - ); - } else { - api.setOutputFilter(undefined); - } + }, + meta: { index: currentDataView.id }, + }; } + + dataControl.setters.setOutputFilter(filter); }); /** @@ -176,11 +175,11 @@ export const getSearchControlFactory = ( ]) .pipe(skip(1)) .subscribe(() => { - searchString.next(undefined); + selections.setSearchString(undefined); }); if (initialState.searchString?.length) { - await dataControl.untilFiltersInitialized(); + await dataControl.api.untilFiltersReady(); } return { @@ -190,7 +189,7 @@ export const getSearchControlFactory = ( * ControlPanel that are necessary for styling */ Component: ({ className: controlPanelClassName }) => { - const currentSearch = useStateFromPublishingSubject(searchString); + const currentSearch = useStateFromPublishingSubject(selections.searchString$); useEffect(() => { return () => { @@ -211,7 +210,7 @@ export const getSearchControlFactory = ( isClearable={false} // this will be handled by the clear floating action instead value={currentSearch ?? ''} onChange={(event) => { - searchString.next(event.target.value); + selections.setSearchString(event.target.value); }} placeholder={i18n.translate('controls.searchControl.placeholder', { defaultMessage: 'Search...', 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..b36ac8aa4304c --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/search_control/search_control_selections.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import { SearchControlState } from './types'; + +export function initializeSearchControlSelections( + initialState: SearchControlState, + onSelectionChange: () => void +) { + const searchString$ = new BehaviorSubject(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, + }; +} 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 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 {}