[Portable Dashboards] Prep Redux Tools (#136572)

* Created new Redux system for embeddables in Presentation Util. Migrated all Controls to use it.
This commit is contained in:
Devon Thomson 2022-07-29 10:38:58 -04:00 committed by GitHub
parent 4a95b2cde3
commit 01fc584a4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1188 additions and 946 deletions

View file

@ -7,7 +7,7 @@
*/
import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query';
import { FieldSpec, DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { FieldSpec, DataView } from '@kbn/data-views-plugin/common';
import { DataControlInput } from '../../types';
@ -17,10 +17,9 @@ export interface OptionsListEmbeddableInput extends DataControlInput {
selectedOptions?: string[];
runPastTimeout?: boolean;
singleSelect?: boolean;
loading?: boolean;
}
export type OptionsListField = DataViewField & {
export type OptionsListField = FieldSpec & {
textFieldName?: string;
parentFieldName?: string;
childFieldName?: string;

View file

@ -19,7 +19,7 @@ import {
import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public';
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { ControlGroupInput } from '../types';
import { ControlGroupReduxState } from '../types';
import { pluginServices } from '../../services';
import { EditControlButton } from '../editor/edit_control';
import { ControlGroupStrings } from '../control_group_strings';
@ -42,11 +42,11 @@ export const ControlFrame = ({
const [hasFatalError, setHasFatalError] = useState(false);
const {
useEmbeddableSelector,
useEmbeddableSelector: select,
containerActions: { untilEmbeddableLoaded, removeEmbeddable },
} = useReduxContainerContext<ControlGroupInput>();
} = useReduxContainerContext<ControlGroupReduxState>();
const { controlStyle } = useEmbeddableSelector((state) => state);
const controlStyle = select((state) => state.explicitInput.controlStyle);
// Controls Services Context
const { overlays } = pluginServices.getHooks();

View file

@ -28,27 +28,31 @@ import {
useSensors,
LayoutMeasuringStrategy,
} from '@dnd-kit/core';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public';
import { ControlGroupInput } from '../types';
import { ControlGroupReduxState } from '../types';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlClone, SortableControl } from './control_group_sortable_item';
export const ControlGroup = () => {
// Redux embeddable container Context
const reduxContainerContext = useReduxContainerContext<
ControlGroupInput,
ControlGroupReduxState,
typeof controlGroupReducers
>();
const {
useEmbeddableSelector,
useEmbeddableDispatch,
actions: { setControlOrders },
useEmbeddableSelector: select,
useEmbeddableDispatch,
} = reduxContainerContext;
const dispatch = useEmbeddableDispatch();
// current state
const { panels, viewMode, controlStyle } = useEmbeddableSelector((state) => state);
const panels = select((state) => state.explicitInput.panels);
const viewMode = select((state) => state.explicitInput.viewMode);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const isEditable = viewMode === ViewMode.EDIT;

View file

@ -13,8 +13,8 @@ import { CSS } from '@dnd-kit/utilities';
import classNames from 'classnames';
import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public';
import { ControlGroupInput } from '../types';
import { ControlFrame, ControlFrameProps } from './control_frame_component';
import { ControlGroupReduxState } from '../types';
import { ControlGroupStrings } from '../control_group_strings';
interface DragInfo {
@ -67,8 +67,8 @@ const SortableControlInner = forwardRef<
dragHandleRef
) => {
const { isOver, isDragging, draggingIndex, index } = dragInfo;
const { useEmbeddableSelector } = useReduxContainerContext<ControlGroupInput>();
const { panels } = useEmbeddableSelector((state) => state);
const { useEmbeddableSelector } = useReduxContainerContext<ControlGroupReduxState>();
const panels = useEmbeddableSelector((state) => state.explicitInput.panels);
const grow = panels[embeddableId].grow;
const width = panels[embeddableId].width;
@ -119,8 +119,9 @@ const SortableControlInner = forwardRef<
* can be quite cumbersome.
*/
export const ControlClone = ({ draggingId }: { draggingId: string }) => {
const { useEmbeddableSelector } = useReduxContainerContext<ControlGroupInput>();
const { panels, controlStyle } = useEmbeddableSelector((state) => state);
const { useEmbeddableSelector: select } = useReduxContainerContext<ControlGroupReduxState>();
const panels = select((state) => state.explicitInput.panels);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const width = panels[draggingId].width;
const title = panels[draggingId].explicitInput.title;

View file

@ -14,7 +14,7 @@ import { OverlayRef } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public';
import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public';
import { ControlGroupInput } from '../types';
import { ControlGroupReduxState } from '../types';
import { ControlEditor } from './control_editor';
import { pluginServices } from '../../services';
import { ControlGroupStrings } from '../control_group_strings';
@ -41,7 +41,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) =>
// Redux embeddable container Context
const reduxContainerContext = useReduxContainerContext<
ControlGroupInput,
ControlGroupReduxState,
typeof controlGroupReducers
>();
const {
@ -53,7 +53,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) =>
const dispatch = useEmbeddableDispatch();
// current state
const { panels } = useEmbeddableSelector((state) => state);
const panels = useEmbeddableSelector((state) => state.explicitInput.panels);
// keep up to date ref of latest panel state for comparison when closing editor.
const latestPanelState = useRef(panels[embeddableId]);

View file

@ -6,37 +6,34 @@
* Side Public License, v 1.
*/
import {
map,
skip,
switchMap,
catchError,
debounceTime,
distinctUntilChanged,
} from 'rxjs/operators';
import React from 'react';
import { uniqBy } from 'lodash';
import ReactDOM from 'react-dom';
import deepEqual from 'fast-deep-equal';
import { Filter, uniqFilters } from '@kbn/es-query';
import { EMPTY, merge, pipe, Subject, Subscription } from 'rxjs';
import { EuiContextMenuPanel } from '@elastic/eui';
import {
distinctUntilChanged,
debounceTime,
catchError,
switchMap,
map,
skip,
mapTo,
} from 'rxjs/operators';
import {
withSuspense,
LazyReduxEmbeddableWrapper,
ReduxEmbeddableWrapperPropsWithChildren,
ReduxEmbeddablePackage,
ReduxEmbeddableTools,
SolutionToolbarPopover,
} from '@kbn/presentation-util-plugin/public';
import { OverlayRef } from '@kbn/core/public';
import { DataView } from '@kbn/data-views-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import {
ControlGroupInput,
ControlGroupOutput,
ControlGroupReduxState,
ControlPanelState,
ControlsPanels,
CONTROL_GROUP_TYPE,
@ -54,10 +51,6 @@ import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control';
const ControlGroupReduxWrapper = withSuspense<
ReduxEmbeddableWrapperPropsWithChildren<ControlGroupInput>
>(LazyReduxEmbeddableWrapper);
let flyoutRef: OverlayRef | undefined;
export const setFlyoutRef = (newRef: OverlayRef | undefined) => {
flyoutRef = newRef;
@ -77,6 +70,11 @@ export class ControlGroupContainer extends Container<
private relevantDataViewId?: string;
private lastUsedDataViewId?: string;
private reduxEmbeddableTools: ReduxEmbeddableTools<
ControlGroupReduxState,
typeof controlGroupReducers
>;
public setLastUsedDataViewId = (lastUsedDataViewId: string) => {
this.lastUsedDataViewId = lastUsedDataViewId;
};
@ -162,10 +160,14 @@ export class ControlGroupContainer extends Container<
);
};
constructor(initialInput: ControlGroupInput, parent?: Container) {
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
initialInput: ControlGroupInput,
parent?: Container
) {
super(
initialInput,
{ embeddableLoaded: {} },
{ dataViewIds: [], embeddableLoaded: {}, filters: [] },
pluginServices.getServices().controls.getControlFactory,
parent,
ControlGroupChainingSystems[initialInput.chainingSystem]?.getContainerSettings(initialInput)
@ -173,6 +175,15 @@ export class ControlGroupContainer extends Container<
this.recalculateFilters$ = new Subject();
// build redux embeddable tools
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
ControlGroupReduxState,
typeof controlGroupReducers
>({
embeddable: this,
reducers: controlGroupReducers,
});
// when all children are ready setup subscriptions
this.untilReady().then(() => {
this.recalculateDataViews();
@ -215,7 +226,7 @@ export class ControlGroupContainer extends Container<
.pipe(
// Embeddables often throw errors into their output streams.
catchError(() => EMPTY),
mapTo(childId)
map(() => childId)
)
)
)
@ -261,12 +272,12 @@ export class ControlGroupContainer extends Container<
};
private recalculateDataViews = () => {
const allDataViews: DataView[] = [];
const allDataViewIds: Set<string> = new Set();
Object.values(this.children).map((child) => {
const childOutput = child.getOutput() as ControlOutput;
allDataViews.push(...(childOutput.dataViews ?? []));
const dataViewId = (child.getOutput() as ControlOutput).dataViewId;
if (dataViewId) allDataViewIds.add(dataViewId);
});
this.updateOutput({ dataViews: uniqBy(allDataViews, 'id') });
this.updateOutput({ dataViewIds: Array.from(allDataViewIds) });
};
protected createNewPanelState<TEmbeddableInput extends ControlInput = ControlInput>(
@ -354,10 +365,11 @@ export class ControlGroupContainer extends Container<
}
this.domNode = dom;
const ControlsServicesProvider = pluginServices.getContextProvider();
const { Wrapper: ControlGroupReduxWrapper } = this.reduxEmbeddableTools;
ReactDOM.render(
<KibanaThemeProvider theme$={pluginServices.getServices().theme.theme$}>
<ControlsServicesProvider>
<ControlGroupReduxWrapper embeddable={this} reducers={controlGroupReducers}>
<ControlGroupReduxWrapper>
<ControlGroup />
</ControlGroupReduxWrapper>
</ControlsServicesProvider>
@ -370,6 +382,7 @@ export class ControlGroupContainer extends Container<
super.destroy();
this.closeAllFlyouts();
this.subscriptions.unsubscribe();
this.reduxEmbeddableTools.cleanup();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
}
}

View file

@ -15,6 +15,7 @@
*/
import { Container, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types';
@ -47,7 +48,8 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition
}
public create = async (initialInput: ControlGroupInput, parent?: Container) => {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const { ControlGroupContainer } = await import('./control_group_container');
return new ControlGroupContainer(initialInput, parent);
return new ControlGroupContainer(reduxEmbeddablePackage, initialInput, parent);
};
}

View file

@ -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<ControlGroupInput>,
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['controlStyle']>
) => {
state.controlStyle = action.payload;
state.explicitInput.controlStyle = action.payload;
},
setDefaultControlWidth: (
state: WritableDraft<ControlGroupInput>,
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['defaultControlWidth']>
) => {
state.defaultControlWidth = action.payload;
state.explicitInput.defaultControlWidth = action.payload;
},
setDefaultControlGrow: (
state: WritableDraft<ControlGroupInput>,
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['defaultControlGrow']>
) => {
state.defaultControlGrow = action.payload;
state.explicitInput.defaultControlGrow = action.payload;
},
setControlWidth: (
state: WritableDraft<ControlGroupInput>,
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<{ width: ControlWidth; embeddableId: string }>
) => {
state.panels[action.payload.embeddableId].width = action.payload.width;
state.explicitInput.panels[action.payload.embeddableId].width = action.payload.width;
},
setControlGrow: (
state: WritableDraft<ControlGroupInput>,
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<{ grow: boolean; embeddableId: string }>
) => {
state.panels[action.payload.embeddableId].grow = action.payload.grow;
state.explicitInput.panels[action.payload.embeddableId].grow = action.payload.grow;
},
setControlOrders: (
state: WritableDraft<ControlGroupInput>,
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<{ ids: string[] }>
) => {
action.payload.ids.forEach((id, index) => {
state.panels[id].order = index;
state.explicitInput.panels[id].order = index;
});
},
};

View file

@ -7,9 +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;
export type ControlGroupOutput = ContainerOutput &
Omit<CommonControlOutput, 'dataViewId'> & { dataViewIds: string[] };
// public only - redux embeddable state type
export type ControlGroupReduxState = ReduxEmbeddableState<ControlGroupInput, ControlGroupOutput>;
export {
type ControlsPanels,

View file

@ -8,60 +8,46 @@
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<string>;
componentStateSubject: BehaviorSubject<OptionsListComponentState>;
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [searchString, setSearchString] = useState('');
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<OptionsListEmbeddableInput, typeof optionsListReducers>();
actions: { replaceSelection, setSearchString },
useEmbeddableSelector: select,
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
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<OptionsListComponentState>(
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.explicitInput.selectedOptions);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const singleSelect = select((state) => state.explicitInput.singleSelect);
const id = select((state) => state.explicitInput.id);
const loading = select((state) => state.output.loading);
// debounce loading state so loading doesn't flash when user types
const [buttonLoading, setButtonLoading] = useState(true);
@ -69,7 +55,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(() => {
@ -81,15 +67,11 @@ export const OptionsListComponent = ({
const updateSearchString = useCallback(
(newSearchString: string) => {
typeaheadSubject.next(newSearchString);
setSearchString(newSearchString);
dispatch(setSearchString(newSearchString));
},
[typeaheadSubject]
[typeaheadSubject, dispatch, setSearchString]
);
useEffect(() => {
updateSearchString('');
}, [field?.spec?.name, updateSearchString]);
const { hasSelections, selectionDisplayNode, validSelectionsCount } = useMemo(() => {
return {
hasSelections: !isEmpty(validSelections) || !isEmpty(invalidSelections),
@ -136,26 +118,17 @@ export const OptionsListComponent = ({
})}
>
<EuiPopover
ownFocus
button={button}
repositionOnScroll
isOpen={isPopoverOpen}
className="optionsList__popoverOverride"
anchorClassName="optionsList__anchorOverride"
closePopover={() => setIsPopoverOpen(false)}
panelPaddingSize="none"
anchorPosition="downCenter"
ownFocus
repositionOnScroll
className="optionsList__popoverOverride"
closePopover={() => setIsPopoverOpen(false)}
anchorClassName="optionsList__anchorOverride"
>
<OptionsListPopover
field={field}
width={dimensions.width}
loading={loading}
searchString={searchString}
totalCardinality={totalCardinality}
availableOptions={availableOptions}
invalidSelections={invalidSelections}
updateSearchString={updateSearchString}
/>
<OptionsListPopover width={dimensions.width} updateSearchString={updateSearchString} />
</EuiPopover>
</EuiFilterGroup>
);

View file

@ -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 { debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators';
import {
Filter,
compareFilters,
@ -13,24 +21,18 @@ 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,
} from '@kbn/presentation-util-plugin/public';
import { ReduxEmbeddableTools, ReduxEmbeddablePackage } 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 +40,6 @@ import { OptionsListStrings } from './options_list_strings';
import { ControlInput, ControlOutput } from '../..';
import { pluginServices } from '../../services';
const OptionsListReduxWrapper = withSuspense<
ReduxEmbeddableWrapperPropsWithChildren<OptionsListEmbeddableInput>
>(LazyReduxEmbeddableWrapper);
const diffDataFetchProps = (
last?: OptionsListDataFetchProps,
current?: OptionsListDataFetchProps
@ -79,27 +77,35 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
private abortController?: AbortController;
private dataView?: DataView;
private field?: OptionsListField;
private searchString = '';
// State to be passed down to component
private componentState: OptionsListComponentState;
private componentStateSubject$ = new BehaviorSubject<OptionsListComponentState>({
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...
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
input: OptionsListEmbeddableInput,
output: ControlOutput,
parent?: IContainer
) {
super(input, output, parent);
// Destructure controls services
({ dataViews: this.dataViewsService, optionsList: this.optionsListService } =
pluginServices.getServices());
this.componentState = { loading: true };
this.updateComponentState(this.componentState);
this.typeaheadSubject = new Subject<string>();
// build redux embeddable tools
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
OptionsListReduxState,
typeof optionsListReducers
>({
embeddable: this,
reducers: optionsListReducers,
});
this.initialize();
}
@ -129,11 +135,8 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
distinctUntilChanged(diffDataFetchProps)
);
// push searchString changes into a debounced typeahead subject
const typeaheadPipe = this.typeaheadSubject.pipe(
debounceTime(100),
tap((newSearchString) => (this.searchString = newSearchString))
);
// debounce typeahead pipe to slow down search string related queries
const typeaheadPipe = this.typeaheadSubject.pipe(debounceTime(100));
// fetch available options when input changes or when search string has changed
this.subscriptions.add(
@ -149,13 +152,19 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.getInput$()
.pipe(distinctUntilChanged((a, b) => 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 +174,15 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
}
newValidSelections.push(selectedOption);
}
this.updateComponentState({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
});
dispatch(
setValidAndInvalidSelections({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
})
);
}
const newFilters = await this.buildFilter();
this.updateOutput({ filters: newFilters });
dispatch(publishFilters(newFilters));
})
);
};
@ -180,7 +191,15 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
dataView?: DataView;
field?: OptionsListField;
}> => {
const { dataViewId, fieldName, parentFieldName, childFieldName } = this.getInput();
const {
dispatch,
getState,
actions: { setField, setDataViewId },
} = this.reduxEmbeddableTools;
const {
explicitInput: { dataViewId, fieldName, parentFieldName, childFieldName },
} = getState();
if (!this.dataView || this.dataView.id !== dataViewId) {
try {
@ -190,49 +209,65 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
} catch (e) {
this.onFatalError(e);
}
this.updateOutput({ dataViews: this.dataView && [this.dataView] });
dispatch(setDataViewId(this.dataView?.id));
}
if (this.dataView && (!this.field || this.field.name !== fieldName)) {
const originalField = this.dataView?.getFieldByName(fieldName);
const childField =
(childFieldName && this.dataView?.getFieldByName(childFieldName)) || undefined;
const parentField =
(parentFieldName && this.dataView?.getFieldByName(parentFieldName)) || undefined;
try {
const originalField = this.dataView.getFieldByName(fieldName);
if (!originalField) {
throw new Error(OptionsListStrings.errors.getfieldNotFoundError(fieldName));
}
const textFieldName = childField?.esTypes?.includes('text')
? childField.name
: parentField?.esTypes?.includes('text')
? parentField.name
: undefined;
(originalField as OptionsListField).textFieldName = textFieldName;
this.field = originalField;
// pair up keyword / text fields for case insensitive search
const childField =
(childFieldName && this.dataView.getFieldByName(childFieldName)) || undefined;
const parentField =
(parentFieldName && this.dataView.getFieldByName(parentFieldName)) || undefined;
const textFieldName = childField?.esTypes?.includes('text')
? childField.name
: parentField?.esTypes?.includes('text')
? parentField.name
: undefined;
if (this.field === undefined) {
this.onFatalError(new Error(OptionsListStrings.errors.getDataViewNotFoundError(fieldName)));
const optionsListField: OptionsListField = originalField.toSpec();
optionsListField.textFieldName = textFieldName;
this.field = optionsListField;
} catch (e) {
this.onFatalError(e);
}
this.updateComponentState({ field: this.field });
dispatch(setField(this.field));
}
return { dataView: this.dataView, field: this.field! };
};
private updateComponentState(changes: Partial<OptionsListComponentState>) {
this.componentState = {
...this.componentState,
...changes,
};
this.componentStateSubject$.next(this.componentState);
}
private runOptionsListQuery = async () => {
const {
dispatch,
getState,
actions: { setLoading, updateQueryResults, publishFilters, setSearchString },
} = this.reduxEmbeddableTools;
const previousFieldName = this.field?.name;
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
this.updateComponentState({ loading: true });
this.updateOutput({ loading: true, dataViews: [dataView] });
const { ignoreParentSettings, filters, query, selectedOptions, timeRange, runPastTimeout } =
this.getInput();
if (previousFieldName && field.name !== previousFieldName) {
dispatch(setSearchString(''));
}
const {
componentState: { searchString },
explicitInput: { selectedOptions, runPastTimeout },
} = getState();
dispatch(setLoading(true));
// need to get filters, query, ignoreParentSettings, and timeRange from input for inheritance
const { ignoreParentSettings, filters, query, timeRange } = this.getInput();
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
@ -244,20 +279,21 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
filters,
dataView,
timeRange,
searchString,
runPastTimeout,
selectedOptions,
searchString: this.searchString,
},
this.abortController.signal
);
if (!selectedOptions || isEmpty(invalidSelections) || ignoreParentSettings?.ignoreValidations) {
this.updateComponentState({
availableOptions: suggestions,
invalidSelections: undefined,
validSelections: selectedOptions,
totalCardinality,
loading: false,
});
dispatch(
updateQueryResults({
availableOptions: suggestions,
invalidSelections: undefined,
validSelections: selectedOptions,
totalCardinality,
})
);
} else {
const valid: string[] = [];
const invalid: string[] = [];
@ -266,22 +302,28 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
if (invalidSelections?.includes(selectedOption)) invalid.push(selectedOption);
else valid.push(selectedOption);
}
this.updateComponentState({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
loading: false,
});
dispatch(
updateQueryResults({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
})
);
}
// publish filter
const newFilters = await this.buildFilter();
this.updateOutput({ loading: false, filters: newFilters });
batch(() => {
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 +349,19 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
super.destroy();
this.abortController?.abort();
this.subscriptions.unsubscribe();
this.reduxEmbeddableTools.cleanup();
};
public render = (node: HTMLElement) => {
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
const { Wrapper: OptionsListReduxWrapper } = this.reduxEmbeddableTools;
this.node = node;
ReactDOM.render(
<KibanaThemeProvider theme$={pluginServices.getServices().theme.theme$}>
<OptionsListReduxWrapper embeddable={this} reducers={optionsListReducers}>
<OptionsListComponent
componentStateSubject={this.componentStateSubject$}
typeaheadSubject={this.typeaheadSubject}
/>
<OptionsListReduxWrapper>
<OptionsListComponent typeaheadSubject={this.typeaheadSubject} />
</OptionsListReduxWrapper>
</KibanaThemeProvider>,
node

View file

@ -8,7 +8,9 @@
import deepEqual from 'fast-deep-equal';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import { OptionsListEditorOptions } from './options_list_editor_options';
import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types';
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types';
@ -27,8 +29,11 @@ export class OptionsListEmbeddableFactory
constructor() {}
public async create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const { OptionsListEmbeddable } = await import('./options_list_embeddable');
return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, parent));
return Promise.resolve(
new OptionsListEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent)
);
}
public presaveTransformFunction = (

View file

@ -23,41 +23,39 @@ 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,
searchString,
availableOptions,
totalCardinality,
invalidSelections,
updateSearchString,
width,
updateSearchString,
}: {
field?: DataViewField;
searchString: string;
totalCardinality?: number;
width: number;
loading: OptionsListComponentState['loading'];
invalidSelections?: string[];
updateSearchString: (newSearchString: string) => void;
availableOptions: OptionsListComponentState['availableOptions'];
}) => {
// Redux embeddable container Context
const {
useEmbeddableSelector,
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { selectOption, deselectOption, clearSelections, replaceSelection },
} = useReduxEmbeddableContext<OptionsListEmbeddableInput, typeof optionsListReducers>();
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
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 searchString = select((state) => state.componentState.searchString);
const field = select((state) => state.componentState.field);
const selectedOptions = select((state) => state.explicitInput.selectedOptions);
const singleSelect = select((state) => state.explicitInput.singleSelect);
const title = select((state) => state.explicitInput.title);
const loading = select((state) => state.output.loading);
// track selectedOptions and invalidSelections in sets for more efficient lookup
const selectedOptionsSet = useMemo(() => new Set<string>(selectedOptions), [selectedOptions]);

View file

@ -5,53 +5,95 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
import { OptionsListEmbeddableInput } from './types';
import { Filter } from '@kbn/es-query';
import { OptionsListField, OptionsListReduxState, OptionsListComponentState } from './types';
export const optionsListReducers = {
deselectOption: (
state: WritableDraft<OptionsListEmbeddableInput>,
action: PayloadAction<string>
) => {
if (!state.selectedOptions) return;
const itemIndex = state.selectedOptions.indexOf(action.payload);
deselectOption: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<string>) => {
if (!state.explicitInput.selectedOptions) return;
const itemIndex = state.explicitInput.selectedOptions.indexOf(action.payload);
if (itemIndex !== -1) {
const newSelections = [...state.selectedOptions];
const newSelections = [...state.explicitInput.selectedOptions];
newSelections.splice(itemIndex, 1);
state.selectedOptions = newSelections;
state.explicitInput.selectedOptions = newSelections;
}
},
deselectOptions: (
state: WritableDraft<OptionsListEmbeddableInput>,
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string[]>
) => {
for (const optionToDeselect of action.payload) {
if (!state.selectedOptions) return;
const itemIndex = state.selectedOptions.indexOf(optionToDeselect);
if (!state.explicitInput.selectedOptions) return;
const itemIndex = state.explicitInput.selectedOptions.indexOf(optionToDeselect);
if (itemIndex !== -1) {
const newSelections = [...state.selectedOptions];
const newSelections = [...state.explicitInput.selectedOptions];
newSelections.splice(itemIndex, 1);
state.selectedOptions = newSelections;
state.explicitInput.selectedOptions = newSelections;
}
}
},
selectOption: (
state: WritableDraft<OptionsListEmbeddableInput>,
action: PayloadAction<string>
) => {
if (!state.selectedOptions) state.selectedOptions = [];
state.selectedOptions?.push(action.payload);
setSearchString: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<string>) => {
state.componentState.searchString = action.payload;
},
selectOption: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<string>) => {
if (!state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = [];
state.explicitInput.selectedOptions?.push(action.payload);
},
replaceSelection: (
state: WritableDraft<OptionsListEmbeddableInput>,
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string>
) => {
state.selectedOptions = [action.payload];
state.explicitInput.selectedOptions = [action.payload];
},
clearSelections: (state: WritableDraft<OptionsListEmbeddableInput>) => {
if (state.selectedOptions) state.selectedOptions = [];
clearSelections: (state: WritableDraft<OptionsListReduxState>) => {
if (state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = [];
},
clearValidAndInvalidSelections: (state: WritableDraft<OptionsListReduxState>) => {
state.componentState.invalidSelections = [];
state.componentState.validSelections = [];
},
setValidAndInvalidSelections: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<{ validSelections: string[]; invalidSelections: string[] }>
) => {
const { invalidSelections, validSelections } = action.payload;
state.componentState.invalidSelections = invalidSelections;
state.componentState.validSelections = validSelections;
},
setLoading: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
state.output.loading = action.payload;
},
setField: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<OptionsListField | undefined>
) => {
state.componentState.field = action.payload;
},
updateQueryResults: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<
Pick<
OptionsListComponentState,
'availableOptions' | 'invalidSelections' | 'validSelections' | 'totalCardinality'
>
>
) => {
state.componentState = { ...(state.componentState ?? {}), ...action.payload };
},
publishFilters: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<Filter[] | undefined>
) => {
state.output.filters = action.payload;
},
setDataViewId: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string | undefined>
) => {
state.output.dataViewId = action.payload;
},
};

View file

@ -6,4 +6,29 @@
* Side Public License, v 1.
*/
import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public';
import { ControlOutput } from '../../types';
import {
OptionsListEmbeddableInput,
OptionsListField,
} 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?: OptionsListField;
totalCardinality?: number;
availableOptions?: string[];
invalidSelections?: string[];
validSelections?: string[];
searchString: string;
}
// public only - redux embeddable state type
export type OptionsListReduxState = ReduxEmbeddableState<
OptionsListEmbeddableInput,
ControlOutput,
OptionsListComponentState
>;

View file

@ -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<RangeSliderComponentState>;
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<Props> = ({ componentStateSubject, ignoreValidation }) => {
// Redux embeddable Context to get state from Embeddable input
const {
useEmbeddableDispatch,
useEmbeddableSelector,
actions: { selectRange },
} = useReduxEmbeddableContext<RangeSliderEmbeddableInput, typeof rangeSliderReducers>();
const dispatch = useEmbeddableDispatch();
// useStateObservable to get component state from Embeddable
const { loading, min, max, fieldFormatter, isInvalid } =
useStateObservable<RangeSliderComponentState>(
componentStateSubject,
componentStateSubject.getValue()
);
const { value, id, title } = useEmbeddableSelector((state) => state);
const onChangeComplete = useCallback(
(range: RangeValue) => {
dispatch(selectRange(range));
},
[selectRange, dispatch]
);
return (
<RangeSliderPopover
id={id}
isLoading={loading}
min={min}
max={max}
title={title}
value={value ?? ['', '']}
onChange={onChangeComplete}
fieldFormatter={fieldFormatter}
isInvalid={!ignoreValidation && isInvalid}
/>
);
export const RangeSliderComponent = () => {
return <RangeSliderPopover />;
};

View file

@ -6,7 +6,6 @@
* Side Public License, v 1.
*/
import { isEmpty } from 'lodash';
import {
compareFilters,
buildRangeFilter,
@ -17,33 +16,27 @@ 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,
} from '@kbn/presentation-util-plugin/public';
import { ReduxEmbeddableTools, ReduxEmbeddablePackage } 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<RangeSliderEmbeddableInput>
>(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,29 +76,30 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
private dataView?: DataView;
private field?: DataViewField;
// State to be passed down to component
private componentState: RangeSliderComponentState;
private componentStateSubject$ = new BehaviorSubject<RangeSliderComponentState>({
min: '',
max: '',
loading: true,
fieldFormatter: (value: string) => value,
});
private reduxEmbeddableTools: ReduxEmbeddableTools<
RangeSliderReduxState,
typeof rangeSliderReducers
>;
constructor(input: RangeSliderEmbeddableInput, output: ControlOutput, parent?: IContainer) {
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
input: RangeSliderEmbeddableInput,
output: ControlOutput,
parent?: IContainer
) {
super(input, output, parent); // get filters for initial output...
// Destructure controls services
({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices());
this.componentState = {
min: '',
max: '',
loading: true,
fieldFormatter: (value: string) => value,
isInvalid: false,
};
this.updateComponentState(this.componentState);
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
RangeSliderReduxState,
typeof rangeSliderReducers
>({
embeddable: this,
reducers: rangeSliderReducers,
initialComponentState: getDefaultComponentState(),
});
this.initialize();
}
@ -158,13 +152,21 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
dataView?: DataView;
field?: DataViewField;
}> => {
const { dataViewId, fieldName } = this.getInput();
const {
getState,
dispatch,
actions: { setField, setDataViewId },
} = this.reduxEmbeddableTools;
const {
explicitInput: { 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(setDataViewId(this.dataView.id));
} catch (e) {
this.onFatalError(e);
}
@ -176,28 +178,19 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
this.onFatalError(new Error(RangeSliderStrings.errors.getDataViewNotFoundError(fieldName)));
}
this.updateComponentState({
field: this.field,
fieldFormatter: this.field
? this.dataView?.getFormatterForField(this.field).getConverterFor('text')
: (value: string) => value,
});
dispatch(setField(this.field?.toSpec()));
}
return { dataView: this.dataView, field: this.field! };
};
private updateComponentState(changes: Partial<RangeSliderComponentState>) {
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 +199,10 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
let { filters = [] } = embeddableInput;
if (!field) {
this.updateComponentState({ loading: false });
this.updateOutput({ filters: [], loading: false });
batch(() => {
dispatch(setLoading(false));
dispatch(publishFilters([]));
});
throw fieldMissingError(fieldName);
}
@ -229,10 +224,12 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
query,
});
this.updateComponentState({
min: `${min ?? ''}`,
max: `${max ?? ''}`,
});
dispatch(
setMinMax({
min: `${min ?? ''}`,
max: `${max ?? ''}`,
})
);
// build filter with new min/max
await this.buildFilter();
@ -293,15 +290,20 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
private buildFilter = async () => {
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, setDataViewId, publishFilters },
} = this.reduxEmbeddableTools;
const {
componentState: { min: availableMin, max: availableMax },
explicitInput: {
query,
timeRange,
filters = [],
ignoreParentSettings,
value: [selectedMin, selectedMax] = ['', ''],
},
} = getState();
const hasData = !isEmpty(availableMin) && !isEmpty(availableMax);
const hasLowerSelection = !isEmpty(selectedMin);
@ -312,11 +314,12 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
if (!dataView || !field) return;
if (!hasData || !hasEitherSelection) {
this.updateComponentState({
loading: false,
isInvalid: !ignoreParentSettings?.ignoreValidations && hasEitherSelection,
batch(() => {
dispatch(setLoading(false));
dispatch(setIsInvalid(!ignoreParentSettings?.ignoreValidations && hasEitherSelection));
dispatch(setDataViewId(dataView.id));
dispatch(publishFilters([]));
});
this.updateOutput({ filters: [], dataViews: dataView && [dataView], loading: false });
return;
}
@ -366,18 +369,22 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
const docCount = typeof total === 'number' ? total : total?.value;
if (!docCount) {
this.updateComponentState({ loading: false, isInvalid: true });
this.updateOutput({
filters: [],
dataViews: [dataView],
loading: false,
batch(() => {
dispatch(setLoading(false));
dispatch(setIsInvalid(true));
dispatch(setDataViewId(dataView.id));
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(setDataViewId(dataView.id));
dispatch(publishFilters([rangeFilter]));
});
};
public reload = () => {
@ -387,25 +394,23 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
public destroy = () => {
super.destroy();
this.subscriptions.unsubscribe();
this.reduxEmbeddableTools.cleanup();
};
public render = (node: HTMLElement) => {
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
const { Wrapper: RangeSliderReduxWrapper } = this.reduxEmbeddableTools;
this.node = node;
const ControlsServicesProvider = pluginServices.getContextProvider();
ReactDOM.render(
<KibanaThemeProvider theme$={pluginServices.getServices().theme.theme$}>
<RangeSliderReduxWrapper embeddable={this} reducers={rangeSliderReducers}>
<RangeSliderComponent
componentStateSubject={this.componentStateSubject$}
ignoreValidation={
this.getInput().ignoreParentSettings !== undefined &&
this.getInput().ignoreParentSettings?.ignoreValidations !== undefined &&
this.getInput().ignoreParentSettings?.ignoreValidations!
}
/>
</RangeSliderReduxWrapper>
<ControlsServicesProvider>
<RangeSliderReduxWrapper>
<RangeSliderComponent />
</RangeSliderReduxWrapper>
</ControlsServicesProvider>
</KibanaThemeProvider>,
node
);

View file

@ -9,6 +9,8 @@
import deepEqual from 'fast-deep-equal';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types';
import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types';
import {
@ -26,8 +28,11 @@ export class RangeSliderEmbeddableFactory
constructor() {}
public async create(initialInput: RangeSliderEmbeddableInput, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const { RangeSliderEmbeddable } = await import('./range_slider_embeddable');
return Promise.resolve(new RangeSliderEmbeddable(initialInput, {}, parent));
return Promise.resolve(
new RangeSliderEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent)
);
}
public presaveTransformFunction = (

View file

@ -6,7 +6,6 @@
* Side Public License, v 1.
*/
import React, { FC, useState, useRef } from 'react';
import {
EuiFieldNumber,
EuiPopoverTitle,
@ -19,39 +18,62 @@ import {
EuiFlexItem,
EuiDualRange,
} from '@elastic/eui';
import React, { useState, useRef, useEffect } 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';
import { pluginServices } from '../../services';
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 = () => {
// Controls Services Context
const { dataViews } = pluginServices.getHooks();
const { get: getDataViewById } = dataViews.useService();
export const RangeSliderPopover: FC<Props> = ({
id,
isInvalid,
isLoading,
min,
max,
title,
value,
onChange,
fieldFormatter,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [rangeSliderMin, setRangeSliderMin] = useState<number>(-Infinity);
const [rangeSliderMax, setRangeSliderMax] = useState<number>(Infinity);
const [fieldFormatter, setFieldFormatter] = useState(() => (toFormat: string) => toFormat);
const rangeRef = useRef<EuiDualRange | null>(null);
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setSelectedRange },
} = useReduxEmbeddableContext<RangeSliderReduxState, typeof rangeSliderReducers>();
const dispatch = useEmbeddableDispatch();
// Select current state from Redux using multiple selectors to avoid rerenders.
const min = select((state) => state.componentState.min);
const max = select((state) => state.componentState.max);
const isInvalid = select((state) => state.componentState.isInvalid);
const fieldSpec = select((state) => state.componentState.field);
const id = select((state) => state.explicitInput.id);
const value = select((state) => state.explicitInput.value) ?? ['', ''];
const title = select((state) => state.explicitInput.title);
const isLoading = select((state) => state.output.loading);
const dataViewId = select((state) => state.output.dataViewId);
// derive field formatter from fieldSpec and dataViewId
useEffect(() => {
(async () => {
if (!dataViewId || !fieldSpec) return;
// dataViews are cached, and should always be available without having to hit ES.
const dataView = await getDataViewById(dataViewId);
setFieldFormatter(
() =>
dataView?.getFormatterForField(fieldSpec).getConverterFor('text') ??
((toFormat: string) => toFormat)
);
})();
}, [fieldSpec, dataViewId, getDataViewById]);
let errorMessage = '';
let helpText = '';
@ -129,7 +151,12 @@ export const RangeSliderPopover: FC<Props> = ({
}`}
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 +178,12 @@ export const RangeSliderPopover: FC<Props> = ({
}`}
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 +241,7 @@ export const RangeSliderPopover: FC<Props> = ({
const updatedUpperBound =
typeof newUpperBound === 'number' ? String(newUpperBound) : value[1];
onChange([updatedLowerBound, updatedUpperBound]);
dispatch(setSelectedRange([updatedLowerBound, updatedUpperBound]));
}}
value={displayedValue}
ticks={hasAvailableRange ? ticks : undefined}
@ -233,7 +265,7 @@ export const RangeSliderPopover: FC<Props> = ({
<EuiButtonIcon
iconType="eraser"
color="danger"
onClick={() => onChange(['', ''])}
onClick={() => dispatch(setSelectedRange(['', '']))}
aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()}
data-test-subj="rangeSlider__clearRangeButton"
/>

View file

@ -6,16 +6,55 @@
* 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 { FieldSpec } from '@kbn/data-views-plugin/common';
import { RangeSliderEmbeddableInput, RangeValue } from './types';
import { RangeSliderReduxState, RangeValue } from './types';
export const getDefaultComponentState = (): RangeSliderReduxState['componentState'] => ({
min: '',
max: '',
isInvalid: false,
});
export const rangeSliderReducers = {
selectRange: (
state: WritableDraft<RangeSliderEmbeddableInput>,
setSelectedRange: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<RangeValue>
) => {
state.value = action.payload;
state.explicitInput.value = action.payload;
},
setField: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<FieldSpec | undefined>
) => {
state.componentState.field = action.payload;
},
setDataViewId: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<string | undefined>
) => {
state.output.dataViewId = action.payload;
},
setLoading: (state: WritableDraft<RangeSliderReduxState>, action: PayloadAction<boolean>) => {
state.output.loading = action.payload;
},
setMinMax: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<{ min: string; max: string }>
) => {
state.componentState.min = action.payload.min;
state.componentState.max = action.payload.max;
},
publishFilters: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<Filter[] | undefined>
) => {
state.output.filters = action.payload;
},
setIsInvalid: (state: WritableDraft<RangeSliderReduxState>, action: PayloadAction<boolean>) => {
state.componentState.isInvalid = action.payload;
},
};

View file

@ -6,5 +6,25 @@
* Side Public License, v 1.
*/
export * from '../../../common/control_types/options_list/types';
import { FieldSpec } 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?: FieldSpec;
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';

View file

@ -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<TimeSliderSubjectState>;
@ -40,15 +32,14 @@ export const TimeSlider: FC<TimeSliderProps> = ({
}) => {
const {
useEmbeddableDispatch,
useEmbeddableSelector,
useEmbeddableSelector: select,
actions: { selectRange },
} = useReduxEmbeddableContext<TimeSliderControlEmbeddableInput, typeof timeSliderReducers>();
} = useReduxEmbeddableContext<TimeSliderReduxState, typeof timeSliderReducers>();
const dispatch = useEmbeddableDispatch();
const { range: availableRange } = useStateObservable<TimeSliderSubjectState>(
componentStateSubject,
componentStateSubject.getValue()
);
const availableRange = select((state) => state.componentState.range);
const value = select((state) => state.explicitInput.value);
const id = select((state) => state.explicitInput.id);
const { min, max } = availableRange
? availableRange
@ -57,8 +48,6 @@ export const TimeSlider: FC<TimeSliderProps> = ({
max?: number;
});
const { value, id } = useEmbeddableSelector((state) => state);
const dispatchChange = useCallback(
(range: [number | null, number | null]) => {
dispatch(selectRange(range));

View file

@ -14,6 +14,7 @@ import { stubLogstashDataView } from '@kbn/data-views-plugin/common/data_view.st
import { pluginServices } from '../../services';
import { TestScheduler } from 'rxjs/testing';
import { buildRangeFilter } from '@kbn/es-query';
import { ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
const buildFilter = (range: [number | null, number | null]) => {
const filterPieces: Record<string, number> = {};
@ -62,6 +63,10 @@ const baseInput: TimeSliderControlEmbeddableInput = {
dataViewId: stubLogstashDataView.id!,
};
const mockReduxEmbeddablePackage = {
createTools: () => {},
} as unknown as ReduxEmbeddablePackage;
describe('Time Slider Control Embeddable', () => {
const services = pluginServices.getServices();
const fetchRange = jest.spyOn(services.data, 'fetchFieldRange');
@ -99,7 +104,7 @@ describe('Time Slider Control Embeddable', () => {
b: expectedFilterAfterRangeFetch ? [expectedFilterAfterRangeFetch] : undefined,
};
const embeddable = new TimeSliderControlEmbeddable(input, {});
const embeddable = new TimeSliderControlEmbeddable(mockReduxEmbeddablePackage, input, {});
const source$ = embeddable.getOutput$().pipe(map((o) => o.filters));
expectObservable(source$).toBe(expectedMarbles, expectedValues);
@ -196,7 +201,11 @@ describe('Time Slider Control Embeddable', () => {
b: mockRange,
};
const embeddable = new TimeSliderControlEmbeddable(baseInput, {});
const embeddable = new TimeSliderControlEmbeddable(
mockReduxEmbeddablePackage,
baseInput,
{}
);
const source$ = embeddable.getComponentState$().pipe(map((state) => state.range));
const { fieldName, ...inputForFetch } = baseInput;
@ -221,7 +230,11 @@ describe('Time Slider Control Embeddable', () => {
const mockRange = { min: 1, max: 2 };
fetchRange$.mockReturnValue(cold('a', { a: mockRange }));
const embeddable = new TimeSliderControlEmbeddable(baseInput, {});
const embeddable = new TimeSliderControlEmbeddable(
mockReduxEmbeddablePackage,
baseInput,
{}
);
const updatedInput = { ...baseInput, fieldName: '@timestamp' };
embeddable.updateInput(updatedInput);
@ -247,7 +260,7 @@ describe('Time Slider Control Embeddable', () => {
timeRange: {} as any,
};
new TimeSliderControlEmbeddable(input, {});
new TimeSliderControlEmbeddable(mockReduxEmbeddablePackage, input, {});
expect(fetchRange$).toBeCalledTimes(1);
const args = fetchRange$.mock.calls[0][2];
@ -274,7 +287,7 @@ describe('Time Slider Control Embeddable', () => {
ignoreParentSettings: { ignoreFilters: true, ignoreQuery: true, ignoreTimerange: true },
};
new TimeSliderControlEmbeddable(input, {});
new TimeSliderControlEmbeddable(mockReduxEmbeddablePackage, input, {});
expect(fetchRange$).toBeCalledTimes(1);
const args = fetchRange$.mock.calls[0][2];

View file

@ -14,27 +14,20 @@ import deepEqual from 'fast-deep-equal';
import { merge, Subscription, BehaviorSubject, Observable } from 'rxjs';
import { map, distinctUntilChanged, skip, take, mergeMap } from 'rxjs/operators';
import {
withSuspense,
LazyReduxEmbeddableWrapper,
ReduxEmbeddableWrapperPropsWithChildren,
} from '@kbn/presentation-util-plugin/public';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-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<TimeSliderControlEmbeddableInput>
>(LazyReduxEmbeddableWrapper);
import { TimeSliderReduxState, TimeSliderSubjectState } from './types';
const diffDataFetchProps = (current?: any, last?: any) => {
if (!current || !last) return false;
@ -77,7 +70,17 @@ export class TimeSliderControlEmbeddable extends Embeddable<
private getDateFormat: ControlsSettingsService['getDateFormat'];
private getTimezone: ControlsSettingsService['getTimezone'];
constructor(input: TimeSliderControlEmbeddableInput, output: ControlOutput, parent?: IContainer) {
private reduxEmbeddableTools: ReduxEmbeddableTools<
TimeSliderReduxState,
typeof timeSliderReducers
>;
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
input: TimeSliderControlEmbeddableInput,
output: ControlOutput,
parent?: IContainer
) {
super(input, output, parent); // get filters for initial output...
const {
@ -94,6 +97,15 @@ export class TimeSliderControlEmbeddable extends Embeddable<
this.internalOutput = {};
// build redux embeddable tools
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
TimeSliderReduxState,
typeof timeSliderReducers
>({
embeddable: this,
reducers: timeSliderReducers,
});
this.initialize();
}
@ -204,7 +216,7 @@ export class TimeSliderControlEmbeddable extends Embeddable<
this.updateInternalOutput({ filters: [rangeFilter] }, true);
this.updateComponentState({ loading: false });
} else {
this.updateInternalOutput({ filters: undefined, dataViews: [dataView] }, true);
this.updateInternalOutput({ filters: undefined, dataViewId: dataView.id }, true);
this.updateComponentState({ loading: false });
}
});
@ -300,8 +312,10 @@ export class TimeSliderControlEmbeddable extends Embeddable<
}
this.node = node;
const { Wrapper: TimeSliderControlReduxWrapper } = this.reduxEmbeddableTools;
ReactDOM.render(
<TimeSliderControlReduxWrapper embeddable={this} reducers={timeSliderReducers}>
<TimeSliderControlReduxWrapper>
<TimeSliderComponent
componentStateSubject={this.componentStateSubject$}
timezone={this.getTimezone()}

View file

@ -8,7 +8,9 @@
import deepEqual from 'fast-deep-equal';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import { TIME_SLIDER_CONTROL } from '../..';
import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types';
import {
@ -27,9 +29,12 @@ export class TimesliderEmbeddableFactory
constructor() {}
public async create(initialInput: TimeSliderControlEmbeddableInput, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const { TimeSliderControlEmbeddable } = await import('./time_slider_embeddable');
return Promise.resolve(new TimeSliderControlEmbeddable(initialInput, {}, parent));
return Promise.resolve(
new TimeSliderControlEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent)
);
}
public presaveTransformFunction = (

View file

@ -8,14 +8,13 @@
import { PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types';
import { TimeSliderReduxState } from './types';
export const timeSliderReducers = {
selectRange: (
state: WritableDraft<TimeSliderControlEmbeddableInput>,
state: WritableDraft<TimeSliderReduxState>,
action: PayloadAction<[number | null, number | null]>
) => {
state.value = action.payload;
state.explicitInput.value = action.payload;
},
};

View file

@ -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
>;

View file

@ -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 = <T extends {} = {}>(
stateObservable: Observable<T>,
initialState: T
) => {
const [innerState, setInnerState] = useState<T>(initialState);
useEffect(() => {
const subscription = stateObservable.subscribe((newState) => setInnerState(newState));
return () => subscription.unsubscribe();
}, [stateObservable]);
return innerState;
};

View file

@ -86,7 +86,7 @@ class OptionsListService implements ControlsOptionsListService {
...passThroughProps,
filters: esFilters,
fieldName: field.name,
fieldSpec: field.toSpec?.(),
fieldSpec: field,
textFieldName: (field as OptionsListField).textFieldName,
};
};

View file

@ -16,14 +16,14 @@ import {
IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataView, DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { ControlInput } from '../common/types';
import { ControlsService } from './services/controls';
export interface CommonControlOutput {
filters?: Filter[];
dataViews?: DataView[];
dataViewId?: string;
}
export type ControlOutput = EmbeddableOutput & CommonControlOutput;

View file

@ -63,6 +63,9 @@ const createDashboardAppStateServices = () => {
defaults.dataViews.getDefaultDataView = jest
.fn()
.mockImplementation(() => Promise.resolve(defaultDataView));
defaults.dataViews.getDefaultId = jest
.fn()
.mockImplementation(() => Promise.resolve(defaultDataView.id));
defaults.dataViews.getDefault = jest
.fn()

View file

@ -14,7 +14,6 @@ import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
import { DashboardConstants } from '../..';
import { ViewMode } from '../../services/embeddable';
import { useKibana } from '../../services/kibana_react';
import { DataView } from '../../services/data_views';
import { getNewDashboardTitle } from '../../dashboard_strings';
import { IKbnUrlStateStorage } from '../../services/kibana_utils';
import { setDashboardState, useDashboardDispatch, useDashboardSelector } from '../state';
@ -255,11 +254,14 @@ export const useDashboardAppState = ({
const dataViewsSubscription = syncDashboardDataViews({
dashboardContainer,
dataViews: dashboardBuildContext.dataViews,
onUpdateDataViews: (newDataViews: DataView[]) => {
if (newDataViews.length > 0 && newDataViews[0].id) {
dashboardContainer.controlGroup?.setRelevantDataViewId(newDataViews[0].id);
onUpdateDataViews: async (newDataViewIds: string[]) => {
if (newDataViewIds?.[0]) {
dashboardContainer.controlGroup?.setRelevantDataViewId(newDataViewIds[0]);
}
setDashboardAppState((s) => ({ ...s, dataViews: newDataViews }));
// fetch all data views. These should be cached locally at this time so we will not need to query ES.
const allDataViews = await Promise.all(newDataViewIds.map((id) => dataViews.get(id)));
setDashboardAppState((s) => ({ ...s, dataViews: allDataViews }));
},
});

View file

@ -9,6 +9,7 @@
import { mockControlGroupInput } from '@kbn/controls-plugin/common/mocks';
import { ControlGroupContainer } from '@kbn/controls-plugin/public/control_group/embeddable/control_group_container';
import { Filter } from '@kbn/es-query';
import { ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group';
jest.mock('@kbn/controls-plugin/public/control_group/embeddable/control_group_container');
@ -50,7 +51,10 @@ const testFilter3: Filter = {
},
};
const mockControlGroupContainer = new ControlGroupContainer(mockControlGroupInput());
const mockControlGroupContainer = new ControlGroupContainer(
{ getTools: () => {} } as unknown as ReduxEmbeddablePackage,
mockControlGroupInput()
);
describe('Test dashboard control group', () => {
describe('Combine dashboard filters with control group filters test', () => {

View file

@ -6,10 +6,9 @@
* Side Public License, v 1.
*/
import { uniqBy } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { Observable, pipe, combineLatest } from 'rxjs';
import { distinctUntilChanged, switchMap, filter, mapTo, map } from 'rxjs/operators';
import { distinctUntilChanged, switchMap, filter, map } from 'rxjs/operators';
import { DashboardContainer } from '..';
import { isErrorEmbeddable } from '../../services/embeddable';
@ -19,7 +18,7 @@ import { DataView } from '../../services/data_views';
interface SyncDashboardDataViewsProps {
dashboardContainer: DashboardContainer;
dataViews: DataViewsContract;
onUpdateDataViews: (newDataViews: DataView[]) => void;
onUpdateDataViews: (newDataViewIds: string[]) => void;
}
export const syncDashboardDataViews = ({
@ -29,54 +28,59 @@ export const syncDashboardDataViews = ({
}: SyncDashboardDataViewsProps) => {
const updateDataViewsOperator = pipe(
filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)),
map((container: DashboardContainer): DataView[] | undefined => {
let panelDataViews: DataView[] = [];
map((container: DashboardContainer): string[] | undefined => {
const panelDataViewIds: Set<string> = new Set<string>();
Object.values(container.getChildIds()).forEach((id) => {
const embeddableInstance = container.getChild(id);
if (isErrorEmbeddable(embeddableInstance)) return;
const embeddableDataViews = (
/**
* TODO - this assumes that all embeddables which communicate data views do so via an `indexPatterns` key on their output.
* This should be replaced with a more generic, interface based method where an embeddable can communicate a data view ID.
*/
const childPanelDataViews = (
embeddableInstance.getOutput() as { indexPatterns: DataView[] }
).indexPatterns;
if (!embeddableDataViews) return;
panelDataViews.push(...embeddableDataViews);
if (!childPanelDataViews) return;
childPanelDataViews.forEach((dataView) => {
if (dataView.id) panelDataViewIds.add(dataView.id);
});
});
if (container.controlGroup) {
panelDataViews.push(...(container.controlGroup.getOutput().dataViews ?? []));
const controlGroupDataViewIds = container.controlGroup.getOutput().dataViewIds;
controlGroupDataViewIds?.forEach((dataViewId) => panelDataViewIds.add(dataViewId));
}
panelDataViews = uniqBy(panelDataViews, 'id');
/**
* If no index patterns have been returned yet, and there is at least one embeddable which
* hasn't yet loaded, defer the loading of the default index pattern by returning undefined.
*/
if (
panelDataViews.length === 0 &&
panelDataViewIds.size === 0 &&
Object.keys(container.getOutput().embeddableLoaded).length > 0 &&
Object.values(container.getOutput().embeddableLoaded).some((value) => value === false)
) {
return;
}
return panelDataViews;
return Array.from(panelDataViewIds);
}),
distinctUntilChanged((a, b) =>
deepEqual(
a?.map((ip) => ip && ip.id),
b?.map((ip) => ip && ip.id)
)
),
distinctUntilChanged((a, b) => deepEqual(a, b)),
// using switchMap for previous task cancellation
switchMap((panelDataViews?: DataView[]) => {
switchMap((allDataViewIds?: string[]) => {
return new Observable((observer) => {
if (!panelDataViews) return;
if (panelDataViews.length > 0) {
if (!allDataViewIds) return;
if (allDataViewIds.length > 0) {
if (observer.closed) return;
onUpdateDataViews(panelDataViews);
onUpdateDataViews(allDataViewIds);
observer.complete();
} else {
dataViews.getDefault().then((defaultDataView) => {
dataViews.getDefaultId().then((defaultDataViewId) => {
if (observer.closed) return;
onUpdateDataViews([defaultDataView as DataView]);
if (defaultDataViewId) {
onUpdateDataViews([defaultDataViewId]);
}
observer.complete();
});
}
@ -89,6 +93,9 @@ export const syncDashboardDataViews = ({
dataViewSources.push(dashboardContainer.controlGroup.getOutput$());
return combineLatest(dataViewSources)
.pipe(mapTo(dashboardContainer), updateDataViewsOperator)
.pipe(
map(() => dashboardContainer),
updateDataViewsOperator
)
.subscribe();
};

View file

@ -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'));

View file

@ -1,42 +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 { configureStore, EnhancedStore } from '@reduxjs/toolkit';
import { combineReducers, Reducer } from 'redux';
export interface InjectReducerProps<StateShape> {
key: string;
asyncReducer: Reducer<StateShape>;
}
type ManagedEmbeddableReduxStore = EnhancedStore & {
asyncReducers: { [key: string]: Reducer<unknown> };
injectReducer: <StateShape>(props: InjectReducerProps<StateShape>) => void;
};
const embeddablesStore = configureStore({ reducer: (state) => state }); // store with blank reducers
const managedEmbeddablesStore = embeddablesStore as ManagedEmbeddableReduxStore;
managedEmbeddablesStore.asyncReducers = {};
managedEmbeddablesStore.injectReducer = <StateShape>({
key,
asyncReducer,
}: InjectReducerProps<StateShape>) => {
if (!managedEmbeddablesStore.asyncReducers[key]) {
managedEmbeddablesStore.asyncReducers[key] = asyncReducer as Reducer<unknown>;
managedEmbeddablesStore.replaceReducer(
combineReducers({ ...managedEmbeddablesStore.asyncReducers })
);
}
};
/**
* A managed Redux store which can be used with multiple embeddables at once. When a new embeddable is created at runtime,
* all passed in reducers will be made into a slice, then combined into the store using combineReducers.
*/
export const getManagedEmbeddablesStore = () => managedEmbeddablesStore;

View file

@ -1,17 +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.
*/
export {
ReduxEmbeddableContext,
useReduxContainerContext,
useReduxEmbeddableContext,
} from './redux_embeddable_context';
export type {
ReduxContainerContextServices,
ReduxEmbeddableWrapperPropsWithChildren,
} from './types';

View file

@ -1,243 +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<EmbeddableInput> & { filters: Filter[] };
export const stateContainsFilters = (
state: Partial<EmbeddableInput>
): 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 = <InputType extends EmbeddableInput = EmbeddableInput>(): Required<
Pick<ReduxEmbeddableWrapperProps<InputType>, 'diffInput'>
> => ({
diffInput: (a, b) => {
const differences: Partial<InputType> = {};
const allKeys = [...Object.keys(a), ...Object.keys(b)] as Array<keyof InputType>;
allKeys.forEach((key) => {
if (!isEqual(a[key], b[key])) differences[key] = a[key];
});
return differences;
},
});
const embeddableIsContainer = (
embeddable: IEmbeddable<EmbeddableInput, EmbeddableOutput>
): embeddable is IContainer => embeddable.isContainer;
export const getExplicitInput = <InputType extends EmbeddableInput = EmbeddableInput>(
embeddable: IEmbeddable<InputType, EmbeddableOutput>
): InputType => {
const root = embeddable.getRoot();
if (!embeddableIsContainer(embeddable) && embeddableIsContainer(root)) {
return (root.getInput().panels[embeddable.id]?.explicitInput ??
embeddable.getInput()) as InputType;
}
return embeddable.getInput() as InputType;
};
/**
* 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 = <InputType extends EmbeddableInput = EmbeddableInput>(
props: ReduxEmbeddableWrapperPropsWithChildren<InputType>
) => {
const { embeddable, reducers, diffInput } = useMemo(
() => ({ ...getDefaultProps<InputType>(), ...props }),
[props]
);
const containerActions: ReduxContainerContextServices['containerActions'] | undefined =
useMemo(() => {
if (embeddableIsContainer(embeddable)) {
return {
untilEmbeddableLoaded: embeddable.untilEmbeddableLoaded.bind(embeddable),
updateInputForChild: embeddable.updateInputForChild.bind(embeddable),
removeEmbeddable: embeddable.removeEmbeddable.bind(embeddable),
addNewEmbeddable: embeddable.addNewEmbeddable.bind(embeddable),
replaceEmbeddable: embeddable.replaceEmbeddable.bind(embeddable),
};
}
return;
}, [embeddable]);
const ReduxEmbeddableStoreProvider = useMemo(
() =>
({ children }: PropsWithChildren<{}>) =>
<Provider store={getManagedEmbeddablesStore()}>{children}</Provider>,
[]
);
const reduxEmbeddableContext: ReduxEmbeddableContextServices | ReduxContainerContextServices =
useMemo(() => {
const key = `${embeddable.type}_${embeddable.id}`;
const store = getManagedEmbeddablesStore();
const initialState = getExplicitInput<InputType>(embeddable);
if (stateContainsFilters(initialState)) {
initialState.filters = cleanFiltersForSerialize(initialState.filters);
}
// A generic reducer used to update redux state when the embeddable input changes
const updateEmbeddableReduxState = (
state: Draft<InputType>,
action: PayloadAction<Partial<InputType>>
) => {
return { ...state, ...action.payload };
};
// A generic reducer used to clear redux state when the embeddable is destroyed
const clearEmbeddableReduxState = () => {
return undefined;
};
const slice = createSlice<InputType, SliceCaseReducers<InputType>>({
initialState,
name: key,
reducers: { ...reducers, updateEmbeddableReduxState, clearEmbeddableReduxState },
});
if (store.asyncReducers[key]) {
// if the store already has reducers set up for this embeddable type & id, update the existing state.
const updateExistingState = (slice.actions as ReduxEmbeddableContextServices['actions'])
.updateEmbeddableReduxState;
store.dispatch(updateExistingState(initialState));
} else {
store.injectReducer({
key,
asyncReducer: slice.reducer,
});
}
const useEmbeddableSelector: TypedUseSelectorHook<InputType> = () =>
useSelector((state: ReturnType<typeof store.getState>) => state[key]);
return {
useEmbeddableDispatch: () => useDispatch<typeof store.dispatch>(),
useEmbeddableSelector,
ReduxEmbeddableStoreProvider,
actions: slice.actions as ReduxEmbeddableContextServices['actions'],
containerActions,
};
}, [reducers, embeddable, containerActions, ReduxEmbeddableStoreProvider]);
return (
<ReduxEmbeddableStoreProvider>
<ReduxEmbeddableContext.Provider value={reduxEmbeddableContext}>
<ReduxEmbeddableSync diffInput={diffInput} embeddable={embeddable}>
{props.children}
</ReduxEmbeddableSync>
</ReduxEmbeddableContext.Provider>
</ReduxEmbeddableStoreProvider>
);
};
interface ReduxEmbeddableSyncProps<InputType extends EmbeddableInput = EmbeddableInput> {
diffInput: (a: InputType, b: InputType) => Partial<InputType>;
embeddable: IEmbeddable<InputType, EmbeddableOutput>;
}
/**
* 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 = <InputType extends EmbeddableInput = EmbeddableInput>({
embeddable,
diffInput,
children,
}: PropsWithChildren<ReduxEmbeddableSyncProps<InputType>>) => {
const {
useEmbeddableSelector,
useEmbeddableDispatch,
actions: { updateEmbeddableReduxState, clearEmbeddableReduxState },
} = useReduxEmbeddableContext<InputType>();
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<InputType>(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<InputType>(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;

View file

@ -1,72 +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 {
ActionCreatorWithPayload,
AnyAction,
CaseReducer,
Dispatch,
PayloadAction,
} from '@reduxjs/toolkit';
import { PropsWithChildren } from 'react';
import { TypedUseSelectorHook } from 'react-redux';
import {
EmbeddableInput,
EmbeddableOutput,
IContainer,
IEmbeddable,
} from '@kbn/embeddable-plugin/public';
export interface GenericEmbeddableReducers<InputType> {
/**
* 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 ReduxEmbeddableContextServices.
*/
[key: string]: CaseReducer<InputType, PayloadAction<any>>;
}
export interface ReduxEmbeddableWrapperProps<InputType extends EmbeddableInput = EmbeddableInput> {
embeddable: IEmbeddable<InputType, EmbeddableOutput>;
reducers: GenericEmbeddableReducers<InputType>;
diffInput?: (a: InputType, b: InputType) => Partial<InputType>;
}
export type ReduxEmbeddableWrapperPropsWithChildren<
InputType extends EmbeddableInput = EmbeddableInput
> = PropsWithChildren<ReduxEmbeddableWrapperProps<InputType>>;
/**
* This context allows components underneath the redux embeddable wrapper to get access to the actions, selector, dispatch, and containerActions.
*/
export interface ReduxEmbeddableContextServices<
InputType extends EmbeddableInput = EmbeddableInput,
ReducerType extends GenericEmbeddableReducers<InputType> = GenericEmbeddableReducers<InputType>
> {
actions: {
[Property in keyof ReducerType]: ActionCreatorWithPayload<
Parameters<ReducerType[Property]>[1]['payload']
>;
} & { updateEmbeddableReduxState: ActionCreatorWithPayload<Partial<InputType>> };
ReduxEmbeddableStoreProvider: React.FC<PropsWithChildren<{}>>;
useEmbeddableSelector: TypedUseSelectorHook<InputType>;
useEmbeddableDispatch: () => Dispatch<AnyAction>;
}
export type ReduxContainerContextServices<
InputType extends EmbeddableInput = EmbeddableInput,
ReducerType extends GenericEmbeddableReducers<InputType> = GenericEmbeddableReducers<InputType>
> = ReduxEmbeddableContextServices<InputType, ReducerType> & {
containerActions: Pick<
IContainer,
| 'untilEmbeddableLoaded'
| 'removeEmbeddable'
| 'addNewEmbeddable'
| 'updateInputForChild'
| 'replaceEmbeddable'
>;
};

View file

@ -43,9 +43,17 @@ export {
withSuspense,
LazyDataViewPicker,
LazyFieldPicker,
LazyReduxEmbeddableWrapper,
} from './components';
export {
useReduxContainerContext,
useReduxEmbeddableContext,
lazyLoadReduxEmbeddablePackage,
type ReduxEmbeddableState,
type ReduxEmbeddableTools,
type ReduxEmbeddablePackage,
} from './redux_embeddables';
export * from './components/types';
export type { QuickButtonProps } from './components/solution_toolbar';
@ -61,14 +69,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.

View file

@ -0,0 +1,50 @@
/*
* 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 { Filter } from '@kbn/es-query';
import { EmbeddableInput } from '@kbn/embeddable-plugin/public';
import { ReduxEmbeddableState } from './types';
// TODO: Make filters serializable so we don't need special treatment for them.
type InputWithFilters = Partial<EmbeddableInput> & { filters: Filter[] };
export const stateContainsFilters = (
state: Partial<EmbeddableInput>
): state is InputWithFilters => {
if ((state as InputWithFilters).filters && (state as InputWithFilters).filters.length > 0)
return true;
return false;
};
export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => {
return filters.map((filter) => {
if (filter.meta.value) delete filter.meta.value;
return filter;
});
};
export const cleanInputForRedux = <
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState
>(
explicitInput: ReduxEmbeddableStateType['explicitInput']
) => {
if (stateContainsFilters(explicitInput)) {
explicitInput.filters = cleanFiltersForSerialize(explicitInput.filters);
}
return explicitInput;
};
export const cleanStateForRedux = <
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState
>(
state: ReduxEmbeddableStateType
) => {
// clean explicit input
state.explicitInput = cleanInputForRedux<ReduxEmbeddableStateType>(state.explicitInput);
return state;
};

View file

@ -0,0 +1,125 @@
/*
* 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 {
configureStore,
createSlice,
Draft,
PayloadAction,
SliceCaseReducers,
} from '@reduxjs/toolkit';
import React, { ReactNode, PropsWithChildren } from 'react';
import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { IEmbeddable } from '@kbn/embeddable-plugin/public';
import {
EmbeddableReducers,
ReduxEmbeddableTools,
ReduxEmbeddableContext,
ReduxEmbeddableState,
ReduxEmbeddableSyncSettings,
} from './types';
import { syncReduxEmbeddable } from './sync_redux_embeddable';
import { EmbeddableReduxContext } from './use_redux_embeddable_context';
import { cleanStateForRedux } from './clean_redux_embeddable_state';
export const createReduxEmbeddableTools = <
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState,
ReducerType extends EmbeddableReducers<ReduxEmbeddableStateType> = EmbeddableReducers<ReduxEmbeddableStateType>
>({
reducers,
embeddable,
syncSettings,
initialComponentState,
}: {
embeddable: IEmbeddable<
ReduxEmbeddableStateType['explicitInput'],
ReduxEmbeddableStateType['output']
>;
initialComponentState?: ReduxEmbeddableStateType['componentState'];
syncSettings?: ReduxEmbeddableSyncSettings;
reducers: ReducerType;
}): ReduxEmbeddableTools<ReduxEmbeddableStateType, ReducerType> => {
// Additional generic reducers to aid in embeddable syncing
const genericReducers = {
updateEmbeddableReduxInput: (
state: Draft<ReduxEmbeddableStateType>,
action: PayloadAction<Partial<ReduxEmbeddableStateType['explicitInput']>>
) => {
state.explicitInput = { ...state.explicitInput, ...action.payload };
},
updateEmbeddableReduxOutput: (
state: Draft<ReduxEmbeddableStateType>,
action: PayloadAction<Partial<ReduxEmbeddableStateType['output']>>
) => {
state.output = { ...state.output, ...action.payload };
},
};
// create initial state from Embeddable
let initialState: ReduxEmbeddableStateType = {
output: embeddable.getOutput(),
componentState: initialComponentState ?? {},
explicitInput: embeddable.getExplicitInput(),
} as ReduxEmbeddableStateType;
initialState = cleanStateForRedux<ReduxEmbeddableStateType>(initialState);
// create slice out of reducers and embeddable initial state.
const slice = createSlice<ReduxEmbeddableStateType, SliceCaseReducers<ReduxEmbeddableStateType>>({
initialState,
name: `${embeddable.type}_${embeddable.id}`,
reducers: { ...reducers, ...genericReducers },
});
const store = configureStore({ reducer: slice.reducer });
// 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<typeof store.dispatch>(),
useEmbeddableSelector: useSelector as TypedUseSelectorHook<ReduxEmbeddableStateType>,
// populate container actions for embeddables which are Containers
containerActions: embeddable.getIsContainer()
? {
untilEmbeddableLoaded: embeddable.untilEmbeddableLoaded.bind(embeddable),
updateInputForChild: embeddable.updateInputForChild.bind(embeddable),
removeEmbeddable: embeddable.removeEmbeddable.bind(embeddable),
addNewEmbeddable: embeddable.addNewEmbeddable.bind(embeddable),
replaceEmbeddable: embeddable.replaceEmbeddable.bind(embeddable),
}
: undefined,
};
const Wrapper: React.FC<PropsWithChildren<{}>> = ({ children }: { children?: ReactNode }) => (
<Provider store={store}>
<EmbeddableReduxContext.Provider value={context}>{children}</EmbeddableReduxContext.Provider>
</Provider>
);
const stopReduxEmbeddableSync = syncReduxEmbeddable<ReduxEmbeddableStateType>({
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?.(),
};
};

View file

@ -0,0 +1,23 @@
/*
* 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 { ReduxEmbeddablePackage } from './types';
export {
useReduxContainerContext,
useReduxEmbeddableContext,
} from './use_redux_embeddable_context';
export type { ReduxEmbeddableState, ReduxEmbeddableTools, ReduxEmbeddablePackage } from './types';
export const lazyLoadReduxEmbeddablePackage = async (): Promise<ReduxEmbeddablePackage> => {
const { createReduxEmbeddableTools } = await import('./create_redux_embeddable_tools');
return {
createTools: createReduxEmbeddableTools,
};
};

View file

@ -0,0 +1,105 @@
/*
* 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';
import { cleanInputForRedux } from './clean_redux_embeddable_state';
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export const syncReduxEmbeddable = <
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState
>({
store,
actions,
settings,
embeddable,
}: {
settings?: ReduxEmbeddableSyncSettings;
store: EnhancedStore<ReduxEmbeddableStateType>;
embeddable: IEmbeddable<
ReduxEmbeddableStateType['explicitInput'],
ReduxEmbeddableStateType['output']
>;
actions: ReduxEmbeddableContext<ReduxEmbeddableStateType>['actions'];
}) => {
if (settings?.disableSync) {
return;
}
let embeddableToReduxInProgress = false;
let reduxToEmbeddableInProgress = false;
const { isInputEqual: inputEqualityCheck, isOutputEqual: outputEqualityCheck } = settings ?? {};
const inputEqual = (
inputA: Partial<ReduxEmbeddableStateType['explicitInput']>,
inputB: Partial<ReduxEmbeddableStateType['explicitInput']>
) => (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(() => {
if (embeddableToReduxInProgress) return;
reduxToEmbeddableInProgress = true;
const reduxState = store.getState();
if (!inputEqual(reduxState.explicitInput, embeddable.getExplicitInput())) {
embeddable.updateInput(reduxState.explicitInput);
}
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);
}
reduxToEmbeddableInProgress = false;
});
// when the embeddable input changes, diff and dispatch to the redux store
const inputSubscription = embeddable.getInput$().subscribe(() => {
if (reduxToEmbeddableInProgress) return;
embeddableToReduxInProgress = true;
const { explicitInput: reduxExplicitInput } = store.getState();
// store only explicit input in the store
const embeddableExplictInput = embeddable.getExplicitInput() as Writeable<
ReduxEmbeddableStateType['explicitInput']
>;
if (!inputEqual(reduxExplicitInput, embeddableExplictInput)) {
store.dispatch(
actions.updateEmbeddableReduxInput(cleanInputForRedux(embeddableExplictInput))
);
}
embeddableToReduxInProgress = false;
});
// when the embeddable output changes, diff and dispatch to the redux store
const outputSubscription = embeddable.getOutput$().subscribe((embeddableOutput) => {
if (reduxToEmbeddableInProgress) return;
embeddableToReduxInProgress = true;
const reduxState = store.getState();
if (!outputEqual(reduxState.output, embeddableOutput)) {
store.dispatch(actions.updateEmbeddableReduxOutput(embeddableOutput));
}
embeddableToReduxInProgress = false;
});
return () => {
unsubscribeFromStore();
inputSubscription.unsubscribe();
outputSubscription.unsubscribe();
};
};

View file

@ -0,0 +1,123 @@
/*
* 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 {
Dispatch,
AnyAction,
CaseReducer,
PayloadAction,
ActionCreatorWithPayload,
EnhancedStore,
} from '@reduxjs/toolkit';
import { TypedUseSelectorHook } from 'react-redux';
import { EmbeddableInput, EmbeddableOutput, IContainer } from '@kbn/embeddable-plugin/public';
import { PropsWithChildren } from 'react';
export interface ReduxEmbeddableSyncSettings<
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState
> {
disableSync: boolean;
isInputEqual?: (
a: Partial<ReduxEmbeddableStateType['explicitInput']>,
b: Partial<ReduxEmbeddableStateType['explicitInput']>
) => boolean;
isOutputEqual?: (
a: Partial<ReduxEmbeddableStateType['output']>,
b: Partial<ReduxEmbeddableStateType['output']>
) => boolean;
}
/**
* The package type is lazily exported from presentation_util and should contain all methods needed to use the redux embeddable tools.
*/
export interface ReduxEmbeddablePackage {
createTools: typeof import('./create_redux_embeddable_tools')['createReduxEmbeddableTools'];
}
/**
* 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<ReduxEmbeddableStateType> = EmbeddableReducers<ReduxEmbeddableStateType>
> {
cleanup: () => void;
Wrapper: React.FC<PropsWithChildren<{}>>;
dispatch: EnhancedStore<ReduxEmbeddableStateType>['dispatch'];
getState: EnhancedStore<ReduxEmbeddableStateType>['getState'];
actions: ReduxEmbeddableContext<ReduxEmbeddableStateType, ReducerType>['actions'];
}
/**
* The Embeddable Redux store should contain Input, Output and State. Input is serialized and used to create the embeddable,
* Output is used as a communication layer for state that the Embeddable creates, and State is used to store ephemeral state which needs
* to be communicated between an embeddable and its inner React components.
*/
export interface ReduxEmbeddableState<
InputType extends EmbeddableInput = EmbeddableInput,
OutputType extends EmbeddableOutput = EmbeddableOutput,
StateType extends unknown = unknown
> {
explicitInput: InputType;
output: OutputType;
componentState: StateType;
}
/**
* The Embeddable Reducers are the shape of the Raw reducers which will be passed into createSlice. These will be used to populate the actions
* object which will be returned in the ReduxEmbeddableContext.
*/
export interface EmbeddableReducers<
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState
> {
/**
* 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 setupReduxEmbeddable.
*/
[key: string]: CaseReducer<ReduxEmbeddableStateType, PayloadAction<any>>;
}
/**
* 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.
*/
export interface ReduxEmbeddableContext<
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState,
ReducerType extends EmbeddableReducers<ReduxEmbeddableStateType> = EmbeddableReducers<ReduxEmbeddableStateType>
> {
actions: {
[Property in keyof ReducerType]: ActionCreatorWithPayload<
Parameters<ReducerType[Property]>[1]['payload']
>;
} & {
// Generic reducers to interact with embeddable Input and Output.
updateEmbeddableReduxInput: ActionCreatorWithPayload<
Partial<ReduxEmbeddableStateType['explicitInput']>
>;
updateEmbeddableReduxOutput: ActionCreatorWithPayload<
Partial<ReduxEmbeddableStateType['output']>
>;
};
useEmbeddableSelector: TypedUseSelectorHook<ReduxEmbeddableStateType>;
useEmbeddableDispatch: () => Dispatch<AnyAction>;
}
export type ReduxContainerContext<
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState,
ReducerType extends EmbeddableReducers<ReduxEmbeddableStateType> = EmbeddableReducers<ReduxEmbeddableStateType>
> = ReduxEmbeddableContext<ReduxEmbeddableStateType, ReducerType> & {
containerActions: Pick<
IContainer,
| 'untilEmbeddableLoaded'
| 'removeEmbeddable'
| 'addNewEmbeddable'
| 'updateInputForChild'
| 'replaceEmbeddable'
>;
};

View file

@ -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<EmbeddableInput>
| ReduxContainerContextServices<EmbeddableInput>
| null
export const EmbeddableReduxContext = createContext<
ReduxEmbeddableContext<ReduxEmbeddableState> | ReduxContainerContext<ReduxEmbeddableState> | 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<InputType> = GenericEmbeddableReducers<InputType>
>(): ReduxEmbeddableContextServices<InputType, ReducerType> => {
const context = useContext<ReduxEmbeddableContextServices<InputType, ReducerType>>(
ReduxEmbeddableContext as unknown as React.Context<
ReduxEmbeddableContextServices<InputType, ReducerType>
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState,
ReducerType extends EmbeddableReducers<ReduxEmbeddableStateType> = EmbeddableReducers<ReduxEmbeddableStateType>
>(): ReduxEmbeddableContext<ReduxEmbeddableStateType, ReducerType> => {
const context = useContext<ReduxEmbeddableContext<ReduxEmbeddableStateType, ReducerType>>(
EmbeddableReduxContext as unknown as React.Context<
ReduxEmbeddableContext<ReduxEmbeddableStateType, ReducerType>
>
);
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<InputType> = GenericEmbeddableReducers<InputType>
>(): ReduxContainerContextServices<InputType, ReducerType> => {
const context = useContext<ReduxContainerContextServices<InputType, ReducerType>>(
ReduxEmbeddableContext as unknown as React.Context<
ReduxContainerContextServices<InputType, ReducerType>
ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState,
ReducerType extends EmbeddableReducers<ReduxEmbeddableStateType> = EmbeddableReducers<ReduxEmbeddableStateType>
>(): ReduxContainerContext<ReduxEmbeddableStateType, ReducerType> => {
const context = useContext<ReduxContainerContext<ReduxEmbeddableStateType, ReducerType>>(
EmbeddableReduxContext as unknown as React.Context<
ReduxContainerContext<ReduxEmbeddableStateType, ReducerType>
>
);
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!;