mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
4a95b2cde3
commit
01fc584a4a
46 changed files with 1188 additions and 946 deletions
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
>;
|
||||
|
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
>;
|
|
@ -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;
|
||||
};
|
|
@ -86,7 +86,7 @@ class OptionsListService implements ControlsOptionsListService {
|
|||
...passThroughProps,
|
||||
filters: esFilters,
|
||||
fieldName: field.name,
|
||||
fieldSpec: field.toSpec?.(),
|
||||
fieldSpec: field,
|
||||
textFieldName: (field as OptionsListField).textFieldName,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 }));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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;
|
|
@ -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';
|
|
@ -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;
|
|
@ -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'
|
||||
>;
|
||||
};
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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?.(),
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
};
|
||||
};
|
123
src/plugins/presentation_util/public/redux_embeddables/types.ts
Normal file
123
src/plugins/presentation_util/public/redux_embeddables/types.ts
Normal 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'
|
||||
>;
|
||||
};
|
|
@ -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!;
|
Loading…
Add table
Add a link
Reference in a new issue