[canvas] fix All embeddables rebuilt on refresh (#209677)

Fixes https://github.com/elastic/kibana/issues/209566

### Problem
Any input change causes Canvas embeddable's to get re-created. This
means that setting a filter control or clicking the refresh button
causes embeddables to get re-created.

In the old embeddable system, the Canvas would only call
`embeddable.updateInput` and `embeddable.reload` on [input
changes](https://github.com/elastic/kibana/blob/8.13/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx#L163).

### Solution
PR updates embeddable renderer to store embeddable API. Then, on input
changes, Canvas calls `embeddable.setFilters`.

There is no `embeddable.updateInput` equivalent in the new embeddable
system. Instead, each state key needs to be updated by a setter. The
[Canvas
documentation](https://www.elastic.co/guide/en/kibana/current/canvas-function-reference.html#embeddable_fn)
states that the embeddable function only accepts `filters`. Therefore,
the only key that is expected to change from the input is `filters`.
Please correct me if this is an incorrect assumption.

### Test instructions
1) install sample web logs
2) install canvas saved object and reload kibana (otherwise canvas is
not available in the nav menu)
3) open new canvas
4) add map embeddable
5) add filter control. set source to sample web logs and field to
`geo.dest`.
<img width="200" alt="Screenshot 2025-02-04 at 2 58 01 PM"
src="https://github.com/user-attachments/assets/6862f0bc-4f61-4f16-aa7c-ea8008cfdbf9"
/>
6) prefix map element expression with `kibana | selectFilter` so it
looks like `kibana | selectFilter | embeddable config=...`
7) change filter. Verify map updates but map embeddable is not
re-created.
8) click refresh button, Verify map updates but is not re-created.

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2025-02-05 10:34:44 -07:00 committed by GitHub
parent ba0b1eca91
commit fe9023efff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 77 additions and 23 deletions

View file

@ -8,10 +8,18 @@
import { CoreStart } from '@kbn/core/public';
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import React, { FC } from 'react';
import React, { FC, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { useSearchApi } from '@kbn/presentation-publishing';
import { omit } from 'lodash';
import {
AggregateQuery,
COMPARE_ALL_OPTIONS,
Filter,
Query,
TimeRange,
onlyDisabledFiltersChanged,
} from '@kbn/es-query';
import { BehaviorSubject } from 'rxjs';
import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib';
import { RendererStrings } from '../../../i18n';
import {
@ -27,6 +35,8 @@ import { useGetAppContext } from './use_get_app_context';
const { embeddable: strings } = RendererStrings;
const children: Record<string, { setFilters: (filters: Filter[] | undefined) => void }> = {};
const renderReactEmbeddable = ({
type,
uuid,
@ -45,7 +55,14 @@ const renderReactEmbeddable = ({
// wrap in functional component to allow usage of hooks
const RendererWrapper: FC<{}> = () => {
const getAppContext = useGetAppContext(core);
const searchApi = useSearchApi({ filters: input.filters });
const searchApi = useMemo(() => {
return {
filters$: new BehaviorSubject<Filter[] | undefined>(input.filters),
query$: new BehaviorSubject<Query | AggregateQuery | undefined>(undefined),
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
};
}, []);
return (
<ReactEmbeddableRenderer
@ -69,6 +86,22 @@ const renderReactEmbeddable = ({
);
if (newExpression) handlers.onEmbeddableInputChange(newExpression);
}}
onApiAvailable={(api) => {
children[uuid] = {
...api,
setFilters: (filters: Filter[] | undefined) => {
if (
!onlyDisabledFiltersChanged(searchApi.filters$.getValue(), filters, {
...COMPARE_ALL_OPTIONS,
// do not compare $state to avoid refreshing when filter is pinned/unpinned (which does not impact results)
state: false,
})
) {
searchApi.filters$.next(filters);
}
},
};
}}
/>
);
};
@ -95,24 +128,30 @@ export const embeddableRendererFactory = (
help: strings.getHelpDescription(),
reuseDomNode: true,
render: async (domNode, { input, embeddableType, canvasApi }, handlers) => {
const uniqueId = handlers.getElementId();
ReactDOM.render(
renderReactEmbeddable({
input,
handlers,
uuid: uniqueId,
type: embeddableType,
container: canvasApi,
core,
}),
domNode,
() => handlers.done()
);
const uuid = handlers.getElementId();
const api = children[uuid];
if (!api) {
ReactDOM.render(
renderReactEmbeddable({
input,
handlers,
uuid,
type: embeddableType,
container: canvasApi,
core,
}),
domNode,
() => handlers.done()
);
handlers.onDestroy(() => {
handlers.onEmbeddableDestroyed();
return ReactDOM.unmountComponentAtNode(domNode);
});
handlers.onDestroy(() => {
delete children[uuid];
handlers.onEmbeddableDestroyed();
return ReactDOM.unmountComponentAtNode(domNode);
});
} else {
api.setFilters(input.filters);
}
},
});
};

View file

@ -7,7 +7,7 @@
import { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { EmbeddableInput } from '@kbn/embeddable-plugin/common';
import { ViewMode } from '@kbn/presentation-publishing';
@ -19,6 +19,8 @@ import { METRIC_TYPE, trackCanvasUiMetric } from '../../lib/ui_metric';
import { addElement } from '../../state/actions/elements';
import { getSelectedPage } from '../../state/selectors/workpad';
const reload$ = new Subject<void>();
export const useCanvasApi: () => CanvasContainerApi = () => {
const selectedPageId = useSelector(getSelectedPage);
const dispatch = useDispatch();
@ -38,6 +40,10 @@ export const useCanvasApi: () => CanvasContainerApi = () => {
const getCanvasApi = useCallback((): CanvasContainerApi => {
return {
reload$,
reload: () => {
reload$.next();
},
viewMode$: new BehaviorSubject<ViewMode>('edit'), // always in edit mode
addNewPanel: async ({
panelType,

View file

@ -15,6 +15,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { fetchAllRenderables } from '../../../state/actions/elements';
import { getInFlight } from '../../../state/selectors/resolved_args';
import { ToolTipShortcut } from '../../tool_tip_shortcut';
import { useCanvasApi } from '../../hooks/use_canvas_api';
const strings = {
getRefreshAriaLabel: () =>
@ -30,7 +31,11 @@ const strings = {
export const RefreshControl = () => {
const dispatch = useDispatch();
const inFlight = useSelector(getInFlight);
const doRefresh = useCallback(() => dispatch(fetchAllRenderables()), [dispatch]);
const canvasApi = useCanvasApi();
const doRefresh = useCallback(() => {
canvasApi.reload();
dispatch(fetchAllRenderables());
}, [canvasApi, dispatch]);
return (
<EuiToolTip

View file

@ -12,6 +12,7 @@ import type {
HasAppContext,
HasDisableTriggers,
HasType,
PublishesReload,
PublishesViewMode,
PublishesUnifiedSearch,
} from '@kbn/presentation-publishing';
@ -28,4 +29,7 @@ export type CanvasContainerApi = PublishesViewMode &
HasDisableTriggers &
HasType &
HasSerializedChildState &
Partial<HasAppContext & PublishesUnifiedSearch>;
PublishesReload &
Partial<HasAppContext & PublishesUnifiedSearch> & {
reload: () => void;
};