[Analyst Experience Components] Dashboard & Control Group APIs (#150121)

Aligns the Portable Dashboard renderer and the Control Group renderer to a new API structure using `useImperativeHandle` rather than the overcomplicated and mostly unused wrapper provider system.
This commit is contained in:
Devon Thomson 2023-04-19 15:34:39 -04:00 committed by GitHub
parent befd429f4f
commit ffc349225e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
146 changed files with 2772 additions and 2854 deletions

View file

@ -9,11 +9,8 @@
import React from 'react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { LazyControlGroupRenderer } from '@kbn/controls-plugin/public';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
import { ControlGroupRenderer } from '@kbn/controls-plugin/public';
export const AddButtonExample = ({ dataViewId }: { dataViewId: string }) => {
return (

View file

@ -6,36 +6,18 @@
* Side Public License, v 1.
*/
import React, { useMemo, useState } from 'react';
import React, { useState } from 'react';
import {
LazyControlGroupRenderer,
ControlGroupContainer,
useControlGroupContainerContext,
ControlStyle,
} from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { EuiButtonGroup, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
import { EuiButtonGroup, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { ControlGroupRenderer, ControlStyle, ControlGroupAPI } from '@kbn/controls-plugin/public';
import { AwaitingControlGroupAPI } from '@kbn/controls-plugin/public/control_group';
export const BasicReduxExample = ({ dataViewId }: { dataViewId: string }) => {
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
const ControlGroupReduxWrapper = useMemo(() => {
if (controlGroup) return controlGroup.getReduxEmbeddableTools().Wrapper;
}, [controlGroup]);
const ButtonControls = () => {
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setControlStyle },
} = useControlGroupContainerContext();
const dispatch = useEmbeddableDispatch();
const controlStyle = select((state) => state.explicitInput.controlStyle);
const [controlGroupAPI, setControlGroupApi] = useState<AwaitingControlGroupAPI>(null);
const Buttons = ({ api }: { api: ControlGroupAPI }) => {
const controlStyle = api.select((state) => state.explicitInput.controlStyle);
return (
<EuiButtonGroup
legend="Text style"
@ -52,9 +34,7 @@ export const BasicReduxExample = ({ dataViewId }: { dataViewId: string }) => {
},
]}
idSelected={controlStyle}
onChange={(id, value) => {
dispatch(setControlStyle(value));
}}
onChange={(id, value) => api.dispatch.setControlStyle(value)}
type="single"
/>
);
@ -70,16 +50,10 @@ export const BasicReduxExample = ({ dataViewId }: { dataViewId: string }) => {
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
{ControlGroupReduxWrapper && (
<ControlGroupReduxWrapper>
<ButtonControls />
</ControlGroupReduxWrapper>
)}
{controlGroupAPI && <Buttons api={controlGroupAPI} />}
<ControlGroupRenderer
onLoadComplete={async (newControlGroup) => {
setControlGroup(newControlGroup);
}}
ref={setControlGroupApi}
getCreationOptions={async (initialInput, builder) => {
await builder.addDataControlFromField(initialInput, {
dataViewId,

View file

@ -23,17 +23,14 @@ import {
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common';
import {
LazyControlGroupRenderer,
ControlGroupContainer,
ControlGroupInput,
type ControlGroupInput,
ControlGroupRenderer,
AwaitingControlGroupAPI,
ACTION_EDIT_CONTROL,
ACTION_DELETE_CONTROL,
} from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { ControlInputTransform } from '@kbn/controls-plugin/common/types';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
const INPUT_KEY = 'kbnControls:saveExample:input';
const WITH_CUSTOM_PLACEHOLDER = 'Custom Placeholder';
@ -41,7 +38,7 @@ const WITH_CUSTOM_PLACEHOLDER = 'Custom Placeholder';
export const EditExample = () => {
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
const [controlGroupAPI, setControlGroupAPI] = useState<AwaitingControlGroupAPI>(null);
const [toggleIconIdToSelectedMapIcon, setToggleIconIdToSelectedMapIcon] = useState<{
[id: string]: boolean;
}>({});
@ -54,20 +51,21 @@ export const EditExample = () => {
},
};
if (controlGroup) {
if (controlGroupAPI) {
const disabledActions: string[] = Object.keys(
pickBy(newToggleIconIdToSelectedMapIcon, (value) => value)
);
controlGroup.updateInput({ disabledActions });
controlGroupAPI.updateInput({ disabledActions });
}
setToggleIconIdToSelectedMapIcon(newToggleIconIdToSelectedMapIcon);
}
async function onSave() {
setIsSaving(true);
if (!controlGroupAPI) return;
localStorage.setItem(INPUT_KEY, JSON.stringify(controlGroup!.getInput()));
setIsSaving(true);
localStorage.setItem(INPUT_KEY, JSON.stringify(controlGroupAPI.getInput()));
// simulated async save await
await new Promise((resolve) => setTimeout(resolve, 1000));
@ -133,9 +131,9 @@ export const EditExample = () => {
<EuiButtonEmpty
color="primary"
iconType="plusInCircle"
isDisabled={controlGroup === undefined}
isDisabled={controlGroupAPI === undefined}
onClick={() => {
controlGroup!.openAddDataControlFlyout(controlInputTransform);
controlGroupAPI!.openAddDataControlFlyout(controlInputTransform);
}}
>
Add control
@ -171,7 +169,7 @@ export const EditExample = () => {
<EuiButton
fill
color="primary"
isDisabled={controlGroup === undefined || isSaving}
isDisabled={controlGroupAPI === undefined || isSaving}
onClick={onSave}
isLoading={isSaving}
>
@ -186,6 +184,7 @@ export const EditExample = () => {
</>
) : null}
<ControlGroupRenderer
ref={setControlGroupAPI}
getCreationOptions={async (initialInput, builder) => {
const persistedInput = await onLoad();
return {
@ -196,9 +195,6 @@ export const EditExample = () => {
},
};
}}
onLoadComplete={async (newControlGroup) => {
setControlGroup(newControlGroup);
}}
/>
</EuiPanel>
</>

View file

@ -22,12 +22,9 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import { LazyControlGroupRenderer, ControlGroupContainer } from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { AwaitingControlGroupAPI, ControlGroupRenderer } from '@kbn/controls-plugin/public';
import { PLUGIN_ID } from './constants';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
interface Props {
data: DataPublicPluginStart;
dataView: DataView;
@ -36,7 +33,7 @@ interface Props {
export const SearchExample = ({ data, dataView, navigation }: Props) => {
const [controlFilters, setControlFilters] = useState<Filter[]>([]);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
const [controlGroupAPI, setControlGroupAPI] = useState<AwaitingControlGroupAPI>();
const [hits, setHits] = useState(0);
const [filters, setFilters] = useState<Filter[]>([]);
const [isSearching, setIsSearching] = useState(false);
@ -47,16 +44,16 @@ export const SearchExample = ({ data, dataView, navigation }: Props) => {
const [timeRange, setTimeRange] = useState<TimeRange>({ from: 'now-7d', to: 'now' });
useEffect(() => {
if (!controlGroup) {
if (!controlGroupAPI) {
return;
}
const subscription = controlGroup.onFiltersPublished$.subscribe((newFilters) => {
const subscription = controlGroupAPI.onFiltersPublished$.subscribe((newFilters) => {
setControlFilters([...newFilters]);
});
return () => {
subscription.unsubscribe();
};
}, [controlGroup]);
}, [controlGroupAPI]);
useEffect(() => {
const abortController = new AbortController();
@ -155,10 +152,8 @@ export const SearchExample = ({ data, dataView, navigation }: Props) => {
},
};
}}
onLoadComplete={async (newControlGroup) => {
setControlGroup(newControlGroup);
}}
query={query}
ref={setControlGroupAPI}
timeRange={timeRange}
/>
<EuiCallOut title="Search results">

View file

@ -18,10 +18,9 @@
"@kbn/data-plugin",
"@kbn/controls-plugin",
"@kbn/navigation-plugin",
"@kbn/presentation-util-plugin",
"@kbn/shared-ux-page-kibana-template",
"@kbn/embeddable-plugin",
"@kbn/data-views-plugin",
"@kbn/es-query"
"@kbn/es-query",
]
}

View file

@ -20,7 +20,6 @@
"unifiedSearch",
"developerExamples",
"embeddableExamples"
],
"requiredBundles": ["presentationUtil"]
]
}
}

View file

@ -6,19 +6,37 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import { withSuspense } from '@kbn/shared-ux-utility';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { controlGroupInputBuilder } from '@kbn/controls-plugin/public';
import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { controlGroupInputBuilder } from '@kbn/controls-plugin/public';
import { getDefaultControlGroupInput } from '@kbn/controls-plugin/common';
import { FILTER_DEBUGGER_EMBEDDABLE } from '@kbn/embeddable-examples-plugin/public';
import { LazyDashboardContainerRenderer } from '@kbn/dashboard-plugin/public';
const DashboardContainerRenderer = withSuspense(LazyDashboardContainerRenderer);
import { AwaitingDashboardAPI, DashboardRenderer } from '@kbn/dashboard-plugin/public';
export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView }) => {
const [dashboard, setDashboard] = useState<AwaitingDashboardAPI>();
// add a filter debugger panel as soon as the dashboard becomes available
useEffect(() => {
if (!dashboard) return;
(async () => {
const embeddable = await dashboard.addNewEmbeddable(FILTER_DEBUGGER_EMBEDDABLE, {});
const prevPanelState = dashboard.getExplicitInput().panels[embeddable.id];
// resize the new panel so that it fills up the entire width of the dashboard
dashboard.updateInput({
panels: {
[embeddable.id]: {
...prevPanelState,
gridData: { i: embeddable.id, x: 0, y: 0, w: 48, h: 12 },
},
},
});
})();
}, [dashboard]);
return (
<>
<EuiTitle>
@ -29,10 +47,10 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
<DashboardContainerRenderer
<DashboardRenderer
getCreationOptions={async () => {
const builder = controlGroupInputBuilder;
const controlGroupInput = {};
const controlGroupInput = getDefaultControlGroupInput();
await builder.addDataControlFromField(controlGroupInput, {
dataViewId: dataView.id ?? '',
title: 'Destintion country',
@ -57,22 +75,7 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
},
};
}}
onDashboardContainerLoaded={(container) => {
const addFilterEmbeddable = async () => {
const embeddable = await container.addNewEmbeddable(FILTER_DEBUGGER_EMBEDDABLE, {});
const prevPanelState = container.getExplicitInput().panels[embeddable.id];
// resize the new panel so that it fills up the entire width of the dashboard
container.updateInput({
panels: {
[embeddable.id]: {
...prevPanelState,
gridData: { i: embeddable.id, x: 0, y: 0, w: 48, h: 12 },
},
},
});
};
addFilterEmbeddable();
}}
ref={setDashboard}
/>
</EuiPanel>
</>

View file

@ -6,9 +6,8 @@
* Side Public License, v 1.
*/
import React, { useMemo, useState } from 'react';
import React, { useState } from 'react';
import { DashboardContainer, LazyDashboardContainerRenderer } from '@kbn/dashboard-plugin/public';
import {
EuiButtonGroup,
EuiFlexGroup,
@ -18,35 +17,19 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import {
AwaitingDashboardAPI,
DashboardAPI,
DashboardRenderer,
} from '@kbn/dashboard-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { useDashboardContainerContext } from '@kbn/dashboard-plugin/public';
const DashboardContainerRenderer = withSuspense(LazyDashboardContainerRenderer);
export const DualReduxExample = () => {
const [firstDashboardContainer, setFirstDashboardContainer] = useState<
DashboardContainer | undefined
>();
const [secondDashboardContainer, setSecondDashboardContainer] = useState<
DashboardContainer | undefined
>();
const [firstDashboardContainer, setFirstDashboardContainer] = useState<AwaitingDashboardAPI>();
const [secondDashboardContainer, setSecondDashboardContainer] = useState<AwaitingDashboardAPI>();
const FirstDashboardReduxWrapper = useMemo(() => {
if (firstDashboardContainer) return firstDashboardContainer.getReduxEmbeddableTools().Wrapper;
}, [firstDashboardContainer]);
const SecondDashboardReduxWrapper = useMemo(() => {
if (secondDashboardContainer) return secondDashboardContainer.getReduxEmbeddableTools().Wrapper;
}, [secondDashboardContainer]);
const ButtonControls = () => {
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setViewMode },
} = useDashboardContainerContext();
const dispatch = useEmbeddableDispatch();
const viewMode = select((state) => state.explicitInput.viewMode);
const ButtonControls = ({ dashboard }: { dashboard: DashboardAPI }) => {
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
return (
<EuiButtonGroup
@ -64,9 +47,7 @@ export const DualReduxExample = () => {
},
]}
idSelected={viewMode}
onChange={(id, value) => {
dispatch(setViewMode(value));
}}
onChange={(id, value) => dashboard.dispatch.setViewMode(value)}
type="single"
/>
);
@ -91,34 +72,18 @@ export const DualReduxExample = () => {
<h3>Dashboard #1</h3>
</EuiTitle>
<EuiSpacer size="m" />
{FirstDashboardReduxWrapper && (
<FirstDashboardReduxWrapper>
<ButtonControls />
</FirstDashboardReduxWrapper>
)}
{firstDashboardContainer && <ButtonControls dashboard={firstDashboardContainer} />}
<EuiSpacer size="m" />
<DashboardContainerRenderer
onDashboardContainerLoaded={(container) => {
setFirstDashboardContainer(container);
}}
/>
<DashboardRenderer ref={setFirstDashboardContainer} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h3>Dashboard #2</h3>
</EuiTitle>
<EuiSpacer size="m" />
{SecondDashboardReduxWrapper && (
<SecondDashboardReduxWrapper>
<ButtonControls />
</SecondDashboardReduxWrapper>
)}
{secondDashboardContainer && <ButtonControls dashboard={secondDashboardContainer} />}
<EuiSpacer size="m" />
<DashboardContainerRenderer
onDashboardContainerLoaded={(container) => {
setSecondDashboardContainer(container);
}}
/>
<DashboardRenderer ref={setSecondDashboardContainer} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -8,7 +8,7 @@
import React, { useMemo, useState } from 'react';
import { DashboardContainer, LazyDashboardContainerRenderer } from '@kbn/dashboard-plugin/public';
import { AwaitingDashboardAPI, DashboardRenderer } from '@kbn/dashboard-plugin/public';
import {
EuiButton,
EuiFlexGroup,
@ -23,19 +23,17 @@ import {
VisualizeInput,
VisualizeOutput,
} from '@kbn/visualizations-plugin/public/embeddable/visualize_embeddable';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
const INPUT_KEY = 'portableDashboard:saveExample:input';
const DashboardContainerRenderer = withSuspense(LazyDashboardContainerRenderer); // make this so we don't have two loading states - loading in the dashboard plugin instead
export const DynamicByReferenceExample = () => {
const [isSaving, setIsSaving] = useState(false);
const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer | undefined>();
const [dashboard, setdashboard] = useState<AwaitingDashboardAPI>();
const onSave = async () => {
if (!dashboard) return;
setIsSaving(true);
localStorage.setItem(INPUT_KEY, JSON.stringify(dashboardContainer!.getInput()));
localStorage.setItem(INPUT_KEY, JSON.stringify(dashboard.getInput()));
// simulated async save await
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsSaving(false);
@ -56,44 +54,37 @@ export const DynamicByReferenceExample = () => {
const resetPersistableInput = () => {
localStorage.removeItem(INPUT_KEY);
if (dashboardContainer) {
const children = dashboardContainer.getChildIds();
if (dashboard) {
const children = dashboard.getChildIds();
children.map((childId) => {
dashboardContainer.removeEmbeddable(childId);
dashboard.removeEmbeddable(childId);
});
}
};
const addByReference = () => {
if (dashboardContainer) {
dashboardContainer.addFromLibrary();
}
};
const addByValue = async () => {
if (dashboardContainer) {
dashboardContainer.addNewEmbeddable<VisualizeInput, VisualizeOutput, VisualizeEmbeddable>(
'visualization',
{
title: 'Sample Markdown Vis',
savedVis: {
type: 'markdown',
title: '',
data: { aggs: [], searchSource: {} },
params: {
fontSize: 12,
openLinksInNewTab: false,
markdown: '### By Value Visualization\nThis is a sample by value panel.',
},
if (!dashboard) return;
dashboard.addNewEmbeddable<VisualizeInput, VisualizeOutput, VisualizeEmbeddable>(
'visualization',
{
title: 'Sample Markdown Vis',
savedVis: {
type: 'markdown',
title: '',
data: { aggs: [], searchSource: {} },
params: {
fontSize: 12,
openLinksInNewTab: false,
markdown: '### By Value Visualization\nThis is a sample by value panel.',
},
}
);
}
},
}
);
};
const disableButtons = useMemo(() => {
return dashboardContainer === undefined || isSaving;
}, [dashboardContainer, isSaving]);
return !dashboard || isSaving;
}, [dashboard, isSaving]);
return (
<>
@ -114,7 +105,7 @@ export const DynamicByReferenceExample = () => {
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton onClick={addByReference} isDisabled={disableButtons}>
<EuiButton onClick={() => dashboard?.addFromLibrary()} isDisabled={disableButtons}>
Add visualization from library
</EuiButton>
</EuiFlexItem>
@ -141,7 +132,7 @@ export const DynamicByReferenceExample = () => {
</EuiFlexGroup>
<EuiSpacer size="m" />
<DashboardContainerRenderer
<DashboardRenderer
getCreationOptions={async () => {
const persistedInput = getPersistableInput();
return {
@ -151,9 +142,7 @@ export const DynamicByReferenceExample = () => {
},
};
}}
onDashboardContainerLoaded={(container) => {
setDashboardContainer(container);
}}
ref={setdashboard}
/>
</EuiPanel>
</>

View file

@ -11,15 +11,9 @@ import { css } from '@emotion/react';
import { buildPhraseFilter, Filter } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public';
import {
LazyDashboardContainerRenderer,
DashboardCreationOptions,
} from '@kbn/dashboard-plugin/public';
import { DashboardRenderer, DashboardCreationOptions } from '@kbn/dashboard-plugin/public';
import { EuiCode, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
const DashboardContainerRenderer = withSuspense(LazyDashboardContainerRenderer);
export const StaticByReferenceExample = ({
dashboardId,
@ -50,7 +44,7 @@ export const StaticByReferenceExample = ({
overflow-y: auto;
`}
>
<DashboardContainerRenderer
<DashboardRenderer
savedObjectId={dashboardId}
getCreationOptions={async () => {
const field = dataView.getFieldByName('machine.os.keyword');
@ -61,13 +55,10 @@ export const StaticByReferenceExample = ({
if (field) {
filter = buildPhraseFilter(field, 'win xp', dataView);
filter.meta.negate = true;
creationOptions = { ...creationOptions, overrideInput: { filters: [filter] } };
creationOptions = { ...creationOptions, initialInput: { filters: [filter] } };
}
return creationOptions; // if can't find the field, then just return no special creation options
}}
onDashboardContainerLoaded={(container) => {
return; // this example is static, so don't need to do anything with the dashboard container
}}
/>
</EuiPanel>
</>

View file

@ -11,13 +11,10 @@ import React from 'react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import type { DashboardPanelMap } from '@kbn/dashboard-plugin/common';
import { LazyDashboardContainerRenderer } from '@kbn/dashboard-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { DashboardRenderer } from '@kbn/dashboard-plugin/public';
import panelsJson from './static_by_value_example_panels.json';
const DashboardContainerRenderer = withSuspense(LazyDashboardContainerRenderer);
export const StaticByValueExample = () => {
return (
<>
@ -29,7 +26,7 @@ export const StaticByValueExample = () => {
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
<DashboardContainerRenderer
<DashboardRenderer
getCreationOptions={async () => {
return {
initialInput: {
@ -39,9 +36,6 @@ export const StaticByValueExample = () => {
},
};
}}
onDashboardContainerLoaded={(container) => {
return; // this example is static, so don't need to do anything with the dashboard container
}}
/>
</EuiPanel>
</>

View file

@ -20,11 +20,9 @@
"@kbn/embeddable-plugin",
"@kbn/data-views-plugin",
"@kbn/visualizations-plugin",
"@kbn/presentation-util-plugin",
"@kbn/developer-examples-plugin",
"@kbn/embeddable-examples-plugin",
"@kbn/shared-ux-page-kibana-template",
"@kbn/shared-ux-utility",
"@kbn/controls-plugin",
"@kbn/shared-ux-router"
]

View file

@ -6,19 +6,15 @@
* Side Public License, v 1.
*/
import { createReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public/redux_embeddables/create_redux_embeddable_tools';
import { OptionsListEmbeddable, OptionsListEmbeddableFactory } from '../../public';
import { OptionsListComponentState, OptionsListReduxState } from '../../public/options_list/types';
import {
getDefaultComponentState,
optionsListReducers,
} from '../../public/options_list/options_list_reducers';
import { ControlFactory, ControlOutput } from '../../public/types';
import { OptionsListEmbeddableInput } from './types';
import * as optionsListStateModule from '../../public/options_list/options_list_reducers';
const mockOptionsListComponentState = {
...getDefaultComponentState(),
searchString: { value: '', valid: true },
field: undefined,
totalCardinality: 0,
availableOptions: {
@ -29,6 +25,8 @@ const mockOptionsListComponentState = {
moo: { doc_count: 5 },
},
invalidSelections: [],
allowExpensiveQueries: true,
popoverOpen: false,
validSelections: [],
} as OptionsListComponentState;
@ -46,26 +44,24 @@ const mockOptionsListOutput = {
loading: false,
} as ControlOutput;
export const mockOptionsListReduxEmbeddableTools = async (
partialState?: Partial<OptionsListReduxState>
) => {
export const mockOptionsListEmbeddable = async (partialState?: Partial<OptionsListReduxState>) => {
const optionsListFactoryStub = new OptionsListEmbeddableFactory();
const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory;
optionsListControlFactory.getDefaultInput = () => ({});
// initial component state can be provided by overriding the defaults.
const initialComponentState = {
...mockOptionsListComponentState,
...partialState?.componentState,
};
jest
.spyOn(optionsListStateModule, 'getDefaultComponentState')
.mockImplementation(() => initialComponentState);
const mockEmbeddable = (await optionsListControlFactory.create({
...mockOptionsListEmbeddableInput,
...partialState?.explicitInput,
})) as OptionsListEmbeddable;
mockEmbeddable.getOutput = jest.fn().mockReturnValue(mockOptionsListOutput);
const mockReduxEmbeddableTools = createReduxEmbeddableTools<OptionsListReduxState>({
embeddable: mockEmbeddable,
reducers: optionsListReducers,
initialComponentState: {
...mockOptionsListComponentState,
...partialState?.componentState,
},
});
return mockReduxEmbeddableTools;
return mockEmbeddable;
};

View file

@ -6,10 +6,6 @@
* Side Public License, v 1.
*/
import {
lazyLoadReduxEmbeddablePackage,
ReduxEmbeddablePackage,
} from '@kbn/presentation-util-plugin/public';
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { ControlOutput } from '../../types';
@ -17,17 +13,15 @@ import { ControlGroupInput } from '../types';
import { pluginServices } from '../../services';
import { DeleteControlAction } from './delete_control_action';
import { OptionsListEmbeddableInput } from '../../options_list';
import { controlGroupInputBuilder } from '../control_group_input_builder';
import { controlGroupInputBuilder } from '../external_api/control_group_input_builder';
import { ControlGroupContainer } from '../embeddable/control_group_container';
import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
let container: ControlGroupContainer;
let embeddable: OptionsListEmbeddable;
let reduxEmbeddablePackage: ReduxEmbeddablePackage;
beforeAll(async () => {
reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
controlGroupInputBuilder.addOptionsListControl(controlGroupInput, {
dataViewId: 'test-data-view',
@ -36,7 +30,7 @@ beforeAll(async () => {
width: 'medium',
grow: false,
});
container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
container = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await container.untilInitialized();
embeddable = container.getChild(container.getChildIds()[0]);
@ -53,7 +47,7 @@ test('Action is incompatible with Error Embeddables', async () => {
test('Execute throws an error when called with an embeddable not in a parent', async () => {
const deleteControlAction = new DeleteControlAction();
const optionsListEmbeddable = new OptionsListEmbeddable(
reduxEmbeddablePackage,
mockedReduxEmbeddablePackage,
{} as OptionsListEmbeddableInput,
{} as ControlOutput
);

View file

@ -6,10 +6,6 @@
* Side Public License, v 1.
*/
import {
lazyLoadReduxEmbeddablePackage,
ReduxEmbeddablePackage,
} from '@kbn/presentation-util-plugin/public';
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { ControlOutput } from '../../types';
@ -21,16 +17,11 @@ import { TimeSliderEmbeddableFactory } from '../../time_slider';
import { OptionsListEmbeddableFactory, OptionsListEmbeddableInput } from '../../options_list';
import { ControlGroupContainer } from '../embeddable/control_group_container';
import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable';
let reduxEmbeddablePackage: ReduxEmbeddablePackage;
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
const deleteControlAction = new DeleteControlAction();
beforeAll(async () => {
reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
});
test('Action is incompatible with Error Embeddables', async () => {
const editControlAction = new EditControlAction(deleteControlAction);
const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' });
@ -44,7 +35,7 @@ test('Action is incompatible with embeddables that are not editable', async () =
pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory;
const editControlAction = new EditControlAction(deleteControlAction);
const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const emptyContainer = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await emptyContainer.untilInitialized();
await emptyContainer.addTimeSliderControl();
@ -62,7 +53,7 @@ test('Action is compatible with embeddables that are editable', async () => {
pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory;
const editControlAction = new EditControlAction(deleteControlAction);
const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const emptyContainer = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await emptyContainer.untilInitialized();
await emptyContainer.addOptionsListControl({
dataViewId: 'test-data-view',
@ -82,7 +73,7 @@ test('Action is compatible with embeddables that are editable', async () => {
test('Execute throws an error when called with an embeddable not in a parent', async () => {
const editControlAction = new EditControlAction(deleteControlAction);
const optionsListEmbeddable = new OptionsListEmbeddable(
reduxEmbeddablePackage,
mockedReduxEmbeddablePackage,
{} as OptionsListEmbeddableInput,
{} as ControlOutput
);
@ -95,7 +86,7 @@ test('Execute should open a flyout', async () => {
const spyOn = jest.fn().mockResolvedValue(undefined);
pluginServices.getServices().overlays.openFlyout = spyOn;
const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
const emptyContainer = new ControlGroupContainer(mockedReduxEmbeddablePackage, controlGroupInput);
await emptyContainer.untilInitialized();
await emptyContainer.addOptionsListControl({
dataViewId: 'test-data-view',

View file

@ -19,7 +19,7 @@ import { DeleteControlAction } from './delete_control_action';
import { ControlGroupStrings } from '../control_group_strings';
import { ACTION_EDIT_CONTROL, ControlGroupContainer } from '..';
import { ControlEmbeddable, DataControlInput } from '../../types';
import { setFlyoutRef } from '../embeddable/control_group_container';
import { ControlGroupContainerContext, setFlyoutRef } from '../embeddable/control_group_container';
import { isControlGroup } from '../embeddable/control_group_helpers';
export interface EditControlActionContext {
@ -91,11 +91,10 @@ export class EditControlAction implements Action<EditControlActionContext> {
throw new IncompatibleActionError();
}
const controlGroup = embeddable.parent as ControlGroupContainer;
const ReduxWrapper = controlGroup.getReduxEmbeddableTools().Wrapper;
const flyoutInstance = this.openFlyout(
toMountPoint(
<ReduxWrapper>
<ControlGroupContainerContext.Provider value={controlGroup}>
<EditControlFlyout
embeddable={embeddable}
removeControl={() => this.deleteControlAction.execute({ embeddable })}
@ -104,7 +103,7 @@ export class EditControlAction implements Action<EditControlActionContext> {
flyoutInstance.close();
}}
/>
</ReduxWrapper>,
</ControlGroupContainerContext.Provider>,
{ theme$: this.theme$ }
),

View file

@ -14,7 +14,7 @@ import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public';
import { DataControlInput, ControlEmbeddable, IEditableControlFactory } from '../../types';
import { pluginServices } from '../../services';
import { ControlGroupStrings } from '../control_group_strings';
import { useControlGroupContainerContext } from '../control_group_renderer';
import { useControlGroupContainer } from '../embeddable/control_group_container';
import { ControlEditor } from '../editor/control_editor';
export const EditControlFlyout = ({
@ -32,17 +32,10 @@ export const EditControlFlyout = ({
controls: { getControlFactory },
} = pluginServices.getServices();
// Redux embeddable container Context
const reduxContext = useControlGroupContainerContext();
const {
embeddableInstance: controlGroup,
actions: { setControlWidth, setControlGrow },
useEmbeddableSelector,
useEmbeddableDispatch,
} = reduxContext;
const dispatch = useEmbeddableDispatch();
const controlGroup = useControlGroupContainer();
// current state
const panels = useEmbeddableSelector((state) => state.explicitInput.panels);
const panels = controlGroup.select((state) => state.explicitInput.panels);
const panel = panels[embeddable.id];
const [currentGrow, setCurrentGrow] = useState(panel.grow);
@ -86,9 +79,9 @@ export const EditControlFlyout = ({
}
if (currentWidth !== panel.width)
dispatch(setControlWidth({ width: currentWidth, embeddableId: embeddable.id }));
controlGroup.dispatch.setControlWidth({ width: currentWidth, embeddableId: embeddable.id });
if (currentGrow !== panel.grow)
dispatch(setControlGrow({ grow: currentGrow, embeddableId: embeddable.id }));
controlGroup.dispatch.setControlGrow({ grow: currentGrow, embeddableId: embeddable.id });
closeFlyout();
await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type);

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import React, { useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import {
EuiButtonEmpty,
EuiFormControlLayout,
@ -17,15 +18,16 @@ import {
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import { useReduxEmbeddableContext, FloatingActions } from '@kbn/presentation-util-plugin/public';
import { ControlGroupReduxState } from '../types';
import { FloatingActions } from '@kbn/presentation-util-plugin/public';
import {
controlGroupSelector,
useControlGroupContainer,
} from '../embeddable/control_group_container';
import { ControlGroupStrings } from '../control_group_strings';
import { useChildEmbeddable } from '../../hooks/use_child_embeddable';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlGroupContainer } from '..';
interface ControlFrameErrorProps {
error: Error;
@ -82,16 +84,11 @@ export const ControlFrame = ({
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const [fatalError, setFatalError] = useState<Error>();
const { useEmbeddableSelector: select, embeddableInstance: controlGroup } =
useReduxEmbeddableContext<
ControlGroupReduxState,
typeof controlGroupReducers,
ControlGroupContainer
>();
const controlGroup = useControlGroupContainer();
const viewMode = select((state) => state.explicitInput.viewMode);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const disabledActions = select((state) => state.explicitInput.disabledActions);
const controlStyle = controlGroupSelector((state) => state.explicitInput.controlStyle);
const viewMode = controlGroupSelector((state) => state.explicitInput.viewMode);
const disabledActions = controlGroupSelector((state) => state.explicitInput.disabledActions);
const embeddable = useChildEmbeddable({
untilEmbeddableLoaded: controlGroup.untilEmbeddableLoaded.bind(controlGroup),

View file

@ -8,9 +8,6 @@
import '../control_group.scss';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import React, { useMemo, useState } from 'react';
import classNames from 'classnames';
import {
arrayMove,
SortableContext,
@ -28,28 +25,28 @@ import {
useSensors,
LayoutMeasuringStrategy,
} from '@dnd-kit/core';
import classNames from 'classnames';
import React, { useMemo, useState } from 'react';
import { TypedUseSelectorHook, useSelector } from 'react-redux';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ControlClone, SortableControl } from './control_group_sortable_item';
import { useControlGroupContainerContext } from '../control_group_renderer';
import { ControlGroupReduxState } from '../types';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlClone, SortableControl } from './control_group_sortable_item';
import { useControlGroupContainer } from '../embeddable/control_group_container';
const contextSelect = useSelector as TypedUseSelectorHook<ControlGroupReduxState>;
export const ControlGroup = () => {
// Redux embeddable container Context
const reduxContext = useControlGroupContainerContext();
const {
embeddableInstance: controlGroup,
actions: { setControlOrders },
useEmbeddableSelector: select,
useEmbeddableDispatch,
} = reduxContext;
const dispatch = useEmbeddableDispatch();
const controlGroup = useControlGroupContainer();
// current state
const panels = select((state) => state.explicitInput.panels);
const viewMode = select((state) => state.explicitInput.viewMode);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const showAddButton = select((state) => state.componentState.showAddButton);
const panels = contextSelect((state) => state.explicitInput.panels);
const viewMode = contextSelect((state) => state.explicitInput.viewMode);
const controlStyle = contextSelect((state) => state.explicitInput.controlStyle);
const showAddButton = contextSelect((state) => state.componentState.showAddButton);
const isEditable = viewMode === ViewMode.EDIT;
@ -80,7 +77,9 @@ export const ControlGroup = () => {
const overIndex = idsInOrder.indexOf(over.id);
if (draggingIndex !== overIndex) {
const newIndex = overIndex;
dispatch(setControlOrders({ ids: arrayMove([...idsInOrder], draggingIndex, newIndex) }));
controlGroup.dispatch.setControlOrders({
ids: arrayMove([...idsInOrder], draggingIndex, newIndex),
});
}
}
setDraggingId(null);

View file

@ -6,16 +6,15 @@
* Side Public License, v 1.
*/
import { EuiFlexItem, EuiFormLabel, EuiIcon, EuiFlexGroup } from '@elastic/eui';
import React, { forwardRef, HTMLAttributes } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import classNames from 'classnames';
import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
import React, { forwardRef, HTMLAttributes } from 'react';
import { EuiFlexItem, EuiFormLabel, EuiIcon, EuiFlexGroup } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { ControlFrame, ControlFrameProps } from './control_frame_component';
import { ControlGroupReduxState } from '../types';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlFrame, ControlFrameProps } from './control_frame_component';
import { controlGroupSelector } from '../embeddable/control_group_container';
interface DragInfo {
isOver?: boolean;
@ -70,8 +69,7 @@ const SortableControlInner = forwardRef<
dragHandleRef
) => {
const { isOver, isDragging, draggingIndex, index } = dragInfo;
const { useEmbeddableSelector } = useReduxEmbeddableContext<ControlGroupReduxState>();
const panels = useEmbeddableSelector((state) => state.explicitInput.panels);
const panels = controlGroupSelector((state) => state.explicitInput.panels);
const grow = panels[embeddableId].grow;
const width = panels[embeddableId].width;
@ -122,9 +120,8 @@ const SortableControlInner = forwardRef<
* can be quite cumbersome.
*/
export const ControlClone = ({ draggingId }: { draggingId: string }) => {
const { useEmbeddableSelector: select } = useReduxEmbeddableContext<ControlGroupReduxState>();
const panels = select((state) => state.explicitInput.panels);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const panels = controlGroupSelector((state) => state.explicitInput.panels);
const controlStyle = controlGroupSelector((state) => state.explicitInput.controlStyle);
const width = panels[draggingId].width;
const title = panels[draggingId].explicitInput.title;

View file

@ -1,128 +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 { v4 as uuidv4 } from 'uuid';
import { isEqual } from 'lodash';
import useLifecycles from 'react-use/lib/useLifecycles';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { compareFilters } from '@kbn/es-query';
import type { Filter, TimeRange, Query } from '@kbn/es-query';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import {
ControlGroupCreationOptions,
ControlGroupInput,
ControlGroupOutput,
ControlGroupReduxState,
CONTROL_GROUP_TYPE,
} from './types';
import { pluginServices } from '../services';
import { getDefaultControlGroupInput } from '../../common';
import { controlGroupReducers } from './state/control_group_reducers';
import { controlGroupInputBuilder } from './control_group_input_builder';
import { ControlGroupContainer } from './embeddable/control_group_container';
import { ControlGroupContainerFactory } from './embeddable/control_group_container_factory';
export interface ControlGroupRendererProps {
filters?: Filter[];
getCreationOptions: (
initialInput: Partial<ControlGroupInput>,
builder: typeof controlGroupInputBuilder
) => Promise<ControlGroupCreationOptions>;
onLoadComplete?: (controlGroup: ControlGroupContainer) => void;
timeRange?: TimeRange;
query?: Query;
}
export const ControlGroupRenderer = ({
onLoadComplete,
getCreationOptions,
filters,
timeRange,
query,
}: ControlGroupRendererProps) => {
const controlGroupRef = useRef(null);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
const id = useMemo(() => uuidv4(), []);
/**
* Use Lifecycles to load initial control group container
*/
useLifecycles(
// onMount
() => {
const { embeddable } = pluginServices.getServices();
(async () => {
const factory = embeddable.getEmbeddableFactory(CONTROL_GROUP_TYPE) as EmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
ControlGroupContainer
> & {
create: ControlGroupContainerFactory['create'];
};
const { initialInput, settings } = await getCreationOptions(
getDefaultControlGroupInput(),
controlGroupInputBuilder
);
const newControlGroup = (await factory?.create(
{
id,
...getDefaultControlGroupInput(),
...initialInput,
},
undefined,
settings
)) as ControlGroupContainer;
if (controlGroupRef.current) {
newControlGroup.render(controlGroupRef.current);
}
setControlGroup(newControlGroup);
if (onLoadComplete) {
onLoadComplete(newControlGroup);
}
})();
},
// onUnmount
() => {
controlGroup?.destroy();
}
);
useEffect(() => {
if (!controlGroup) {
return;
}
if (
(timeRange && !isEqual(controlGroup.getInput().timeRange, timeRange)) ||
!compareFilters(controlGroup.getInput().filters ?? [], filters ?? []) ||
!isEqual(controlGroup.getInput().query, query)
) {
controlGroup.updateInput({
timeRange,
query,
filters,
});
}
}, [query, filters, controlGroup, timeRange]);
return <div ref={controlGroupRef} />;
};
export const useControlGroupContainerContext = () =>
useReduxEmbeddableContext<
ControlGroupReduxState,
typeof controlGroupReducers,
ControlGroupContainer
>();
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default ControlGroupRenderer;

View file

@ -53,7 +53,8 @@ import {
import { CONTROL_WIDTH_OPTIONS } from './editor_constants';
import { pluginServices } from '../../services';
import { getDataControlFieldRegistry } from './data_control_editor_tools';
import { useControlGroupContainerContext } from '../control_group_renderer';
import { useControlGroupContainer } from '../embeddable/control_group_container';
interface EditControlProps {
embeddable?: ControlEmbeddable<DataControlInput>;
isCreate: boolean;
@ -95,9 +96,11 @@ export const ControlEditor = ({
controls: { getControlFactory },
} = pluginServices.getServices();
const { useEmbeddableSelector: select } = useControlGroupContainerContext();
const editorConfig = select((state) => state.componentState.editorConfig);
const customFilterPredicate = select((state) => state.componentState.fieldFilterPredicate);
const controlGroup = useControlGroupContainer();
const editorConfig = controlGroup.select((state) => state.componentState.editorConfig);
const customFilterPredicate = controlGroup.select(
(state) => state.componentState.fieldFilterPredicate
);
const [defaultTitle, setDefaultTitle] = useState<string>();
const [currentTitle, setCurrentTitle] = useState(title ?? '');

View file

@ -8,22 +8,27 @@
import React from 'react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { ControlInputTransform } from '../../../common/types';
import {
ControlGroupContainer,
ControlGroupContainerContext,
setFlyoutRef,
} from '../embeddable/control_group_container';
import type {
AddDataControlProps,
AddOptionsListControlProps,
AddRangeSliderControlProps,
} from '../control_group_input_builder';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container';
import { pluginServices } from '../../services';
import { ControlEditor } from './control_editor';
import { DataControlInput, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../..';
import { IEditableControlFactory } from '../../types';
} from '../external_api/control_group_input_builder';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '../../../common/control_group/control_group_constants';
import { pluginServices } from '../../services';
import { ControlEditor } from './control_editor';
import { IEditableControlFactory } from '../../types';
import { ControlInputTransform } from '../../../common/types';
import { ControlGroupStrings } from '../control_group_strings';
import { DataControlInput, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../..';
export function openAddDataControlFlyout(
this: ControlGroupContainer,
@ -34,8 +39,6 @@ export function openAddDataControlFlyout(
controls: { getControlFactory },
theme: { theme$ },
} = pluginServices.getServices();
const ControlsServicesProvider = pluginServices.getContextProvider();
const ReduxWrapper = this.getReduxEmbeddableTools().Wrapper;
let controlInput: Partial<DataControlInput> = {};
const onCancel = () => {
@ -58,51 +61,49 @@ export function openAddDataControlFlyout(
const flyoutInstance = openFlyout(
toMountPoint(
<ControlsServicesProvider>
<ReduxWrapper>
<ControlEditor
setLastUsedDataViewId={(newId) => this.setLastUsedDataViewId(newId)}
getRelevantDataViewId={this.getMostRelevantDataViewId}
isCreate={true}
width={this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
grow={this.getInput().defaultControlGrow ?? DEFAULT_CONTROL_GROW}
updateTitle={(newTitle) => (controlInput.title = newTitle)}
updateWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })}
updateGrow={(defaultControlGrow: boolean) => this.updateInput({ defaultControlGrow })}
onSave={(type) => {
this.closeAllFlyouts();
if (!type) {
return;
}
const factory = getControlFactory(type) as IEditableControlFactory;
if (factory.presaveTransformFunction) {
controlInput = factory.presaveTransformFunction(controlInput);
}
if (controlInputTransform) {
controlInput = controlInputTransform({ ...controlInput }, type);
}
if (type === OPTIONS_LIST_CONTROL) {
this.addOptionsListControl(controlInput as AddOptionsListControlProps);
return;
}
if (type === RANGE_SLIDER_CONTROL) {
this.addRangeSliderControl(controlInput as AddRangeSliderControlProps);
return;
}
this.addDataControlFromField(controlInput as AddDataControlProps);
}}
onCancel={onCancel}
onTypeEditorChange={(partialInput) =>
(controlInput = { ...controlInput, ...partialInput })
<ControlGroupContainerContext.Provider value={this}>
<ControlEditor
setLastUsedDataViewId={(newId) => this.setLastUsedDataViewId(newId)}
getRelevantDataViewId={this.getMostRelevantDataViewId}
isCreate={true}
width={this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
grow={this.getInput().defaultControlGrow ?? DEFAULT_CONTROL_GROW}
updateTitle={(newTitle) => (controlInput.title = newTitle)}
updateWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })}
updateGrow={(defaultControlGrow: boolean) => this.updateInput({ defaultControlGrow })}
onSave={(type) => {
this.closeAllFlyouts();
if (!type) {
return;
}
/>
</ReduxWrapper>
</ControlsServicesProvider>,
const factory = getControlFactory(type) as IEditableControlFactory;
if (factory.presaveTransformFunction) {
controlInput = factory.presaveTransformFunction(controlInput);
}
if (controlInputTransform) {
controlInput = controlInputTransform({ ...controlInput }, type);
}
if (type === OPTIONS_LIST_CONTROL) {
this.addOptionsListControl(controlInput as AddOptionsListControlProps);
return;
}
if (type === RANGE_SLIDER_CONTROL) {
this.addRangeSliderControl(controlInput as AddRangeSliderControlProps);
return;
}
this.addDataControlFromField(controlInput as AddDataControlProps);
}}
onCancel={onCancel}
onTypeEditorChange={(partialInput) =>
(controlInput = { ...controlInput, ...partialInput })
}
/>
</ControlGroupContainerContext.Provider>,
{ theme$ }
),
{

View file

@ -13,14 +13,17 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { pluginServices } from '../../services';
import { ControlGroupEditor } from './control_group_editor';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container';
import {
ControlGroupContainer,
ControlGroupContainerContext,
setFlyoutRef,
} from '../embeddable/control_group_container';
export function openEditControlGroupFlyout(this: ControlGroupContainer) {
const {
overlays: { openFlyout, openConfirm },
theme: { theme$ },
} = pluginServices.getServices();
const ReduxWrapper = this.getReduxEmbeddableTools().Wrapper;
const onDeleteAll = (ref: OverlayRef) => {
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
@ -37,7 +40,7 @@ export function openEditControlGroupFlyout(this: ControlGroupContainer) {
const flyoutInstance = openFlyout(
toMountPoint(
<ReduxWrapper>
<ControlGroupContainerContext.Provider value={this}>
<ControlGroupEditor
initialInput={this.getInput()}
updateInput={(changes) => this.updateInput(changes)}
@ -45,7 +48,7 @@ export function openEditControlGroupFlyout(this: ControlGroupContainer) {
onDeleteAll={() => onDeleteAll(flyoutInstance)}
onClose={() => flyoutInstance.close()}
/>
</ReduxWrapper>,
</ControlGroupContainerContext.Provider>,
{ theme$ }
),
{

View file

@ -35,7 +35,7 @@ interface OnChildChangedProps {
interface ChainingSystem {
getContainerSettings: (
initialInput: ControlGroupInput
) => EmbeddableContainerSettings<ControlGroupInput> | undefined;
) => EmbeddableContainerSettings | undefined;
getPrecedingFilters: (
props: GetPrecedingFiltersProps
) => { filters: Filter[]; timeslice?: [number, number] } | undefined;

View file

@ -5,18 +5,19 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { skip, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import React from 'react';
import { isEqual } from 'lodash';
import ReactDOM from 'react-dom';
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/es-query';
import { Provider, TypedUseSelectorHook, useSelector } from 'react-redux';
import React, { createContext, useContext } from 'react';
import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
import _ from 'lodash';
import { skip, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/es-query';
import { ReduxEmbeddablePackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import { OverlayRef } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import {
ControlGroupInput,
ControlGroupOutput,
@ -31,23 +32,21 @@ import {
ControlGroupChainingSystems,
controlOrdersAreEqual,
} from './control_group_chaining_system';
import { pluginServices } from '../../services';
import { openAddDataControlFlyout } from '../editor/open_add_data_control_flyout';
import { ControlGroup } from '../component/control_group_component';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
import { getNextPanelOrder } from './control_group_helpers';
import type {
AddDataControlProps,
AddOptionsListControlProps,
AddRangeSliderControlProps,
} from '../control_group_input_builder';
import {
type AddDataControlProps,
type AddOptionsListControlProps,
type AddRangeSliderControlProps,
getDataControlPanelState,
getOptionsListPanelState,
getRangeSliderPanelState,
getTimeSliderPanelState,
} from '../control_group_input_builder';
} from '../external_api/control_group_input_builder';
import { pluginServices } from '../../services';
import { getNextPanelOrder } from './control_group_helpers';
import { ControlGroup } from '../component/control_group_component';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
import { openAddDataControlFlyout } from '../editor/open_add_data_control_flyout';
import { openEditControlGroupFlyout } from '../editor/open_edit_control_group_flyout';
let flyoutRef: OverlayRef | undefined;
@ -55,6 +54,21 @@ export const setFlyoutRef = (newRef: OverlayRef | undefined) => {
flyoutRef = newRef;
};
export const ControlGroupContainerContext = createContext<ControlGroupContainer | null>(null);
export const controlGroupSelector = useSelector as TypedUseSelectorHook<ControlGroupReduxState>;
export const useControlGroupContainer = (): ControlGroupContainer => {
const controlGroup = useContext<ControlGroupContainer | null>(ControlGroupContainerContext);
if (controlGroup == null) {
throw new Error('useControlGroupContainer must be used inside ControlGroupContainerContext.');
}
return controlGroup!;
};
type ControlGroupReduxEmbeddableTools = ReduxEmbeddableTools<
ControlGroupReduxState,
typeof controlGroupReducers
>;
export class ControlGroupContainer extends Container<
ControlInput,
ControlGroupInput,
@ -71,63 +85,21 @@ export class ControlGroupContainer extends Container<
private relevantDataViewId?: string;
private lastUsedDataViewId?: string;
private reduxEmbeddableTools: ReduxEmbeddableTools<
ControlGroupReduxState,
typeof controlGroupReducers
>;
// state management
public select: ControlGroupReduxEmbeddableTools['select'];
public getState: ControlGroupReduxEmbeddableTools['getState'];
public dispatch: ControlGroupReduxEmbeddableTools['dispatch'];
public onStateChange: ControlGroupReduxEmbeddableTools['onStateChange'];
private store: ControlGroupReduxEmbeddableTools['store'];
private cleanupStateTools: () => void;
public onFiltersPublished$: Subject<Filter[]>;
public onControlRemoved$: Subject<string>;
public setLastUsedDataViewId = (lastUsedDataViewId: string) => {
this.lastUsedDataViewId = lastUsedDataViewId;
};
public setRelevantDataViewId = (newRelevantDataViewId: string) => {
this.relevantDataViewId = newRelevantDataViewId;
};
public getMostRelevantDataViewId = () => {
const staticDataViewId =
this.getReduxEmbeddableTools().getState().componentState.staticDataViewId;
return staticDataViewId ?? this.lastUsedDataViewId ?? this.relevantDataViewId;
};
public getReduxEmbeddableTools = () => {
return this.reduxEmbeddableTools;
};
public closeAllFlyouts() {
flyoutRef?.close();
flyoutRef = undefined;
}
public async addDataControlFromField(controlProps: AddDataControlProps) {
const panelState = await getDataControlPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public addOptionsListControl(controlProps: AddOptionsListControlProps) {
const panelState = getOptionsListPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public addRangeSliderControl(controlProps: AddRangeSliderControlProps) {
const panelState = getRangeSliderPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public addTimeSliderControl() {
const panelState = getTimeSliderPanelState(this.getInput());
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public openAddDataControlFlyout = openAddDataControlFlyout;
public openEditControlGroupFlyout = openEditControlGroupFlyout;
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
reduxToolsPackage: ReduxToolsPackage,
initialInput: ControlGroupInput,
parent?: Container,
settings?: ControlGroupSettings
@ -145,7 +117,7 @@ export class ControlGroupContainer extends Container<
this.onControlRemoved$ = new Subject<string>();
// build redux embeddable tools
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools<
ControlGroupReduxState,
typeof controlGroupReducers
>({
@ -154,6 +126,14 @@ export class ControlGroupContainer extends Container<
initialComponentState: settings,
});
this.select = reduxEmbeddableTools.select;
this.getState = reduxEmbeddableTools.getState;
this.dispatch = reduxEmbeddableTools.dispatch;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.store = reduxEmbeddableTools.store;
// when all children are ready setup subscriptions
this.untilAllChildrenReady().then(() => {
this.recalculateDataViews();
@ -204,6 +184,47 @@ export class ControlGroupContainer extends Container<
);
};
public setLastUsedDataViewId = (lastUsedDataViewId: string) => {
this.lastUsedDataViewId = lastUsedDataViewId;
};
public setRelevantDataViewId = (newRelevantDataViewId: string) => {
this.relevantDataViewId = newRelevantDataViewId;
};
public getMostRelevantDataViewId = () => {
return this.lastUsedDataViewId ?? this.relevantDataViewId;
};
public closeAllFlyouts() {
flyoutRef?.close();
flyoutRef = undefined;
}
public async addDataControlFromField(controlProps: AddDataControlProps) {
const panelState = await getDataControlPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public addOptionsListControl(controlProps: AddOptionsListControlProps) {
const panelState = getOptionsListPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public addRangeSliderControl(controlProps: AddRangeSliderControlProps) {
const panelState = getRangeSliderPanelState(this.getInput(), controlProps);
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public addTimeSliderControl() {
const panelState = getTimeSliderPanelState(this.getInput());
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
public openAddDataControlFlyout = openAddDataControlFlyout;
public openEditControlGroupFlyout = openEditControlGroupFlyout;
public getPanelCount = () => {
return Object.keys(this.getInput().panels).length;
};
@ -225,7 +246,7 @@ export class ControlGroupContainer extends Container<
// if filters are different, publish them
if (
!compareFilters(this.output.filters ?? [], allFilters ?? [], COMPARE_ALL_OPTIONS) ||
!_.isEqual(this.output.timeslice, timeslice)
!isEqual(this.output.timeslice, timeslice)
) {
this.updateOutput({ filters: uniqFilters(allFilters), timeslice });
this.onFiltersPublished$.next(allFilters);
@ -337,15 +358,13 @@ export class ControlGroupContainer extends Container<
ReactDOM.unmountComponentAtNode(this.domNode);
}
this.domNode = dom;
const ControlsServicesProvider = pluginServices.getContextProvider();
const { Wrapper: ControlGroupReduxWrapper } = this.reduxEmbeddableTools;
ReactDOM.render(
<KibanaThemeProvider theme$={pluginServices.getServices().theme.theme$}>
<ControlsServicesProvider>
<ControlGroupReduxWrapper>
<Provider store={this.store}>
<ControlGroupContainerContext.Provider value={this}>
<ControlGroup />
</ControlGroupReduxWrapper>
</ControlsServicesProvider>
</ControlGroupContainerContext.Provider>
</Provider>
</KibanaThemeProvider>,
dom
);
@ -355,7 +374,7 @@ export class ControlGroupContainer extends Container<
super.destroy();
this.closeAllFlyouts();
this.subscriptions.unsubscribe();
this.reduxEmbeddableTools.cleanup();
this.cleanupStateTools();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
}
}

View file

@ -16,7 +16,7 @@
import { i18n } from '@kbn/i18n';
import { Container, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import { ControlGroupInput, ControlGroupSettings, CONTROL_GROUP_TYPE } from '../types';
@ -54,7 +54,7 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition
parent?: Container,
settings?: ControlGroupSettings
) => {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const { ControlGroupContainer } = await import('./control_group_container');
return new ControlGroupContainer(reduxEmbeddablePackage, initialInput, parent, settings);
};

View file

@ -0,0 +1,16 @@
/*
* 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 { ControlGroupContainer } from '..';
// TODO lock down ControlGroupAPI
export type ControlGroupAPI = ControlGroupContainer;
export type AwaitingControlGroupAPI = ControlGroupAPI | null;
export const buildApiFromControlGroupContainer = (container?: ControlGroupContainer) =>
container ?? null;

View file

@ -8,22 +8,22 @@
import { i18n } from '@kbn/i18n';
import { v4 as uuidv4 } from 'uuid';
import { ControlPanelState, OptionsListEmbeddableInput } from '../../common';
import {
ControlPanelState,
ControlWidth,
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
TIME_SLIDER_CONTROL,
} from '../../../common';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '../../common/control_group/control_group_constants';
import { RangeValue } from '../../common/range_slider/types';
import {
ControlInput,
ControlWidth,
DataControlInput,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
TIME_SLIDER_CONTROL,
} from '..';
import { ControlGroupInput } from './types';
import { getCompatibleControlType, getNextPanelOrder } from './embeddable/control_group_helpers';
} from '../../../common/control_group/control_group_constants';
import { ControlGroupInput } from '../types';
import { ControlInput, DataControlInput } from '../../types';
import { RangeValue, RANGE_SLIDER_CONTROL } from '../../../common/range_slider/types';
import { getCompatibleControlType, getNextPanelOrder } from '../embeddable/control_group_helpers';
export interface AddDataControlProps {
controlId?: string;
@ -40,6 +40,8 @@ export type AddRangeSliderControlProps = AddDataControlProps & {
value?: RangeValue;
};
export type ControlGroupInputBuilder = typeof controlGroupInputBuilder;
export const controlGroupInputBuilder = {
addDataControlFromField: async (
initialInput: Partial<ControlGroupInput>,

View file

@ -0,0 +1,129 @@
/*
* 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 React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import {
ControlGroupContainer,
ControlGroupContainerFactory,
ControlGroupRenderer,
CONTROL_GROUP_TYPE,
} from '..';
import { pluginServices } from '../../services/plugin_services';
import { ReactWrapper } from 'enzyme';
import { Filter } from '@kbn/es-query';
describe('control group renderer', () => {
let mockControlGroupFactory: ControlGroupContainerFactory;
let mockControlGroupContainer: ControlGroupContainer;
beforeEach(() => {
mockControlGroupContainer = {
destroy: jest.fn(),
render: jest.fn(),
updateInput: jest.fn(),
getInput: jest.fn().mockReturnValue({}),
} as unknown as ControlGroupContainer;
mockControlGroupFactory = {
create: jest.fn().mockReturnValue(mockControlGroupContainer),
} as unknown as ControlGroupContainerFactory;
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(mockControlGroupFactory);
});
test('calls create method on the Control Group embeddable factory with returned initial input', async () => {
await act(async () => {
mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { controlStyle: 'twoLine' } })}
/>
);
});
expect(pluginServices.getServices().embeddable.getEmbeddableFactory).toHaveBeenCalledWith(
CONTROL_GROUP_TYPE
);
expect(mockControlGroupFactory.create).toHaveBeenCalledWith(
expect.objectContaining({ controlStyle: 'twoLine' }),
undefined,
undefined
);
});
test('destroys control group container on unmount', async () => {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = await mountWithIntl(<ControlGroupRenderer />);
});
wrapper!.unmount();
expect(mockControlGroupContainer.destroy).toHaveBeenCalledTimes(1);
});
test('filter changes are dispatched to control group if they are different', async () => {
const initialFilters: Filter[] = [
{ meta: { alias: 'test', disabled: false, negate: false, index: 'test' } },
];
const updatedFilters: Filter[] = [
{ meta: { alias: 'test', disabled: false, negate: true, index: 'test' } },
];
let wrapper: ReactWrapper;
await act(async () => {
wrapper = mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { filters: initialFilters } })}
/>
);
});
await act(async () => {
await wrapper.setProps({ filters: updatedFilters });
});
expect(mockControlGroupContainer.updateInput).toHaveBeenCalledWith(
expect.objectContaining({ filters: updatedFilters })
);
});
test('query changes are dispatched to control group if they are different', async () => {
const initialQuery = { language: 'kql', query: 'query' };
const updatedQuery = { language: 'kql', query: 'super query' };
let wrapper: ReactWrapper;
await act(async () => {
wrapper = mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { query: initialQuery } })}
/>
);
});
await act(async () => {
await wrapper.setProps({ query: updatedQuery });
});
expect(mockControlGroupContainer.updateInput).toHaveBeenCalledWith(
expect.objectContaining({ query: updatedQuery })
);
});
test('time range changes are dispatched to control group if they are different', async () => {
const initialTime = { from: new Date().toISOString(), to: new Date().toISOString() };
const updatedTime = { from: new Date().toISOString() + 10, to: new Date().toISOString() + 20 };
let wrapper: ReactWrapper;
await act(async () => {
wrapper = mountWithIntl(
<ControlGroupRenderer
getCreationOptions={() => Promise.resolve({ initialInput: { timeRange: initialTime } })}
/>
);
});
await act(async () => {
await wrapper.setProps({ timeRange: updatedTime });
});
expect(mockControlGroupContainer.updateInput).toHaveBeenCalledWith(
expect.objectContaining({ timeRange: updatedTime })
);
});
});

View file

@ -0,0 +1,130 @@
/*
* 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 React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { isEqual } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { compareFilters } from '@kbn/es-query';
import type { Filter, TimeRange, Query } from '@kbn/es-query';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import {
ControlGroupInput,
CONTROL_GROUP_TYPE,
ControlGroupOutput,
ControlGroupCreationOptions,
} from '../types';
import {
ControlGroupAPI,
AwaitingControlGroupAPI,
buildApiFromControlGroupContainer,
} from './control_group_api';
import { getDefaultControlGroupInput } from '../../../common';
import { controlGroupInputBuilder, ControlGroupInputBuilder } from './control_group_input_builder';
import { ControlGroupContainer } from '../embeddable/control_group_container';
import { ControlGroupContainerFactory } from '../embeddable/control_group_container_factory';
export interface ControlGroupRendererProps {
filters?: Filter[];
getCreationOptions?: (
initialInput: Partial<ControlGroupInput>,
builder: ControlGroupInputBuilder
) => Promise<ControlGroupCreationOptions>;
timeRange?: TimeRange;
query?: Query;
}
export const ControlGroupRenderer = forwardRef<AwaitingControlGroupAPI, ControlGroupRendererProps>(
({ getCreationOptions, filters, timeRange, query }, ref) => {
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
useImperativeHandle(
ref,
() => buildApiFromControlGroupContainer(controlGroup) as ControlGroupAPI,
[controlGroup]
);
const controlGroupDomRef = useRef(null);
const id = useMemo(() => uuidv4(), []);
// onMount
useEffect(() => {
let canceled = false;
let destroyControlGroup: () => void;
(async () => {
// Lazy loading all services is required in this component because it is exported and contributes to the bundle size.
const { pluginServices } = await import('../../services/plugin_services');
const { embeddable } = pluginServices.getServices();
const factory = embeddable.getEmbeddableFactory(CONTROL_GROUP_TYPE) as EmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
ControlGroupContainer
> & {
create: ControlGroupContainerFactory['create'];
};
const { initialInput, settings } =
(await getCreationOptions?.(getDefaultControlGroupInput(), controlGroupInputBuilder)) ??
{};
const newControlGroup = (await factory?.create(
{
id,
...getDefaultControlGroupInput(),
...initialInput,
},
undefined,
settings
)) as ControlGroupContainer;
if (canceled) {
newControlGroup.destroy();
controlGroup?.destroy();
return;
}
if (controlGroupDomRef.current) {
newControlGroup.render(controlGroupDomRef.current);
}
setControlGroup(newControlGroup);
destroyControlGroup = () => newControlGroup.destroy();
})();
return () => {
canceled = true;
destroyControlGroup?.();
};
// exhaustive deps disabled because we want the control group to be created only on first render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!controlGroup) return;
if (
(timeRange && !isEqual(controlGroup.getInput().timeRange, timeRange)) ||
!compareFilters(controlGroup.getInput().filters ?? [], filters ?? []) ||
!isEqual(controlGroup.getInput().query, query)
) {
controlGroup.updateInput({
timeRange,
query,
filters,
});
}
}, [query, filters, controlGroup, timeRange]);
return <div ref={controlGroupDomRef} />;
}
);

View file

@ -6,8 +6,6 @@
* Side Public License, v 1.
*/
import React from 'react';
export type { ControlGroupContainer } from './embeddable/control_group_container';
export type { ControlGroupInput, ControlGroupOutput } from './types';
@ -19,12 +17,14 @@ export { ACTION_EDIT_CONTROL, ACTION_DELETE_CONTROL } from './actions';
export {
type AddDataControlProps,
type AddOptionsListControlProps,
type ControlGroupInputBuilder,
type AddRangeSliderControlProps,
controlGroupInputBuilder,
} from './control_group_input_builder';
} from './external_api/control_group_input_builder';
export type { ControlGroupAPI, AwaitingControlGroupAPI } from './external_api/control_group_api';
export {
type ControlGroupRendererProps,
useControlGroupContainerContext,
} from './control_group_renderer';
export const LazyControlGroupRenderer = React.lazy(() => import('./control_group_renderer'));
ControlGroupRenderer,
} from './external_api/control_group_renderer';

View file

@ -19,6 +19,12 @@ export const controlGroupReducers = {
) => {
state.explicitInput.controlStyle = action.payload;
},
setChainingSystem: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['chainingSystem']>
) => {
state.explicitInput.chainingSystem = action.payload;
},
setDefaultControlWidth: (
state: WritableDraft<ControlGroupReduxState>,
action: PayloadAction<ControlGroupInput['defaultControlWidth']>

View file

@ -39,8 +39,11 @@ export {
type ControlGroupContainer,
ControlGroupContainerFactory,
type ControlGroupInput,
controlGroupInputBuilder,
type ControlGroupInputBuilder,
type ControlGroupAPI,
type AwaitingControlGroupAPI,
type ControlGroupOutput,
controlGroupInputBuilder,
} from './control_group';
export {
@ -56,11 +59,10 @@ export {
} from './range_slider';
export {
LazyControlGroupRenderer,
useControlGroupContainerContext,
type ControlGroupRendererProps,
ACTION_DELETE_CONTROL,
ACTION_EDIT_CONTROL,
ACTION_DELETE_CONTROL,
ControlGroupRenderer,
type ControlGroupRendererProps,
} from './control_group';
export function plugin() {

View file

@ -11,9 +11,10 @@ import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks';
import { mockOptionsListEmbeddable } from '../../../common/mocks';
import { OptionsListControl } from './options_list_control';
import { BehaviorSubject } from 'rxjs';
@ -30,16 +31,16 @@ describe('Options list control', () => {
}
async function mountComponent(options?: Partial<MountOptions>) {
const mockReduxEmbeddableTools = await mockOptionsListReduxEmbeddableTools({
const optionsListEmbeddable = await mockOptionsListEmbeddable({
componentState: options?.componentState ?? {},
explicitInput: options?.explicitInput ?? {},
output: options?.output ?? {},
} as Partial<OptionsListReduxState>);
return mountWithIntl(
<mockReduxEmbeddableTools.Wrapper>
<OptionsListEmbeddableContext.Provider value={optionsListEmbeddable}>
<OptionsListControl {...defaultProps} />
</mockReduxEmbeddableTools.Wrapper>
</OptionsListEmbeddableContext.Provider>
);
}

View file

@ -12,12 +12,11 @@ import { debounce, isEmpty } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { OptionsListPopover } from './options_list_popover';
import { optionsListReducers } from '../options_list_reducers';
import { MAX_OPTIONS_LIST_REQUEST_SIZE, OptionsListReduxState } from '../types';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import './options_list.scss';
@ -29,38 +28,29 @@ export const OptionsListControl = ({
loadMoreSubject: Subject<number>;
}) => {
const resizeRef = useRef(null);
const optionsList = useOptionsList();
const dimensions = useResizeObserver(resizeRef.current);
// Redux embeddable Context
const {
useEmbeddableDispatch,
actions: { replaceSelection, setSearchString, setPopoverOpen },
useEmbeddableSelector: select,
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
const isPopoverOpen = optionsList.select((state) => state.componentState.popoverOpen);
const validSelections = optionsList.select((state) => state.componentState.validSelections);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
// 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 isPopoverOpen = select((state) => state.componentState.popoverOpen);
const id = optionsList.select((state) => state.explicitInput.id);
const exclude = optionsList.select((state) => state.explicitInput.exclude);
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const placeholder = optionsList.select((state) => state.explicitInput.placeholder);
const controlStyle = optionsList.select((state) => state.explicitInput.controlStyle);
const singleSelect = optionsList.select((state) => state.explicitInput.singleSelect);
const existsSelected = optionsList.select((state) => state.explicitInput.existsSelected);
const selectedOptions = optionsList.select((state) => state.explicitInput.selectedOptions);
const selectedOptions = select((state) => state.explicitInput.selectedOptions);
const existsSelected = select((state) => state.explicitInput.existsSelected);
const controlStyle = select((state) => state.explicitInput.controlStyle);
const singleSelect = select((state) => state.explicitInput.singleSelect);
const fieldName = select((state) => state.explicitInput.fieldName);
const exclude = select((state) => state.explicitInput.exclude);
const id = select((state) => state.explicitInput.id);
const placeholder = select((state) => state.explicitInput.placeholder);
const loading = select((state) => state.output.loading);
const loading = optionsList.select((state) => state.output.loading);
useEffect(() => {
return () => {
dispatch(setPopoverOpen(false)); // on unmount, close the popover
optionsList.dispatch.setPopoverOpen(false); // on unmount, close the popover
};
}, [dispatch, setPopoverOpen]);
}, [optionsList]);
// debounce loading state so loading doesn't flash when user types
const [debouncedLoading, setDebouncedLoading] = useState(true);
@ -76,16 +66,16 @@ export const OptionsListControl = ({
// remove all other selections if this control is single select
useEffect(() => {
if (singleSelect && selectedOptions && selectedOptions?.length > 1) {
dispatch(replaceSelection(selectedOptions[0]));
optionsList.dispatch.replaceSelection(selectedOptions[0]);
}
}, [selectedOptions, singleSelect, dispatch, replaceSelection]);
}, [selectedOptions, singleSelect, optionsList.dispatch]);
const updateSearchString = useCallback(
(newSearchString: string) => {
typeaheadSubject.next(newSearchString);
dispatch(setSearchString(newSearchString));
optionsList.dispatch.setSearchString(newSearchString);
},
[typeaheadSubject, dispatch, setSearchString]
[typeaheadSubject, optionsList.dispatch]
);
const loadMoreSuggestions = useCallback(
@ -141,7 +131,7 @@ export const OptionsListControl = ({
'optionsList--filterBtnPlaceholder': !hasSelections,
})}
data-test-subj={`optionsList-control-${id}`}
onClick={() => dispatch(setPopoverOpen(!isPopoverOpen))}
onClick={() => optionsList.dispatch.setPopoverOpen(!isPopoverOpen)}
isSelected={isPopoverOpen}
numActiveFilters={validSelectionsCount}
hasActiveFilters={Boolean(validSelectionsCount)}
@ -168,7 +158,7 @@ export const OptionsListControl = ({
anchorPosition="downCenter"
initialFocus={'[data-test-subj=optionsList-control-search-input]'}
className="optionsList__popoverOverride"
closePopover={() => dispatch(setPopoverOpen(false))}
closePopover={() => optionsList.dispatch.setPopoverOpen(false)}
anchorClassName="optionsList__anchorOverride"
aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)}
>

View file

@ -13,10 +13,12 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks';
import { mockOptionsListEmbeddable } from '../../../common/mocks';
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
import { pluginServices } from '../../services';
describe('Options list popover', () => {
const defaultProps = {
@ -35,16 +37,16 @@ describe('Options list popover', () => {
async function mountComponent(options?: Partial<MountOptions>) {
const compProps = { ...defaultProps, ...(options?.popoverProps ?? {}) };
const mockReduxEmbeddableTools = await mockOptionsListReduxEmbeddableTools({
const optionsListEmbeddable = await mockOptionsListEmbeddable({
componentState: options?.componentState ?? {},
explicitInput: options?.explicitInput ?? {},
output: options?.output ?? {},
} as Partial<OptionsListReduxState>);
return mountWithIntl(
<mockReduxEmbeddableTools.Wrapper>
<OptionsListEmbeddableContext.Provider value={optionsListEmbeddable}>
<OptionsListPopover {...compProps} />
</mockReduxEmbeddableTools.Wrapper>
</OptionsListEmbeddableContext.Provider>
);
}
@ -290,6 +292,9 @@ describe('Options list popover', () => {
});
test('ensure warning icon shows up when testAllowExpensiveQueries = false', async () => {
pluginServices.getServices().optionsList.getAllowExpensiveQueries = jest.fn(() =>
Promise.resolve(false)
);
const popover = await mountComponent({
componentState: {
field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec,

View file

@ -9,16 +9,13 @@
import React, { useState } from 'react';
import { isEmpty } from 'lodash';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { OptionsListPopoverTitle } from './options_list_popover_title';
import { OptionsListPopoverFooter } from './options_list_popover_footer';
import { OptionsListPopoverActionBar } from './options_list_popover_action_bar';
import { OptionsListPopoverSuggestions } from './options_list_popover_suggestions';
import { OptionsListPopoverInvalidSelections } from './options_list_popover_invalid_selections';
import { useOptionsList } from '../embeddable/options_list_embeddable';
export interface OptionsListPopoverProps {
width: number;
@ -33,21 +30,16 @@ export const OptionsListPopover = ({
updateSearchString,
loadMoreSuggestions,
}: OptionsListPopoverProps) => {
// Redux embeddable container Context
const { useEmbeddableSelector: select } = useReduxEmbeddableContext<
OptionsListReduxState,
typeof optionsListReducers
>();
const optionsList = useOptionsList();
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
const availableOptions = select((state) => state.componentState.availableOptions);
const field = select((state) => state.componentState.field);
const field = optionsList.select((state) => state.componentState.field);
const availableOptions = optionsList.select((state) => state.componentState.availableOptions);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const hideActionBar = select((state) => state.explicitInput.hideActionBar);
const hideExclude = select((state) => state.explicitInput.hideExclude);
const fieldName = select((state) => state.explicitInput.fieldName);
const id = select((state) => state.explicitInput.id);
const id = optionsList.select((state) => state.explicitInput.id);
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const hideExclude = optionsList.select((state) => state.explicitInput.hideExclude);
const hideActionBar = optionsList.select((state) => state.explicitInput.hideActionBar);
const [showOnlySelected, setShowOnlySelected] = useState(false);

View file

@ -17,11 +17,9 @@ import {
EuiToolTip,
EuiText,
} from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListPopoverSortingButton } from './options_list_popover_sorting_button';
interface OptionsListPopoverProps {
@ -35,20 +33,18 @@ export const OptionsListPopoverActionBar = ({
updateSearchString,
setShowOnlySelected,
}: OptionsListPopoverProps) => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { clearSelections },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
const optionsList = useOptionsList();
// Select current state from Redux using multiple selectors to avoid rerenders.
const allowExpensiveQueries = select((state) => state.componentState.allowExpensiveQueries);
const invalidSelections = select((state) => state.componentState.invalidSelections);
const totalCardinality = select((state) => state.componentState.totalCardinality) ?? 0;
const searchString = select((state) => state.componentState.searchString);
const hideSort = select((state) => state.explicitInput.hideSort);
const totalCardinality =
optionsList.select((state) => state.componentState.totalCardinality) ?? 0;
const searchString = optionsList.select((state) => state.componentState.searchString);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const allowExpensiveQueries = optionsList.select(
(state) => state.componentState.allowExpensiveQueries
);
const hideSort = optionsList.select((state) => state.explicitInput.hideSort);
return (
<div className="optionsList__actions">
@ -142,7 +138,7 @@ export const OptionsListPopoverActionBar = ({
size="xs"
color="danger"
iconType="eraser"
onClick={() => dispatch(clearSelections({}))}
onClick={() => optionsList.dispatch.clearSelections({})}
data-test-subj="optionsList-control-clear-all-selections"
aria-label={OptionsListStrings.popover.getClearAllSelectionsButtonTitle()}
/>

View file

@ -7,19 +7,18 @@
*/
import React from 'react';
import {
useEuiBackgroundColor,
useEuiPaddingSize,
EuiPopoverFooter,
EuiButtonGroup,
EuiProgress,
EuiButtonGroup,
EuiPopoverFooter,
useEuiPaddingSize,
useEuiBackgroundColor,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { useOptionsList } from '../embeddable/options_list_embeddable';
const aggregationToggleButtons = [
{
@ -33,16 +32,9 @@ const aggregationToggleButtons = [
];
export const OptionsListPopoverFooter = ({ isLoading }: { isLoading: boolean }) => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setExclude },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
const optionsList = useOptionsList();
// Select current state from Redux using multiple selectors to avoid rerenders.
const exclude = select((state) => state.explicitInput.exclude);
const exclude = optionsList.select((state) => state.explicitInput.exclude);
return (
<>
@ -71,7 +63,7 @@ export const OptionsListPopoverFooter = ({ isLoading }: { isLoading: boolean })
options={aggregationToggleButtons}
idSelected={exclude ? 'optionsList__excludeResults' : 'optionsList__includeResults'}
onChange={(optionId) =>
dispatch(setExclude(optionId === 'optionsList__excludeResults'))
optionsList.dispatch.setExclude(optionId === 'optionsList__excludeResults')
}
buttonSize="compressed"
data-test-subj="optionsList__includeExcludeButtonGroup"

View file

@ -15,24 +15,15 @@ import {
EuiTitle,
EuiScreenReaderOnly,
} from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { useOptionsList } from '../embeddable/options_list_embeddable';
export const OptionsListPopoverInvalidSelections = () => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { deselectOption },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
const optionsList = useOptionsList();
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
const fieldName = select((state) => state.explicitInput.fieldName);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const [selectableOptions, setSelectableOptions] = useState<EuiSelectableOption[]>([]); // will be set in following useEffect
useEffect(() => {
@ -80,7 +71,7 @@ export const OptionsListPopoverInvalidSelections = () => {
listProps={{ onFocusBadge: false, isVirtualized: false }}
onChange={(newSuggestions, _, changedOption) => {
setSelectableOptions(newSuggestions);
dispatch(deselectOption(changedOption.label));
optionsList.dispatch.deselectOption(changedOption.label);
}}
>
{(list) => list}

View file

@ -21,16 +21,14 @@ import {
Direction,
EuiToolTip,
} from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import {
getCompatibleSortingTypes,
OPTIONS_LIST_DEFAULT_SORT,
OptionsListSortBy,
} from '../../../common/options_list/suggestions_sorting';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { useOptionsList } from '../embeddable/options_list_embeddable';
interface OptionsListSortingPopoverProps {
showOnlySelected: boolean;
@ -42,17 +40,10 @@ type SortByItem = EuiSelectableOption & {
export const OptionsListPopoverSortingButton = ({
showOnlySelected,
}: OptionsListSortingPopoverProps) => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setSort },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
const optionsList = useOptionsList();
// Select current state from Redux using multiple selectors to avoid rerenders.
const field = select((state) => state.componentState.field);
const sort = select((state) => state.explicitInput.sort ?? OPTIONS_LIST_DEFAULT_SORT);
const field = optionsList.select((state) => state.componentState.field);
const sort = optionsList.select((state) => state.explicitInput.sort ?? OPTIONS_LIST_DEFAULT_SORT);
const [isSortingPopoverOpen, setIsSortingPopoverOpen] = useState(false);
@ -87,7 +78,7 @@ export const OptionsListPopoverSortingButton = ({
setSortByOptions(updatedOptions);
const selectedOption = updatedOptions.find(({ checked }) => checked === 'on');
if (selectedOption) {
dispatch(setSort({ by: selectedOption.data.sortBy }));
optionsList.dispatch.setSort({ by: selectedOption.data.sortBy });
}
};
@ -135,7 +126,9 @@ export const OptionsListPopoverSortingButton = ({
options={sortOrderOptions}
idSelected={sort.direction}
legend={OptionsListStrings.editorAndPopover.getSortDirectionLegend()}
onChange={(value) => dispatch(setSort({ direction: value as Direction }))}
onChange={(value) =>
optionsList.dispatch.setSort({ direction: value as Direction })
}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -10,12 +10,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiSelectable } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
import { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { MAX_OPTIONS_LIST_REQUEST_SIZE, OptionsListReduxState } from '../types';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListPopoverEmptyMessage } from './options_list_popover_empty_message';
import { OptionsListPopoverSuggestionBadge } from './options_list_popover_suggestion_badge';
@ -28,27 +27,21 @@ export const OptionsListPopoverSuggestions = ({
showOnlySelected,
loadMoreSuggestions,
}: OptionsListPopoverSuggestionsProps) => {
// Redux embeddable container Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { replaceSelection, deselectOption, selectOption, selectExists },
} = useReduxEmbeddableContext<OptionsListReduxState, typeof optionsListReducers>();
const dispatch = useEmbeddableDispatch();
const optionsList = useOptionsList();
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
const availableOptions = select((state) => state.componentState.availableOptions);
const totalCardinality = select((state) => state.componentState.totalCardinality);
const searchString = select((state) => state.componentState.searchString);
const searchString = optionsList.select((state) => state.componentState.searchString);
const availableOptions = optionsList.select((state) => state.componentState.availableOptions);
const totalCardinality = optionsList.select((state) => state.componentState.totalCardinality);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const selectedOptions = select((state) => state.explicitInput.selectedOptions);
const existsSelected = select((state) => state.explicitInput.existsSelected);
const singleSelect = select((state) => state.explicitInput.singleSelect);
const hideExists = select((state) => state.explicitInput.hideExists);
const isLoading = select((state) => state.output.loading) ?? false;
const fieldName = select((state) => state.explicitInput.fieldName);
const sort = select((state) => state.explicitInput.sort);
const sort = optionsList.select((state) => state.explicitInput.sort);
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const hideExists = optionsList.select((state) => state.explicitInput.hideExists);
const singleSelect = optionsList.select((state) => state.explicitInput.singleSelect);
const existsSelected = optionsList.select((state) => state.explicitInput.existsSelected);
const selectedOptions = optionsList.select((state) => state.explicitInput.selectedOptions);
const isLoading = optionsList.select((state) => state.output.loading) ?? false;
const listRef = useRef<HTMLDivElement>(null);
@ -173,13 +166,13 @@ export const OptionsListPopoverSuggestions = ({
setSelectableOptions(newSuggestions);
// the order of these checks matters, so be careful if rearranging them
if (key === 'exists-option') {
dispatch(selectExists(!Boolean(existsSelected)));
optionsList.dispatch.selectExists(!Boolean(existsSelected));
} else if (showOnlySelected || selectedOptionsSet.has(key)) {
dispatch(deselectOption(key));
optionsList.dispatch.deselectOption(key);
} else if (singleSelect) {
dispatch(replaceSelection(key));
optionsList.dispatch.replaceSelection(key);
} else {
dispatch(selectOption(key));
optionsList.dispatch.selectOption(key);
}
}}
>

View file

@ -9,22 +9,17 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPopoverTitle, EuiIconTip } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
import { useOptionsList } from '../embeddable/options_list_embeddable';
export const OptionsListPopoverTitle = () => {
// Redux embeddable container Context
const { useEmbeddableSelector: select } = useReduxEmbeddableContext<
OptionsListReduxState,
typeof optionsListReducers
>();
const optionsList = useOptionsList();
// Select current state from Redux using multiple selectors to avoid rerenders.
const allowExpensiveQueries = select((state) => state.componentState.allowExpensiveQueries);
const title = select((state) => state.explicitInput.title);
const allowExpensiveQueries = optionsList.select(
(state) => state.componentState.allowExpensiveQueries
);
const title = optionsList.select((state) => state.explicitInput.title);
return (
<EuiPopoverTitle paddingSize="s">

View file

@ -6,15 +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 React, { createContext, useContext } from 'react';
import { debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import {
Filter,
compareFilters,
@ -23,23 +22,24 @@ import {
COMPARE_ALL_OPTIONS,
buildExistsFilter,
} from '@kbn/es-query';
import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { i18n } from '@kbn/i18n';
import { DataView, FieldSpec } from '@kbn/data-views-plugin/public';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { MIN_OPTIONS_LIST_REQUEST_SIZE, OptionsListReduxState } from '../types';
import { pluginServices } from '../../services';
import {
ControlInput,
ControlOutput,
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
OptionsListEmbeddableInput,
} from '../..';
import { getDefaultComponentState, optionsListReducers } from '../options_list_reducers';
import { pluginServices } from '../../services';
import { MIN_OPTIONS_LIST_REQUEST_SIZE, OptionsListReduxState } from '../types';
import { OptionsListControl } from '../components/options_list_control';
import { ControlsDataViewsService } from '../../services/data_views/types';
import { ControlsOptionsListService } from '../../services/options_list/types';
import { getDefaultComponentState, optionsListReducers } from '../options_list_reducers';
const diffDataFetchProps = (
last?: OptionsListDataFetchProps,
@ -62,6 +62,20 @@ interface OptionsListDataFetchProps {
filters?: ControlInput['filters'];
}
export const OptionsListEmbeddableContext = createContext<OptionsListEmbeddable | null>(null);
export const useOptionsList = (): OptionsListEmbeddable => {
const optionsList = useContext<OptionsListEmbeddable | null>(OptionsListEmbeddableContext);
if (optionsList == null) {
throw new Error('useOptionsList must be used inside OptionsListEmbeddableContext.');
}
return optionsList!;
};
type OptionsListReduxEmbeddableTools = ReduxEmbeddableTools<
OptionsListReduxState,
typeof optionsListReducers
>;
export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput, ControlOutput> {
public readonly type = OPTIONS_LIST_CONTROL;
public deferEmbeddableLoad = true;
@ -80,13 +94,16 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
private dataView?: DataView;
private field?: FieldSpec;
private reduxEmbeddableTools: ReduxEmbeddableTools<
OptionsListReduxState,
typeof optionsListReducers
>;
// state management
public select: OptionsListReduxEmbeddableTools['select'];
public getState: OptionsListReduxEmbeddableTools['getState'];
public dispatch: OptionsListReduxEmbeddableTools['dispatch'];
public onStateChange: OptionsListReduxEmbeddableTools['onStateChange'];
private cleanupStateTools: () => void;
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
reduxToolsPackage: ReduxToolsPackage,
input: OptionsListEmbeddableInput,
output: ControlOutput,
parent?: IContainer
@ -101,7 +118,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.loadMoreSubject = new Subject<number>();
// build redux embeddable tools
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools<
OptionsListReduxState,
typeof optionsListReducers
>({
@ -110,6 +127,12 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
initialComponentState: getDefaultComponentState(),
});
this.select = reduxEmbeddableTools.select;
this.getState = reduxEmbeddableTools.getState;
this.dispatch = reduxEmbeddableTools.dispatch;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.initialize();
}
@ -117,11 +140,9 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
const { selectedOptions: initialSelectedOptions } = this.getInput();
if (!initialSelectedOptions) this.setInitializationFinished();
const {
actions: { setAllowExpensiveQueries },
dispatch,
} = this.reduxEmbeddableTools;
dispatch(setAllowExpensiveQueries(await this.optionsListService.getAllowExpensiveQueries()));
this.dispatch.setAllowExpensiveQueries(
await this.optionsListService.getAllowExpensiveQueries()
);
this.runOptionsListQuery().then(async () => {
if (initialSelectedOptions) {
@ -183,19 +204,10 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
)
)
.subscribe(async ({ selectedOptions: newSelectedOptions }) => {
const {
actions: {
clearValidAndInvalidSelections,
setValidAndInvalidSelections,
publishFilters,
},
dispatch,
} = this.reduxEmbeddableTools;
if (!newSelectedOptions || isEmpty(newSelectedOptions)) {
dispatch(clearValidAndInvalidSelections({}));
this.dispatch.clearValidAndInvalidSelections({});
} else {
const { invalidSelections } = this.reduxEmbeddableTools.getState().componentState ?? {};
const { invalidSelections } = this.getState().componentState ?? {};
const newValidSelections: string[] = [];
const newInvalidSelections: string[] = [];
for (const selectedOption of newSelectedOptions) {
@ -205,15 +217,13 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
}
newValidSelections.push(selectedOption);
}
dispatch(
setValidAndInvalidSelections({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
})
);
this.dispatch.setValidAndInvalidSelections({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
});
}
const newFilters = await this.buildFilter();
dispatch(publishFilters(newFilters));
this.dispatch.publishFilters(newFilters);
})
);
};
@ -222,15 +232,9 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
dataView?: DataView;
field?: FieldSpec;
}> => {
const {
dispatch,
getState,
actions: { setField, setDataViewId },
} = this.reduxEmbeddableTools;
const {
explicitInput: { dataViewId, fieldName },
} = getState();
} = this.getState();
if (!this.dataView || this.dataView.id !== dataViewId) {
try {
@ -246,7 +250,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.onFatalError(e);
}
dispatch(setDataViewId(this.dataView?.id));
this.dispatch.setDataViewId(this.dataView?.id);
}
if (this.dataView && (!this.field || this.field.name !== fieldName)) {
@ -265,31 +269,26 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
} catch (e) {
this.onFatalError(e);
}
dispatch(setField(this.field));
this.dispatch.setField(this.field);
}
return { dataView: this.dataView, field: this.field! };
};
private runOptionsListQuery = async (size: number = MIN_OPTIONS_LIST_REQUEST_SIZE) => {
const {
dispatch,
getState,
actions: { setLoading, publishFilters, setSearchString, updateQueryResults },
} = this.reduxEmbeddableTools;
const previousFieldName = this.field?.name;
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
if (previousFieldName && field.name !== previousFieldName) {
dispatch(setSearchString(''));
this.dispatch.setSearchString('');
}
const {
componentState: { searchString, allowExpensiveQueries },
explicitInput: { selectedOptions, runPastTimeout, existsSelected, sort },
} = getState();
dispatch(setLoading(true));
} = this.getState();
this.dispatch.setLoading(true);
if (searchString.valid) {
// need to get filters, query, ignoreParentSettings, and timeRange from input for inheritance
const {
@ -342,14 +341,12 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
isEmpty(invalidSelections) ||
ignoreParentSettings?.ignoreValidations
) {
dispatch(
updateQueryResults({
availableOptions: suggestions,
invalidSelections: undefined,
validSelections: selectedOptions,
totalCardinality,
})
);
this.dispatch.updateQueryResults({
availableOptions: suggestions,
invalidSelections: undefined,
validSelections: selectedOptions,
totalCardinality,
});
} else {
const valid: string[] = [];
const invalid: string[] = [];
@ -357,38 +354,33 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
if (invalidSelections?.includes(selectedOption)) invalid.push(selectedOption);
else valid.push(selectedOption);
}
dispatch(
updateQueryResults({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
})
);
this.dispatch.updateQueryResults({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
});
}
// publish filter
const newFilters = await this.buildFilter();
batch(() => {
dispatch(setLoading(false));
dispatch(publishFilters(newFilters));
this.dispatch.setLoading(false);
this.dispatch.publishFilters(newFilters);
});
} else {
batch(() => {
dispatch(
updateQueryResults({
availableOptions: {},
})
);
dispatch(setLoading(false));
this.dispatch.updateQueryResults({
availableOptions: {},
});
this.dispatch.setLoading(false);
});
}
};
private buildFilter = async () => {
const { getState } = this.reduxEmbeddableTools;
const { validSelections } = getState().componentState ?? {};
const { existsSelected } = getState().explicitInput ?? {};
const { validSelections } = this.getState().componentState ?? {};
const { existsSelected } = this.getState().explicitInput ?? {};
const { exclude } = this.getInput();
if ((!validSelections || isEmpty(validSelections)) && !existsSelected) {
@ -421,22 +413,18 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
};
public onFatalError = (e: Error) => {
const {
dispatch,
actions: { setPopoverOpen, setLoading },
} = this.reduxEmbeddableTools;
batch(() => {
dispatch(setLoading(false));
dispatch(setPopoverOpen(false));
this.dispatch.setLoading(false);
this.dispatch.setPopoverOpen(false);
});
super.onFatalError(e);
};
public destroy = () => {
super.destroy();
this.cleanupStateTools();
this.abortController?.abort();
this.subscriptions.unsubscribe();
this.reduxEmbeddableTools.cleanup();
if (this.node) ReactDOM.unmountComponentAtNode(this.node);
};
@ -444,16 +432,15 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
const { Wrapper: OptionsListReduxWrapper } = this.reduxEmbeddableTools;
this.node = node;
ReactDOM.render(
<KibanaThemeProvider theme$={pluginServices.getServices().theme.theme$}>
<OptionsListReduxWrapper>
<OptionsListEmbeddableContext.Provider value={this}>
<OptionsListControl
typeaheadSubject={this.typeaheadSubject}
loadMoreSubject={this.loadMoreSubject}
/>
</OptionsListReduxWrapper>
</OptionsListEmbeddableContext.Provider>
</KibanaThemeProvider>,
node
);

View file

@ -10,7 +10,7 @@ import deepEqual from 'fast-deep-equal';
import { i18n } from '@kbn/i18n';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import {
@ -33,7 +33,7 @@ export class OptionsListEmbeddableFactory
constructor() {}
public async create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const { OptionsListEmbeddable } = await import('./options_list_embeddable');
return Promise.resolve(
new OptionsListEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent)

View file

@ -16,10 +16,8 @@ import {
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { rangeSliderReducers } from '../range_slider_reducers';
import { RangeSliderReduxState } from '../types';
import { useRangeSlider } from '../embeddable/range_slider_embeddable';
import { RangeSliderPopover, EuiDualRangeRef } from './range_slider_popover';
import './range_slider.scss';
@ -30,21 +28,14 @@ export const RangeSliderControl: FC = () => {
const rangeRef = useRef<EuiDualRangeRef>(null);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
// Controls Services Context
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setSelectedRange },
} = useReduxEmbeddableContext<RangeSliderReduxState, typeof rangeSliderReducers>();
const dispatch = useEmbeddableDispatch();
const rangeSlider = useRangeSlider();
// 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 id = select((state) => state.explicitInput.id);
const value = select((state) => state.explicitInput.value) ?? ['', ''];
const isLoading = select((state) => state.output.loading);
const min = rangeSlider.select((state) => state.componentState.min);
const max = rangeSlider.select((state) => state.componentState.max);
const isInvalid = rangeSlider.select((state) => state.componentState.isInvalid);
const id = rangeSlider.select((state) => state.explicitInput.id);
const value = rangeSlider.select((state) => state.explicitInput.value) ?? ['', ''];
const isLoading = rangeSlider.select((state) => state.output.loading);
const hasAvailableRange = min !== '' && max !== '';
@ -76,12 +67,10 @@ export const RangeSliderControl: FC = () => {
}`}
value={hasLowerBoundSelection ? lowerBoundValue : ''}
onChange={(event) => {
dispatch(
setSelectedRange([
event.target.value,
isNaN(upperBoundValue) ? '' : String(upperBoundValue),
])
);
rangeSlider.dispatch.setSelectedRange([
event.target.value,
isNaN(upperBoundValue) ? '' : String(upperBoundValue),
]);
}}
disabled={isLoading}
placeholder={`${hasAvailableRange ? roundedMin : ''}`}
@ -103,12 +92,10 @@ export const RangeSliderControl: FC = () => {
}`}
value={hasUpperBoundSelection ? upperBoundValue : ''}
onChange={(event) => {
dispatch(
setSelectedRange([
isNaN(lowerBoundValue) ? '' : String(lowerBoundValue),
event.target.value,
])
);
rangeSlider.dispatch.setSelectedRange([
isNaN(lowerBoundValue) ? '' : String(lowerBoundValue),
event.target.value,
]);
}}
disabled={isLoading}
placeholder={`${hasAvailableRange ? roundedMax : ''}`}

View file

@ -19,13 +19,11 @@ import {
EuiButtonIcon,
} from '@elastic/eui';
import type { EuiDualRangeClass } from '@elastic/eui/src/components/form/range/dual_range';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { RangeValue } from '../../../common/range_slider/types';
import { pluginServices } from '../../services';
import { rangeSliderReducers } from '../range_slider_reducers';
import { RangeSliderReduxState } from '../types';
import { RangeSliderStrings } from './range_slider_strings';
import { RangeValue } from '../../../common/range_slider/types';
import { useRangeSlider } from '../embeddable/range_slider_embeddable';
// Unfortunately, wrapping EuiDualRange in `withEuiTheme` has created this annoying/verbose typing
export type EuiDualRangeRef = EuiDualRangeClass & ComponentProps<typeof EuiDualRange>;
@ -37,23 +35,17 @@ export const RangeSliderPopover: FC<{ rangeRef?: Ref<EuiDualRangeRef> }> = ({ ra
const {
dataViews: { get: getDataViewById },
} = pluginServices.getServices();
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setSelectedRange },
} = useReduxEmbeddableContext<RangeSliderReduxState, typeof rangeSliderReducers>();
const dispatch = useEmbeddableDispatch();
const rangeSlider = useRangeSlider();
// Select current state from Redux using multiple selectors to avoid rerenders.
const dataViewId = select((state) => state.output.dataViewId);
const fieldSpec = select((state) => state.componentState.field);
const id = select((state) => state.explicitInput.id);
const isInvalid = select((state) => state.componentState.isInvalid);
const max = select((state) => state.componentState.max);
const min = select((state) => state.componentState.min);
const title = select((state) => state.explicitInput.title);
const value = select((state) => state.explicitInput.value) ?? ['', ''];
const dataViewId = rangeSlider.select((state) => state.output.dataViewId);
const fieldSpec = rangeSlider.select((state) => state.componentState.field);
const id = rangeSlider.select((state) => state.explicitInput.id);
const isInvalid = rangeSlider.select((state) => state.componentState.isInvalid);
const max = rangeSlider.select((state) => state.componentState.max);
const min = rangeSlider.select((state) => state.componentState.min);
const title = rangeSlider.select((state) => state.explicitInput.title);
const value = rangeSlider.select((state) => state.explicitInput.value) ?? ['', ''];
const hasAvailableRange = min !== '' && max !== '';
const hasLowerBoundSelection = value[0] !== '';
@ -154,7 +146,7 @@ export const RangeSliderPopover: FC<{ rangeRef?: Ref<EuiDualRangeRef> }> = ({ ra
const updatedUpperBound =
typeof newUpperBound === 'number' ? String(newUpperBound) : value[1];
dispatch(setSelectedRange([updatedLowerBound, updatedUpperBound]));
rangeSlider.dispatch.setSelectedRange([updatedLowerBound, updatedUpperBound]);
}}
value={displayedValue}
ticks={hasAvailableRange ? ticks : undefined}
@ -179,7 +171,7 @@ export const RangeSliderPopover: FC<{ rangeRef?: Ref<EuiDualRangeRef> }> = ({ ra
iconType="eraser"
color="danger"
onClick={() => {
dispatch(setSelectedRange(['', '']));
rangeSlider.dispatch.setSelectedRange(['', '']);
}}
aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()}
data-test-subj="rangeSlider__clearRangeButton"

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { isEmpty } from 'lodash';
import { batch } from 'react-redux';
@ -15,8 +15,6 @@ import deepEqual from 'fast-deep-equal';
import { Subscription, lastValueFrom } from 'rxjs';
import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators';
import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import {
compareFilters,
buildRangeFilter,
@ -27,7 +25,9 @@ import {
} from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { KibanaThemeProvider } from '@kbn/kibana-react-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 { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import {
ControlInput,
@ -36,11 +36,11 @@ import {
RANGE_SLIDER_CONTROL,
} from '../..';
import { pluginServices } from '../../services';
import { RangeSliderControl } from '../components/range_slider_control';
import { getDefaultComponentState, rangeSliderReducers } from '../range_slider_reducers';
import { RangeSliderReduxState } from '../types';
import { ControlsDataService } from '../../services/data/types';
import { RangeSliderControl } from '../components/range_slider_control';
import { ControlsDataViewsService } from '../../services/data_views/types';
import { getDefaultComponentState, rangeSliderReducers } from '../range_slider_reducers';
const diffDataFetchProps = (
current?: RangeSliderDataFetchProps,
@ -65,6 +65,20 @@ interface RangeSliderDataFetchProps {
const fieldMissingError = (fieldName: string) =>
new Error(`field ${fieldName} not found in index pattern`);
export const RangeSliderControlContext = createContext<RangeSliderEmbeddable | null>(null);
export const useRangeSlider = (): RangeSliderEmbeddable => {
const rangeSlider = useContext<RangeSliderEmbeddable | null>(RangeSliderControlContext);
if (rangeSlider == null) {
throw new Error('useRangeSlider must be used inside RangeSliderControlContext.');
}
return rangeSlider!;
};
type RangeSliderReduxEmbeddableTools = ReduxEmbeddableTools<
RangeSliderReduxState,
typeof rangeSliderReducers
>;
export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput, ControlOutput> {
public readonly type = RANGE_SLIDER_CONTROL;
public deferEmbeddableLoad = true;
@ -80,13 +94,16 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
private dataView?: DataView;
private field?: DataViewField;
private reduxEmbeddableTools: ReduxEmbeddableTools<
RangeSliderReduxState,
typeof rangeSliderReducers
>;
// state management
public select: RangeSliderReduxEmbeddableTools['select'];
public getState: RangeSliderReduxEmbeddableTools['getState'];
public dispatch: RangeSliderReduxEmbeddableTools['dispatch'];
public onStateChange: RangeSliderReduxEmbeddableTools['onStateChange'];
private cleanupStateTools: () => void;
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
reduxToolsPackage: ReduxToolsPackage,
input: RangeSliderEmbeddableInput,
output: ControlOutput,
parent?: IContainer
@ -96,7 +113,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
// Destructure controls services
({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices());
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools<
RangeSliderReduxState,
typeof rangeSliderReducers
>({
@ -104,6 +121,11 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
reducers: rangeSliderReducers,
initialComponentState: getDefaultComponentState(),
});
this.select = reduxEmbeddableTools.select;
this.getState = reduxEmbeddableTools.getState;
this.dispatch = reduxEmbeddableTools.dispatch;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.initialize();
}
@ -157,14 +179,9 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
dataView?: DataView;
field?: DataViewField;
}> => {
const {
getState,
dispatch,
actions: { setField, setDataViewId },
} = this.reduxEmbeddableTools;
const {
explicitInput: { dataViewId, fieldName },
} = getState();
} = this.getState();
if (!this.dataView || this.dataView.id !== dataViewId) {
try {
@ -179,7 +196,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
);
}
dispatch(setDataViewId(this.dataView.id));
this.dispatch.setDataViewId(this.dataView.id);
} catch (e) {
this.onFatalError(e);
}
@ -198,19 +215,14 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
);
}
dispatch(setField(this.field?.toSpec()));
this.dispatch.setField(this.field?.toSpec());
}
return { dataView: this.dataView, field: this.field! };
};
private runRangeSliderQuery = async () => {
const {
dispatch,
actions: { setLoading, publishFilters, setMinMax },
} = this.reduxEmbeddableTools;
dispatch(setLoading(true));
this.dispatch.setLoading(true);
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
@ -226,8 +238,8 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
if (!field) {
batch(() => {
dispatch(setLoading(false));
dispatch(publishFilters([]));
this.dispatch.setLoading(false);
this.dispatch.publishFilters([]);
});
throw fieldMissingError(fieldName);
}
@ -258,12 +270,10 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
query,
});
dispatch(
setMinMax({
min: `${min ?? ''}`,
max: `${max ?? ''}`,
})
);
this.dispatch.setMinMax({
min: `${min ?? ''}`,
max: `${max ?? ''}`,
});
// build filter with new min/max
await this.buildFilter();
@ -323,11 +333,6 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
};
private buildFilter = async () => {
const {
dispatch,
getState,
actions: { setLoading, setIsInvalid, setDataViewId, publishFilters },
} = this.reduxEmbeddableTools;
const {
componentState: { min: availableMin, max: availableMax },
explicitInput: {
@ -337,7 +342,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
ignoreParentSettings,
value: [selectedMin, selectedMax] = ['', ''],
},
} = getState();
} = this.getState();
const hasData = !isEmpty(availableMin) && !isEmpty(availableMax);
const hasLowerSelection = !isEmpty(selectedMin);
@ -349,10 +354,10 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
if (!hasData || !hasEitherSelection) {
batch(() => {
dispatch(setLoading(false));
dispatch(setIsInvalid(!ignoreParentSettings?.ignoreValidations && hasEitherSelection));
dispatch(setDataViewId(dataView.id));
dispatch(publishFilters([]));
this.dispatch.setLoading(false);
this.dispatch.setIsInvalid(!ignoreParentSettings?.ignoreValidations && hasEitherSelection);
this.dispatch.setDataViewId(dataView.id);
this.dispatch.publishFilters([]);
});
return;
}
@ -404,20 +409,20 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
if (!docCount) {
batch(() => {
dispatch(setLoading(false));
dispatch(setIsInvalid(true));
dispatch(setDataViewId(dataView.id));
dispatch(publishFilters([]));
this.dispatch.setLoading(false);
this.dispatch.setIsInvalid(true);
this.dispatch.setDataViewId(dataView.id);
this.dispatch.publishFilters([]);
});
return;
}
}
batch(() => {
dispatch(setLoading(false));
dispatch(setIsInvalid(false));
dispatch(setDataViewId(dataView.id));
dispatch(publishFilters([rangeFilter]));
this.dispatch.setLoading(false);
this.dispatch.setIsInvalid(false);
this.dispatch.setDataViewId(dataView.id);
this.dispatch.publishFilters([rangeFilter]);
});
};
@ -427,23 +432,22 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
public destroy = () => {
super.destroy();
this.cleanupStateTools();
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$}>
<ControlsServicesProvider>
<RangeSliderReduxWrapper>
<RangeSliderControlContext.Provider value={this}>
<RangeSliderControl />
</RangeSliderReduxWrapper>
</RangeSliderControlContext.Provider>
</ControlsServicesProvider>
</KibanaThemeProvider>,
node

View file

@ -6,12 +6,12 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import deepEqual from 'fast-deep-equal';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { i18n } from '@kbn/i18n';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import {
createRangeSliderExtract,
@ -45,7 +45,7 @@ export class RangeSliderEmbeddableFactory
public isEditable = () => Promise.resolve(true);
public async create(initialInput: RangeSliderEmbeddableInput, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const { RangeSliderEmbeddable } = await import('./range_slider_embeddable');
return Promise.resolve(

View file

@ -8,14 +8,12 @@
import React, { FC, useRef } from 'react';
import { EuiInputPopover } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { timeSliderReducers } from '../time_slider_reducers';
import { TimeSliderReduxState } from '../types';
import { FROM_INDEX, TO_INDEX } from '../time_utils';
import { EuiDualRangeRef } from './time_slider_sliding_window_range';
import { getRoundedTimeRangeBounds } from '../time_slider_selectors';
import { useTimeSlider } from '../embeddable/time_slider_embeddable';
import { TimeSliderPopoverButton } from './time_slider_popover_button';
import { TimeSliderPopoverContent } from './time_slider_popover_content';
import { EuiDualRangeRef } from './time_slider_sliding_window_range';
import { FROM_INDEX, TO_INDEX } from '../time_utils';
import { getRoundedTimeRangeBounds } from '../time_slider_selectors';
import './index.scss';
@ -25,25 +23,21 @@ interface Props {
}
export const TimeSlider: FC<Props> = (props: Props) => {
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions,
} = useReduxEmbeddableContext<TimeSliderReduxState, typeof timeSliderReducers>();
const dispatch = useEmbeddableDispatch();
const stepSize = select((state) => {
const timeSlider = useTimeSlider();
const stepSize = timeSlider.select((state) => {
return state.componentState.stepSize;
});
const ticks = select((state) => {
const ticks = timeSlider.select((state) => {
return state.componentState.ticks;
});
const timeRangeBounds = select(getRoundedTimeRangeBounds);
const timeRangeBounds = timeSlider.select(getRoundedTimeRangeBounds);
const timeRangeMin = timeRangeBounds[FROM_INDEX];
const timeRangeMax = timeRangeBounds[TO_INDEX];
const value = select((state) => {
const value = timeSlider.select((state) => {
return state.componentState.value;
});
const isOpen = select((state) => {
const isOpen = timeSlider.select((state) => {
return state.componentState.isOpen;
});
@ -64,7 +58,7 @@ export const TimeSlider: FC<Props> = (props: Props) => {
input={
<TimeSliderPopoverButton
onClick={() => {
dispatch(actions.setIsOpen({ isOpen: !isOpen }));
timeSlider.dispatch.setIsOpen({ isOpen: !isOpen });
}}
formatDate={props.formatDate}
from={from}
@ -72,7 +66,7 @@ export const TimeSlider: FC<Props> = (props: Props) => {
/>
}
isOpen={isOpen}
closePopover={() => dispatch(actions.setIsOpen({ isOpen: false }))}
closePopover={() => timeSlider.dispatch.setIsOpen({ isOpen: false })}
panelPaddingSize="s"
anchorPosition="downCenter"
disableFocusTrap

View file

@ -8,13 +8,12 @@
import React, { Ref } from 'react';
import { EuiButtonIcon, EuiRangeTick, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { TimeSliderAnchoredRange } from './time_slider_anchored_range';
import { EuiDualRangeRef, TimeSliderSlidingWindowRange } from './time_slider_sliding_window_range';
import { timeSliderReducers } from '../time_slider_reducers';
import { TimeSliderReduxState } from '../types';
import { getIsAnchored } from '../time_slider_selectors';
import { TimeSliderStrings } from './time_slider_strings';
import { useTimeSlider } from '../embeddable/time_slider_embeddable';
import { TimeSliderAnchoredRange } from './time_slider_anchored_range';
import { EuiDualRangeRef, TimeSliderSlidingWindowRange } from './time_slider_sliding_window_range';
interface Props {
value: [number, number];
@ -41,13 +40,8 @@ export function TimeSliderPopoverContent(props: Props) {
};
});
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setIsAnchored },
} = useReduxEmbeddableContext<TimeSliderReduxState, typeof timeSliderReducers>();
const dispatch = useEmbeddableDispatch();
const isAnchored = select(getIsAnchored);
const timeSlider = useTimeSlider();
const isAnchored = timeSlider.select(getIsAnchored);
const rangeInput = isAnchored ? (
<TimeSliderAnchoredRange
value={props.value}
@ -88,7 +82,7 @@ export function TimeSliderPopoverContent(props: Props) {
if (nextIsAnchored) {
props.onChange([props.timeRangeMin, props.value[1]]);
}
dispatch(setIsAnchored({ isAnchored: nextIsAnchored }));
timeSlider.dispatch.setIsAnchored({ isAnchored: nextIsAnchored });
}}
aria-label={anchorStartToggleButtonLabel}
data-test-subj="timeSlider__anchorStartToggleButton"

View file

@ -6,14 +6,12 @@
* Side Public License, v 1.
*/
import React, { FC, useState } from 'react';
import { Observable, Subscription } from 'rxjs';
import { first } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import { first } from 'rxjs/operators';
import React, { FC, useState } from 'react';
import { EuiButtonIcon } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { timeSliderReducers } from '../time_slider_reducers';
import { TimeSliderReduxState } from '../types';
import { Observable, Subscription } from 'rxjs';
import { useTimeSlider } from '../embeddable/time_slider_embeddable';
interface Props {
onNext: () => void;
@ -22,11 +20,7 @@ interface Props {
}
export const TimeSliderPrepend: FC<Props> = (props: Props) => {
const { useEmbeddableDispatch, actions } = useReduxEmbeddableContext<
TimeSliderReduxState,
typeof timeSliderReducers
>();
const dispatch = useEmbeddableDispatch();
const timeSlider = useTimeSlider();
const [isPaused, setIsPaused] = useState(true);
const [timeoutId, setTimeoutId] = useState<number | undefined>(undefined);
@ -51,13 +45,13 @@ export const TimeSliderPrepend: FC<Props> = (props: Props) => {
};
const onPlay = () => {
dispatch(actions.setIsOpen({ isOpen: true }));
timeSlider.dispatch.setIsOpen({ isOpen: true });
setIsPaused(false);
playNextFrame();
};
const onPause = () => {
dispatch(actions.setIsOpen({ isOpen: true }));
timeSlider.dispatch.setIsOpen({ isOpen: true });
setIsPaused(true);
if (subscription) {
subscription.unsubscribe();

View file

@ -10,10 +10,10 @@ import _ from 'lodash';
import { debounceTime, first, map } from 'rxjs/operators';
import moment from 'moment-timezone';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import type { TimeRange } from '@kbn/es-query';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import React from 'react';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { Subscription } from 'rxjs';
import { TIME_SLIDER_CONTROL } from '../..';
@ -37,6 +37,20 @@ import {
} from '../time_utils';
import { getIsAnchored, getRoundedTimeRangeBounds } from '../time_slider_selectors';
export const TimeSliderControlContext = createContext<TimeSliderControlEmbeddable | null>(null);
export const useTimeSlider = (): TimeSliderControlEmbeddable => {
const timeSlider = useContext<TimeSliderControlEmbeddable | null>(TimeSliderControlContext);
if (timeSlider == null) {
throw new Error('useTimeSlider must be used inside TimeSliderControlContext.');
}
return timeSlider!;
};
type TimeSliderReduxEmbeddableTools = ReduxEmbeddableTools<
TimeSliderReduxState,
typeof timeSliderReducers
>;
export class TimeSliderControlEmbeddable extends Embeddable<
TimeSliderControlEmbeddableInput,
ControlOutput
@ -47,6 +61,14 @@ export class TimeSliderControlEmbeddable extends Embeddable<
private inputSubscription: Subscription;
private node?: HTMLElement;
// state management
public select: TimeSliderReduxEmbeddableTools['select'];
public getState: TimeSliderReduxEmbeddableTools['getState'];
public dispatch: TimeSliderReduxEmbeddableTools['dispatch'];
public onStateChange: TimeSliderReduxEmbeddableTools['onStateChange'];
private cleanupStateTools: () => void;
private getTimezone: ControlsSettingsService['getTimezone'];
private timefilter: ControlsDataService['timefilter'];
private prevTimeRange: TimeRange | undefined;
@ -56,13 +78,8 @@ export class TimeSliderControlEmbeddable extends Embeddable<
};
private readonly waitForControlOutputConsumersToLoad$;
private reduxEmbeddableTools: ReduxEmbeddableTools<
TimeSliderReduxState,
typeof timeSliderReducers
>;
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
reduxToolsPackage: ReduxToolsPackage,
input: TimeSliderControlEmbeddableInput,
output: ControlOutput,
parent?: IContainer
@ -85,7 +102,7 @@ export class TimeSliderControlEmbeddable extends Embeddable<
timeRangeBounds[TO_INDEX],
this.getTimezone()
);
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools<
TimeSliderReduxState,
typeof timeSliderReducers
>({
@ -99,6 +116,12 @@ export class TimeSliderControlEmbeddable extends Embeddable<
},
});
this.select = reduxEmbeddableTools.select;
this.getState = reduxEmbeddableTools.getState;
this.dispatch = reduxEmbeddableTools.dispatch;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.inputSubscription = this.getInput$().subscribe(() => this.onInputChange());
this.waitForControlOutputConsumersToLoad$ =
@ -125,7 +148,7 @@ export class TimeSliderControlEmbeddable extends Embeddable<
public destroy = () => {
super.destroy();
this.reduxEmbeddableTools.cleanup();
this.cleanupStateTools();
if (this.inputSubscription) {
this.inputSubscription.unsubscribe();
}
@ -136,7 +159,6 @@ export class TimeSliderControlEmbeddable extends Embeddable<
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } =
this.prevTimesliceAsPercentage ?? {};
const { actions, dispatch } = this.reduxEmbeddableTools;
if (
timesliceStartAsPercentageOfTimeRange !== input.timesliceStartAsPercentageOfTimeRange ||
timesliceEndAsPercentageOfTimeRange !== input.timesliceEndAsPercentageOfTimeRange
@ -149,8 +171,8 @@ export class TimeSliderControlEmbeddable extends Embeddable<
) {
// If no selections have been saved into the timeslider, then both `timesliceStartAsPercentageOfTimeRange`
// and `timesliceEndAsPercentageOfTimeRange` will be undefined - so, need to reset component state to match
dispatch(actions.publishValue({ value: undefined }));
dispatch(actions.setValue({ value: undefined }));
this.dispatch.publishValue({ value: undefined });
this.dispatch.setValue({ value: undefined });
} else {
// Otherwise, need to call `syncWithTimeRange` so that the component state value can be calculated and set
this.syncWithTimeRange();
@ -158,31 +180,26 @@ export class TimeSliderControlEmbeddable extends Embeddable<
} else if (input.timeRange && !_.isEqual(input.timeRange, this.prevTimeRange)) {
const nextBounds = this.timeRangeToBounds(input.timeRange);
const ticks = getTicks(nextBounds[FROM_INDEX], nextBounds[TO_INDEX], this.getTimezone());
dispatch(
actions.setTimeRangeBounds({
...getStepSize(ticks),
ticks,
timeRangeBounds: nextBounds,
})
);
this.dispatch.setTimeRangeBounds({
...getStepSize(ticks),
ticks,
timeRangeBounds: nextBounds,
});
this.syncWithTimeRange();
}
}
private syncWithTimeRange() {
this.prevTimeRange = this.getInput().timeRange;
const { actions, dispatch, getState } = this.reduxEmbeddableTools;
const stepSize = getState().componentState.stepSize;
const timesliceStartAsPercentageOfTimeRange =
getState().explicitInput.timesliceStartAsPercentageOfTimeRange;
const timesliceEndAsPercentageOfTimeRange =
getState().explicitInput.timesliceEndAsPercentageOfTimeRange;
const stepSize = this.getState().componentState.stepSize;
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } =
this.getState().explicitInput;
if (
timesliceStartAsPercentageOfTimeRange !== undefined &&
timesliceEndAsPercentageOfTimeRange !== undefined
) {
const timeRangeBounds = getState().componentState.timeRangeBounds;
const timeRangeBounds = this.getState().componentState.timeRangeBounds;
const timeRange = timeRangeBounds[TO_INDEX] - timeRangeBounds[FROM_INDEX];
const from = timeRangeBounds[FROM_INDEX] + timesliceStartAsPercentageOfTimeRange * timeRange;
const to = timeRangeBounds[FROM_INDEX] + timesliceEndAsPercentageOfTimeRange * timeRange;
@ -190,8 +207,8 @@ export class TimeSliderControlEmbeddable extends Embeddable<
roundDownToNextStepSizeFactor(from, stepSize),
roundUpToNextStepSizeFactor(to, stepSize),
] as [number, number];
dispatch(actions.publishValue({ value }));
dispatch(actions.setValue({ value }));
this.dispatch.publishValue({ value });
this.dispatch.setValue({ value });
this.onRangeChange(value[TO_INDEX] - value[FROM_INDEX]);
}
}
@ -208,16 +225,14 @@ export class TimeSliderControlEmbeddable extends Embeddable<
}
private debouncedPublishChange = _.debounce((value?: [number, number]) => {
const { actions, dispatch } = this.reduxEmbeddableTools;
dispatch(actions.publishValue({ value }));
this.dispatch.publishValue({ value });
}, 500);
private getTimeSliceAsPercentageOfTimeRange(value?: [number, number]) {
const { getState } = this.reduxEmbeddableTools;
let timesliceStartAsPercentageOfTimeRange: number | undefined;
let timesliceEndAsPercentageOfTimeRange: number | undefined;
if (value) {
const timeRangeBounds = getState().componentState.timeRangeBounds;
const timeRangeBounds = this.getState().componentState.timeRangeBounds;
const timeRange = timeRangeBounds[TO_INDEX] - timeRangeBounds[FROM_INDEX];
timesliceStartAsPercentageOfTimeRange =
(value[FROM_INDEX] - timeRangeBounds[FROM_INDEX]) / timeRange;
@ -232,39 +247,29 @@ export class TimeSliderControlEmbeddable extends Embeddable<
}
private onTimesliceChange = (value?: [number, number]) => {
const { actions, dispatch } = this.reduxEmbeddableTools;
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } =
this.getTimeSliceAsPercentageOfTimeRange(value);
dispatch(
actions.setValueAsPercentageOfTimeRange({
timesliceStartAsPercentageOfTimeRange,
timesliceEndAsPercentageOfTimeRange,
})
);
dispatch(actions.setValue({ value }));
this.dispatch.setValueAsPercentageOfTimeRange({
timesliceStartAsPercentageOfTimeRange,
timesliceEndAsPercentageOfTimeRange,
});
this.dispatch.setValue({ value });
this.debouncedPublishChange(value);
};
private onRangeChange = (range?: number) => {
const { actions, dispatch, getState } = this.reduxEmbeddableTools;
const timeRangeBounds = getState().componentState.timeRangeBounds;
const timeRangeBounds = this.getState().componentState.timeRangeBounds;
const timeRange = timeRangeBounds[TO_INDEX] - timeRangeBounds[FROM_INDEX];
dispatch(
actions.setRange({
range: range !== undefined && range < timeRange ? range : undefined,
})
);
this.dispatch.setRange({
range: range !== undefined && range < timeRange ? range : undefined,
});
};
private onNext = () => {
const { getState } = this.reduxEmbeddableTools;
const value = getState().componentState.value;
const range = getState().componentState.range;
const ticks = getState().componentState.ticks;
const isAnchored = getIsAnchored(getState());
const { value, range, ticks } = this.getState().componentState;
const isAnchored = getIsAnchored(this.getState());
const tickRange = ticks[1].value - ticks[0].value;
const timeRangeBounds = getRoundedTimeRangeBounds(getState());
const timeRangeBounds = getRoundedTimeRangeBounds(this.getState());
if (isAnchored) {
if (value === undefined || value[TO_INDEX] >= timeRangeBounds[TO_INDEX]) {
@ -304,13 +309,10 @@ export class TimeSliderControlEmbeddable extends Embeddable<
};
private onPrevious = () => {
const { getState } = this.reduxEmbeddableTools;
const value = getState().componentState.value;
const range = getState().componentState.range;
const ticks = getState().componentState.ticks;
const isAnchored = getIsAnchored(getState());
const { value, range, ticks } = this.getState().componentState;
const isAnchored = getIsAnchored(this.getState());
const tickRange = ticks[1].value - ticks[0].value;
const timeRangeBounds = getRoundedTimeRangeBounds(getState());
const timeRangeBounds = getRoundedTimeRangeBounds(this.getState());
if (isAnchored) {
const prevTick = value
@ -347,10 +349,9 @@ export class TimeSliderControlEmbeddable extends Embeddable<
};
private formatDate = (epoch: number) => {
const { getState } = this.reduxEmbeddableTools;
return moment
.tz(epoch, getMomentTimezone(this.getTimezone()))
.format(getState().componentState.format);
.format(this.getState().componentState.format);
};
public render = (node: HTMLElement) => {
@ -358,12 +359,9 @@ export class TimeSliderControlEmbeddable extends Embeddable<
ReactDOM.unmountComponentAtNode(this.node);
}
this.node = node;
const { Wrapper: TimeSliderControlReduxWrapper } = this.reduxEmbeddableTools;
ReactDOM.render(
<KibanaThemeProvider theme$={pluginServices.getServices().theme.theme$}>
<TimeSliderControlReduxWrapper>
<TimeSliderControlContext.Provider value={this}>
<TimeSlider
formatDate={this.formatDate}
onChange={(value?: [number, number]) => {
@ -372,22 +370,21 @@ export class TimeSliderControlEmbeddable extends Embeddable<
this.onRangeChange(range);
}}
/>
</TimeSliderControlReduxWrapper>
</TimeSliderControlContext.Provider>
</KibanaThemeProvider>,
node
);
};
public renderPrepend() {
const { Wrapper: TimeSliderControlReduxWrapper } = this.reduxEmbeddableTools;
return (
<TimeSliderControlReduxWrapper>
<TimeSliderControlContext.Provider value={this}>
<TimeSliderPrepend
onNext={this.onNext}
onPrevious={this.onPrevious}
waitForControlOutputConsumersToLoad$={this.waitForControlOutputConsumersToLoad$}
/>
</TimeSliderControlReduxWrapper>
</TimeSliderControlContext.Provider>
);
}

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import {
createTimeSliderExtract,
createTimeSliderInject,
@ -24,7 +24,7 @@ export class TimeSliderEmbeddableFactory
constructor() {}
public async create(initialInput: any, parent?: IContainer) {
const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage();
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
const { TimeSliderControlEmbeddable } = await import('./time_slider_embeddable');
return Promise.resolve(

View file

@ -1,25 +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 type {
DashboardContainerByReferenceInput,
DashboardContainerByValueInput,
DashboardContainerInput,
} from './types';
export const dashboardContainerInputIsByValue = (
containerInput: DashboardContainerInput
): containerInput is DashboardContainerByValueInput => {
if (
(containerInput as DashboardContainerByValueInput).panels &&
!(containerInput as DashboardContainerByReferenceInput).savedObjectId
) {
return true;
}
return false;
};

View file

@ -30,13 +30,9 @@ export interface DashboardPanelState<
panelRefName?: string;
}
export type DashboardContainerInput =
| DashboardContainerByReferenceInput
| DashboardContainerByValueInput;
export type DashboardContainerByReferenceInput = SavedObjectEmbeddableInput;
export type DashboardContainerByReferenceInput = SavedObjectEmbeddableInput & { panels: never };
export interface DashboardContainerByValueInput extends EmbeddableInput {
export interface DashboardContainerInput extends EmbeddableInput {
// filter context to be passed to children
query: Query;
filters: Filter[];

View file

@ -17,7 +17,6 @@ export type {
DashboardPanelMap,
DashboardPanelState,
DashboardContainerInput,
DashboardContainerByValueInput,
DashboardContainerByReferenceInput,
} from './dashboard_container/types';

View file

@ -10,7 +10,7 @@ import { EmbeddableInput, EmbeddableStateWithType } from '@kbn/embeddable-plugin
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { SavedDashboardPanel } from './dashboard_saved_object/types';
import { DashboardContainerByValueInput, DashboardPanelState } from './dashboard_container/types';
import { DashboardContainerInput, DashboardPanelState } from './dashboard_container/types';
export interface DashboardOptions {
hidePanelTitles: boolean;
@ -32,7 +32,7 @@ export interface DashboardCapabilities {
* For BWC reasons, dashboard state is stored with panels as an array instead of a map
*/
export type SharedDashboardState = Partial<
Omit<DashboardContainerByValueInput, 'panels'> & { panels: SavedDashboardPanel[] }
Omit<DashboardContainerInput, 'panels'> & { panels: SavedDashboardPanel[] }
>;
/**

View file

@ -22,7 +22,7 @@ import {
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { getSampleDashboardInput } from '../mocks';
import { buildMockDashboard } from '../mocks';
import { pluginServices } from '../services/plugin_services';
import { AddToLibraryAction } from './add_to_library_action';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
@ -48,8 +48,7 @@ Object.defineProperty(pluginServices.getServices().application, 'capabilities',
beforeEach(async () => {
pluginServices.getServices().application.capabilities = defaultCapabilities;
container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
container = buildMockDashboard();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -21,7 +21,7 @@ import { ErrorEmbeddable, IContainer, isErrorEmbeddable } from '@kbn/embeddable-
import { DashboardPanelState } from '../../common';
import { ClonePanelAction } from './clone_panel_action';
import { pluginServices } from '../services/plugin_services';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks';
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
let container: DashboardContainer;
@ -37,7 +37,12 @@ beforeEach(async () => {
create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })),
};
const input = getSampleDashboardInput({
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(mockEmbeddableFactory);
container = buildMockDashboard({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Kibanana', id: '123' },
@ -45,13 +50,6 @@ beforeEach(async () => {
}),
},
});
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(mockEmbeddableFactory);
container = new DashboardContainer(input);
await container.untilInitialized();
const refOrValContactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -7,7 +7,7 @@
*/
import { ExpandPanelAction } from './expand_panel_action';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks';
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
@ -30,7 +30,7 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.mockReturnValue(mockEmbeddableFactory);
beforeEach(async () => {
const input = getSampleDashboardInput({
container = buildMockDashboard({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
@ -39,9 +39,6 @@ beforeEach(async () => {
},
});
container = new DashboardContainer(input);
await container.untilInitialized();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,

View file

@ -20,7 +20,7 @@ import { isErrorEmbeddable, IContainer, ErrorEmbeddable } from '@kbn/embeddable-
import { ExportCSVAction } from './export_csv_action';
import { pluginServices } from '../services/plugin_services';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks';
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
describe('Export CSV action', () => {
@ -45,7 +45,7 @@ describe('Export CSV action', () => {
create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })),
};
const input = getSampleDashboardInput({
container = buildMockDashboard({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Kibanana', id: '123' },
@ -53,8 +53,6 @@ describe('Export CSV action', () => {
}),
},
});
container = new DashboardContainer(input);
await container.untilInitialized();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -17,9 +17,8 @@ import {
import { type Query, type AggregateQuery, Filter } from '@kbn/es-query';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { getSampleDashboardInput } from '../mocks';
import { buildMockDashboard } from '../mocks';
import { pluginServices } from '../services/plugin_services';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
import { FiltersNotificationAction } from './filters_notification_action';
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
@ -51,8 +50,7 @@ const getMockPhraseFilter = (key: string, value: string) => {
};
const buildEmbeddable = async (input?: Partial<ContactCardEmbeddableInput>) => {
const container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
const container = buildMockDashboard();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,

View file

@ -13,7 +13,7 @@ import { FilterableEmbeddable, isErrorEmbeddable, ViewMode } from '@kbn/embeddab
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { getSampleDashboardInput } from '../mocks';
import { buildMockDashboard } from '../mocks';
import { EuiPopover } from '@elastic/eui';
import {
FiltersNotificationPopover,
@ -40,8 +40,7 @@ describe('filters notification popover', () => {
let defaultProps: FiltersNotificationProps;
beforeEach(async () => {
container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
container = buildMockDashboard();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,

View file

@ -22,11 +22,11 @@ import {
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { getSampleDashboardInput } from '../mocks';
import { pluginServices } from '../services/plugin_services';
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
import { LibraryNotificationAction } from './library_notification_action';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
import { buildMockDashboard } from '../mocks';
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
@ -43,8 +43,7 @@ beforeEach(async () => {
execute: jest.fn(),
} as unknown as UnlinkFromLibraryAction;
container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
container = buildMockDashboard();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -24,7 +24,7 @@ import {
LibraryNotificationPopover,
LibraryNotificationProps,
} from './library_notification_popover';
import { getSampleDashboardInput } from '../mocks';
import { buildMockDashboard } from '../mocks';
import { pluginServices } from '../services/plugin_services';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
@ -38,8 +38,7 @@ describe('LibraryNotificationPopover', () => {
let defaultProps: LibraryNotificationProps;
beforeEach(async () => {
container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
container = buildMockDashboard();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -17,7 +17,7 @@ import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { ReplacePanelAction } from './replace_panel_action';
import { pluginServices } from '../services/plugin_services';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../mocks';
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
@ -28,7 +28,7 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest
let container: DashboardContainer;
let embeddable: ContactCardEmbeddable;
beforeEach(async () => {
const input = getSampleDashboardInput({
container = buildMockDashboard({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
@ -36,8 +36,6 @@ beforeEach(async () => {
}),
},
});
container = new DashboardContainer(input);
await container.untilInitialized();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
@ -54,7 +52,7 @@ beforeEach(async () => {
}
});
test('Executes the replace panel action', async () => {
test('Executes the replace panel action', () => {
let SavedObjectFinder: any;
const action = new ReplacePanelAction(SavedObjectFinder);
action.execute({ embeddable });
@ -82,13 +80,13 @@ test('Execute throws an error when called with an embeddable not in a parent', a
await expect(check()).rejects.toThrow(Error);
});
test('Returns title', async () => {
test('Returns title', () => {
let SavedObjectFinder: any;
const action = new ReplacePanelAction(SavedObjectFinder);
expect(action.getDisplayName({ embeddable })).toBeDefined();
});
test('Returns an icon', async () => {
test('Returns an icon', () => {
let SavedObjectFinder: any;
const action = new ReplacePanelAction(SavedObjectFinder);
expect(action.getIconType({ embeddable })).toBeDefined();

View file

@ -23,7 +23,7 @@ import {
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { getSampleDashboardInput } from '../mocks';
import { buildMockDashboard } from '../mocks';
import { DashboardPanelState } from '../../common';
import { pluginServices } from '../services/plugin_services';
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
@ -38,8 +38,7 @@ pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.mockReturnValue(mockEmbeddableFactory);
beforeEach(async () => {
container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
container = buildMockDashboard();
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -8,7 +8,8 @@
import { History } from 'history';
import useMount from 'react-use/lib/useMount';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
@ -28,14 +29,14 @@ import {
removeSearchSessionIdFromURL,
createSessionRestorationDataProvider,
} from './url/search_sessions_integration';
import { DashboardAPI, DashboardRenderer } from '..';
import { DASHBOARD_APP_ID } from '../dashboard_constants';
import { pluginServices } from '../services/plugin_services';
import { DashboardTopNav } from './top_nav/dashboard_top_nav';
import type { DashboardContainer } from '../dashboard_container';
import { AwaitingDashboardAPI } from '../dashboard_container';
import { type DashboardEmbedSettings, DashboardRedirect } from './types';
import { useDashboardMountContext } from './hooks/dashboard_mount_context';
import { useDashboardOutcomeValidation } from './hooks/use_dashboard_outcome_validation';
import DashboardContainerRenderer from '../dashboard_container/dashboard_container_renderer';
import { loadDashboardHistoryLocationState } from './locator/load_dashboard_history_location_state';
import type { DashboardCreationOptions } from '../dashboard_container/embeddable/dashboard_container_factory';
@ -46,6 +47,16 @@ export interface DashboardAppProps {
embedSettings?: DashboardEmbedSettings;
}
export const DashboardAPIContext = createContext<AwaitingDashboardAPI>(null);
export const useDashboardAPI = (): DashboardAPI => {
const api = useContext<AwaitingDashboardAPI>(DashboardAPIContext);
if (api == null) {
throw new Error('useDashboardAPI must be used inside DashboardAPIContext');
}
return api!;
};
export function DashboardApp({
savedDashboardId,
embedSettings,
@ -57,10 +68,7 @@ export function DashboardApp({
useMount(() => {
(async () => setShowNoDataPage(await isDashboardAppInNoDataState()))();
});
const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer | undefined>(
undefined
);
const [dashboardAPI, setDashboardAPI] = useState<AwaitingDashboardAPI>(null);
/**
* Unpack & set up dashboard services
@ -72,7 +80,9 @@ export function DashboardApp({
notifications: { toasts },
settings: { uiSettings },
data: { search },
customBranding,
} = pluginServices.getServices();
const showPlainSpinner = useObservable(customBranding.hasCustomBranding$, false);
const incomingEmbeddable = getStateTransfer().getIncomingEmbeddablePackage(
DASHBOARD_APP_ID,
@ -140,7 +150,7 @@ export function DashboardApp({
},
// Override all state with URL + Locator input
overrideInput: {
initialInput: {
// State loaded from the dashboard app URL and from the locator overrides all other dashboard state.
...initialUrlState,
...stateFromLocator,
@ -163,26 +173,17 @@ export function DashboardApp({
getScreenshotContext,
]);
/**
* Get the redux wrapper from the dashboard container. This is used to wrap the top nav so it can interact with the
* dashboard's redux state.
*/
const DashboardReduxWrapper = useMemo(
() => dashboardContainer?.getReduxEmbeddableTools().Wrapper,
[dashboardContainer]
);
/**
* When the dashboard container is created, or re-created, start syncing dashboard state with the URL
*/
useEffect(() => {
if (!dashboardContainer) return;
if (!dashboardAPI) return;
const { stopWatchingAppStateInUrl } = startSyncingDashboardUrlState({
kbnUrlStateStorage,
dashboardContainer,
dashboardAPI,
});
return () => stopWatchingAppStateInUrl();
}, [dashboardContainer, kbnUrlStateStorage]);
}, [dashboardAPI, kbnUrlStateStorage]);
return (
<div className="dshAppWrapper">
@ -191,18 +192,19 @@ export function DashboardApp({
)}
{!showNoDataPage && (
<>
{DashboardReduxWrapper && (
<DashboardReduxWrapper>
{dashboardAPI && (
<DashboardAPIContext.Provider value={dashboardAPI}>
<DashboardTopNav redirectTo={redirectTo} embedSettings={embedSettings} />
</DashboardReduxWrapper>
</DashboardAPIContext.Provider>
)}
{getLegacyConflictWarning?.()}
<DashboardContainerRenderer
<DashboardRenderer
ref={setDashboardAPI}
savedObjectId={savedDashboardId}
showPlainSpinner={showPlainSpinner}
getCreationOptions={getCreationOptions}
onDashboardContainerLoaded={(container) => setDashboardContainer(container)}
/>
</>
)}

View file

@ -8,11 +8,11 @@
import { ScopedHistory } from '@kbn/core-application-browser';
import { ForwardedDashboardState } from './locator';
import { convertSavedPanelsToPanelMap, DashboardContainerByValueInput } from '../../../common';
import { convertSavedPanelsToPanelMap, DashboardContainerInput } from '../../../common';
export const loadDashboardHistoryLocationState = (
getScopedHistory: () => ScopedHistory
): Partial<DashboardContainerByValueInput> => {
): Partial<DashboardContainerInput> => {
const state = getScopedHistory().location.state as undefined | ForwardedDashboardState;
if (!state) {

View file

@ -16,7 +16,7 @@ import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'
import type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public';
import { DASHBOARD_APP_ID, SEARCH_SESSION_ID } from '../../dashboard_constants';
import type { DashboardContainerByValueInput, SavedDashboardPanel } from '../../../common';
import type { DashboardContainerInput, SavedDashboardPanel } from '../../../common';
/**
* Useful for ensuring that we don't pass any non-serializable values to history.push (for example, functions).
@ -36,7 +36,7 @@ export const DASHBOARD_APP_LOCATOR = 'DASHBOARD_APP_LOCATOR';
export type DashboardAppLocatorParams = Partial<
Omit<
DashboardContainerByValueInput,
DashboardContainerInput,
'panels' | 'controlGroupInput' | 'executionContext' | 'isEmbeddedExternally'
>
> & {

View file

@ -6,25 +6,31 @@
* Side Public License, v 1.
*/
import { METRIC_TYPE } from '@kbn/analytics';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { IconButton, IconButtonGroup } from '@kbn/shared-ux-button-toolbar';
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import React from 'react';
import { useCallback } from 'react';
import { IconType, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants';
import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_context';
import { pluginServices } from '../../services/plugin_services';
import React, { useCallback } from 'react';
import { METRIC_TYPE } from '@kbn/analytics';
import { IconType, useEuiTheme } from '@elastic/eui';
import {
AddFromLibraryButton,
IconButton,
IconButtonGroup,
Toolbar,
ToolbarButton,
} from '@kbn/shared-ux-button-toolbar';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import {
getCreateVisualizationButtonTitle,
getQuickCreateButtonGroupLegend,
} from '../_dashboard_app_strings';
import { EditorMenu } from './editor_menu';
import { useDashboardAPI } from '../dashboard_app';
import { pluginServices } from '../../services/plugin_services';
import { ControlsToolbarButton } from './controls_toolbar_button';
import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants';
import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
export function DashboardEditingToolbar() {
const {
@ -36,7 +42,7 @@ export function DashboardEditingToolbar() {
} = pluginServices.getServices();
const { euiTheme } = useEuiTheme();
const { embeddableInstance: dashboardContainer } = useDashboardContainerContext();
const dashboard = useDashboardAPI();
const stateTransferService = getStateTransfer();
@ -101,10 +107,7 @@ export function DashboardEditingToolbar() {
return;
}
const newEmbeddable = await dashboardContainer.addNewEmbeddable(
embeddableFactory.type,
explicitInput
);
const newEmbeddable = await dashboard.addNewEmbeddable(embeddableFactory.type, explicitInput);
if (newEmbeddable) {
toasts.addSuccess({
@ -113,7 +116,7 @@ export function DashboardEditingToolbar() {
});
}
},
[trackUiMetric, dashboardContainer, toasts]
[trackUiMetric, dashboard, toasts]
);
const getVisTypeQuickButton = (
@ -170,12 +173,12 @@ export function DashboardEditingToolbar() {
const extraButtons = [
<EditorMenu createNewVisType={createNewVisType} createNewEmbeddable={createNewEmbeddable} />,
<AddFromLibraryButton
onClick={() => dashboardContainer.addFromLibrary()}
onClick={() => dashboard.addFromLibrary()}
data-test-subj="dashboardAddPanelButton"
/>,
];
if (dashboardContainer.controlGroup) {
extraButtons.push(<ControlsToolbarButton controlGroup={dashboardContainer.controlGroup} />);
if (dashboard.controlGroup) {
extraButtons.push(<ControlsToolbarButton controlGroup={dashboard.controlGroup} />);
}
return (

View file

@ -26,13 +26,13 @@ import {
unsavedChangesBadgeStrings,
} from '../_dashboard_app_strings';
import { UI_SETTINGS } from '../../../common';
import { useDashboardAPI } from '../dashboard_app';
import { pluginServices } from '../../services/plugin_services';
import { useDashboardMenuItems } from './use_dashboard_menu_items';
import { DashboardEmbedSettings, DashboardRedirect } from '../types';
import { DashboardEditingToolbar } from './dashboard_editing_toolbar';
import { useDashboardMountContext } from '../hooks/dashboard_mount_context';
import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../../dashboard_constants';
import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_context';
import './_dashboard_top_nav.scss';
export interface DashboardTopNavProps {
@ -69,36 +69,26 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
} = pluginServices.getServices();
const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI);
const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext();
/**
* Unpack dashboard state from redux
*/
const {
useEmbeddableDispatch,
actions: { setSavedQueryId },
useEmbeddableSelector: select,
embeddableInstance: dashboardContainer,
} = useDashboardContainerContext();
const dispatch = useEmbeddableDispatch();
const dashboard = useDashboardAPI();
const PresentationUtilContextProvider = getPresentationUtilContextProvider();
const hasUnsavedChanges = select((state) => state.componentState.hasUnsavedChanges);
const fullScreenMode = select((state) => state.componentState.fullScreenMode);
const savedQueryId = select((state) => state.componentState.savedQueryId);
const lastSavedId = select((state) => state.componentState.lastSavedId);
const viewMode = select((state) => state.explicitInput.viewMode);
const query = select((state) => state.explicitInput.query);
const title = select((state) => state.explicitInput.title);
const hasUnsavedChanges = dashboard.select((state) => state.componentState.hasUnsavedChanges);
const fullScreenMode = dashboard.select((state) => state.componentState.fullScreenMode);
const savedQueryId = dashboard.select((state) => state.componentState.savedQueryId);
const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const query = dashboard.select((state) => state.explicitInput.query);
const title = dashboard.select((state) => state.explicitInput.title);
// store data views in state & subscribe to dashboard data view changes.
const [allDataViews, setAllDataViews] = useState<DataView[]>(
dashboardContainer.getAllDataViews()
);
const [allDataViews, setAllDataViews] = useState<DataView[]>(dashboard.getAllDataViews());
useEffect(() => {
const subscription = dashboardContainer.onDataViewsUpdate$.subscribe((dataViews) =>
const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) =>
setAllDataViews(dataViews)
);
return () => subscription.unsubscribe();
}, [dashboardContainer]);
}, [dashboard]);
const dashboardTitle = useMemo(() => {
return getDashboardTitle(title, viewMode, !lastSavedId);
@ -212,7 +202,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
}, [embedSettings, filterManager, fullScreenMode, isChromeVisible, viewMode]);
UseUnmount(() => {
dashboardContainer.clearOverlays();
dashboard.clearOverlays();
});
return (
@ -264,12 +254,12 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
}
onQuerySubmit={(_payload, isUpdate) => {
if (isUpdate === false) {
dashboardContainer.forceRefresh();
dashboard.forceRefresh();
}
}}
onSavedQueryIdChange={(newId: string | undefined) => {
dispatch(setSavedQueryId(newId));
}}
onSavedQueryIdChange={(newId: string | undefined) =>
dashboard.dispatch.setSavedQueryId(newId)
}
/>
{viewMode !== ViewMode.PRINT && isLabsEnabled && isLabsShown ? (
<PresentationUtilContextProvider>

View file

@ -7,7 +7,7 @@
*/
import { Capabilities } from '@kbn/core/public';
import { convertPanelMapToSavedPanels, DashboardContainerByValueInput } from '../../../../common';
import { convertPanelMapToSavedPanels, DashboardContainerInput } from '../../../../common';
import { DashboardAppLocatorParams } from '../../..';
import { pluginServices } from '../../../services/plugin_services';
@ -68,7 +68,7 @@ describe('ShowShareModal', () => {
});
const getPropsAndShare = (
unsavedState?: Partial<DashboardContainerByValueInput>
unsavedState?: Partial<DashboardContainerInput>
): ShowShareModalProps => {
pluginServices.getServices().dashboardSessionStorage.getState = jest
.fn()
@ -94,7 +94,7 @@ describe('ShowShareModal', () => {
});
it('locatorParams unsaved state is properly propagated to locator', () => {
const unsavedDashboardState: DashboardContainerByValueInput = {
const unsavedDashboardState: DashboardContainerInput = {
panels: {
panel_1: {
type: 'panel_type',
@ -121,7 +121,7 @@ describe('ShowShareModal', () => {
},
],
query: { query: 'bye', language: 'kuery' },
} as unknown as DashboardContainerByValueInput;
} as unknown as DashboardContainerInput;
const showModalProps = getPropsAndShare(unsavedDashboardState);
ShowShareModal(showModalProps);
expect(toggleShareMenuSpy).toHaveBeenCalledTimes(1);

View file

@ -14,13 +14,13 @@ import { TopNavMenuData } from '@kbn/navigation-plugin/public';
import { DashboardRedirect } from '../types';
import { UI_SETTINGS } from '../../../common';
import { useDashboardAPI } from '../dashboard_app';
import { topNavStrings } from '../_dashboard_app_strings';
import { ShowShareModal } from './share/show_share_modal';
import { pluginServices } from '../../services/plugin_services';
import { CHANGE_CHECK_DEBOUNCE } from '../../dashboard_constants';
import { SaveDashboardReturn } from '../../services/dashboard_saved_object/types';
import { confirmDiscardUnsavedChanges } from '../../dashboard_listing/confirm_overlays';
import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_context';
export const useDashboardMenuItems = ({
redirectTo,
@ -46,18 +46,12 @@ export const useDashboardMenuItems = ({
/**
* Unpack dashboard state from redux
*/
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
embeddableInstance: dashboardContainer,
actions: { setViewMode, setFullScreenMode },
} = useDashboardContainerContext();
const dispatch = useEmbeddableDispatch();
const dashboard = useDashboardAPI();
const hasUnsavedChanges = select((state) => state.componentState.hasUnsavedChanges);
const hasOverlays = select((state) => state.componentState.hasOverlays);
const lastSavedId = select((state) => state.componentState.lastSavedId);
const dashboardTitle = select((state) => state.explicitInput.title);
const hasUnsavedChanges = dashboard.select((state) => state.componentState.hasUnsavedChanges);
const hasOverlays = dashboard.select((state) => state.componentState.hasOverlays);
const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId);
const dashboardTitle = dashboard.select((state) => state.explicitInput.title);
/**
* Show the Dashboard app's share menu
@ -95,41 +89,41 @@ export const useDashboardMenuItems = ({
*/
const quickSaveDashboard = useCallback(() => {
setIsSaveInProgress(true);
dashboardContainer
dashboard
.runQuickSave()
.then(() => setTimeout(() => setIsSaveInProgress(false), CHANGE_CHECK_DEBOUNCE));
}, [dashboardContainer]);
}, [dashboard]);
/**
* Show the dashboard's save modal
*/
const saveDashboardAs = useCallback(() => {
dashboardContainer.runSaveAs().then((result) => maybeRedirect(result));
}, [maybeRedirect, dashboardContainer]);
dashboard.runSaveAs().then((result) => maybeRedirect(result));
}, [maybeRedirect, dashboard]);
/**
* Clone the dashboard
*/
const clone = useCallback(() => {
dashboardContainer.runClone().then((result) => maybeRedirect(result));
}, [maybeRedirect, dashboardContainer]);
dashboard.runClone().then((result) => maybeRedirect(result));
}, [maybeRedirect, dashboard]);
/**
* Returns to view mode. If the dashboard has unsaved changes shows a warning and resets to last saved state.
*/
const returnToViewMode = useCallback(() => {
dashboardContainer.clearOverlays();
dashboard.clearOverlays();
if (hasUnsavedChanges) {
confirmDiscardUnsavedChanges(() => {
batch(() => {
dashboardContainer.resetToLastSavedState();
dispatch(setViewMode(ViewMode.VIEW));
dashboard.resetToLastSavedState();
dashboard.dispatch.setViewMode(ViewMode.VIEW);
});
});
return;
}
dispatch(setViewMode(ViewMode.VIEW));
}, [dashboardContainer, dispatch, hasUnsavedChanges, setViewMode]);
dashboard.dispatch.setViewMode(ViewMode.VIEW);
}, [dashboard, hasUnsavedChanges]);
/**
* Register all of the top nav configs that can be used by dashboard.
@ -140,7 +134,7 @@ export const useDashboardMenuItems = ({
...topNavStrings.fullScreen,
id: 'full-screen',
testId: 'dashboardFullScreenMode',
run: () => dispatch(setFullScreenMode(true)),
run: () => dashboard.dispatch.setFullScreenMode(true),
} as TopNavMenuData,
labs: {
@ -158,8 +152,8 @@ export const useDashboardMenuItems = ({
testId: 'dashboardEditMode',
className: 'eui-hideFor--s eui-hideFor--xs', // hide for small screens - editing doesn't work in mobile mode.
run: () => {
dashboardContainer.clearOverlays();
dispatch(setViewMode(ViewMode.EDIT));
dashboard.dispatch.setViewMode(ViewMode.EDIT);
dashboard.clearOverlays();
},
} as TopNavMenuData,
@ -206,7 +200,7 @@ export const useDashboardMenuItems = ({
id: 'settings',
testId: 'dashboardSettingsButton',
disableButton: isSaveInProgress || hasOverlays,
run: () => dashboardContainer.showSettings(),
run: () => dashboard.showSettings(),
} as TopNavMenuData,
clone: {
@ -219,19 +213,16 @@ export const useDashboardMenuItems = ({
};
}, [
quickSaveDashboard,
dashboardContainer,
hasUnsavedChanges,
hasOverlays,
setFullScreenMode,
isSaveInProgress,
returnToViewMode,
saveDashboardAs,
setIsLabsShown,
hasOverlays,
lastSavedId,
setViewMode,
isLabsShown,
showShare,
dispatch,
dashboard,
clone,
]);

View file

@ -83,7 +83,7 @@ function getLocatorParams({
const {
componentState: { lastSavedId },
explicitInput: { panels, query, viewMode },
} = container.getReduxEmbeddableTools().getState();
} = container.getState();
return {
viewMode,

View file

@ -18,9 +18,9 @@ import {
SavedDashboardPanel,
SharedDashboardState,
convertSavedPanelsToPanelMap,
DashboardContainerByValueInput,
DashboardContainerInput,
} from '../../../common';
import { DashboardContainer } from '../../dashboard_container';
import { DashboardAPI } from '../../dashboard_container';
import { pluginServices } from '../../services/plugin_services';
import { getPanelTooOldErrorString } from '../_dashboard_app_strings';
import { DASHBOARD_STATE_STORAGE_KEY } from '../../dashboard_constants';
@ -59,7 +59,7 @@ function getPanelsMap(appStateInUrl: SharedDashboardState): DashboardPanelMap |
*/
export const loadAndRemoveDashboardState = (
kbnUrlStateStorage: IKbnUrlStateStorage
): Partial<DashboardContainerByValueInput> => {
): Partial<DashboardContainerInput> => {
const rawAppStateInUrl = kbnUrlStateStorage.get<SharedDashboardState>(
DASHBOARD_STATE_STORAGE_KEY
);
@ -72,7 +72,7 @@ export const loadAndRemoveDashboardState = (
return hashQuery;
});
kbnUrlStateStorage.kbnUrlControls.update(nextUrl, true);
const partialState: Partial<DashboardContainerByValueInput> = {
const partialState: Partial<DashboardContainerInput> = {
..._.omit(rawAppStateInUrl, ['panels', 'query']),
...(panelsMap ? { panels: panelsMap } : {}),
...(rawAppStateInUrl.query ? { query: migrateLegacyQuery(rawAppStateInUrl.query) } : {}),
@ -83,10 +83,10 @@ export const loadAndRemoveDashboardState = (
export const startSyncingDashboardUrlState = ({
kbnUrlStateStorage,
dashboardContainer,
dashboardAPI,
}: {
kbnUrlStateStorage: IKbnUrlStateStorage;
dashboardContainer: DashboardContainer;
dashboardAPI: DashboardAPI;
}) => {
const appStateSubscription = kbnUrlStateStorage
.change$(DASHBOARD_STATE_STORAGE_KEY)
@ -94,7 +94,7 @@ export const startSyncingDashboardUrlState = ({
.subscribe(() => {
const stateFromUrl = loadAndRemoveDashboardState(kbnUrlStateStorage);
if (Object.keys(stateFromUrl).length === 0) return;
dashboardContainer.updateInput(stateFromUrl);
dashboardAPI.updateInput(stateFromUrl);
});
const stopWatchingAppStateInUrl = () => appStateSubscription.unsubscribe();

View file

@ -7,7 +7,7 @@
*/
import { ViewMode } from '@kbn/embeddable-plugin/common';
import type { DashboardContainerByValueInput } from '../common';
import type { DashboardContainerInput } from '../common';
// ------------------------------------------------------------------
// URL Constants
@ -69,7 +69,7 @@ export const CHANGE_CHECK_DEBOUNCE = 100;
// ------------------------------------------------------------------
// Default State
// ------------------------------------------------------------------
export const DEFAULT_DASHBOARD_INPUT: Omit<DashboardContainerByValueInput, 'id'> = {
export const DEFAULT_DASHBOARD_INPUT: Omit<DashboardContainerInput, 'id'> = {
viewMode: ViewMode.EDIT, // new dashboards start in edit mode.
timeRestore: false,
query: { query: '', language: 'kuery' },

View file

@ -6,17 +6,15 @@
* Side Public License, v 1.
*/
// @ts-ignore
import React from 'react';
import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { CONTACT_CARD_EMBEDDABLE } from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
import { pluginServices } from '../../../services/plugin_services';
import { DashboardGrid } from './dashboard_grid';
import { DashboardContainer } from '../../embeddable/dashboard_container';
import { getSampleDashboardInput } from '../../../mocks';
import { buildMockDashboard } from '../../../mocks';
import type { Props as DashboardGridItemProps } from './dashboard_grid_item';
import { DashboardContainerContext } from '../../embeddable/dashboard_container';
jest.mock('./dashboard_grid_item', () => {
return {
@ -39,10 +37,8 @@ jest.mock('./dashboard_grid_item', () => {
};
});
const DashboardServicesProvider = pluginServices.getContextProvider();
async function getDashboardContainer() {
const initialInput = getSampleDashboardInput({
const createAndMountDashboardGrid = () => {
const dashboardContainer = buildMockDashboard({
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
@ -56,55 +52,29 @@ async function getDashboardContainer() {
},
},
});
const dashboardContainer = new DashboardContainer(initialInput);
await dashboardContainer.untilInitialized();
return dashboardContainer;
}
const component = mountWithIntl(
<DashboardContainerContext.Provider value={dashboardContainer}>
<DashboardGrid viewportWidth={1000} />
</DashboardContainerContext.Provider>
);
return { dashboardContainer, component };
};
test('renders DashboardGrid', async () => {
const dashboardContainer = await getDashboardContainer();
const { Wrapper: DashboardReduxWrapper } = dashboardContainer.getReduxEmbeddableTools();
const component = mountWithIntl(
<DashboardServicesProvider>
<DashboardReduxWrapper>
<DashboardGrid viewportWidth={1000} />
</DashboardReduxWrapper>
</DashboardServicesProvider>
);
const { component } = createAndMountDashboardGrid();
const panelElements = component.find('GridItem');
expect(panelElements.length).toBe(2);
});
test('renders DashboardGrid with no visualizations', async () => {
const dashboardContainer = await getDashboardContainer();
const { Wrapper: DashboardReduxWrapper } = dashboardContainer.getReduxEmbeddableTools();
const component = mountWithIntl(
<DashboardServicesProvider>
<DashboardReduxWrapper>
<DashboardGrid viewportWidth={1000} />
</DashboardReduxWrapper>
</DashboardServicesProvider>
);
const { dashboardContainer, component } = createAndMountDashboardGrid();
dashboardContainer.updateInput({ panels: {} });
component.update();
expect(component.find('GridItem').length).toBe(0);
});
test('DashboardGrid removes panel when removed from container', async () => {
const dashboardContainer = await getDashboardContainer();
const { Wrapper: DashboardReduxWrapper } = dashboardContainer.getReduxEmbeddableTools();
const component = mountWithIntl(
<DashboardServicesProvider>
<DashboardReduxWrapper>
<DashboardGrid viewportWidth={1000} />
</DashboardReduxWrapper>
</DashboardServicesProvider>
);
const { dashboardContainer, component } = createAndMountDashboardGrid();
const originalPanels = dashboardContainer.getInput().panels;
const filteredPanels = { ...originalPanels };
delete filteredPanels['1'];
@ -115,17 +85,7 @@ test('DashboardGrid removes panel when removed from container', async () => {
});
test('DashboardGrid renders expanded panel', async () => {
const dashboardContainer = await getDashboardContainer();
const { Wrapper: DashboardReduxWrapper } = dashboardContainer.getReduxEmbeddableTools();
const component = mountWithIntl(
<DashboardServicesProvider>
<DashboardReduxWrapper>
<DashboardGrid viewportWidth={1000} />
</DashboardReduxWrapper>
</DashboardServicesProvider>
);
const { dashboardContainer, component } = createAndMountDashboardGrid();
dashboardContainer.setExpandedPanelId('1');
component.update();
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.

View file

@ -19,23 +19,18 @@ import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardPanelState } from '../../../../common';
import { DashboardGridItem } from './dashboard_grid_item';
import { DASHBOARD_GRID_HEIGHT, DASHBOARD_MARGIN_SIZE } from '../../../dashboard_constants';
import { useDashboardGridSettings } from './use_dashboard_grid_settings';
import { useDashboardContainerContext } from '../../dashboard_container_context';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
import { useDashboardPerformanceTracker } from './use_dashboard_performance_tracker';
import { getPanelLayoutsAreEqual } from '../../embeddable/integrations/diff_state/dashboard_diffing_utils';
import { getPanelLayoutsAreEqual } from '../../state/diffing/dashboard_diffing_utils';
import { DASHBOARD_GRID_HEIGHT, DASHBOARD_MARGIN_SIZE } from '../../../dashboard_constants';
export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
const {
useEmbeddableSelector: select,
actions: { setPanels },
useEmbeddableDispatch,
} = useDashboardContainerContext();
const dispatch = useEmbeddableDispatch();
const panels = select((state) => state.explicitInput.panels);
const viewMode = select((state) => state.explicitInput.viewMode);
const useMargins = select((state) => state.explicitInput.useMargins);
const expandedPanelId = select((state) => state.componentState.expandedPanelId);
const dashboard = useDashboardContainer();
const panels = dashboard.select((state) => state.explicitInput.panels);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const useMargins = dashboard.select((state) => state.explicitInput.useMargins);
const expandedPanelId = dashboard.select((state) => state.componentState.expandedPanelId);
// turn off panel transform animations for the first 500ms so that the dashboard doesn't animate on its first render.
const [animatePanelTransforms, setAnimatePanelTransforms] = useState(false);
@ -93,10 +88,10 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
{} as { [key: string]: DashboardPanelState }
);
if (!getPanelLayoutsAreEqual(panels, updatedPanels)) {
dispatch(setPanels(updatedPanels));
dashboard.dispatch.setPanels(updatedPanels);
}
},
[dispatch, panels, setPanels]
[dashboard, panels]
);
const classes = classNames({

View file

@ -18,7 +18,7 @@ import {
import { DashboardPanelState } from '../../../../common';
import { pluginServices } from '../../../services/plugin_services';
import { useDashboardContainerContext } from '../../dashboard_container_context';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
type DivProps = Pick<React.HTMLAttributes<HTMLDivElement>, 'className' | 'style' | 'children'>;
@ -55,7 +55,7 @@ const Item = React.forwardRef<HTMLDivElement, Props>(
const {
embeddable: { EmbeddablePanel: PanelComponent },
} = pluginServices.getServices();
const { embeddableInstance: container } = useDashboardContainerContext();
const container = useDashboardContainer();
const expandPanel = expandedPanelId !== undefined && expandedPanelId === id;
const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id;
@ -134,9 +134,9 @@ export const DashboardGridItem = React.forwardRef<HTMLDivElement, Props>((props,
settings: { isProjectEnabledInLabs },
} = pluginServices.getServices();
const { useEmbeddableSelector: select } = useDashboardContainerContext();
const dashboard = useDashboardContainer();
const isPrintMode = select((state) => state.explicitInput.viewMode) === ViewMode.PRINT;
const isPrintMode = dashboard.select((state) => state.explicitInput.viewMode) === ViewMode.PRINT;
const isEnabled = !isPrintMode && isProjectEnabledInLabs('labs:dashboard:deferBelowFold');
return isEnabled ? <ObservedItem ref={ref} {...props} /> : <Item ref={ref} {...props} />;

View file

@ -12,14 +12,14 @@ import { useEuiTheme } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants';
import { useDashboardContainerContext } from '../../dashboard_container_context';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
export const useDashboardGridSettings = (panelsInOrder: string[]) => {
const { useEmbeddableSelector: select } = useDashboardContainerContext();
const dashboard = useDashboardContainer();
const { euiTheme } = useEuiTheme();
const panels = select((state) => state.explicitInput.panels);
const viewMode = select((state) => state.explicitInput.viewMode);
const panels = dashboard.select((state) => state.explicitInput.panels);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const layouts = useMemo(() => {
return {

View file

@ -10,7 +10,7 @@ import { useCallback, useRef } from 'react';
import { EmbeddablePhaseEvent } from '@kbn/embeddable-plugin/public';
import { useDashboardContainerContext } from '../../dashboard_container_context';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
import { DashboardLoadedEventStatus, DashboardRenderPerformanceStats } from '../../types';
type DashboardRenderPerformanceTracker = DashboardRenderPerformanceStats & {
@ -30,7 +30,7 @@ const getDefaultPerformanceTracker: () => DashboardRenderPerformanceTracker = ()
});
export const useDashboardPerformanceTracker = ({ panelCount }: { panelCount: number }) => {
const { embeddableInstance: dashboardContainer } = useDashboardContainerContext();
const dashboard = useDashboardContainer();
// reset performance tracker on each render.
const performanceRefs = useRef<DashboardRenderPerformanceTracker>(getDefaultPerformanceTracker());
@ -52,11 +52,11 @@ export const useDashboardPerformanceTracker = ({ panelCount }: { panelCount: num
performanceRefs.current.doneCount++;
if (performanceRefs.current.doneCount === panelCount) {
performanceRefs.current.panelsRenderDoneTime = performance.now();
dashboardContainer.reportPerformanceMetrics(performanceRefs.current);
dashboard.reportPerformanceMetrics(performanceRefs.current);
}
}
},
[dashboardContainer, panelCount]
[dashboard, panelCount]
);
return { onPanelStatusChange };

View file

@ -26,9 +26,9 @@ import {
EuiSwitch,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DashboardContainerByValueInput } from '../../../../common';
import { DashboardContainerInput } from '../../../../common';
import { pluginServices } from '../../../services/plugin_services';
import { useDashboardContainerContext } from '../../dashboard_container_context';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
interface DashboardSettingsProps {
onClose: () => void;
@ -42,23 +42,18 @@ export const DashboardSettings = ({ onClose }: DashboardSettingsProps) => {
dashboardSavedObject: { checkForDuplicateDashboardTitle },
} = pluginServices.getServices();
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setStateFromSettingsFlyout },
embeddableInstance: dashboardContainer,
} = useDashboardContainerContext();
const dashboard = useDashboardContainer();
const [dashboardSettingsState, setDashboardSettingsState] = useState({
...dashboardContainer.getInputAsValueType(),
...dashboard.getInput(),
});
const [isTitleDuplicate, setIsTitleDuplicate] = useState(false);
const [isTitleDuplicateConfirmed, setIsTitleDuplicateConfirmed] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const lastSavedId = select((state) => state.componentState.lastSavedId);
const lastSavedTitle = select((state) => state.explicitInput.title);
const lastSavedId = dashboard.select((state) => state.componentState.lastSavedId);
const lastSavedTitle = dashboard.select((state) => state.explicitInput.title);
const isMounted = useMountedState();
@ -83,24 +78,19 @@ export const DashboardSettings = ({ onClose }: DashboardSettingsProps) => {
setIsApplying(false);
if (validTitle) {
dispatch(setStateFromSettingsFlyout({ lastSavedId, ...dashboardSettingsState }));
dashboard.dispatch.setStateFromSettingsFlyout({ lastSavedId, ...dashboardSettingsState });
onClose();
}
};
const updateDashboardSetting = useCallback(
(newSettings: Partial<DashboardContainerByValueInput>) => {
setDashboardSettingsState((prevDashboardSettingsState) => {
return {
...prevDashboardSettingsState,
...newSettings,
};
});
},
[]
);
const dispatch = useEmbeddableDispatch();
const updateDashboardSetting = useCallback((newSettings: Partial<DashboardContainerInput>) => {
setDashboardSettingsState((prevDashboardSettingsState) => {
return {
...prevDashboardSettingsState,
...newSettings,
};
});
}, []);
const renderDuplicateTitleCallout = () => {
if (!isTitleDuplicate) {

View file

@ -17,8 +17,8 @@ import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen';
import { DashboardGrid } from '../grid';
import { pluginServices } from '../../../services/plugin_services';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
import { useDashboardContainerContext } from '../../dashboard_container_context';
export const useDebouncedWidthObserver = (wait = 250) => {
const [width, setWidth] = useState<number>(0);
@ -38,26 +38,25 @@ export const DashboardViewportComponent = () => {
} = pluginServices.getServices();
const controlsRoot = useRef(null);
const { useEmbeddableSelector: select, embeddableInstance: dashboardContainer } =
useDashboardContainerContext();
const dashboard = useDashboardContainer();
/**
* Render Control group
*/
const controlGroup = dashboardContainer.controlGroup;
const controlGroup = dashboard.controlGroup;
useEffect(() => {
if (controlGroup && controlsRoot.current) controlGroup.render(controlsRoot.current);
}, [controlGroup]);
const panelCount = Object.keys(select((state) => state.explicitInput.panels)).length;
const panelCount = Object.keys(dashboard.select((state) => state.explicitInput.panels)).length;
const controlCount = Object.keys(
select((state) => state.explicitInput.controlGroupInput?.panels) ?? {}
dashboard.select((state) => state.explicitInput.controlGroupInput?.panels) ?? {}
).length;
const viewMode = select((state) => state.explicitInput.viewMode);
const dashboardTitle = select((state) => state.explicitInput.title);
const description = select((state) => state.explicitInput.description);
const expandedPanelId = select((state) => state.componentState.expandedPanelId);
const viewMode = dashboard.select((state) => state.explicitInput.viewMode);
const dashboardTitle = dashboard.select((state) => state.explicitInput.title);
const description = dashboard.select((state) => state.explicitInput.description);
const expandedPanelId = dashboard.select((state) => state.componentState.expandedPanelId);
const controlsEnabled = isProjectEnabledInLabs('labs:dashboard:dashboardControls');
const { ref: resizeRef, width: viewportWidth } = useDebouncedWidthObserver();
@ -98,15 +97,12 @@ export const DashboardViewportComponent = () => {
// because ExitFullScreenButton sets isFullscreenMode to false on unmount while rerendering.
// This specifically fixed maximizing/minimizing panels without exiting fullscreen mode.
const WithFullScreenButton = ({ children }: { children: JSX.Element }) => {
const {
useEmbeddableDispatch,
useEmbeddableSelector: select,
actions: { setFullScreenMode },
} = useDashboardContainerContext();
const dispatch = useEmbeddableDispatch();
const dashboard = useDashboardContainer();
const isFullScreenMode = select((state) => state.componentState.fullScreenMode);
const isEmbeddedExternally = select((state) => state.componentState.isEmbeddedExternally);
const isFullScreenMode = dashboard.select((state) => state.componentState.fullScreenMode);
const isEmbeddedExternally = dashboard.select(
(state) => state.componentState.isEmbeddedExternally
);
return (
<>
@ -114,7 +110,7 @@ const WithFullScreenButton = ({ children }: { children: JSX.Element }) => {
{isFullScreenMode && (
<EuiPortal>
<ExitFullScreenButton
onExit={() => dispatch(setFullScreenMode(false))}
onExit={() => dashboard.dispatch.setFullScreenMode(false)}
toggleChrome={!isEmbeddedExternally}
/>
</EuiPortal>

View file

@ -1,19 +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 { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { dashboardContainerReducers } from './state/dashboard_container_reducers';
import { DashboardReduxState } from './types';
import { DashboardContainer } from '..';
export const useDashboardContainerContext = () =>
useReduxEmbeddableContext<
DashboardReduxState,
typeof dashboardContainerReducers,
DashboardContainer
>();

View file

@ -1,123 +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 './_dashboard_container.scss';
import { v4 as uuidv4 } from 'uuid';
import classNames from 'classnames';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { EuiLoadingElastic, EuiLoadingSpinner } from '@elastic/eui';
import {
DashboardContainerFactory,
DashboardContainerFactoryDefinition,
DashboardCreationOptions,
} from './embeddable/dashboard_container_factory';
import { DASHBOARD_CONTAINER_TYPE } from '..';
import { pluginServices } from '../services/plugin_services';
import { DEFAULT_DASHBOARD_INPUT } from '../dashboard_constants';
import { DashboardContainer } from './embeddable/dashboard_container';
export interface DashboardContainerRendererProps {
savedObjectId?: string;
getCreationOptions?: () => Promise<DashboardCreationOptions>;
onDashboardContainerLoaded?: (dashboardContainer: DashboardContainer) => void;
}
export const DashboardContainerRenderer = ({
savedObjectId,
getCreationOptions,
onDashboardContainerLoaded,
}: DashboardContainerRendererProps) => {
const {
embeddable,
screenshotMode: { isScreenshotMode },
customBranding,
} = pluginServices.getServices();
const dashboardRoot = useRef(null);
const [dashboardIdToBuild, setDashboardIdToBuild] = useState<string | undefined>(savedObjectId);
const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer>();
const [loading, setLoading] = useState(true);
const showPlainSpinner = useObservable(customBranding.hasCustomBranding$, false);
useEffect(() => {
// check if dashboard container is expecting id change... if not, update dashboardIdToBuild to force it to rebuild the container.
if (!dashboardContainer) return;
if (!dashboardContainer.isExpectingIdChange()) setDashboardIdToBuild(savedObjectId);
// Disabling exhaustive deps because this useEffect should only be triggered when the savedObjectId changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [savedObjectId]);
const id = useMemo(() => uuidv4(), []);
useEffect(() => {
let canceled = false;
let destroyContainer: () => void;
(async () => {
const creationOptions = await getCreationOptions?.();
const dashboardFactory = embeddable.getEmbeddableFactory(
DASHBOARD_CONTAINER_TYPE
) as DashboardContainerFactory & { create: DashboardContainerFactoryDefinition['create'] };
const container = (await dashboardFactory?.create(
{
id,
...DEFAULT_DASHBOARD_INPUT,
...creationOptions?.initialInput,
savedObjectId: dashboardIdToBuild,
},
undefined,
creationOptions
)) as DashboardContainer;
await container.untilInitialized();
if (canceled) {
container.destroy();
return;
}
setLoading(false);
if (dashboardRoot.current) {
container.render(dashboardRoot.current);
}
onDashboardContainerLoaded?.(container);
setDashboardContainer(container);
destroyContainer = () => container.destroy();
})();
return () => {
canceled = true;
destroyContainer?.();
};
// Disabling exhaustive deps because embeddable should only be created when the dashboardIdToBuild changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardIdToBuild]);
const viewportClasses = classNames(
'dashboardViewport',
{ 'dashboardViewport--screenshotMode': isScreenshotMode() },
{ 'dashboardViewport--loading': loading }
);
const loadingSpinner = showPlainSpinner ? (
<EuiLoadingSpinner size="xxl" />
) : (
<EuiLoadingElastic size="xxl" />
);
return (
<div className={viewportClasses}>{loading ? loadingSpinner : <div ref={dashboardRoot} />}</div>
);
};
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default DashboardContainerRenderer;

View file

@ -17,7 +17,7 @@ import { DashboardSaveModal } from './overlays/save_modal';
import { DashboardContainer } from '../dashboard_container';
import { showCloneModal } from './overlays/show_clone_modal';
import { pluginServices } from '../../../services/plugin_services';
import { DashboardContainerByValueInput } from '../../../../common';
import { DashboardContainerInput } from '../../../../common';
import { SaveDashboardReturn } from '../../../services/dashboard_saved_object/types';
export function runSaveAs(this: DashboardContainer) {
@ -31,15 +31,10 @@ export function runSaveAs(this: DashboardContainer) {
dashboardSavedObject: { checkForDuplicateDashboardTitle, saveDashboardStateToSavedObject },
} = pluginServices.getServices();
const {
getState,
dispatch,
actions: { setStateFromSaveModal, setLastSavedInput },
} = this.getReduxEmbeddableTools();
const {
explicitInput: currentState,
componentState: { lastSavedId },
} = getState();
} = this.getState();
return new Promise<SaveDashboardReturn | undefined>((resolve) => {
const onSave = async ({
@ -81,7 +76,7 @@ export function runSaveAs(this: DashboardContainer) {
// do not save if title is duplicate and is unconfirmed
return {};
}
const stateToSave: DashboardContainerByValueInput = {
const stateToSave: DashboardContainerInput = {
...currentState,
...stateFromSaveModal,
};
@ -103,8 +98,8 @@ export function runSaveAs(this: DashboardContainer) {
stateFromSaveModal.lastSavedId = saveResult.id;
if (saveResult.id) {
batch(() => {
dispatch(setStateFromSaveModal(stateFromSaveModal));
dispatch(setLastSavedInput(stateToSave));
this.dispatch.setStateFromSaveModal(stateFromSaveModal);
this.dispatch.setLastSavedInput(stateToSave);
});
}
if (newCopyOnSave || !lastSavedId) this.expectIdChange();
@ -136,22 +131,17 @@ export async function runQuickSave(this: DashboardContainer) {
dashboardSavedObject: { saveDashboardStateToSavedObject },
} = pluginServices.getServices();
const {
getState,
dispatch,
actions: { setLastSavedInput },
} = this.getReduxEmbeddableTools();
const {
explicitInput: currentState,
componentState: { lastSavedId },
} = getState();
} = this.getState();
const saveResult = await saveDashboardStateToSavedObject({
lastSavedId,
currentState,
saveOptions: {},
});
dispatch(setLastSavedInput(currentState));
this.dispatch.setLastSavedInput(currentState);
return saveResult;
}
@ -161,12 +151,7 @@ export async function runClone(this: DashboardContainer) {
dashboardSavedObject: { saveDashboardStateToSavedObject, checkForDuplicateDashboardTitle },
} = pluginServices.getServices();
const {
getState,
dispatch,
actions: { setTitle },
} = this.getReduxEmbeddableTools();
const { explicitInput: currentState } = getState();
const { explicitInput: currentState } = this.getState();
return new Promise<SaveDashboardReturn | undefined>((resolve) => {
const onClone = async (
@ -191,7 +176,7 @@ export async function runClone(this: DashboardContainer) {
currentState: { ...currentState, title: newTitle },
});
dispatch(setTitle(newTitle));
this.dispatch.setTitle(newTitle);
resolve(saveResult);
this.expectIdChange();
return saveResult.id ? { id: saveResult.id } : { error: saveResult.error };

View file

@ -10,9 +10,9 @@ import React from 'react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { DashboardSettings } from '../../component/settings/settings_flyout';
import { DashboardContainer } from '../dashboard_container';
import { pluginServices } from '../../../services/plugin_services';
import { DashboardSettings } from '../../component/settings/settings_flyout';
import { DashboardContainer, DashboardContainerContext } from '../dashboard_container';
export function showSettings(this: DashboardContainer) {
const {
@ -22,26 +22,20 @@ export function showSettings(this: DashboardContainer) {
overlays,
} = pluginServices.getServices();
const {
dispatch,
Wrapper: DashboardReduxWrapper,
actions: { setHasOverlays },
} = this.getReduxEmbeddableTools();
// TODO Move this action into DashboardContainer.openOverlay
dispatch(setHasOverlays(true));
this.dispatch.setHasOverlays(true);
this.openOverlay(
overlays.openFlyout(
toMountPoint(
<DashboardReduxWrapper>
<DashboardContainerContext.Provider value={this}>
<DashboardSettings
onClose={() => {
dispatch(setHasOverlays(false));
this.dispatch.setHasOverlays(false);
this.clearOverlays();
}}
/>
</DashboardReduxWrapper>,
</DashboardContainerContext.Provider>,
{ theme$ }
),
{
@ -49,7 +43,7 @@ export function showSettings(this: DashboardContainer) {
'data-test-subj': 'dashboardSettingsFlyout',
onClose: (flyout) => {
this.clearOverlays();
dispatch(setHasOverlays(false));
this.dispatch.setHasOverlays(false);
flyout.close();
},
}

View file

@ -9,7 +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 { ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group_integration';
jest.mock('@kbn/controls-plugin/public/control_group/embeddable/control_group_container');
@ -52,7 +52,7 @@ const testFilter3: Filter = {
};
const mockControlGroupContainer = new ControlGroupContainer(
{ getTools: () => {} } as unknown as ReduxEmbeddablePackage,
{ getTools: () => {} } as unknown as ReduxToolsPackage,
mockControlGroupInput()
);

View file

@ -6,24 +6,21 @@
* Side Public License, v 1.
*/
import _, { identity, pickBy } from 'lodash';
import { Observable, Subscription } from 'rxjs';
import { isEqual } from 'lodash';
import { Observable } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query';
import { debounceTime, distinctUntilChanged, distinctUntilKeyChanged, skip } from 'rxjs/operators';
import {
ControlGroupInput,
CONTROL_GROUP_TYPE,
getDefaultControlGroupInput,
persistableControlGroupInputIsEqual,
} from '@kbn/controls-plugin/common';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { ControlGroupContainer, ControlGroupOutput } from '@kbn/controls-plugin/public';
import { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { DashboardContainer } from '../../dashboard_container';
import { pluginServices } from '../../../../services/plugin_services';
import { DashboardContainerByValueInput } from '../../../../../common';
import { DashboardContainerInput } from '../../../../../common';
interface DiffChecks {
[key: string]: (a?: unknown, b?: unknown) => boolean;
@ -35,61 +32,16 @@ const distinctUntilDiffCheck = <T extends {}>(a: T, b: T, diffChecks: DiffChecks
.includes(false);
type DashboardControlGroupCommonKeys = keyof Pick<
DashboardContainerByValueInput | ControlGroupInput,
DashboardContainerInput | ControlGroupInput,
'filters' | 'lastReloadRequestTime' | 'timeRange' | 'query'
>;
export async function startControlGroupIntegration(
this: DashboardContainer,
initialInput: DashboardContainerByValueInput
): Promise<ControlGroupContainer | undefined> {
const {
embeddable: { getEmbeddableFactory },
} = pluginServices.getServices();
const controlsGroupFactory = getEmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
ControlGroupContainer
>(CONTROL_GROUP_TYPE);
const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput;
const controlGroup = await controlsGroupFactory?.create({
id: `control_group_${id ?? 'new_dashboard'}`,
...getDefaultControlGroupInput(),
...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults
timeRange,
viewMode,
filters,
query,
});
if (!controlGroup || isErrorEmbeddable(controlGroup)) {
return;
}
this.untilInitialized().then(() => {
const stopSyncingControlGroup =
startSyncingDashboardControlGroup.bind(this)()?.stopSyncingWithControlGroup;
this.onDestroyControlGroup = () => {
stopSyncingControlGroup?.();
this.controlGroup?.destroy();
};
});
await controlGroup.untilInitialized();
return controlGroup;
}
function startSyncingDashboardControlGroup(this: DashboardContainer) {
export function startSyncingDashboardControlGroup(this: DashboardContainer) {
if (!this.controlGroup) return;
const subscriptions = new Subscription();
const {
actions: { setControlGroupState },
dispatch,
} = this.getReduxEmbeddableTools();
const isControlGroupInputEqual = () =>
persistableControlGroupInputIsEqual(
this.controlGroup!.getInput(),
this.getInputAsValueType().controlGroupInput
this.getInput().controlGroupInput
);
// Because dashboard container stores control group state, certain control group changes need to be passed up dashboard container
@ -99,7 +51,7 @@ function startSyncingDashboardControlGroup(this: DashboardContainer) {
chainingSystem: deepEqual,
ignoreParentSettings: deepEqual,
};
subscriptions.add(
this.subscriptions.add(
this.controlGroup
.getInput$()
.pipe(
@ -111,9 +63,12 @@ function startSyncingDashboardControlGroup(this: DashboardContainer) {
const { panels, controlStyle, chainingSystem, ignoreParentSettings } =
this.controlGroup!.getInput();
if (!isControlGroupInputEqual()) {
dispatch(
setControlGroupState({ panels, controlStyle, chainingSystem, ignoreParentSettings })
);
this.dispatch.setControlGroupState({
panels,
controlStyle,
chainingSystem,
ignoreParentSettings,
});
}
})
);
@ -129,23 +84,20 @@ function startSyncingDashboardControlGroup(this: DashboardContainer) {
};
// pass down any pieces of input needed to refetch or force refetch data for the controls
subscriptions.add(
(this.getInput$() as Readonly<Observable<DashboardContainerByValueInput>>)
this.subscriptions.add(
(this.getInput$() as Readonly<Observable<DashboardContainerInput>>)
.pipe(
distinctUntilChanged((a, b) =>
distinctUntilDiffCheck<DashboardContainerByValueInput>(a, b, dashboardRefetchDiff)
distinctUntilDiffCheck<DashboardContainerInput>(a, b, dashboardRefetchDiff)
)
)
.subscribe(() => {
const newInput: { [key: string]: unknown } = {};
(Object.keys(dashboardRefetchDiff) as DashboardControlGroupCommonKeys[]).forEach((key) => {
if (
!dashboardRefetchDiff[key]?.(
this.getInputAsValueType()[key],
this.controlGroup!.getInput()[key]
)
!dashboardRefetchDiff[key]?.(this.getInput()[key], this.controlGroup!.getInput()[key])
) {
newInput[key] = this.getInputAsValueType()[key];
newInput[key] = this.getInput()[key];
}
});
if (Object.keys(newInput).length > 0) {
@ -155,24 +107,24 @@ function startSyncingDashboardControlGroup(this: DashboardContainer) {
);
// dashboard may reset the control group input when discarding changes. Subscribe to these changes and update accordingly
subscriptions.add(
(this.getInput$() as Readonly<Observable<DashboardContainerByValueInput>>)
this.subscriptions.add(
(this.getInput$() as Readonly<Observable<DashboardContainerInput>>)
.pipe(debounceTime(10), distinctUntilKeyChanged('controlGroupInput'))
.subscribe(() => {
if (!isControlGroupInputEqual()) {
if (!this.getInputAsValueType().controlGroupInput) {
if (!this.getInput().controlGroupInput) {
this.controlGroup!.updateInput(getDefaultControlGroupInput());
return;
}
this.controlGroup!.updateInput({
...this.getInputAsValueType().controlGroupInput,
...this.getInput().controlGroupInput,
});
}
})
);
// when control group outputs filters, force a refresh!
subscriptions.add(
this.subscriptions.add(
this.controlGroup
.getOutput$()
.pipe(
@ -184,23 +136,23 @@ function startSyncingDashboardControlGroup(this: DashboardContainer) {
.subscribe(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted
);
subscriptions.add(
this.subscriptions.add(
this.controlGroup
.getOutput$()
.pipe(
distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) =>
_.isEqual(timesliceA, timesliceB)
isEqual(timesliceA, timesliceB)
)
)
.subscribe(({ timeslice }) => {
if (!_.isEqual(timeslice, this.getInputAsValueType().timeslice)) {
this.updateInput({ timeslice });
if (!isEqual(timeslice, this.getInput().timeslice)) {
this.dispatch.setTimeslice(timeslice);
}
})
);
// the Control Group needs to know when any dashboard children are loading in order to know when to move on to the next time slice when playing.
subscriptions.add(
this.subscriptions.add(
this.getAnyChildOutputChange$().subscribe(() => {
if (!this.controlGroup) {
return;
@ -216,12 +168,6 @@ function startSyncingDashboardControlGroup(this: DashboardContainer) {
this.controlGroup.anyControlOutputConsumerLoading$.next(false);
})
);
return {
stopSyncingWithControlGroup: () => {
subscriptions.unsubscribe();
},
};
}
export const combineDashboardFiltersWithControlGroupFilters = (

View file

@ -0,0 +1,255 @@
/*
* 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 {
ContactCardEmbeddableInput,
CONTACT_CARD_EMBEDDABLE,
} from '@kbn/embeddable-plugin/public/lib/test_samples';
import {
ControlGroupInput,
ControlGroupContainer,
ControlGroupContainerFactory,
} from '@kbn/controls-plugin/public';
import { Filter } from '@kbn/es-query';
import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { createDashboard } from './create_dashboard';
import { getSampleDashboardPanel } from '../../../mocks';
import { pluginServices } from '../../../services/plugin_services';
import { DashboardCreationOptions } from '../dashboard_container_factory';
import { Observable } from 'rxjs';
const embeddableId = 'create-dat-dashboard';
test('throws error when no data views are available', async () => {
pluginServices.getServices().data.dataViews.getDefaultDataView = jest
.fn()
.mockReturnValue(undefined);
await expect(async () => {
await createDashboard(embeddableId);
}).rejects.toThrow('Dashboard requires at least one data view before it can be initialized.');
// reset get default data view
pluginServices.getServices().data.dataViews.getDefaultDataView = jest.fn().mockResolvedValue({});
});
test('throws error when provided validation function returns invalid', async () => {
const creationOptions: DashboardCreationOptions = {
validateLoadedSavedObject: jest.fn().mockImplementation(() => false),
};
await expect(async () => {
await createDashboard(embeddableId, creationOptions, 0, 'test-id');
}).rejects.toThrow('Dashboard failed saved object result validation');
});
test('pulls state from dashboard saved object when given a saved object id', async () => {
pluginServices.getServices().dashboardSavedObject.loadDashboardStateFromSavedObject = jest
.fn()
.mockResolvedValue({ dashboardInput: { description: 'wow would you look at that? Wow.' } });
const dashboard = await createDashboard(embeddableId, {}, 0, 'wow-such-id');
expect(
pluginServices.getServices().dashboardSavedObject.loadDashboardStateFromSavedObject
).toHaveBeenCalledWith({ id: 'wow-such-id' });
expect(dashboard.getState().explicitInput.description).toBe('wow would you look at that? Wow.');
});
test('pulls state from session storage which overrides state from saved object', async () => {
pluginServices.getServices().dashboardSavedObject.loadDashboardStateFromSavedObject = jest
.fn()
.mockResolvedValue({ dashboardInput: { description: 'wow this description is okay' } });
pluginServices.getServices().dashboardSessionStorage.getState = jest
.fn()
.mockReturnValue({ description: 'wow this description marginally better' });
const dashboard = await createDashboard(
embeddableId,
{ useSessionStorageIntegration: true },
0,
'wow-such-id'
);
expect(dashboard.getState().explicitInput.description).toBe(
'wow this description marginally better'
);
});
test('pulls state from creation options initial input which overrides all other state sources', async () => {
pluginServices.getServices().dashboardSavedObject.loadDashboardStateFromSavedObject = jest
.fn()
.mockResolvedValue({ dashboardInput: { description: 'wow this description is okay' } });
pluginServices.getServices().dashboardSessionStorage.getState = jest
.fn()
.mockReturnValue({ description: 'wow this description marginally better' });
const dashboard = await createDashboard(
embeddableId,
{
useSessionStorageIntegration: true,
initialInput: { description: 'wow this description is a masterpiece' },
},
0,
'wow-such-id'
);
expect(dashboard.getState().explicitInput.description).toBe(
'wow this description is a masterpiece'
);
});
test('applies filters and query from state to query service', async () => {
const filters: Filter[] = [
{ meta: { alias: 'test', disabled: false, negate: false, index: 'test' } },
];
const query = { language: 'kql', query: 'query' };
await createDashboard(embeddableId, {
useUnifiedSearchIntegration: true,
unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(),
},
initialInput: { filters, query },
});
expect(pluginServices.getServices().data.query.queryString.setQuery).toHaveBeenCalledWith(query);
expect(pluginServices.getServices().data.query.filterManager.setAppFilters).toHaveBeenCalledWith(
filters
);
});
test('applies time range and refresh interval from initial input to query service if time restore is on', async () => {
const timeRange = { from: new Date().toISOString(), to: new Date().toISOString() };
const refreshInterval = { pause: false, value: 42 };
await createDashboard(embeddableId, {
useUnifiedSearchIntegration: true,
unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(),
},
initialInput: { timeRange, refreshInterval, timeRestore: true },
});
expect(
pluginServices.getServices().data.query.timefilter.timefilter.setTime
).toHaveBeenCalledWith(timeRange);
expect(
pluginServices.getServices().data.query.timefilter.timefilter.setRefreshInterval
).toHaveBeenCalledWith(refreshInterval);
});
test('applied time range from query service to initial input if time restore is off', async () => {
const timeRange = { from: new Date().toISOString(), to: new Date().toISOString() };
pluginServices.getServices().data.query.timefilter.timefilter.getTime = jest
.fn()
.mockReturnValue(timeRange);
const dashboard = await createDashboard(embeddableId, {
useUnifiedSearchIntegration: true,
unifiedSearchSettings: {
kbnUrlStateStorage: createKbnUrlStateStorage(),
},
});
expect(dashboard.getState().explicitInput.timeRange).toEqual(timeRange);
});
test('replaces panel with incoming embeddable if id matches existing panel', async () => {
const incomingEmbeddable: EmbeddablePackageState = {
type: CONTACT_CARD_EMBEDDABLE,
input: {
id: 'i_match',
firstName: 'wow look at this replacement wow',
} as ContactCardEmbeddableInput,
embeddableId: 'i_match',
};
const dashboard = await createDashboard(embeddableId, {
incomingEmbeddable,
initialInput: {
panels: {
i_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: {
id: 'i_match',
firstName: 'oh no, I am about to get replaced',
},
type: CONTACT_CARD_EMBEDDABLE,
}),
},
},
});
expect(dashboard.getState().explicitInput.panels.i_match.explicitInput).toStrictEqual(
expect.objectContaining({
id: 'i_match',
firstName: 'wow look at this replacement wow',
})
);
});
test('creates new embeddable with incoming embeddable if id does not match existing panel', async () => {
const incomingEmbeddable: EmbeddablePackageState = {
type: CONTACT_CARD_EMBEDDABLE,
input: {
id: 'i_match',
firstName: 'wow look at this new panel wow',
} as ContactCardEmbeddableInput,
embeddableId: 'i_match',
};
const mockContactCardFactory = {
create: jest.fn().mockReturnValue({ destroy: jest.fn() }),
getDefaultInput: jest.fn().mockResolvedValue({}),
};
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(mockContactCardFactory);
await createDashboard(embeddableId, {
incomingEmbeddable,
initialInput: {
panels: {
i_do_not_match: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: {
id: 'i_do_not_match',
firstName: 'phew... I will not be replaced',
},
type: CONTACT_CARD_EMBEDDABLE,
}),
},
},
});
// flush promises
await new Promise((r) => setTimeout(r, 1));
expect(mockContactCardFactory.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'i_match',
firstName: 'wow look at this new panel wow',
}),
expect.any(Object)
);
});
test('creates a control group from the control group factory and waits for it to be initialized', async () => {
const mockControlGroupContainer = {
destroy: jest.fn(),
render: jest.fn(),
updateInput: jest.fn(),
untilInitialized: jest.fn(),
getInput: jest.fn().mockReturnValue({}),
getInput$: jest.fn().mockReturnValue(new Observable()),
getOutput$: jest.fn().mockReturnValue(new Observable()),
} as unknown as ControlGroupContainer;
const mockControlGroupFactory = {
create: jest.fn().mockReturnValue(mockControlGroupContainer),
} as unknown as ControlGroupContainerFactory;
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(mockControlGroupFactory);
await createDashboard(embeddableId, {
useControlGroupIntegration: true,
initialInput: {
controlGroupInput: { controlStyle: 'twoLine' } as unknown as ControlGroupInput,
},
});
// flush promises
await new Promise((r) => setTimeout(r, 1));
expect(pluginServices.getServices().embeddable.getEmbeddableFactory).toHaveBeenCalledWith(
'control_group'
);
expect(mockControlGroupFactory.create).toHaveBeenCalledWith(
expect.objectContaining({ controlStyle: 'twoLine' })
);
expect(mockControlGroupContainer.untilInitialized).toHaveBeenCalled();
});

View file

@ -0,0 +1,289 @@
/*
* 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 { Subject } from 'rxjs';
import { cloneDeep, identity, pickBy } from 'lodash';
import {
ControlGroupInput,
CONTROL_GROUP_TYPE,
getDefaultControlGroupInput,
} from '@kbn/controls-plugin/common';
import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
import { isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { type ControlGroupContainer, ControlGroupOutput } from '@kbn/controls-plugin/public';
import { DashboardContainerInput } from '../../../../common';
import { DashboardContainer } from '../dashboard_container';
import { pluginServices } from '../../../services/plugin_services';
import { DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants';
import { DashboardCreationOptions } from '../dashboard_container_factory';
import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views';
import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state';
import { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_integration';
import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration';
/**
*
* @param creationOptions
*/
export const createDashboard = async (
embeddableId: string,
creationOptions?: DashboardCreationOptions,
dashboardCreationStartTime?: number,
savedObjectId?: string
): Promise<DashboardContainer> => {
// --------------------------------------------------------------------------------------
// Unpack services & Options
// --------------------------------------------------------------------------------------
const {
dashboardSessionStorage,
embeddable: { getEmbeddableFactory },
data: {
dataViews,
query: queryService,
search: { session },
},
dashboardSavedObject: { loadDashboardStateFromSavedObject },
} = pluginServices.getServices();
const {
queryString,
filterManager,
timefilter: { timefilter: timefilterService },
} = queryService;
const {
searchSessionSettings,
unifiedSearchSettings,
validateLoadedSavedObject,
useControlGroupIntegration,
useUnifiedSearchIntegration,
initialInput: overrideInput,
useSessionStorageIntegration,
} = creationOptions ?? {};
// --------------------------------------------------------------------------------------
// Create method which allows work to be done on the dashboard container when it's ready.
// --------------------------------------------------------------------------------------
const dashboardContainerReady$ = new Subject<DashboardContainer>();
const untilDashboardReady = () =>
new Promise<DashboardContainer>((resolve) => {
const subscription = dashboardContainerReady$.subscribe((container) => {
subscription.unsubscribe();
resolve(container);
});
});
// --------------------------------------------------------------------------------------
// Lazy load required systems and Dashboard saved object.
// --------------------------------------------------------------------------------------
const reduxEmbeddablePackagePromise = lazyLoadReduxToolsPackage();
const defaultDataViewAssignmentPromise = dataViews.getDefaultDataView();
const dashboardSavedObjectPromise = savedObjectId
? loadDashboardStateFromSavedObject({ id: savedObjectId })
: Promise.resolve(undefined);
const [reduxEmbeddablePackage, savedObjectResult, defaultDataView] = await Promise.all([
reduxEmbeddablePackagePromise,
dashboardSavedObjectPromise,
defaultDataViewAssignmentPromise,
]);
// --------------------------------------------------------------------------------------
// Run validations.
// --------------------------------------------------------------------------------------
if (!defaultDataView) {
throw new Error('Dashboard requires at least one data view before it can be initialized.');
}
if (
savedObjectResult &&
validateLoadedSavedObject &&
!validateLoadedSavedObject(savedObjectResult)
) {
throw new Error('Dashboard failed saved object result validation');
}
// --------------------------------------------------------------------------------------
// Gather input from session storage if integration is used.
// --------------------------------------------------------------------------------------
const sessionStorageInput = ((): Partial<DashboardContainerInput> | undefined => {
if (!useSessionStorageIntegration) return;
return dashboardSessionStorage.getState(savedObjectId);
})();
// --------------------------------------------------------------------------------------
// Combine input from saved object, session storage, & passed input to create initial input.
// --------------------------------------------------------------------------------------
const initialInput: DashboardContainerInput = cloneDeep({
...DEFAULT_DASHBOARD_INPUT,
...(savedObjectResult?.dashboardInput ?? {}),
...sessionStorageInput,
...overrideInput,
id: embeddableId,
});
initialInput.executionContext = {
type: 'dashboard',
description: initialInput.title,
};
// --------------------------------------------------------------------------------------
// Set up unified search integration.
// --------------------------------------------------------------------------------------
if (useUnifiedSearchIntegration && unifiedSearchSettings?.kbnUrlStateStorage) {
const { filters, query, timeRestore, timeRange, refreshInterval } = initialInput;
const { kbnUrlStateStorage } = unifiedSearchSettings;
// apply filters and query to the query service
filterManager.setAppFilters(cloneDeep(filters ?? []));
queryString.setQuery(query ?? queryString.getDefaultQuery());
/**
* If a global time range is not set explicitly and the time range was saved with the dashboard, apply
* time range and refresh interval to the query service. Otherwise, set the current dashboard time range
* from the query service. The order of the following lines is very important.
*/
if (timeRestore) {
if (timeRange) timefilterService.setTime(timeRange);
if (refreshInterval) timefilterService.setRefreshInterval(refreshInterval);
}
const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl(
queryService,
kbnUrlStateStorage
);
if (!timeRestore) {
initialInput.timeRange = timefilterService.getTime();
}
untilDashboardReady().then((dashboardContainer) => {
const stopSyncingUnifiedSearchState =
syncUnifiedSearchState.bind(dashboardContainer)(kbnUrlStateStorage);
dashboardContainer.stopSyncingWithUnifiedSearch = () => {
stopSyncingUnifiedSearchState();
stopSyncingQueryServiceStateWithUrl();
};
});
}
// --------------------------------------------------------------------------------------
// Place the incoming embeddable if there is one
// --------------------------------------------------------------------------------------
const incomingEmbeddable = creationOptions?.incomingEmbeddable;
if (incomingEmbeddable) {
initialInput.viewMode = ViewMode.EDIT; // view mode must always be edit to recieve an embeddable.
if (
incomingEmbeddable.embeddableId &&
Boolean(initialInput.panels[incomingEmbeddable.embeddableId])
) {
// this embeddable already exists, we will update the explicit input.
const panelToUpdate = initialInput.panels[incomingEmbeddable.embeddableId];
const sameType = panelToUpdate.type === incomingEmbeddable.type;
panelToUpdate.type = incomingEmbeddable.type;
panelToUpdate.explicitInput = {
// if the incoming panel is the same type as what was there before we can safely spread the old panel's explicit input
...(sameType ? panelToUpdate.explicitInput : {}),
...incomingEmbeddable.input,
id: incomingEmbeddable.embeddableId,
// maintain hide panel titles setting.
hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles,
};
} else {
// otherwise this incoming embeddable is brand new and can be added via the default method after the dashboard container is created.
untilDashboardReady().then((container) =>
container.addNewEmbeddable(incomingEmbeddable.type, incomingEmbeddable.input)
);
}
}
// --------------------------------------------------------------------------------------
// Set up search sessions integration.
// --------------------------------------------------------------------------------------
if (searchSessionSettings) {
const { sessionIdToRestore } = searchSessionSettings;
// if this incoming embeddable has a session, continue it.
if (incomingEmbeddable?.searchSessionId) {
session.continue(incomingEmbeddable.searchSessionId);
}
if (sessionIdToRestore) {
session.restore(sessionIdToRestore);
}
const existingSession = session.getSessionId();
const initialSearchSessionId =
sessionIdToRestore ??
(existingSession && incomingEmbeddable ? existingSession : session.start());
untilDashboardReady().then((container) => {
startDashboardSearchSessionIntegration.bind(container)(
creationOptions?.searchSessionSettings
);
});
initialInput.searchSessionId = initialSearchSessionId;
}
// --------------------------------------------------------------------------------------
// Start the control group integration.
// --------------------------------------------------------------------------------------
if (useControlGroupIntegration) {
const controlsGroupFactory = getEmbeddableFactory<
ControlGroupInput,
ControlGroupOutput,
ControlGroupContainer
>(CONTROL_GROUP_TYPE);
const { filters, query, timeRange, viewMode, controlGroupInput, id } = initialInput;
const controlGroup = await controlsGroupFactory?.create({
id: `control_group_${id ?? 'new_dashboard'}`,
...getDefaultControlGroupInput(),
...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults
timeRange,
viewMode,
filters,
query,
});
if (!controlGroup || isErrorEmbeddable(controlGroup)) {
throw new Error('Error in control group startup');
}
untilDashboardReady().then((dashboardContainer) => {
dashboardContainer.controlGroup = controlGroup;
startSyncingDashboardControlGroup.bind(dashboardContainer)();
});
await controlGroup.untilInitialized();
}
// --------------------------------------------------------------------------------------
// Start the data views integration.
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboardContainer) => {
dashboardContainer.subscriptions.add(startSyncingDashboardDataViews.bind(dashboardContainer)());
});
// --------------------------------------------------------------------------------------
// Build and return the dashboard container.
// --------------------------------------------------------------------------------------
const dashboardContainer = new DashboardContainer(
initialInput,
reduxEmbeddablePackage,
savedObjectResult?.dashboardInput,
dashboardCreationStartTime,
undefined,
creationOptions,
savedObjectId
);
dashboardContainerReady$.next(dashboardContainer);
return dashboardContainer;
};

View file

@ -0,0 +1,91 @@
/*
* 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 { debounceTime, pairwise, skip } from 'rxjs/operators';
import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public';
import { DashboardContainer } from '../../dashboard_container';
import { DashboardContainerInput } from '../../../../../common';
import { pluginServices } from '../../../../services/plugin_services';
import { CHANGE_CHECK_DEBOUNCE } from '../../../../dashboard_constants';
import { DashboardCreationOptions } from '../../dashboard_container_factory';
import { getShouldRefresh } from '../../../state/diffing/dashboard_diffing_integration';
/**
* Enables dashboard search sessions.
*/
export function startDashboardSearchSessionIntegration(
this: DashboardContainer,
searchSessionSettings: DashboardCreationOptions['searchSessionSettings']
) {
if (!searchSessionSettings) return;
const {
data: {
search: { session },
},
dashboardCapabilities: { storeSearchSession: canStoreSearchSession },
} = pluginServices.getServices();
const {
sessionIdUrlChangeObservable,
getSearchSessionIdFromURL,
removeSessionIdFromUrl,
createSessionRestorationDataProvider,
} = searchSessionSettings;
session.enableStorage(createSessionRestorationDataProvider(this), {
isDisabled: () =>
canStoreSearchSession
? { disabled: false }
: {
disabled: true,
reasonText: noSearchSessionStorageCapabilityMessage,
},
});
// force refresh when the session id in the URL changes. This will also fire off the "handle search session change" below.
const searchSessionIdChangeSubscription = sessionIdUrlChangeObservable
?.pipe(skip(1))
.subscribe(() => this.forceRefresh());
// listen to and compare states to determine when to launch a new session.
this.getInput$()
.pipe(pairwise(), debounceTime(CHANGE_CHECK_DEBOUNCE))
.subscribe(async (states) => {
const [previous, current] = states as DashboardContainerInput[];
const shouldRefetch = await getShouldRefresh.bind(this)(previous, current);
if (!shouldRefetch) return;
const currentSearchSessionId = this.getState().explicitInput.searchSessionId;
const updatedSearchSessionId: string | undefined = (() => {
// do not update session id if this is irrelevant state change to prevent excessive searches
if (!shouldRefetch) return;
let searchSessionIdFromURL = getSearchSessionIdFromURL();
if (searchSessionIdFromURL) {
if (session.isRestore() && session.isCurrentSession(searchSessionIdFromURL)) {
// we had previously been in a restored session but have now changed state so remove the session id from the URL.
removeSessionIdFromUrl();
searchSessionIdFromURL = undefined;
} else {
session.restore(searchSessionIdFromURL);
}
}
return searchSessionIdFromURL ?? session.start();
})();
if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) {
this.dispatch.setSearchSessionId(updatedSearchSessionId);
}
});
this.subscriptions.add(searchSessionIdChangeSubscription);
}

View file

@ -31,19 +31,12 @@ export function syncUnifiedSearchState(
const { queryString, timefilter } = queryService;
const { timefilter: timefilterService } = timefilter;
const {
getState,
dispatch,
onStateChange,
actions: { setFiltersAndQuery, setTimeRange, setRefreshInterval },
} = this.getReduxEmbeddableTools();
// get Observable for when the dashboard's saved filters or query change.
const OnFiltersChange$ = new Subject<{ filters: Filter[]; query: Query }>();
const unsubscribeFromSavedFilterChanges = onStateChange(() => {
const unsubscribeFromSavedFilterChanges = this.onStateChange(() => {
const {
explicitInput: { filters, query },
} = getState();
} = this.getState();
OnFiltersChange$.next({
filters: filters ?? [],
query: query ?? queryString.getDefaultQuery(),
@ -53,7 +46,7 @@ export function syncUnifiedSearchState(
// starts syncing app filters between dashboard state and filterManager
const {
explicitInput: { filters, query },
} = getState();
} = this.getState();
const intermediateFilterState: { filters: Filter[]; query: Query } = {
query: query ?? queryString.getDefaultQuery(),
filters: filters ?? [],
@ -66,7 +59,7 @@ export function syncUnifiedSearchState(
set: ({ filters: newFilters, query: newQuery }) => {
intermediateFilterState.filters = cleanFiltersForSerialize(newFilters);
intermediateFilterState.query = newQuery;
dispatch(setFiltersAndQuery(intermediateFilterState));
this.dispatch.setFiltersAndQuery(intermediateFilterState);
},
state$: OnFiltersChange$.pipe(distinctUntilChanged()),
},
@ -78,11 +71,11 @@ export function syncUnifiedSearchState(
const timeUpdateSubscription = timefilterService
.getTimeUpdate$()
.subscribe(() => dispatch(setTimeRange(timefilterService.getTime())));
.subscribe(() => this.dispatch.setTimeRange(timefilterService.getTime()));
const refreshIntervalSubscription = timefilterService
.getRefreshIntervalUpdate$()
.subscribe(() => dispatch(setRefreshInterval(timefilterService.getRefreshInterval())));
.subscribe(() => this.dispatch.setRefreshInterval(timefilterService.getRefreshInterval()));
const autoRefreshSubscription = timefilterService
.getAutoRefreshFetch$()

View file

@ -29,8 +29,7 @@ import { applicationServiceMock, coreMock } from '@kbn/core/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { createEditModeActionDefinition } from '@kbn/embeddable-plugin/public/lib/test_samples';
import { DashboardContainer } from './dashboard_container';
import { getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks';
import { buildMockDashboard, getSampleDashboardPanel } from '../../mocks';
import { pluginServices } from '../../services/plugin_services';
import { ApplicationStart } from '@kbn/core-application-browser';
@ -47,7 +46,7 @@ beforeEach(() => {
});
test('DashboardContainer initializes embeddables', (done) => {
const initialInput = getSampleDashboardInput({
const container = buildMockDashboard({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
@ -55,7 +54,6 @@ test('DashboardContainer initializes embeddables', (done) => {
}),
},
});
const container = new DashboardContainer(initialInput);
const subscription = container.getOutput$().subscribe((output) => {
if (container.getOutput().embeddableLoaded['123']) {
@ -76,8 +74,7 @@ test('DashboardContainer initializes embeddables', (done) => {
});
test('DashboardContainer.addNewEmbeddable', async () => {
const container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
const container = buildMockDashboard();
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
{
@ -99,7 +96,8 @@ test('DashboardContainer.addNewEmbeddable', async () => {
test('DashboardContainer.replacePanel', (done) => {
const ID = '123';
const initialInput = getSampleDashboardInput({
const container = buildMockDashboard({
panels: {
[ID]: getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: ID },
@ -107,8 +105,6 @@ test('DashboardContainer.replacePanel', (done) => {
}),
},
});
const container = new DashboardContainer(initialInput);
let counter = 0;
const subscription = container.getInput$().subscribe(
@ -141,7 +137,7 @@ test('DashboardContainer.replacePanel', (done) => {
});
test('Container view mode change propagates to existing children', async () => {
const initialInput = getSampleDashboardInput({
const container = buildMockDashboard({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
explicitInput: { firstName: 'Sam', id: '123' },
@ -149,8 +145,6 @@ test('Container view mode change propagates to existing children', async () => {
}),
},
});
const container = new DashboardContainer(initialInput);
await container.untilInitialized();
const embeddable = await container.untilEmbeddableLoaded('123');
expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW);
@ -159,8 +153,7 @@ test('Container view mode change propagates to existing children', async () => {
});
test('Container view mode change propagates to new children', async () => {
const container = new DashboardContainer(getSampleDashboardInput());
await container.untilInitialized();
const container = buildMockDashboard();
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
@ -178,10 +171,7 @@ test('Container view mode change propagates to new children', async () => {
test('searchSessionId propagates to children', async () => {
const searchSessionId1 = 'searchSessionId1';
const container = new DashboardContainer(
getSampleDashboardInput({ searchSessionId: searchSessionId1 })
);
await container.untilInitialized();
const container = buildMockDashboard({ searchSessionId: searchSessionId1 });
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
@ -205,9 +195,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
uiActionsSetup.registerAction(editModeAction);
uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction);
const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW });
const container = new DashboardContainer(initialInput);
await container.untilInitialized();
const container = buildMockDashboard({ viewMode: ViewMode.VIEW });
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,

View file

@ -6,19 +6,14 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { cloneDeep, omit } from 'lodash';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { Subject, Subscription } from 'rxjs';
import {
lazyLoadReduxEmbeddablePackage,
ReduxEmbeddableTools,
} from '@kbn/presentation-util-plugin/public';
import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import {
ViewMode,
Container,
type Embeddable,
type IEmbeddable,
type EmbeddableInput,
type EmbeddableOutput,
@ -44,36 +39,19 @@ import {
showPlaceholderUntil,
addOrUpdateEmbeddable,
} from './api';
import {
DashboardReduxState,
DashboardContainerOutput,
DashboardRenderPerformanceStats,
} from '../types';
import {
DashboardPanelState,
DashboardContainerInput,
DashboardContainerByValueInput,
} from '../../../common';
import {
startDiffingDashboardState,
startControlGroupIntegration,
startUnifiedSearchIntegration,
startSyncingDashboardDataViews,
startDashboardSearchSessionIntegration,
combineDashboardFiltersWithControlGroupFilters,
getUnsavedChanges,
keysNotConsideredUnsavedChanges,
} from './integrations';
import { DASHBOARD_CONTAINER_TYPE } from '../..';
import { createPanelState } from '../component/panel';
import { pluginServices } from '../../services/plugin_services';
import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants';
import { DashboardCreationOptions } from './dashboard_container_factory';
import { DashboardAnalyticsService } from '../../services/analytics/types';
import { DashboardViewport } from '../component/viewport/dashboard_viewport';
import { DashboardPanelState, DashboardContainerInput } from '../../../common';
import { DashboardReduxState, DashboardRenderPerformanceStats } from '../types';
import { dashboardContainerReducers } from '../state/dashboard_container_reducers';
import { DashboardSavedObjectService } from '../../services/dashboard_saved_object/types';
import { dashboardContainerInputIsByValue } from '../../../common/dashboard_container/type_guards';
import { startDiffingDashboardState } from '../state/diffing/dashboard_diffing_integration';
import { DASHBOARD_LOADED_EVENT, DEFAULT_DASHBOARD_INPUT } from '../../dashboard_constants';
import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration';
export interface InheritedChildInput {
filters: Filter[];
@ -91,25 +69,40 @@ export interface InheritedChildInput {
executionContext?: KibanaExecutionContext;
}
type DashboardReduxEmbeddableTools = ReduxEmbeddableTools<
DashboardReduxState,
typeof dashboardContainerReducers
>;
export const DashboardContainerContext = createContext<DashboardContainer | null>(null);
export const useDashboardContainer = (): DashboardContainer => {
const dashboard = useContext<DashboardContainer | null>(DashboardContainerContext);
if (dashboard == null) {
throw new Error('useDashboardContainer must be used inside DashboardContainerContext.');
}
return dashboard!;
};
export class DashboardContainer extends Container<InheritedChildInput, DashboardContainerInput> {
public readonly type = DASHBOARD_CONTAINER_TYPE;
// state management
public select: DashboardReduxEmbeddableTools['select'];
public getState: DashboardReduxEmbeddableTools['getState'];
public dispatch: DashboardReduxEmbeddableTools['dispatch'];
public onStateChange: DashboardReduxEmbeddableTools['onStateChange'];
public subscriptions: Subscription = new Subscription();
public controlGroup?: ControlGroupContainer;
// Dashboard State
public onDestroyControlGroup?: () => void;
private subscriptions: Subscription = new Subscription();
// cleanup
public stopSyncingWithUnifiedSearch?: () => void;
private cleanupStateTools: () => void;
private initialized$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
// performance monitoring
private dashboardCreationStartTime?: number;
private savedObjectLoadTime?: number;
private initialSavedDashboardId?: string;
private reduxEmbeddableTools?: ReduxEmbeddableTools<
DashboardReduxState,
typeof dashboardContainerReducers
>;
private domNode?: HTMLElement;
private overlayRef?: OverlayRef;
private allDataViews: DataView[] = [];
@ -117,19 +110,19 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
// Services that are used in the Dashboard container code
private creationOptions?: DashboardCreationOptions;
private analyticsService: DashboardAnalyticsService;
private dashboardSavedObjectService: DashboardSavedObjectService;
private theme$;
private chrome;
private customBranding;
constructor(
initialInput: DashboardContainerInput,
reduxToolsPackage: ReduxToolsPackage,
initialLastSavedInput?: DashboardContainerInput,
dashboardCreationStartTime?: number,
parent?: Container,
creationOptions?: DashboardCreationOptions
creationOptions?: DashboardCreationOptions,
savedObjectId?: string
) {
// we won't initialize any embeddable children until after the dashboard is done initializing.
const readyToInitializeChildren$ = new Subject<DashboardContainerInput>();
const {
embeddable: { getEmbeddableFactory },
} = pluginServices.getServices();
@ -140,15 +133,11 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
},
{ embeddableLoaded: {} },
getEmbeddableFactory,
parent,
{
readyToInitializeChildren$,
}
parent
);
({
analytics: this.analyticsService,
dashboardSavedObject: this.dashboardSavedObjectService,
settings: {
theme: { theme$: this.theme$ },
},
@ -156,215 +145,44 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
customBranding: this.customBranding,
} = pluginServices.getServices());
this.initialSavedDashboardId = dashboardContainerInputIsByValue(this.input)
? undefined
: this.input.savedObjectId;
this.creationOptions = creationOptions;
this.dashboardCreationStartTime = dashboardCreationStartTime;
this.initializeDashboard(readyToInitializeChildren$, creationOptions);
}
public getDashboardSavedObjectId() {
if (this.initialized$.value) {
return this.getReduxEmbeddableTools().getState().componentState.lastSavedId;
}
return this.initialSavedDashboardId;
}
public getInputAsValueType = () => {
if (!dashboardContainerInputIsByValue(this.input)) {
throw new Error('cannot get input as value type until after dashboard input is unwrapped.');
}
return this.getInput() as DashboardContainerByValueInput;
};
private async unwrapDashboardContainerInput(): Promise<
DashboardContainerByValueInput | undefined
> {
if (dashboardContainerInputIsByValue(this.input)) {
return this.input;
}
// if this dashboard is loaded by reference, unwrap it and track the saved object load time.
const savedObjectLoadStartTime = performance.now();
const unwrapResult = await this.dashboardSavedObjectService.loadDashboardStateFromSavedObject({
id: this.input.savedObjectId,
});
this.updateInput({ savedObjectId: undefined });
this.savedObjectLoadTime = performance.now() - savedObjectLoadStartTime;
if (
!this.creationOptions?.validateLoadedSavedObject ||
this.creationOptions.validateLoadedSavedObject(unwrapResult)
) {
return unwrapResult.dashboardInput;
}
}
private async initializeDashboard(
readyToInitializeChildren$: Subject<DashboardContainerInput>,
creationOptions?: DashboardCreationOptions
) {
const {
data: { dataViews },
} = pluginServices.getServices();
const reduxEmbeddablePackagePromise = lazyLoadReduxEmbeddablePackage();
const defaultDataViewAssignmentPromise = dataViews.getDefaultDataView();
const dashboardStateUnwrapPromise = this.unwrapDashboardContainerInput();
const [reduxEmbeddablePackage, inputFromSavedObject, defaultDataView] = await Promise.all([
reduxEmbeddablePackagePromise,
dashboardStateUnwrapPromise,
defaultDataViewAssignmentPromise,
]);
if (!defaultDataView) {
throw new Error('Dashboard requires at least one data view before it can be initialized.');
}
// inputFromSavedObject will only be undefined if the provided valiation function returns false.
if (!inputFromSavedObject) {
this.destroy();
return;
}
// Gather input from session storage if integration is used
let sessionStorageInput: Partial<DashboardContainerByValueInput> = {};
if (creationOptions?.useSessionStorageIntegration) {
const { dashboardSessionStorage } = pluginServices.getServices();
const sessionInput = dashboardSessionStorage.getState(this.initialSavedDashboardId);
if (sessionInput) sessionStorageInput = sessionInput;
}
// Combine input from saved object with override input.
const initialInput: DashboardContainerByValueInput = cloneDeep({
...inputFromSavedObject,
...sessionStorageInput,
...creationOptions?.overrideInput,
});
// set up execution context
initialInput.executionContext = {
type: 'dashboard',
description: initialInput.title,
};
// set up unified search integration
if (
creationOptions?.useUnifiedSearchIntegration &&
creationOptions.unifiedSearchSettings?.kbnUrlStateStorage
) {
const { kbnUrlStateStorage } = creationOptions.unifiedSearchSettings;
const initialTimeRange = startUnifiedSearchIntegration.bind(this)({
initialInput,
kbnUrlStateStorage,
setCleanupFunction: (cleanup) => {
this.stopSyncingWithUnifiedSearch = cleanup;
},
});
if (initialTimeRange) initialInput.timeRange = initialTimeRange;
}
// place the incoming embeddable if there is one
const incomingEmbeddable = creationOptions?.incomingEmbeddable;
if (incomingEmbeddable) {
initialInput.viewMode = ViewMode.EDIT; // view mode must always be edit to recieve an embeddable.
if (
incomingEmbeddable.embeddableId &&
Boolean(initialInput.panels[incomingEmbeddable.embeddableId])
) {
// this embeddable already exists, we will update the explicit input.
const panelToUpdate = initialInput.panels[incomingEmbeddable.embeddableId];
const sameType = panelToUpdate.type === incomingEmbeddable.type;
panelToUpdate.type = incomingEmbeddable.type;
panelToUpdate.explicitInput = {
// if the incoming panel is the same type as what was there before we can safely spread the old panel's explicit input
...(sameType ? panelToUpdate.explicitInput : {}),
...incomingEmbeddable.input,
id: incomingEmbeddable.embeddableId,
// maintain hide panel titles setting.
hidePanelTitles: panelToUpdate.explicitInput.hidePanelTitles,
};
} else {
// otherwise this incoming embeddable is brand new and can be added via the default method after the dashboard container is created.
this.untilInitialized().then(() =>
this.addNewEmbeddable(incomingEmbeddable.type, incomingEmbeddable.input)
);
}
}
// start search sessions integration
if (creationOptions?.useSearchSessionsIntegration) {
const { initialSearchSessionId, stopSyncingDashboardSearchSessions } =
startDashboardSearchSessionIntegration.bind(this)(
creationOptions?.searchSessionSettings,
incomingEmbeddable
);
initialInput.searchSessionId = initialSearchSessionId;
this.stopSyncingDashboardSearchSessions = stopSyncingDashboardSearchSessions;
}
// update input so the redux embeddable tools get the unwrapped, initial input
this.updateInput({ ...initialInput });
// build Control Group
if (creationOptions?.useControlGroupIntegration) {
this.controlGroup = await startControlGroupIntegration.bind(this)(initialInput);
}
// now that the input with the initial panel state has been set and the control group is ready, we can tell the container class it's time to start loading children.
readyToInitializeChildren$.next(initialInput);
// start diffing dashboard state
const diffingMiddleware = startDiffingDashboardState.bind(this)({
useSessionBackup: creationOptions?.useSessionStorageIntegration,
setCleanupFunction: (cleanup) => {
this.stopDiffingDashboardState = cleanup;
},
});
// set up data views integration
this.dataViewsChangeSubscription = startSyncingDashboardDataViews.bind(this)();
const diffingMiddleware = startDiffingDashboardState.bind(this)(creationOptions);
// build redux embeddable tools
this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools<
const reduxTools = reduxToolsPackage.createReduxEmbeddableTools<
DashboardReduxState,
typeof dashboardContainerReducers
>({
embeddable: this as Embeddable<DashboardContainerByValueInput, DashboardContainerOutput>, // cast to unwrapped state type
embeddable: this,
reducers: dashboardContainerReducers,
additionalMiddleware: [diffingMiddleware],
initialComponentState: {
lastSavedInput: inputFromSavedObject,
lastSavedInput: initialLastSavedInput ?? {
...DEFAULT_DASHBOARD_INPUT,
id: initialInput.id,
},
hasUnsavedChanges: false, // if there is initial unsaved changes, the initial diff will catch them.
lastSavedId: this.initialSavedDashboardId,
lastSavedId: savedObjectId,
},
});
this.initialized$.next(true);
this.onStateChange = reduxTools.onStateChange;
this.cleanupStateTools = reduxTools.cleanup;
this.getState = reduxTools.getState;
this.dispatch = reduxTools.dispatch;
this.select = reduxTools.select;
}
public async untilInitialized() {
if (this.initialized$.value) return Promise.resolve();
return new Promise<void>((resolve) => {
const subscription = this.initialized$.subscribe((isInitialized) => {
if (isInitialized) {
resolve();
subscription.unsubscribe();
}
});
});
public getDashboardSavedObjectId() {
return this.getState().componentState.lastSavedId;
}
public reportPerformanceMetrics(stats: DashboardRenderPerformanceStats) {
if (this.analyticsService && this.dashboardCreationStartTime) {
const panelCount = Object.keys(
this.getReduxEmbeddableTools().getState().explicitInput.panels
).length;
const panelCount = Object.keys(this.getState().explicitInput.panels).length;
const totalDuration = stats.panelsRenderDoneTime - this.dashboardCreationStartTime;
reportPerformanceMetricEvent(this.analyticsService, {
eventName: DASHBOARD_LOADED_EVENT,
@ -393,44 +211,22 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
return newPanel;
}
public async getExplicitInputIsEqual(lastExplicitInput: DashboardContainerByValueInput) {
const currentInput = this.getReduxEmbeddableTools().getState().explicitInput;
return (
omit(
Object.keys(await getUnsavedChanges.bind(this)(lastExplicitInput, currentInput)),
keysNotConsideredUnsavedChanges
).length > 0
);
}
public getReduxEmbeddableTools() {
if (!this.reduxEmbeddableTools) {
throw new Error('Dashboard must be initialized before accessing redux embeddable tools');
}
return this.reduxEmbeddableTools;
}
public render(dom: HTMLElement) {
if (!this.reduxEmbeddableTools) {
throw new Error('Dashboard must be initialized before it can be rendered');
}
if (this.domNode) {
ReactDOM.unmountComponentAtNode(this.domNode);
}
this.domNode = dom;
this.domNode.className = 'dashboardContainer';
const { Wrapper: DashboardReduxWrapper } = this.reduxEmbeddableTools;
ReactDOM.render(
<I18nProvider>
<ExitFullScreenButtonKibanaProvider
coreStart={{ chrome: this.chrome, customBranding: this.customBranding }}
>
<KibanaThemeProvider theme$={this.theme$}>
<DashboardReduxWrapper>
<DashboardContainerContext.Provider value={this}>
<DashboardViewport />
</DashboardReduxWrapper>
</DashboardContainerContext.Provider>
</KibanaThemeProvider>
</ExitFullScreenButtonKibanaProvider>
</I18nProvider>,
@ -451,7 +247,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
searchSessionId,
refreshInterval,
executionContext,
} = this.input as DashboardContainerByValueInput;
} = this.input;
let combinedFilters = filters;
if (this.controlGroup) {
@ -476,20 +272,12 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
// ------------------------------------------------------------------------------------------------------
// Cleanup
// ------------------------------------------------------------------------------------------------------
private stopDiffingDashboardState?: () => void;
private stopSyncingWithUnifiedSearch?: () => void;
private dataViewsChangeSubscription?: Subscription = undefined;
private stopSyncingDashboardSearchSessions: (() => void) | undefined;
public destroy() {
super.destroy();
this.onDestroyControlGroup?.();
this.cleanupStateTools();
this.controlGroup?.destroy();
this.subscriptions.unsubscribe();
this.stopDiffingDashboardState?.();
this.reduxEmbeddableTools?.cleanup();
this.stopSyncingWithUnifiedSearch?.();
this.stopSyncingDashboardSearchSessions?.();
this.dataViewsChangeSubscription?.unsubscribe();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
}
@ -529,29 +317,20 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
public addOrUpdateEmbeddable = addOrUpdateEmbeddable;
public forceRefresh(refreshControlGroup: boolean = true) {
const {
dispatch,
actions: { setLastReloadRequestTimeToNow },
} = this.getReduxEmbeddableTools();
dispatch(setLastReloadRequestTimeToNow({}));
this.dispatch.setLastReloadRequestTimeToNow({});
if (refreshControlGroup) this.controlGroup?.reload();
}
public onDataViewsUpdate$ = new Subject<DataView[]>();
public resetToLastSavedState() {
const {
dispatch,
getState,
actions: { resetToLastSavedInput },
} = this.getReduxEmbeddableTools();
dispatch(resetToLastSavedInput({}));
this.dispatch.resetToLastSavedInput({});
const {
explicitInput: { timeRange, refreshInterval },
componentState: {
lastSavedInput: { timeRestore: lastSavedTimeRestore },
},
} = getState();
} = this.getState();
// if we are using the unified search integration, we need to force reset the time picker.
if (this.creationOptions?.useUnifiedSearchIntegration && lastSavedTimeRestore) {
@ -585,8 +364,11 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
};
public getExpandedPanelId = () => {
if (!this.reduxEmbeddableTools) throw new Error();
return this.reduxEmbeddableTools.getState().componentState.expandedPanelId;
return this.getState().componentState.expandedPanelId;
};
public setExpandedPanelId = (newId?: string) => {
this.dispatch.setExpandedPanelId(newId);
};
public openOverlay = (ref: OverlayRef) => {
@ -599,15 +381,6 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
this.overlayRef?.close();
};
public setExpandedPanelId = (newId?: string) => {
if (!this.reduxEmbeddableTools) throw new Error();
const {
actions: { setExpandedPanelId },
dispatch,
} = this.reduxEmbeddableTools;
dispatch(setExpandedPanelId(newId));
};
public getPanelCount = () => {
return Object.keys(this.getInput().panels).length;
};

View file

@ -21,15 +21,10 @@ import { SearchSessionInfoProvider } from '@kbn/data-plugin/public';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import {
createInject,
createExtract,
DashboardContainerInput,
DashboardContainerByValueInput,
} from '../../../common';
import { DASHBOARD_CONTAINER_TYPE } from '..';
import type { DashboardContainer } from './dashboard_container';
import { DEFAULT_DASHBOARD_INPUT } from '../../dashboard_constants';
import { createInject, createExtract, DashboardContainerInput } from '../../../common';
import { LoadDashboardFromSavedObjectReturn } from '../../services/dashboard_saved_object/lib/load_dashboard_state_from_saved_object';
export type DashboardContainerFactory = EmbeddableFactory<
@ -40,7 +35,6 @@ export type DashboardContainerFactory = EmbeddableFactory<
export interface DashboardCreationOptions {
initialInput?: Partial<DashboardContainerInput>;
overrideInput?: Partial<DashboardContainerByValueInput>;
incomingEmbeddable?: EmbeddablePackageState;
@ -97,19 +91,17 @@ export class DashboardContainerFactoryDefinition
public create = async (
initialInput: DashboardContainerInput,
parent?: Container,
creationOptions?: DashboardCreationOptions
creationOptions?: DashboardCreationOptions,
savedObjectId?: string
): Promise<DashboardContainer | ErrorEmbeddable> => {
const dashboardCreationStartTime = performance.now();
const { DashboardContainer: DashboardContainerEmbeddable } = await import(
'./dashboard_container'
);
return Promise.resolve(
new DashboardContainerEmbeddable(
initialInput,
dashboardCreationStartTime,
parent,
creationOptions
)
);
const { createDashboard } = await import('./create/create_dashboard');
try {
return Promise.resolve(
createDashboard(initialInput.id, creationOptions, dashboardCreationStartTime, savedObjectId)
);
} catch (e) {
return new ErrorEmbeddable(e.text, { id: e.id });
}
};
}

Some files were not shown because too many files have changed in this diff Show more