[8.7] [embeddable] centralize should fetch embeddable observable logic (#151799) (#152838)

# Backport

This will backport the following commits from `main` to `8.7`:
- [[embeddable] centralize should fetch embeddable observable logic
(#151799)](https://github.com/elastic/kibana/pull/151799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Nathan
Reese","email":"reese.nathan@elastic.co"},"sourceCommit":{"committedDate":"2023-03-01T01:47:48Z","message":"[embeddable]
centralize should fetch embeddable observable logic (#151799)\n\nFixes
https://github.com/elastic/kibana/issues/151223
and\r\nhttps://github.com/elastic/kibana/issues/151128\r\n\r\nPR does
the following\r\n1) creates `shouldFetch# Backport

This will backport the following commits from `main` to `8.7`:
- [[embeddable] centralize should fetch embeddable observable logic
(#151799)](https://github.com/elastic/kibana/pull/151799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT method that centralizes logic for checking\r\nwhen an
embeddable should fetch.\r\n2) updates Lens and Maps embeddable to use
`shouldFetch# Backport

This will backport the following commits from `main` to `8.7`:
- [[embeddable] centralize should fetch embeddable observable logic
(#151799)](https://github.com/elastic/kibana/pull/151799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT \r\n3) Adds unit tests for Maps embeddable to capture
behavior of unique\r\nedge cases\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"afb251c624552024076ae75890de9c7d5f4458f0","branchLabelMapping":{"^v8.8.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Visualizations","Feature:Embedding","Team:Presentation","release_note:skip","backport:skip","Feature:Maps","v8.8.0"],"number":151799,"url":"https://github.com/elastic/kibana/pull/151799","mergeCommit":{"message":"[embeddable]
centralize should fetch embeddable observable logic (#151799)\n\nFixes
https://github.com/elastic/kibana/issues/151223
and\r\nhttps://github.com/elastic/kibana/issues/151128\r\n\r\nPR does
the following\r\n1) creates `shouldFetch# Backport

This will backport the following commits from `main` to `8.7`:
- [[embeddable] centralize should fetch embeddable observable logic
(#151799)](https://github.com/elastic/kibana/pull/151799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT method that centralizes logic for checking\r\nwhen an
embeddable should fetch.\r\n2) updates Lens and Maps embeddable to use
`shouldFetch# Backport

This will backport the following commits from `main` to `8.7`:
- [[embeddable] centralize should fetch embeddable observable logic
(#151799)](https://github.com/elastic/kibana/pull/151799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT \r\n3) Adds unit tests for Maps embeddable to capture
behavior of unique\r\nedge cases\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"afb251c624552024076ae75890de9c7d5f4458f0"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.8.0","labelRegex":"^v8.8.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/151799","number":151799,"mergeCommit":{"message":"[embeddable]
centralize should fetch embeddable observable logic (#151799)\n\nFixes
https://github.com/elastic/kibana/issues/151223
and\r\nhttps://github.com/elastic/kibana/issues/151128\r\n\r\nPR does
the following\r\n1) creates `shouldFetch# Backport

This will backport the following commits from `main` to `8.7`:
- [[embeddable] centralize should fetch embeddable observable logic
(#151799)](https://github.com/elastic/kibana/pull/151799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT method that centralizes logic for checking\r\nwhen an
embeddable should fetch.\r\n2) updates Lens and Maps embeddable to use
`shouldFetch# Backport

This will backport the following commits from `main` to `8.7`:
- [[embeddable] centralize should fetch embeddable observable logic
(#151799)](https://github.com/elastic/kibana/pull/151799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT \r\n3) Adds unit tests for Maps embeddable to capture
behavior of unique\r\nedge cases\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"afb251c624552024076ae75890de9c7d5f4458f0"}}]}]
BACKPORT-->

Co-authored-by: Nathan Reese <reese.nathan@elastic.co>
This commit is contained in:
Marco Liberati 2023-03-07 18:55:12 +01:00 committed by GitHub
parent 26c8ad7b45
commit 09ce818e81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 381 additions and 57 deletions

View file

@ -15,6 +15,7 @@ import {
isFilterPinned,
onlyDisabledFiltersChanged,
} from '@kbn/es-query';
import { shouldRefreshFilterCompareOptions } from '@kbn/embeddable-plugin/public';
import { DashboardContainer } from '../../dashboard_container';
import { DashboardContainerByValueInput } from '../../../../../common';
@ -117,12 +118,6 @@ export const unsavedChangesDiffingFunctions: DashboardDiffFunctions = {
viewMode: () => false, // When compared view mode is always considered unequal so that it gets backed up.
};
const shouldRefreshFilterCompareOptions = {
...COMPARE_ALL_OPTIONS,
// do not compare $state to avoid refreshing when filter is pinned/unpinned (which does not impact results)
state: false,
};
export const shouldRefreshDiffingFunctions: DashboardDiffFunctions = {
...unsavedChangesDiffingFunctions,
filters: ({ currentValue, lastValue }) =>

View file

@ -87,6 +87,8 @@ export {
EmbeddableRenderer,
useEmbeddableFactory,
isFilterableEmbeddable,
shouldFetch$,
shouldRefreshFilterCompareOptions,
} from './lib';
export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service';

View file

@ -8,3 +8,4 @@
export type { FilterableEmbeddable } from './types';
export { isFilterableEmbeddable } from './types';
export { shouldFetch$, shouldRefreshFilterCompareOptions } from './should_fetch';

View file

@ -0,0 +1,44 @@
/*
* 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 fastIsEqual from 'fast-deep-equal';
import { Observable } from 'rxjs';
import { map, distinctUntilChanged, skip, startWith } from 'rxjs/operators';
import { COMPARE_ALL_OPTIONS, onlyDisabledFiltersChanged } from '@kbn/es-query';
import type { FilterableEmbeddableInput } from './types';
export const shouldRefreshFilterCompareOptions = {
...COMPARE_ALL_OPTIONS,
// do not compare $state to avoid refreshing when filter is pinned/unpinned (which does not impact results)
state: false,
};
export function shouldFetch$<
TFilterableEmbeddableInput extends FilterableEmbeddableInput = FilterableEmbeddableInput
>(
updated$: Observable<unknown>,
getInput: () => TFilterableEmbeddableInput
): Observable<TFilterableEmbeddableInput> {
return updated$.pipe(map(() => getInput())).pipe(
// wrapping distinctUntilChanged with startWith and skip to prime distinctUntilChanged with an initial input value.
startWith(getInput()),
distinctUntilChanged((a: TFilterableEmbeddableInput, b: TFilterableEmbeddableInput) => {
if (
!fastIsEqual(
[a.searchSessionId, a.query, a.timeRange, a.timeslice],
[b.searchSessionId, b.query, b.timeRange, b.timeslice]
)
) {
return false;
}
return onlyDisabledFiltersChanged(a.filters, b.filters, shouldRefreshFilterCompareOptions);
}),
skip(1)
);
}

View file

@ -6,7 +6,15 @@
* Side Public License, v 1.
*/
import { type AggregateQuery, type Filter, type Query } from '@kbn/es-query';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { EmbeddableInput } from '../embeddables';
export type FilterableEmbeddableInput = EmbeddableInput & {
filters?: Filter[];
query?: Query;
timeRange?: TimeRange;
timeslice?: [number, number];
};
/**
* All embeddables that implement this interface should support being filtered

View file

@ -55,6 +55,7 @@ import {
cellValueTrigger,
CELL_VALUE_TRIGGER,
type CellValueContext,
shouldFetch$,
} from '@kbn/embeddable-plugin/public';
import type { Action, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { DataViewsContract, DataView } from '@kbn/data-views-plugin/public';
@ -432,20 +433,11 @@ export class Embeddable
// Update search context and reload on changes related to search
this.inputReloadSubscriptions.push(
this.getUpdated$()
.pipe(map(() => this.getInput()))
.pipe(
distinctUntilChanged((a, b) =>
fastIsEqual(
[a.filters, a.query, a.timeRange, a.searchSessionId],
[b.filters, b.query, b.timeRange, b.searchSessionId]
)
),
skip(1)
)
.subscribe(async (input) => {
shouldFetch$<LensEmbeddableInput>(this.getUpdated$(), () => this.getInput()).subscribe(
(input) => {
this.onContainerStateChanged(input);
})
}
)
);
}

View file

@ -0,0 +1,290 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { v4 as uuidv4 } from 'uuid';
import { getControlledBy, MapEmbeddable } from './map_embeddable';
import { buildExistsFilter, disableFilter, pinFilter, toggleFilterNegated } from '@kbn/es-query';
import type { DataViewFieldBase, DataViewBase } from '@kbn/es-query';
import { MapEmbeddableConfig, MapEmbeddableInput } from './types';
import { MapSavedObjectAttributes } from '../../common/map_saved_object_type';
jest.mock('../kibana_services', () => {
return {
getHttp() {
return {
basePath: {
prepend: (url: string) => url,
},
};
},
getMapsCapabilities() {
return { save: true };
},
getSearchService() {
return {
session: {
getSearchOptions() {
return undefined;
},
},
};
},
getShowMapsInspectorAdapter() {
return false;
},
getTimeFilter() {
return {
getTime() {
return { from: 'now-7d', to: 'now' };
},
};
},
};
});
jest.mock('../connected_components/map_container', () => {
return {
MapContainer: () => {
return <div>mockLayerTOC</div>;
},
};
});
jest.mock('../routes/map_page', () => {
class MockSavedMap {
// eslint-disable-next-line @typescript-eslint/no-var-requires
private _store = require('../reducers/store').createMapStore();
private _attributes: MapSavedObjectAttributes = {
title: 'myMap',
};
whenReady = async function () {};
getStore() {
return this._store;
}
getAttributes() {
return this._attributes;
}
getAutoFitToBounds() {
return true;
}
getSharingSavedObjectProps() {
return null;
}
}
return { SavedMap: MockSavedMap };
});
function untilInitialized(mapEmbeddable: MapEmbeddable): Promise<void> {
return new Promise((resolve) => {
// @ts-expect-error setInitializationFinished is protected but we are overriding it to know when embeddable is initialized
mapEmbeddable.setInitializationFinished = () => {
resolve();
};
});
}
function onNextTick(): Promise<void> {
// wait one tick to give observables time to fire
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe('shouldFetch$', () => {
test('should not fetch when search context does not change', async () => {
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
title: 'updated map title',
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
describe('on filters change', () => {
test('should fetch on filter change', async () => {
const existsFilter = buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
);
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filters: [existsFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [toggleFilterNegated(existsFilter)],
});
await onNextTick();
expect(fetchSpy).toHaveBeenCalled();
});
test('should not fetch on disabled filter change', async () => {
const disabledFilter = disableFilter(
buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
)
);
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filters: [disabledFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [toggleFilterNegated(disabledFilter)],
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
test('should not fetch when unpinned filter is pinned', async () => {
const unpinnedFilter = buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
);
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filters: [unpinnedFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [pinFilter(unpinnedFilter)],
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
test('should not fetch on filter controlled by map embeddable change', async () => {
const embeddableId = 'map1';
const filter = buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
);
const controlledByFilter = {
...filter,
meta: {
...filter.meta,
controlledBy: getControlledBy(embeddableId),
},
};
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: embeddableId,
filters: [controlledByFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [toggleFilterNegated(controlledByFilter)],
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('on searchSessionId change', () => {
test('should fetch when filterByMapExtent is false', async () => {
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filterByMapExtent: false,
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
searchSessionId: uuidv4(),
});
await onNextTick();
expect(fetchSpy).toHaveBeenCalled();
});
test('should not fetch when filterByMapExtent is true', async () => {
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filterByMapExtent: true,
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
searchSessionId: uuidv4(),
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
});

View file

@ -14,7 +14,7 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { Subscription } from 'rxjs';
import { Unsubscribe } from 'redux';
import { EuiEmptyPrompt } from '@elastic/eui';
import { type Filter, compareFilters, type TimeRange, type Query } from '@kbn/es-query';
import { type Filter } from '@kbn/es-query';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import {
Embeddable,
@ -24,6 +24,7 @@ import {
VALUE_CLICK_TRIGGER,
omitGenericEmbeddableInput,
FilterableEmbeddable,
shouldFetch$,
} from '@kbn/embeddable-plugin/public';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
@ -104,6 +105,10 @@ function getIsRestore(searchSessionId?: string) {
return searchSessionOptions ? searchSessionOptions.isRestore : false;
}
export function getControlledBy(id: string) {
return `mapEmbeddablePanel${id}`;
}
export class MapEmbeddable
extends Embeddable<MapEmbeddableInput, MapEmbeddableOutput>
implements ReferenceOrValueEmbeddable<MapByValueInput, MapByReferenceInput>, FilterableEmbeddable
@ -114,15 +119,10 @@ export class MapEmbeddable
private _isActive: boolean;
private _savedMap: SavedMap;
private _renderTooltipContent?: RenderToolTipContent;
private _subscription: Subscription;
private _subscriptions: Subscription[] = [];
private _prevIsRestore: boolean = false;
private _prevMapExtent?: MapExtent;
private _prevTimeRange?: TimeRange;
private _prevTimeslice?: [number, number];
private _prevQuery?: Query;
private _prevFilters: Filter[] = [];
private _prevSyncColors?: boolean;
private _prevSearchSessionId?: string;
private _domNode?: HTMLElement;
private _unsubscribeFromStore?: Unsubscribe;
private _isInitialized = false;
@ -145,8 +145,8 @@ export class MapEmbeddable
this._isActive = true;
this._savedMap = new SavedMap({ mapEmbeddableInput: initialInput });
this._initializeSaveMap();
this._subscription = this.getUpdated$().subscribe(() => this.onUpdate());
this._controlledBy = `mapEmbeddablePanel${this.id}`;
this._subscriptions.push(this.getUpdated$().subscribe(() => this.onUpdate()));
this._controlledBy = getControlledBy(this.id);
}
public reportsEmbeddableLoad() {
@ -193,9 +193,20 @@ export class MapEmbeddable
// Passing callback into redux store instead of regular pattern of getting redux state changes for performance reasons
store.dispatch(setOnMapMove(this._propogateMapMovement));
this._dispatchSetQuery({
forceRefresh: false,
});
this._dispatchSetQuery({ forceRefresh: false });
this._subscriptions.push(
shouldFetch$<MapEmbeddableInput>(this.getUpdated$(), () => {
return {
...this.getInput(),
filters: this._getInputFilters(),
searchSessionId: this._getSearchSessionId(),
};
}).subscribe(() => {
this._dispatchSetQuery({
forceRefresh: false,
});
})
);
const mapStateJSON = this._savedMap.getAttributes().mapStateJSON;
if (mapStateJSON) {
@ -309,18 +320,6 @@ export class MapEmbeddable
}
onUpdate() {
if (
!_.isEqual(this.input.timeRange, this._prevTimeRange) ||
!_.isEqual(this.input.timeslice, this._prevTimeslice) ||
!_.isEqual(this.input.query, this._prevQuery) ||
!compareFilters(this._getFilters(), this._prevFilters) ||
this._getSearchSessionId() !== this._prevSearchSessionId
) {
this._dispatchSetQuery({
forceRefresh: false,
});
}
if (this.input.syncColors !== this._prevSyncColors) {
this._dispatchSetChartsPaletteServiceGetColor(this.input.syncColors);
}
@ -382,7 +381,7 @@ export class MapEmbeddable
}
};
_getFilters() {
_getInputFilters() {
return this.input.filters
? this.input.filters.filter(
(filter) => !filter.meta.disabled && filter.meta.controlledBy !== this._controlledBy
@ -401,15 +400,9 @@ export class MapEmbeddable
}
_dispatchSetQuery({ forceRefresh }: { forceRefresh: boolean }) {
const filters = this._getFilters();
this._prevTimeRange = this.input.timeRange;
this._prevTimeslice = this.input.timeslice;
this._prevQuery = this.input.query;
this._prevFilters = filters;
this._prevSearchSessionId = this._getSearchSessionId();
this._savedMap.getStore().dispatch<any>(
setQuery({
filters,
filters: this._getInputFilters(),
query: this.input.query,
timeFilters: this.input.timeRange,
timeslice: this.input.timeslice
@ -675,9 +668,9 @@ export class MapEmbeddable
unmountComponentAtNode(this._domNode);
}
if (this._subscription) {
this._subscription.unsubscribe();
}
this._subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
}
reload() {

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { Filter } from '@kbn/es-query';
import type { DataView } from '@kbn/data-plugin/common';
import {
Embeddable,
@ -13,7 +12,7 @@ import {
EmbeddableOutput,
SavedObjectEmbeddableInput,
} from '@kbn/embeddable-plugin/public';
import type { Query, TimeRange } from '@kbn/es-query';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { MapCenterAndZoom, MapExtent, MapSettings } from '../../common/descriptor_types';
import { MapSavedObjectAttributes } from '../../common/map_saved_object_type';