diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 4b805210f37d39..f93646b3ab2d38 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -17,7 +17,6 @@ export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; runPastTimeout?: boolean; singleSelect?: boolean; - loading?: boolean; } export type OptionsListField = DataViewField & { diff --git a/src/plugins/controls/public/control_group/state/control_group_reducers.ts b/src/plugins/controls/public/control_group/state/control_group_reducers.ts index 25167ac9247ddb..cfc93818bc1d8b 100644 --- a/src/plugins/controls/public/control_group/state/control_group_reducers.ts +++ b/src/plugins/controls/public/control_group/state/control_group_reducers.ts @@ -10,45 +10,45 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; import { ControlWidth } from '../../types'; -import { ControlGroupInput } from '../types'; +import { ControlGroupInput, ControlGroupReduxState } from '../types'; export const controlGroupReducers = { setControlStyle: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.controlStyle = action.payload; + state.input.controlStyle = action.payload; }, setDefaultControlWidth: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.defaultControlWidth = action.payload; + state.input.defaultControlWidth = action.payload; }, setDefaultControlGrow: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.defaultControlGrow = action.payload; + state.input.defaultControlGrow = action.payload; }, setControlWidth: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction<{ width: ControlWidth; embeddableId: string }> ) => { - state.panels[action.payload.embeddableId].width = action.payload.width; + state.input.panels[action.payload.embeddableId].width = action.payload.width; }, setControlGrow: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction<{ grow: boolean; embeddableId: string }> ) => { - state.panels[action.payload.embeddableId].grow = action.payload.grow; + state.input.panels[action.payload.embeddableId].grow = action.payload.grow; }, setControlOrders: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction<{ ids: string[] }> ) => { action.payload.ids.forEach((id, index) => { - state.panels[id].order = index; + state.input.panels[id].order = index; }); }, }; diff --git a/src/plugins/controls/public/control_group/types.ts b/src/plugins/controls/public/control_group/types.ts index 05422fd0c97292..e3e871abee57e0 100644 --- a/src/plugins/controls/public/control_group/types.ts +++ b/src/plugins/controls/public/control_group/types.ts @@ -7,10 +7,15 @@ */ import { ContainerOutput } from '@kbn/embeddable-plugin/public'; +import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; +import { ControlGroupInput } from '../../common/control_group/types'; import { CommonControlOutput } from '../types'; export type ControlGroupOutput = ContainerOutput & CommonControlOutput; +// public only - redux embeddable state type +export type ControlGroupReduxState = ReduxEmbeddableState; + export { type ControlsPanels, type ControlGroupInput, diff --git a/src/plugins/controls/public/control_types/options_list/options_list_component.tsx b/src/plugins/controls/public/control_types/options_list/options_list_component.tsx index 5d72e1eb2564c6..191dfbd4256321 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_component.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_component.tsx @@ -8,36 +8,22 @@ import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import classNames from 'classnames'; import { debounce, isEmpty } from 'lodash'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { DataViewField } from '@kbn/data-views-plugin/public'; import { OptionsListStrings } from './options_list_strings'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListPopover } from './options_list_popover_component'; import './options_list.scss'; -import { useStateObservable } from '../../hooks/use_state_observable'; -import { OptionsListEmbeddableInput } from './types'; - -// OptionsListComponentState is controlled by the embeddable, but is not considered embeddable input. -export interface OptionsListComponentState { - loading: boolean; - field?: DataViewField; - totalCardinality?: number; - availableOptions?: string[]; - invalidSelections?: string[]; - validSelections?: string[]; -} +import { OptionsListReduxState } from './types'; export const OptionsListComponent = ({ typeaheadSubject, - componentStateSubject, }: { typeaheadSubject: Subject; - componentStateSubject: BehaviorSubject; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [searchString, setSearchString] = useState(''); @@ -45,23 +31,22 @@ export const OptionsListComponent = ({ const resizeRef = useRef(null); const dimensions = useResizeObserver(resizeRef.current); - // Redux embeddable Context to get state from Embeddable input + // Redux embeddable Context const { useEmbeddableDispatch, - useEmbeddableSelector, actions: { replaceSelection }, - } = useReduxEmbeddableContext(); + useEmbeddableSelector: select, + } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { controlStyle, selectedOptions, singleSelect, id } = useEmbeddableSelector( - (state) => state - ); - // useStateObservable to get component state from Embeddable - const { availableOptions, loading, invalidSelections, validSelections, totalCardinality, field } = - useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); + // Select current state from Redux using multiple selectors to avoid rerenders. + const invalidSelections = select((state) => state.componentState?.invalidSelections); + const validSelections = select((state) => state.componentState?.validSelections); + const selectedOptions = select((state) => state.input.selectedOptions); + const controlStyle = select((state) => state.input.controlStyle); + const singleSelect = select((state) => state.input.singleSelect); + const loading = select((state) => state.output.loading); + const id = select((state) => state.input.id); // debounce loading state so loading doesn't flash when user types const [buttonLoading, setButtonLoading] = useState(true); @@ -69,7 +54,7 @@ export const OptionsListComponent = ({ () => debounce((latestLoading: boolean) => setButtonLoading(latestLoading), 100), [] ); - useEffect(() => debounceSetButtonLoading(loading), [loading, debounceSetButtonLoading]); + useEffect(() => debounceSetButtonLoading(loading ?? false), [loading, debounceSetButtonLoading]); // remove all other selections if this control is single select useEffect(() => { @@ -132,24 +117,19 @@ export const OptionsListComponent = ({ })} > setIsPopoverOpen(false)} panelPaddingSize="none" anchorPosition="downCenter" - ownFocus - repositionOnScroll + className="optionsList__popoverOverride" + closePopover={() => setIsPopoverOpen(false)} + anchorClassName="optionsList__anchorOverride" > diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index cd9422af86ce9a..ba9d47f9a8588b 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -6,6 +6,14 @@ * Side Public License, v 1. */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { batch } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; +import { isEmpty, isEqual } from 'lodash'; +import { merge, Subject, Subscription } from 'rxjs'; +import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; + import { Filter, compareFilters, @@ -13,24 +21,21 @@ import { buildPhrasesFilter, COMPARE_ALL_OPTIONS, } from '@kbn/es-query'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import { isEmpty, isEqual } from 'lodash'; -import deepEqual from 'fast-deep-equal'; -import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs'; -import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; - import { - withSuspense, - LazyReduxEmbeddableWrapper, - ReduxEmbeddableWrapperPropsWithChildren, + createReduxEmbeddableTools, + ReduxEmbeddableTools, } from '@kbn/presentation-util-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { OptionsListEmbeddableInput, OptionsListField, OPTIONS_LIST_CONTROL } from './types'; -import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; +import { + OptionsListEmbeddableInput, + OptionsListField, + OptionsListReduxState, + OPTIONS_LIST_CONTROL, +} from './types'; +import { OptionsListComponent } from './options_list_component'; import { ControlsOptionsListService } from '../../services/options_list'; import { ControlsDataViewsService } from '../../services/data_views'; import { optionsListReducers } from './options_list_reducers'; @@ -38,10 +43,6 @@ import { OptionsListStrings } from './options_list_strings'; import { ControlInput, ControlOutput } from '../..'; import { pluginServices } from '../../services'; -const OptionsListReduxWrapper = withSuspense< - ReduxEmbeddableWrapperPropsWithChildren ->(LazyReduxEmbeddableWrapper); - const diffDataFetchProps = ( last?: OptionsListDataFetchProps, current?: OptionsListDataFetchProps @@ -81,13 +82,10 @@ export class OptionsListEmbeddable extends Embeddable({ - invalidSelections: [], - validSelections: [], - loading: true, - }); + private reduxEmbeddableTools: ReduxEmbeddableTools< + OptionsListReduxState, + typeof optionsListReducers + >; constructor(input: OptionsListEmbeddableInput, output: ControlOutput, parent?: IContainer) { super(input, output, parent); // get filters for initial output... @@ -96,10 +94,17 @@ export class OptionsListEmbeddable extends Embeddable(); + // build redux embeddable tools + this.reduxEmbeddableTools = createReduxEmbeddableTools< + OptionsListReduxState, + typeof optionsListReducers + >({ + embeddable: this, + reducers: optionsListReducers, + }); + this.initialize(); } @@ -149,13 +154,19 @@ export class OptionsListEmbeddable extends Embeddable isEqual(a.selectedOptions, b.selectedOptions))) .subscribe(async ({ selectedOptions: newSelectedOptions }) => { + const { + actions: { + clearValidAndInvalidSelections, + setValidAndInvalidSelections, + publishFilters, + }, + dispatch, + } = this.reduxEmbeddableTools; + if (!newSelectedOptions || isEmpty(newSelectedOptions)) { - this.updateComponentState({ - validSelections: [], - invalidSelections: [], - }); + dispatch(clearValidAndInvalidSelections({})); } else { - const { invalidSelections } = this.componentStateSubject$.getValue(); + const { invalidSelections } = this.reduxEmbeddableTools.getState().componentState ?? {}; const newValidSelections: string[] = []; const newInvalidSelections: string[] = []; for (const selectedOption of newSelectedOptions) { @@ -165,13 +176,15 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName, parentFieldName, childFieldName } = this.getInput(); + const { + dispatch, + getState, + actions: { setField, setDataView }, + } = this.reduxEmbeddableTools; + + const { + input: { dataViewId, fieldName, parentFieldName, childFieldName }, + } = getState(); if (!this.dataView || this.dataView.id !== dataViewId) { try { @@ -190,7 +211,7 @@ export class OptionsListEmbeddable extends Embeddable) { - this.componentState = { - ...this.componentState, - ...changes, - }; - this.componentStateSubject$.next(this.componentState); - } - private runOptionsListQuery = async () => { const { dataView, field } = await this.getCurrentDataViewAndField(); + + const { + dispatch, + getState, + actions: { setLoading, updateQueryResults, publishFilters }, + } = this.reduxEmbeddableTools; + if (!dataView || !field) return; - this.updateComponentState({ loading: true }); - this.updateOutput({ loading: true, dataViews: [dataView] }); - const { ignoreParentSettings, filters, query, selectedOptions, timeRange, runPastTimeout } = - this.getInput(); + dispatch(setLoading(true)); + + const { + input: { ignoreParentSettings, filters, query, selectedOptions, timeRange, runPastTimeout }, + } = getState(); if (this.abortController) this.abortController.abort(); this.abortController = new AbortController(); @@ -251,13 +272,14 @@ export class OptionsListEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(publishFilters(newFilters)); + }); }; private buildFilter = async () => { - const { validSelections } = this.componentState; + const { getState } = this.reduxEmbeddableTools; + const { validSelections } = getState().componentState ?? {}; + if (!validSelections || isEmpty(validSelections)) { return []; } @@ -307,20 +335,19 @@ export class OptionsListEmbeddable extends Embeddable { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); } + const { Wrapper: OptionsListReduxWrapper } = this.reduxEmbeddableTools; this.node = node; ReactDOM.render( - - + + , node diff --git a/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx b/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx index aa6b14c73f6e18..de32a243535d36 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx @@ -23,41 +23,38 @@ import { } from '@elastic/eui'; import { isEmpty } from 'lodash'; -import { DataViewField } from '@kbn/data-views-plugin/public'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { OptionsListEmbeddableInput } from './types'; -import { OptionsListStrings } from './options_list_strings'; import { optionsListReducers } from './options_list_reducers'; -import { OptionsListComponentState } from './options_list_component'; +import { OptionsListStrings } from './options_list_strings'; +import { OptionsListReduxState } from './types'; export const OptionsListPopover = ({ - field, - loading, + width, searchString, - availableOptions, - totalCardinality, - invalidSelections, updateSearchString, - width, }: { - field?: DataViewField; - searchString: string; - totalCardinality?: number; width: number; - loading: OptionsListComponentState['loading']; - invalidSelections?: string[]; + searchString: string; updateSearchString: (newSearchString: string) => void; - availableOptions: OptionsListComponentState['availableOptions']; }) => { // Redux embeddable container Context const { - useEmbeddableSelector, useEmbeddableDispatch, + useEmbeddableSelector: select, actions: { selectOption, deselectOption, clearSelections, replaceSelection }, - } = useReduxEmbeddableContext(); + } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { selectedOptions, singleSelect, title } = useEmbeddableSelector((state) => state); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const invalidSelections = select((state) => state.componentState?.invalidSelections); + const totalCardinality = select((state) => state.componentState?.totalCardinality); + const availableOptions = select((state) => state.componentState?.availableOptions); + const selectedOptions = select((state) => state.input.selectedOptions); + const singleSelect = select((state) => state.input.singleSelect); + const field = select((state) => state.componentState?.field); + const loading = select((state) => state.output.loading); + const title = select((state) => state.input.title); // track selectedOptions and invalidSelections in sets for more efficient lookup const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts b/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts index 15f41380e0d72d..24ebb727f17456 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts +++ b/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts @@ -6,52 +6,92 @@ * Side Public License, v 1. */ +import { DataView } from '@kbn/data-views-plugin/common'; +import { Filter } from '@kbn/es-query'; import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; -import { OptionsListEmbeddableInput } from './types'; +import { OptionsListField, OptionsListReduxState, OptionsListComponentState } from './types'; export const optionsListReducers = { - deselectOption: ( - state: WritableDraft, - action: PayloadAction - ) => { - if (!state.selectedOptions) return; - const itemIndex = state.selectedOptions.indexOf(action.payload); + deselectOption: (state: WritableDraft, action: PayloadAction) => { + if (!state.input.selectedOptions) return; + const itemIndex = state.input.selectedOptions.indexOf(action.payload); if (itemIndex !== -1) { - const newSelections = [...state.selectedOptions]; + const newSelections = [...state.input.selectedOptions]; newSelections.splice(itemIndex, 1); - state.selectedOptions = newSelections; + state.input.selectedOptions = newSelections; } }, deselectOptions: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { for (const optionToDeselect of action.payload) { - if (!state.selectedOptions) return; - const itemIndex = state.selectedOptions.indexOf(optionToDeselect); + if (!state.input.selectedOptions) return; + const itemIndex = state.input.selectedOptions.indexOf(optionToDeselect); if (itemIndex !== -1) { - const newSelections = [...state.selectedOptions]; + const newSelections = [...state.input.selectedOptions]; newSelections.splice(itemIndex, 1); - state.selectedOptions = newSelections; + state.input.selectedOptions = newSelections; } } }, - selectOption: ( - state: WritableDraft, - action: PayloadAction - ) => { - if (!state.selectedOptions) state.selectedOptions = []; - state.selectedOptions?.push(action.payload); + selectOption: (state: WritableDraft, action: PayloadAction) => { + if (!state.input.selectedOptions) state.input.selectedOptions = []; + state.input.selectedOptions?.push(action.payload); }, replaceSelection: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.selectedOptions = [action.payload]; + state.input.selectedOptions = [action.payload]; + }, + clearSelections: (state: WritableDraft) => { + if (state.input.selectedOptions) state.input.selectedOptions = []; + }, + clearValidAndInvalidSelections: (state: WritableDraft) => { + state.componentState.invalidSelections = []; + state.componentState.validSelections = []; + }, + setValidAndInvalidSelections: ( + state: WritableDraft, + action: PayloadAction<{ validSelections: string[]; invalidSelections: string[] }> + ) => { + const { invalidSelections, validSelections } = action.payload; + state.componentState.invalidSelections = invalidSelections; + state.componentState.validSelections = validSelections; }, - clearSelections: (state: WritableDraft) => { - if (state.selectedOptions) state.selectedOptions = []; + setLoading: (state: WritableDraft, action: PayloadAction) => { + state.output.loading = action.payload; + }, + setField: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.componentState.field = action.payload; + }, + updateQueryResults: ( + state: WritableDraft, + action: PayloadAction< + Pick< + OptionsListComponentState, + 'availableOptions' | 'invalidSelections' | 'validSelections' | 'totalCardinality' + > + > + ) => { + state.componentState = { ...(state.componentState ?? {}), ...action.payload }; + }, + publishFilters: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.filters = action.payload; + }, + setDataView: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.dataViews = action.payload ? [action.payload] : []; }, }; diff --git a/src/plugins/controls/public/control_types/options_list/types.ts b/src/plugins/controls/public/control_types/options_list/types.ts index f537cccf3d690a..1b3a7e1baa2e11 100644 --- a/src/plugins/controls/public/control_types/options_list/types.ts +++ b/src/plugins/controls/public/control_types/options_list/types.ts @@ -6,4 +6,26 @@ * Side Public License, v 1. */ +import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; +import { DataViewField } from '@kbn/data-views-plugin/common'; + +import { ControlOutput } from '../../types'; +import { OptionsListEmbeddableInput } from '../../../common/control_types/options_list/types'; + export * from '../../../common/control_types/options_list/types'; + +// Component state is only used by public components. +export interface OptionsListComponentState { + field?: DataViewField; + totalCardinality?: number; + availableOptions?: string[]; + invalidSelections?: string[]; + validSelections?: string[]; +} + +// public only - redux embeddable state type +export type OptionsListReduxState = ReduxEmbeddableState< + OptionsListEmbeddableInput, + ControlOutput, + OptionsListComponentState +>; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx index 54b53f25da89f7..ca9c1e4b2dd0c3 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx @@ -6,68 +6,12 @@ * Side Public License, v 1. */ -import React, { FC, useCallback } from 'react'; -import { BehaviorSubject } from 'rxjs'; +import React from 'react'; -import { DataViewField } from '@kbn/data-views-plugin/public'; -import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { useStateObservable } from '../../hooks/use_state_observable'; import { RangeSliderPopover } from './range_slider_popover'; -import { rangeSliderReducers } from './range_slider_reducers'; -import { RangeSliderEmbeddableInput, RangeValue } from './types'; import './range_slider.scss'; -interface Props { - componentStateSubject: BehaviorSubject; - ignoreValidation: boolean; -} -// Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. -export interface RangeSliderComponentState { - field?: DataViewField; - fieldFormatter: (value: string) => string; - min: string; - max: string; - loading: boolean; - isInvalid?: boolean; -} - -export const RangeSliderComponent: FC = ({ componentStateSubject, ignoreValidation }) => { - // Redux embeddable Context to get state from Embeddable input - const { - useEmbeddableDispatch, - useEmbeddableSelector, - actions: { selectRange }, - } = useReduxEmbeddableContext(); - const dispatch = useEmbeddableDispatch(); - - // useStateObservable to get component state from Embeddable - const { loading, min, max, fieldFormatter, isInvalid } = - useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); - - const { value, id, title } = useEmbeddableSelector((state) => state); - - const onChangeComplete = useCallback( - (range: RangeValue) => { - dispatch(selectRange(range)); - }, - [selectRange, dispatch] - ); - - return ( - - ); +export const RangeSliderComponent = () => { + return ; }; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx index 86231c4b05b202..64620e4a671d6c 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { isEmpty } from 'lodash'; import { compareFilters, buildRangeFilter, @@ -17,33 +16,30 @@ import { } from '@kbn/es-query'; import React from 'react'; import ReactDOM from 'react-dom'; +import { isEmpty } from 'lodash'; +import { batch } from 'react-redux'; import { get, isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { Subscription, BehaviorSubject, lastValueFrom } from 'rxjs'; +import { Subscription, lastValueFrom } from 'rxjs'; import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators'; import { - withSuspense, - LazyReduxEmbeddableWrapper, - ReduxEmbeddableWrapperPropsWithChildren, + ReduxEmbeddableTools, + createReduxEmbeddableTools, } from '@kbn/presentation-util-plugin/public'; import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { ControlsDataViewsService } from '../../services/data_views'; -import { ControlsDataService } from '../../services/data'; -import { ControlInput, ControlOutput } from '../..'; import { pluginServices } from '../../services'; +import { ControlInput, ControlOutput } from '../..'; +import { ControlsDataService } from '../../services/data'; +import { ControlsDataViewsService } from '../../services/data_views'; -import { RangeSliderComponent, RangeSliderComponentState } from './range_slider.component'; -import { rangeSliderReducers } from './range_slider_reducers'; import { RangeSliderStrings } from './range_slider_strings'; -import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; - -const RangeSliderReduxWrapper = withSuspense< - ReduxEmbeddableWrapperPropsWithChildren ->(LazyReduxEmbeddableWrapper); +import { RangeSliderComponent } from './range_slider.component'; +import { getDefaultComponentState, rangeSliderReducers } from './range_slider_reducers'; +import { RangeSliderEmbeddableInput, RangeSliderReduxState, RANGE_SLIDER_CONTROL } from './types'; const diffDataFetchProps = ( current?: RangeSliderDataFetchProps, @@ -83,14 +79,10 @@ export class RangeSliderEmbeddable extends Embeddable({ - min: '', - max: '', - loading: true, - fieldFormatter: (value: string) => value, - }); + private reduxEmbeddableTools: ReduxEmbeddableTools< + RangeSliderReduxState, + typeof rangeSliderReducers + >; constructor(input: RangeSliderEmbeddableInput, output: ControlOutput, parent?: IContainer) { super(input, output, parent); // get filters for initial output... @@ -98,14 +90,14 @@ export class RangeSliderEmbeddable extends Embeddable value, - isInvalid: false, - }; - this.updateComponentState(this.componentState); + this.reduxEmbeddableTools = createReduxEmbeddableTools< + RangeSliderReduxState, + typeof rangeSliderReducers + >({ + embeddable: this, + reducers: rangeSliderReducers, + initialComponentState: getDefaultComponentState(), + }); this.initialize(); } @@ -158,13 +150,21 @@ export class RangeSliderEmbeddable extends Embeddable => { - const { dataViewId, fieldName } = this.getInput(); + const { + getState, + dispatch, + actions: { setFieldAndFormatter, setDataView }, + } = this.reduxEmbeddableTools; + const { + input: { dataViewId, fieldName }, + } = getState(); if (!this.dataView || this.dataView.id !== dataViewId) { try { this.dataView = await this.dataViewsService.get(dataViewId); if (!this.dataView) throw new Error(RangeSliderStrings.errors.getDataViewNotFoundError(dataViewId)); + dispatch(setDataView(this.dataView)); } catch (e) { this.onFatalError(e); } @@ -176,28 +176,25 @@ export class RangeSliderEmbeddable extends Embeddable value, - }); + dispatch( + setFieldAndFormatter({ + field: this.field, + fieldFormatter: + this.field && this.dataView?.getFormatterForField(this.field).getConverterFor('text'), + }) + ); } return { dataView: this.dataView, field: this.field! }; }; - private updateComponentState(changes: Partial) { - this.componentState = { - ...this.componentState, - ...changes, - }; - this.componentStateSubject$.next(this.componentState); - } - private runRangeSliderQuery = async () => { - this.updateComponentState({ loading: true }); - this.updateOutput({ loading: true }); + const { + dispatch, + actions: { setLoading, publishFilters, setMinMax }, + } = this.reduxEmbeddableTools; + + dispatch(setLoading(true)); const { dataView, field } = await this.getCurrentDataViewAndField(); if (!dataView || !field) return; @@ -206,8 +203,10 @@ export class RangeSliderEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(publishFilters([])); + }); throw fieldMissingError(fieldName); } @@ -229,10 +228,12 @@ export class RangeSliderEmbeddable extends Embeddable { const { - value: [selectedMin, selectedMax] = ['', ''], - query, - timeRange, - filters = [], - ignoreParentSettings, - } = this.getInput(); - - const availableMin = this.componentState.min; - const availableMax = this.componentState.max; + dispatch, + getState, + actions: { setLoading, setIsInvalid, setDataView, publishFilters }, + } = this.reduxEmbeddableTools; + const { + componentState: { min: availableMin, max: availableMax }, + input: { + query, + timeRange, + filters = [], + ignoreParentSettings, + value: [selectedMin, selectedMax] = ['', ''], + }, + } = getState(); const hasData = !isEmpty(availableMin) && !isEmpty(availableMax); const hasLowerSelection = !isEmpty(selectedMin); @@ -312,11 +318,12 @@ export class RangeSliderEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(setIsInvalid(!ignoreParentSettings?.ignoreValidations && hasEitherSelection)); + dispatch(setDataView(dataView)); + dispatch(publishFilters([])); }); - this.updateOutput({ filters: [], dataViews: dataView && [dataView], loading: false }); return; } @@ -366,18 +373,22 @@ export class RangeSliderEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(setIsInvalid(true)); + dispatch(setDataView(dataView)); + dispatch(publishFilters([])); }); return; } } - this.updateComponentState({ loading: false, isInvalid: false }); - this.updateOutput({ filters: [rangeFilter], dataViews: [dataView], loading: false }); + batch(() => { + dispatch(setLoading(false)); + dispatch(setIsInvalid(false)); + dispatch(setDataView(dataView)); + dispatch(publishFilters([rangeFilter])); + }); }; public reload = () => { @@ -393,18 +404,12 @@ export class RangeSliderEmbeddable extends Embeddable - - + + , node diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx index fce3dbdfe7009e..7d7bbdd0908fa3 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import React, { FC, useState, useRef } from 'react'; import { EuiFieldNumber, EuiPopoverTitle, @@ -19,39 +18,38 @@ import { EuiFlexItem, EuiDualRange, } from '@elastic/eui'; +import React, { useState, useRef } from 'react'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { RangeSliderStrings } from './range_slider_strings'; -import { RangeValue } from './types'; +import { RangeSliderReduxState, RangeValue } from './types'; +import { rangeSliderReducers } from './range_slider_reducers'; const INVALID_CLASS = 'rangeSliderAnchor__fieldNumber--invalid'; -export interface Props { - id: string; - isInvalid?: boolean; - isLoading?: boolean; - min: string; - max: string; - title?: string; - value: RangeValue; - onChange: (value: RangeValue) => void; - fieldFormatter: (value: string) => string; -} - -export const RangeSliderPopover: FC = ({ - id, - isInvalid, - isLoading, - min, - max, - title, - value, - onChange, - fieldFormatter, -}) => { +export const RangeSliderPopover = () => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [rangeSliderMin, setRangeSliderMin] = useState(-Infinity); const [rangeSliderMax, setRangeSliderMax] = useState(Infinity); const rangeRef = useRef(null); + + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { setSelectedRange }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const id = select((state) => state.input.id); + const value = select((state) => state.input.value); + const title = select((state) => state.input.title); + const min = select((state) => state.componentState.min); + const max = select((state) => state.componentState.max); + const isLoading = select((state) => state.output.loading); + const isInvalid = select((state) => state.componentState.isInvalid); + const fieldFormatter = select((state) => state.componentState.fieldFormatter); + let errorMessage = ''; let helpText = ''; @@ -129,7 +127,12 @@ export const RangeSliderPopover: FC = ({ }`} value={hasLowerBoundSelection ? lowerBoundValue : ''} onChange={(event) => { - onChange([event.target.value, isNaN(upperBoundValue) ? '' : String(upperBoundValue)]); + dispatch( + setSelectedRange([ + event.target.value, + isNaN(upperBoundValue) ? '' : String(upperBoundValue), + ]) + ); }} disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMin : ''}`} @@ -151,7 +154,12 @@ export const RangeSliderPopover: FC = ({ }`} value={hasUpperBoundSelection ? upperBoundValue : ''} onChange={(event) => { - onChange([isNaN(lowerBoundValue) ? '' : String(lowerBoundValue), event.target.value]); + dispatch( + setSelectedRange([ + isNaN(lowerBoundValue) ? '' : String(lowerBoundValue), + event.target.value, + ]) + ); }} disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMax : ''}`} @@ -209,7 +217,7 @@ export const RangeSliderPopover: FC = ({ const updatedUpperBound = typeof newUpperBound === 'number' ? String(newUpperBound) : value[1]; - onChange([updatedLowerBound, updatedUpperBound]); + dispatch(setSelectedRange([updatedLowerBound, updatedUpperBound])); }} value={displayedValue} ticks={hasAvailableRange ? ticks : undefined} @@ -233,7 +241,7 @@ export const RangeSliderPopover: FC = ({ onChange(['', ''])} + onClick={() => dispatch(setSelectedRange(['', '']))} aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} data-test-subj="rangeSlider__clearRangeButton" /> diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts b/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts index ce7e5ced101a61..bc9ac9e9c5f575 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts @@ -6,16 +6,62 @@ * Side Public License, v 1. */ +import { Filter } from '@kbn/es-query'; import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; +import { DataViewField, DataView } from '@kbn/data-views-plugin/common'; -import { RangeSliderEmbeddableInput, RangeValue } from './types'; +import { RangeSliderComponentState, RangeSliderReduxState, RangeValue } from './types'; + +export const getDefaultComponentState = () => ({ + min: '', + max: '', + loading: true, + isInvalid: false, + fieldFormatter: (value: string) => value, +}); export const rangeSliderReducers = { - selectRange: ( - state: WritableDraft, + setSelectedRange: ( + state: WritableDraft, action: PayloadAction ) => { - state.value = action.payload; + state.input.value = action.payload; + }, + setFieldAndFormatter: ( + state: WritableDraft, + action: PayloadAction<{ + field?: DataViewField; + fieldFormatter?: RangeSliderComponentState['fieldFormatter']; + }> + ) => { + state.componentState.field = action.payload.field; + state.componentState.fieldFormatter = + action.payload.fieldFormatter ?? ((value: string) => value); + }, + setDataView: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.dataViews = action.payload ? [action.payload] : []; + }, + setLoading: (state: WritableDraft, action: PayloadAction) => { + state.output.loading = action.payload; + }, + setMinMax: ( + state: WritableDraft, + action: PayloadAction<{ min: string; max: string }> + ) => { + state.componentState.min = action.payload.min; + state.componentState.max = action.payload.max; + }, + publishFilters: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.filters = action.payload; + }, + setIsInvalid: (state: WritableDraft, action: PayloadAction) => { + state.componentState.isInvalid = action.payload; }, }; diff --git a/src/plugins/controls/public/control_types/range_slider/types.ts b/src/plugins/controls/public/control_types/range_slider/types.ts index e9ebe61bf62674..c82fd82ac50bdc 100644 --- a/src/plugins/controls/public/control_types/range_slider/types.ts +++ b/src/plugins/controls/public/control_types/range_slider/types.ts @@ -6,5 +6,26 @@ * Side Public License, v 1. */ -export * from '../../../common/control_types/options_list/types'; +import { DataViewField } from '@kbn/data-views-plugin/common'; +import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; + +import { RangeSliderEmbeddableInput } from '../../../common/control_types/range_slider/types'; +import { ControlOutput } from '../../types'; + +// Component state is only used by public components. +export interface RangeSliderComponentState { + field?: DataViewField; + fieldFormatter: (value: string) => string; + min: string; + max: string; + isInvalid?: boolean; +} + +// public only - redux embeddable state type +export type RangeSliderReduxState = ReduxEmbeddableState< + RangeSliderEmbeddableInput, + ControlOutput, + RangeSliderComponentState +>; + export * from '../../../common/control_types/range_slider/types'; diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider.tsx index a530a86c5a2d0e..e72a5245cb95ff 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider.tsx @@ -10,18 +10,10 @@ import React, { FC, useCallback, useMemo } from 'react'; import { BehaviorSubject } from 'rxjs'; import { debounce } from 'lodash'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { useStateObservable } from '../../hooks/use_state_observable'; -import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; + import { timeSliderReducers } from './time_slider_reducers'; import { TimeSlider as Component } from './time_slider.component'; - -export interface TimeSliderSubjectState { - range?: { - min?: number; - max?: number; - }; - loading: boolean; -} +import { TimeSliderReduxState, TimeSliderSubjectState } from './types'; interface TimeSliderProps { componentStateSubject: BehaviorSubject; @@ -40,15 +32,14 @@ export const TimeSlider: FC = ({ }) => { const { useEmbeddableDispatch, - useEmbeddableSelector, + useEmbeddableSelector: select, actions: { selectRange }, - } = useReduxEmbeddableContext(); + } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { range: availableRange } = useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); + const availableRange = select((state) => state.componentState.range); + const value = select((state) => state.input.value); + const id = select((state) => state.input.id); const { min, max } = availableRange ? availableRange @@ -57,8 +48,6 @@ export const TimeSlider: FC = ({ max?: number; }); - const { value, id } = useEmbeddableSelector((state) => state); - const dispatchChange = useCallback( (range: [number | null, number | null]) => { dispatch(selectRange(range)); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx index 7900cc193ac248..34dfeca35997b6 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx @@ -14,27 +14,23 @@ import deepEqual from 'fast-deep-equal'; import { merge, Subscription, BehaviorSubject, Observable } from 'rxjs'; import { map, distinctUntilChanged, skip, take, mergeMap } from 'rxjs/operators'; +import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { - withSuspense, - LazyReduxEmbeddableWrapper, - ReduxEmbeddableWrapperPropsWithChildren, + ReduxEmbeddableTools, + createReduxEmbeddableTools, } from '@kbn/presentation-util-plugin/public'; -import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; -import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; +import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; import { TIME_SLIDER_CONTROL } from '../..'; import { ControlsSettingsService } from '../../services/settings'; import { ControlsDataService } from '../../services/data'; import { ControlOutput } from '../..'; import { pluginServices } from '../../services'; -import { TimeSlider as TimeSliderComponent, TimeSliderSubjectState } from './time_slider'; +import { TimeSlider as TimeSliderComponent } from './time_slider'; import { timeSliderReducers } from './time_slider_reducers'; - -const TimeSliderControlReduxWrapper = withSuspense< - ReduxEmbeddableWrapperPropsWithChildren ->(LazyReduxEmbeddableWrapper); +import { TimeSliderReduxState, TimeSliderSubjectState } from './types'; const diffDataFetchProps = (current?: any, last?: any) => { if (!current || !last) return false; @@ -77,6 +73,11 @@ export class TimeSliderControlEmbeddable extends Embeddable< private getDateFormat: ControlsSettingsService['getDateFormat']; private getTimezone: ControlsSettingsService['getTimezone']; + private reduxEmbeddableTools: ReduxEmbeddableTools< + TimeSliderReduxState, + typeof timeSliderReducers + >; + constructor(input: TimeSliderControlEmbeddableInput, output: ControlOutput, parent?: IContainer) { super(input, output, parent); // get filters for initial output... @@ -94,6 +95,15 @@ export class TimeSliderControlEmbeddable extends Embeddable< this.internalOutput = {}; + // build redux embeddable tools + this.reduxEmbeddableTools = createReduxEmbeddableTools< + TimeSliderReduxState, + typeof timeSliderReducers + >({ + embeddable: this, + reducers: timeSliderReducers, + }); + this.initialize(); } @@ -300,8 +310,10 @@ export class TimeSliderControlEmbeddable extends Embeddable< } this.node = node; + const { Wrapper: TimeSliderControlReduxWrapper } = this.reduxEmbeddableTools; + ReactDOM.render( - + , + state: WritableDraft, action: PayloadAction<[number | null, number | null]> ) => { - state.value = action.payload; + state.input.value = action.payload; }, }; diff --git a/src/plugins/controls/public/control_types/time_slider/types.ts b/src/plugins/controls/public/control_types/time_slider/types.ts new file mode 100644 index 00000000000000..fc147dc3ba9596 --- /dev/null +++ b/src/plugins/controls/public/control_types/time_slider/types.ts @@ -0,0 +1,30 @@ +/* + * 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 { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; + +import { ControlOutput } from '../../types'; +import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; + +export * from '../../../common/control_types/time_slider/types'; + +// Component state is only used by public components. +export interface TimeSliderSubjectState { + range?: { + min?: number; + max?: number; + }; + loading: boolean; +} + +// public only - redux embeddable state type +export type TimeSliderReduxState = ReduxEmbeddableState< + TimeSliderControlEmbeddableInput, + ControlOutput, + TimeSliderSubjectState +>; diff --git a/src/plugins/controls/public/hooks/use_state_observable.ts b/src/plugins/controls/public/hooks/use_state_observable.ts deleted file mode 100644 index 79decd14ba358b..00000000000000 --- a/src/plugins/controls/public/hooks/use_state_observable.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 { useEffect, useState } from 'react'; -import { Observable } from 'rxjs'; - -export const useStateObservable = ( - stateObservable: Observable, - initialState: T -) => { - const [innerState, setInnerState] = useState(initialState); - useEffect(() => { - const subscription = stateObservable.subscribe((newState) => setInnerState(newState)); - return () => subscription.unsubscribe(); - }, [stateObservable]); - - return innerState; -}; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index 5a254877399edd..fc5e97ad317366 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -8,7 +8,6 @@ import React, { Suspense, ComponentType, ReactElement, Ref } from 'react'; import { EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; -import { ReduxEmbeddableWrapperType } from './redux_embeddables/redux_embeddable_wrapper'; /** * A HOC which supplies React.Suspense with a fallback component, and a `EuiErrorBoundary` to contain errors. @@ -39,10 +38,6 @@ export const LazySavedObjectSaveModalDashboard = React.lazy( () => import('./saved_object_save_modal_dashboard') ); -export const LazyReduxEmbeddableWrapper = React.lazy( - () => import('./redux_embeddables/redux_embeddable_wrapper') -) as ReduxEmbeddableWrapperType; // Lazy component needs to be casted due to generic type props - export const LazyDataViewPicker = React.lazy(() => import('./data_view_picker/data_view_picker')); export const LazyFieldPicker = React.lazy(() => import('./field_picker/field_picker')); diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx deleted file mode 100644 index fbe86862c3e4c0..00000000000000 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* - * 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 { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; -import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; -import { Draft } from 'immer/dist/types/types-external'; -import { debounceTime, finalize } from 'rxjs/operators'; -import { Filter } from '@kbn/es-query'; -import { isEqual } from 'lodash'; - -import { - IContainer, - IEmbeddable, - EmbeddableInput, - EmbeddableOutput, - isErrorEmbeddable, -} from '@kbn/embeddable-plugin/public'; -import { - ReduxEmbeddableWrapperProps, - ReduxContainerContextServices, - ReduxEmbeddableContextServices, - ReduxEmbeddableWrapperPropsWithChildren, -} from './types'; -import { getManagedEmbeddablesStore } from './generic_embeddable_store'; -import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; - -type InputWithFilters = Partial & { filters: Filter[] }; -export const stateContainsFilters = ( - state: Partial -): state is InputWithFilters => { - if ((state as InputWithFilters).filters) return true; - return false; -}; - -export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => { - return filters.map((filter) => { - if (filter.meta.value) delete filter.meta.value; - return filter; - }); -}; - -const getDefaultProps = (): Required< - Pick, 'diffInput'> -> => ({ - diffInput: (a, b) => { - const differences: Partial = {}; - const allKeys = [...Object.keys(a), ...Object.keys(b)] as Array; - allKeys.forEach((key) => { - if (!isEqual(a[key], b[key])) differences[key] = a[key]; - }); - return differences; - }, -}); - -/** - * Place this wrapper around the react component when rendering an embeddable to automatically set up - * redux for use with the embeddable via the supplied reducers. Any child components can then use ReduxEmbeddableContext - * or ReduxContainerContext to interface with the state of the embeddable. - */ -export const ReduxEmbeddableWrapper = ( - props: ReduxEmbeddableWrapperPropsWithChildren -) => { - return ( - - - - {props.children} - - - - ); -}; - -interface ReduxEmbeddableSyncProps { - diffInput: (a: InputType, b: InputType) => Partial; - embeddable: IEmbeddable; -} - -/** - * This component uses the context from the embeddable wrapper to set up a generic two-way binding between the embeddable input and - * the redux store. a custom diffInput function can be provided, this function should always prioritize input A over input B. - */ -const ReduxEmbeddableSync = ({ - embeddable, - diffInput, - children, -}: PropsWithChildren>) => { - const { - useEmbeddableSelector, - useEmbeddableDispatch, - actions: { updateEmbeddableReduxState, clearEmbeddableReduxState }, - } = useReduxEmbeddableContext(); - - const dispatch = useEmbeddableDispatch(); - const currentState = useEmbeddableSelector((state) => state); - const stateRef = useRef(currentState); - const destroyedRef = useRef(false); - - useEffect(() => { - // When Embeddable Input changes, push differences to redux. - const inputSubscription = embeddable - .getInput$() - .pipe( - finalize(() => { - // empty redux store, when embeddable is destroyed. - destroyedRef.current = true; - dispatch(clearEmbeddableReduxState(undefined)); - }), - debounceTime(0) - ) // debounce input changes to ensure that when many updates are made in one render the latest wins out - .subscribe(() => { - const differences = diffInput(getExplicitInput(embeddable), stateRef.current); - if (differences && Object.keys(differences).length > 0) { - if (stateContainsFilters(differences)) { - differences.filters = cleanFiltersForSerialize(differences.filters); - } - dispatch(updateEmbeddableReduxState(differences)); - } - }); - return () => inputSubscription.unsubscribe(); - }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState, clearEmbeddableReduxState]); - - useEffect(() => { - if (isErrorEmbeddable(embeddable) || destroyedRef.current) return; - // When redux state changes, push differences to Embeddable Input. - stateRef.current = currentState; - const differences = diffInput(currentState, getExplicitInput(embeddable)); - if (differences && Object.keys(differences).length > 0) { - if (stateContainsFilters(differences)) { - differences.filters = cleanFiltersForSerialize(differences.filters); - } - embeddable.updateInput(differences); - } - }, [currentState, diffInput, embeddable]); - - return <>{children}; -}; - -// required for dynamic import using React.lazy() -// eslint-disable-next-line import/no-default-export -export default ReduxEmbeddableWrapper; - -export type ReduxEmbeddableWrapperType = typeof ReduxEmbeddableWrapper; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/sync_redux_embeddable.ts b/src/plugins/presentation_util/public/components/redux_embeddables/sync_redux_embeddable.ts deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index cb1cbb60d44126..a499679a149b97 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -43,9 +43,16 @@ export { withSuspense, LazyDataViewPicker, LazyFieldPicker, - LazyReduxEmbeddableWrapper, } from './components'; +export { + useReduxContainerContext, + useReduxEmbeddableContext, + createReduxEmbeddableTools, + type ReduxEmbeddableState, + type ReduxEmbeddableTools, +} from './redux_embeddables'; + export * from './components/types'; export type { QuickButtonProps } from './components/solution_toolbar'; @@ -61,14 +68,6 @@ export { SolutionToolbarPopover, } from './components/solution_toolbar'; -export { - ReduxEmbeddableContext, - useReduxContainerContext, - useReduxEmbeddableContext, - type ReduxContainerContextServices, - type ReduxEmbeddableWrapperPropsWithChildren, -} from './components/redux_embeddables'; - /** * Register a set of Expression Functions with the Presentation Utility ExpressionInput. This allows * the Monaco Editor to understand the functions and their arguments. diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/build_redux_embeddable_context.ts b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx similarity index 53% rename from src/plugins/presentation_util/public/components/redux_embeddables/build_redux_embeddable_context.ts rename to src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx index 46537da1e36046..579e8b1d7e7ed9 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/build_redux_embeddable_context.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { IEmbeddable } from '@kbn/embeddable-plugin/public'; import { configureStore, createSlice, @@ -14,24 +13,48 @@ import { PayloadAction, SliceCaseReducers, } from '@reduxjs/toolkit'; -import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import { cleanFiltersForSerialize, stateContainsFilters } from './redux_embeddable_wrapper'; +import { Filter } from '@kbn/es-query'; +import React, { ReactNode, PropsWithChildren } from 'react'; +import { EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; + import { EmbeddableReducers, - ReduxContainerContext, + ReduxEmbeddableTools, ReduxEmbeddableContext, ReduxEmbeddableState, + ReduxEmbeddableSyncSettings, } from './types'; +import { syncReduxEmbeddable } from './sync_redux_embeddable'; +import { EmbeddableReduxContext } from './use_redux_embeddable_context'; -export const buildReduxEmbeddableContext = < - ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +// TODO: Make filters serializable so we don't need special treatment for them. +type InputWithFilters = Partial & { filters: Filter[] }; +const stateContainsFilters = (state: Partial): state is InputWithFilters => { + if ((state as InputWithFilters).filters) return true; + return false; +}; +const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => { + return filters.map((filter) => { + if (filter.meta.value) delete filter.meta.value; + return filter; + }); +}; + +export const createReduxEmbeddableTools = < + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers >({ - embeddable, reducers, + embeddable, + syncSettings, + initialComponentState, }: { embeddable: IEmbeddable; - reducers: EmbeddableReducers; -}): ReduxEmbeddableContext | ReduxContainerContext => { + initialComponentState?: ReduxEmbeddableStateType['componentState']; + syncSettings?: ReduxEmbeddableSyncSettings; + reducers: ReducerType; +}): ReduxEmbeddableTools => { // Additional generic reducers to aid in embeddable syncing const genericReducers = { updateEmbeddableReduxInput: ( @@ -52,6 +75,7 @@ export const buildReduxEmbeddableContext = < const initialState: ReduxEmbeddableStateType = { input: embeddable.getInput(), output: embeddable.getOutput(), + componentState: initialComponentState ?? {}, } as ReduxEmbeddableStateType; if (stateContainsFilters(initialState.input)) { @@ -67,8 +91,12 @@ export const buildReduxEmbeddableContext = < const store = configureStore({ reducer: slice.reducer }); - return { - actions: slice.actions as ReduxEmbeddableContext['actions'], + // create the context which will wrap this embeddable's react components to allow access to update and read from the store. + const context = { + actions: slice.actions as ReduxEmbeddableContext< + ReduxEmbeddableStateType, + typeof reducers + >['actions'], useEmbeddableDispatch: () => useDispatch(), useEmbeddableSelector: useSelector as TypedUseSelectorHook, @@ -83,4 +111,26 @@ export const buildReduxEmbeddableContext = < } : undefined, }; + + const Wrapper: React.FC> = ({ children }: { children?: ReactNode }) => ( + + {children} + + ); + + const stopReduxEmbeddableSync = syncReduxEmbeddable({ + actions: context.actions, + settings: syncSettings, + embeddable, + store, + }); + + // return redux tools for the embeddable class to use. + return { + Wrapper, + actions: context.actions, + dispatch: store.dispatch, + getState: store.getState, + cleanup: () => stopReduxEmbeddableSync?.(), + }; }; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/index.ts b/src/plugins/presentation_util/public/redux_embeddables/index.ts similarity index 68% rename from src/plugins/presentation_util/public/components/redux_embeddables/index.ts rename to src/plugins/presentation_util/public/redux_embeddables/index.ts index 55fb913635e81a..a2aee7e710679a 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/index.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/index.ts @@ -7,11 +7,10 @@ */ export { - ReduxEmbeddableContext, useReduxContainerContext, useReduxEmbeddableContext, -} from './redux_embeddable_context'; -export type { - ReduxContainerContextServices, - ReduxEmbeddableWrapperPropsWithChildren, -} from './types'; +} from './use_redux_embeddable_context'; + +export { createReduxEmbeddableTools } from './create_redux_embeddable_tools'; + +export type { ReduxEmbeddableState, ReduxEmbeddableTools } from './types'; diff --git a/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts b/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts new file mode 100644 index 00000000000000..05bdb03b0020d2 --- /dev/null +++ b/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts @@ -0,0 +1,79 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; + +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { EnhancedStore } from '@reduxjs/toolkit'; +import { ReduxEmbeddableContext, ReduxEmbeddableState, ReduxEmbeddableSyncSettings } from './types'; + +export const syncReduxEmbeddable = < + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +>({ + store, + actions, + settings, + embeddable, +}: { + settings?: ReduxEmbeddableSyncSettings; + store: EnhancedStore; + embeddable: IEmbeddable; + actions: ReduxEmbeddableContext['actions']; +}) => { + if (settings?.disableSync) { + return; + } + + const { isInputEqual: inputEqualityCheck, isOutputEqual: outputEqualityCheck } = settings ?? {}; + const inputEqual = ( + inputA: ReduxEmbeddableStateType['input'], + inputB: ReduxEmbeddableStateType['input'] + ) => (inputEqualityCheck ? inputEqualityCheck(inputA, inputB) : deepEqual(inputA, inputB)); + const outputEqual = ( + outputA: ReduxEmbeddableStateType['output'], + outputB: ReduxEmbeddableStateType['output'] + ) => (outputEqualityCheck ? outputEqualityCheck(outputA, outputB) : deepEqual(outputA, outputB)); + + // when the redux store changes, diff, and push updates to the embeddable input or to the output. + const unsubscribeFromStore = store.subscribe(() => { + const reduxState = store.getState(); + if (!inputEqual(reduxState.input, embeddable.getInput())) { + embeddable.updateInput(reduxState.input); + } + if (!outputEqual(reduxState.output, embeddable.getOutput())) { + // updating output is usually not accessible from outside of the embeddable. + // This redux sync utility is meant to be used from inside the embeddable, so we need to workaround the typescript error via casting. + ( + embeddable as unknown as { + updateOutput: (newOutput: ReduxEmbeddableStateType['output']) => void; + } + ).updateOutput(reduxState.output); + } + }); + + // when the embeddable input changes, diff and dispatch to the redux store + const inputSubscription = embeddable.getInput$().subscribe((embeddableInput) => { + const reduxState = store.getState(); + if (!inputEqual(reduxState.input, embeddableInput)) { + store.dispatch(actions.updateEmbeddableReduxInput(embeddableInput)); + } + }); + + // when the embeddable output changes, diff and dispatch to the redux store + const outputSubscription = embeddable.getOutput$().subscribe((embeddableOutput) => { + const reduxState = store.getState(); + if (!outputEqual(reduxState.output, embeddableOutput)) { + store.dispatch(actions.updateEmbeddableReduxOutput(embeddableOutput)); + } + }); + return () => { + unsubscribeFromStore(); + inputSubscription.unsubscribe(); + outputSubscription.unsubscribe(); + }; +}; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/types.ts b/src/plugins/presentation_util/public/redux_embeddables/types.ts similarity index 66% rename from src/plugins/presentation_util/public/components/redux_embeddables/types.ts rename to src/plugins/presentation_util/public/redux_embeddables/types.ts index cf3d40d6dc382b..7b576344f31eba 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/types.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/types.ts @@ -7,20 +7,45 @@ */ import { - ActionCreatorWithPayload, + Dispatch, AnyAction, CaseReducer, - Dispatch, PayloadAction, + ActionCreatorWithPayload, + EnhancedStore, } from '@reduxjs/toolkit'; -import { PropsWithChildren } from 'react'; import { TypedUseSelectorHook } from 'react-redux'; -import { - EmbeddableInput, - EmbeddableOutput, - IContainer, - IEmbeddable, -} from '@kbn/embeddable-plugin/public'; +import { EmbeddableInput, EmbeddableOutput, IContainer } from '@kbn/embeddable-plugin/public'; +import { PropsWithChildren } from 'react'; + +export interface ReduxEmbeddableSyncSettings< + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +> { + disableSync: boolean; + isInputEqual?: ( + a: ReduxEmbeddableStateType['input'], + b: ReduxEmbeddableStateType['input'] + ) => boolean; + isOutputEqual?: ( + a: ReduxEmbeddableStateType['output'], + b: ReduxEmbeddableStateType['output'] + ) => boolean; +} + +/** + * The return type from setupReduxEmbeddable. Contains a wrapper which comes with the store provider and provides the context to react components, + * but also returns the context object to allow the embeddable class to interact with the redux store. + */ +export interface ReduxEmbeddableTools< + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +> { + cleanup: () => void; + Wrapper: React.FC>; + dispatch: EnhancedStore['dispatch']; + getState: EnhancedStore['getState']; + actions: ReduxEmbeddableContext['actions']; +} /** * The Embeddable Redux store should contain Input, Output and State. Input is serialized and used to create the embeddable, @@ -34,7 +59,7 @@ export interface ReduxEmbeddableState< > { input: InputType; output: OutputType; - state?: StateType; + componentState: StateType; } /** @@ -46,21 +71,11 @@ export interface EmbeddableReducers< > { /** * PayloadAction of type any is strategic here because we want to allow payloads of any shape in generic reducers. - * This type will be overridden to remove any and be type safe when returned by buildReduxEmbeddableContext. + * This type will be overridden to remove any and be type safe when returned by setupReduxEmbeddable. */ [key: string]: CaseReducer>; } -export interface ReduxEmbeddableWrapperProps { - embeddable: IEmbeddable; - reducers: GenericEmbeddableReducers; - diffInput?: (a: InputType, b: InputType) => Partial; -} - -// export type ReduxEmbeddableWrapperPropsWithChildren< -// InputType extends EmbeddableInput = EmbeddableInput -// > = PropsWithChildren>; - /** * This context type contains the actions, selector, and dispatch that embeddables need to interact with their state. This * should be passed down from the embeddable class, to its react components by wrapping the embeddable's render output in ReduxEmbeddableContext. @@ -75,13 +90,11 @@ export interface ReduxEmbeddableContext< >; } & { // Generic reducers to interact with embeddable Input and Output. - updateEmbeddableReduxInput: CaseReducer< - ReduxEmbeddableStateType, - PayloadAction> + updateEmbeddableReduxInput: ActionCreatorWithPayload< + Partial >; - updateEmbeddableReduxOutput: CaseReducer< - ReduxEmbeddableStateType, - PayloadAction> + updateEmbeddableReduxOutput: ActionCreatorWithPayload< + Partial >; }; useEmbeddableSelector: TypedUseSelectorHook; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts b/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts similarity index 51% rename from src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts rename to src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts index 4e0a7886d87957..4454d090a95664 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts @@ -7,21 +7,19 @@ */ import { createContext, useContext } from 'react'; -import type { ContainerInput, EmbeddableInput } from '@kbn/embeddable-plugin/public'; import type { - GenericEmbeddableReducers, - ReduxContainerContextServices, - ReduxEmbeddableContextServices, + ReduxEmbeddableState, + ReduxContainerContext, + ReduxEmbeddableContext, + EmbeddableReducers, } from './types'; /** * When creating the context, a generic EmbeddableInput as placeholder is used. This will later be cast to - * the generic type passed in by the useReduxEmbeddableContext or useReduxContainerContext hooks + * the type passed in by the useReduxEmbeddableContext or useReduxContainerContext hooks **/ -export const ReduxEmbeddableContext = createContext< - | ReduxEmbeddableContextServices - | ReduxContainerContextServices - | null +export const EmbeddableReduxContext = createContext< + ReduxEmbeddableContext | ReduxContainerContext | null >(null); /** @@ -31,17 +29,17 @@ export const ReduxEmbeddableContext = createContext< * types of your reducers. use `typeof MyReducers` here to retain them. */ export const useReduxEmbeddableContext = < - InputType extends EmbeddableInput = EmbeddableInput, - ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers ->(): ReduxEmbeddableContextServices => { - const context = useContext>( - ReduxEmbeddableContext as unknown as React.Context< - ReduxEmbeddableContextServices + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +>(): ReduxEmbeddableContext => { + const context = useContext>( + EmbeddableReduxContext as unknown as React.Context< + ReduxEmbeddableContext > ); if (context == null) { throw new Error( - 'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.' + 'useReduxEmbeddableContext must be used inside the ReduxEmbeddableWrapper from build_redux_embeddable_context.' ); } @@ -56,17 +54,17 @@ export const useReduxEmbeddableContext = < * key which contains most of the commonly used container operations */ export const useReduxContainerContext = < - InputType extends ContainerInput = ContainerInput, - ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers ->(): ReduxContainerContextServices => { - const context = useContext>( - ReduxEmbeddableContext as unknown as React.Context< - ReduxContainerContextServices + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +>(): ReduxContainerContext => { + const context = useContext>( + EmbeddableReduxContext as unknown as React.Context< + ReduxContainerContext > ); if (context == null) { throw new Error( - 'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.' + 'useReduxEmbeddableContext must be used inside the ReduxEmbeddableWrapper from build_redux_embeddable_context.' ); } return context!;