[embeddable refactor] decouple MapEmbeddable from Security Solution plugin (#182737)

Part of https://github.com/elastic/kibana/issues/182020

@elastic/kibana-presentation is refactoring the Embeddable framework.
Part of this effort is decoupling plugins from the legacy Embeddable
framework. The Maps plugin provides a react component from the plugins
start contract that can be used instead of natively using MapEmbeddable.

This PR swaps out the native MapEmbeddable implementation with the react
component exposed from the Maps plugin.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-05-13 14:17:26 -06:00 committed by GitHub
parent 98329a8879
commit 1c15e9a677
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 76 additions and 425 deletions

View file

@ -21,6 +21,7 @@ import type { ILayer } from '../classes/layers/layer';
export type MapApi = HasType<'map'> & {
getLayerList: () => ILayer[];
reload: () => void;
} & PublishesDataViews &
PublishesPanelTitle &
PublishesUnifiedSearch &

View file

@ -14,6 +14,7 @@ import type { LayerDescriptor, MapCenterAndZoom, MapSettings } from '../../commo
import { MapEmbeddable } from './map_embeddable';
import { createBasemapLayerDescriptor } from '../classes/layers/create_basemap_layer_descriptor';
import { RenderToolTipContent } from '../classes/tooltips/tooltip_property';
import { MapApi } from './map_api';
export interface Props {
title?: string;
@ -27,6 +28,7 @@ export interface Props {
mapCenter?: MapCenterAndZoom;
onInitialRenderComplete?: () => void;
getTooltipRenderer?: () => RenderToolTipContent;
onApiAvailable?: (api: MapApi) => void;
/*
* Set to false to exclude sharing attributes 'data-*'.
*/
@ -70,6 +72,9 @@ export class MapComponent extends Component<Props> {
if (this.props.getTooltipRenderer) {
this._mapEmbeddable.setRenderTooltipContent(this.props.getTooltipRenderer());
}
if (this.props.onApiAvailable) {
this.props.onApiAvailable(this._mapEmbeddable as MapApi);
}
if (this.props.onInitialRenderComplete) {
this._mapEmbeddable

View file

@ -398,36 +398,9 @@ const mockApmDataStreamClientServerLineLayer = {
label: 'traces-apm*,logs-apm*,metrics-apm*,apm-* | Line',
};
export const mockLayerList = [
{
sourceDescriptor: { type: 'EMS_TMS', isAutoSelect: true },
id: 'uuidv4()',
label: null,
minZoom: 0,
maxZoom: 24,
alpha: 1,
visible: true,
style: null,
type: LAYER_TYPE.EMS_VECTOR_TILE,
},
mockLineLayer,
mockDestinationLayer,
mockSourceLayer,
mockLayerGroup,
];
export const mockLayerList = [mockLineLayer, mockDestinationLayer, mockSourceLayer, mockLayerGroup];
export const mockLayerListDouble = [
{
sourceDescriptor: { type: 'EMS_TMS', isAutoSelect: true },
id: 'uuidv4()',
label: null,
minZoom: 0,
maxZoom: 24,
alpha: 1,
visible: true,
style: null,
type: LAYER_TYPE.EMS_VECTOR_TILE,
},
mockLineLayer,
mockDestinationLayer,
mockSourceLayer,
@ -439,17 +412,6 @@ export const mockLayerListDouble = [
];
export const mockLayerListMixed = [
{
sourceDescriptor: { type: 'EMS_TMS', isAutoSelect: true },
id: 'uuidv4()',
label: null,
minZoom: 0,
maxZoom: 24,
alpha: 1,
visible: true,
style: null,
type: LAYER_TYPE.EMS_VECTOR_TILE,
},
mockLineLayer,
mockDestinationLayer,
mockSourceLayer,

View file

@ -1,54 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { createEmbeddable } from './create_embeddable';
import { createHtmlPortalNode } from 'react-reverse-portal';
const mockEmbeddable = embeddablePluginMock.createStartContract();
mockEmbeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({
create: () => ({
reload: jest.fn(),
setRenderTooltipContent: jest.fn(),
setLayerList: jest.fn(),
}),
}));
describe('createEmbeddable', () => {
test('attaches refresh action', async () => {
const setQueryMock = jest.fn();
await createEmbeddable(
[],
[],
{ query: '', language: 'kuery' },
'2020-07-07T08:20:18.966Z',
'2020-07-08T08:20:18.966Z',
setQueryMock,
createHtmlPortalNode(),
mockEmbeddable
);
expect(setQueryMock).toHaveBeenCalledTimes(1);
});
test('attaches refresh action with correct reference', async () => {
const setQueryMock = jest.fn(({ id, inspect, loading, refetch }) => refetch);
const embeddable = await createEmbeddable(
[],
[],
{ query: '', language: 'kuery' },
'2020-07-07T08:20:18.966Z',
'2020-07-08T08:20:18.966Z',
setQueryMock,
createHtmlPortalNode(),
mockEmbeddable
);
expect(setQueryMock.mock.calls[0][0].refetch).not.toBe(embeddable.reload);
setQueryMock.mock.results[0].value();
expect(embeddable.reload).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,126 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v4 as uuidv4 } from 'uuid';
import React from 'react';
import type { HtmlPortalNode } from 'react-reverse-portal';
import { OutPortal } from 'react-reverse-portal';
import type { Filter, Query } from '@kbn/es-query';
import { MAP_SAVED_OBJECT_TYPE } from '@kbn/maps-plugin/public';
import type {
RenderTooltipContentParams,
MapEmbeddable,
MapEmbeddableInput,
} from '@kbn/maps-plugin/public';
import type {
EmbeddableStart,
EmbeddableOutput,
ErrorEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
import type { IndexPatternMapping } from './types';
import { getLayerList } from './map_config';
import * as i18n from './translations';
import type { GlobalTimeArgs } from '../../../../common/containers/use_global_time';
/**
* Creates MapEmbeddable with provided initial configuration
*
* @param filters any existing global filters
* @param indexPatterns list of index patterns to configure layers for
* @param query initial query constraints as Query
* @param startDate
* @param endDate
* @param setQuery function as provided by the GlobalTime component for reacting to refresh
* @param portalNode wrapper for MapToolTip so it is not rendered in the embeddables component tree
* @param embeddableApi
*
* @throws Error if EmbeddableFactory does not exist
*/
export const createEmbeddable = async (
filters: Filter[],
indexPatterns: IndexPatternMapping[],
query: Query,
startDate: GlobalTimeArgs['from'],
endDate: GlobalTimeArgs['to'],
setQuery: GlobalTimeArgs['setQuery'],
portalNode: HtmlPortalNode,
embeddableApi: EmbeddableStart
): Promise<MapEmbeddable | ErrorEmbeddable> => {
const factory = embeddableApi.getEmbeddableFactory<
MapEmbeddableInput,
EmbeddableOutput,
MapEmbeddable
>(MAP_SAVED_OBJECT_TYPE);
if (!factory) {
throw new Error('Map embeddable factory undefined');
}
const input: MapEmbeddableInput = {
title: i18n.MAP_TITLE,
attributes: { title: '' },
id: uuidv4(),
filters,
hidePanelTitles: true,
query,
timeRange: {
from: new Date(startDate).toISOString(),
to: new Date(endDate).toISOString(),
},
viewMode: ViewMode.VIEW,
isLayerTOCOpen: false,
openTOCDetails: [],
hideFilterActions: false,
mapCenter: { lon: -1.05469, lat: 15.96133, zoom: 1 },
disabledActions: ['CUSTOM_TIME_RANGE', 'CUSTOM_TIME_RANGE_BADGE'],
};
const renderTooltipContent = ({
addFilters,
closeTooltip,
features,
isLocked,
getLayerName,
loadFeatureProperties,
loadFeatureGeometry,
}: RenderTooltipContentParams) => {
const props = {
addFilters,
closeTooltip,
features,
isLocked,
getLayerName,
loadFeatureProperties,
loadFeatureGeometry,
};
return <OutPortal node={portalNode} {...props} />;
};
const embeddableObject = await factory.create(input);
if (!embeddableObject) {
throw new Error('Map embeddable is undefined');
}
if (!isErrorEmbeddable(embeddableObject)) {
embeddableObject.setRenderTooltipContent(renderTooltipContent);
// @ts-expect-error
embeddableObject.setLayerList(getLayerList(indexPatterns));
}
// Wire up to app refresh action
setQuery({
id: 'embeddedMap', // Scope to page type if using map elsewhere
inspect: null,
loading: false,
refetch: () => embeddableObject.reload(),
});
return embeddableObject;
};

View file

@ -10,14 +10,11 @@ import React from 'react';
import { TestProviders, mockGlobalState, createMockStore } from '../../../../common/mock';
import { EmbeddedMapComponent } from './embedded_map';
import { createEmbeddable } from './create_embeddable';
import { getLayerList } from './map_config';
import { useIsFieldInIndexPattern } from '../../../containers/fields';
import { buildTimeRangeFilter } from '../../../../detections/components/alerts_table/helpers';
import { setStubKibanaServices } from '@kbn/embeddable-plugin/public/mocks';
jest.mock('./create_embeddable');
jest.mock('./map_config');
jest.mock('../../../../common/containers/sourcerer');
jest.mock('../../../containers/fields');
@ -33,6 +30,9 @@ jest.mock('../../../../common/lib/kibana', () => ({
siem: { networkMap: '' },
},
},
maps: {
Map: () => <div data-test-subj="MapPanel">{'mockMap'}</div>,
},
storage: {
get: mockGetStorage,
set: mockSetStorage,
@ -46,12 +46,7 @@ jest.mock('../../../../common/lib/kibana', () => ({
remove: jest.fn(),
}),
}));
jest.mock('@kbn/embeddable-plugin/public', () => ({
...jest.requireActual('@kbn/embeddable-plugin/public'),
EmbeddablePanel: jest.fn().mockReturnValue(<div data-test-subj="EmbeddablePanel" />),
}));
const mockCreateEmbeddable = createEmbeddable as jest.Mock;
const mockUseIsFieldInIndexPattern = useIsFieldInIndexPattern as jest.Mock;
const mockGetStorage = jest.fn();
const mockSetStorage = jest.fn();
@ -92,35 +87,6 @@ const mockState = {
},
};
const defaultMockStore = createMockStore(mockState);
const mockUpdateInput = jest.fn();
const embeddableValue = {
destroyed: false,
enhancements: { dynamicActions: {} },
getActionContext: jest.fn(),
getFilterActions: jest.fn(),
id: '70969ddc-4d01-4048-8073-4ea63d595638',
input: {
viewMode: 'view',
title: 'Source -> Destination Point-to-Point Map',
id: '70969ddc-4d01-4048-8073-4ea63d595638',
filters: Array(0),
hidePanelTitles: true,
},
input$: {},
isContainer: false,
output: {},
output$: {},
parent: undefined,
parentSubscription: undefined,
renderComplete: {},
runtimeId: 1,
reload: jest.fn(),
setLayerList: jest.fn(),
setEventHandlers: jest.fn(),
setRenderTooltipContent: jest.fn(),
type: 'map',
updateInput: mockUpdateInput,
};
const testProps = {
endDate: '2019-08-28T05:50:57.877Z',
filters: [],
@ -132,7 +98,6 @@ describe('EmbeddedMapComponent', () => {
beforeEach(() => {
setQuery.mockClear();
mockGetStorage.mockReturnValue(true);
mockCreateEmbeddable.mockResolvedValue(embeddableValue);
mockUseIsFieldInIndexPattern.mockReturnValue(() => true);
// stub Kibana services for the embeddable plugin to ensure embeddable panel renders.
@ -154,21 +119,7 @@ describe('EmbeddedMapComponent', () => {
});
});
test('calls updateInput with time range filter', async () => {
render(
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
expect(mockUpdateInput).toHaveBeenCalledTimes(2);
expect(mockUpdateInput).toHaveBeenNthCalledWith(2, {
filters: buildTimeRangeFilter(testProps.startDate, testProps.endDate),
});
});
});
test('renders EmbeddablePanel from embeddable plugin', async () => {
test('renders Map', async () => {
const { getByTestId, queryByTestId } = render(
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
@ -176,9 +127,8 @@ describe('EmbeddedMapComponent', () => {
);
await waitFor(() => {
expect(getByTestId('EmbeddablePanel')).toBeInTheDocument();
expect(getByTestId('MapPanel')).toBeInTheDocument();
expect(queryByTestId('IndexPatternsMissingPrompt')).not.toBeInTheDocument();
expect(queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
});
@ -198,24 +148,8 @@ describe('EmbeddedMapComponent', () => {
</TestProviders>
);
await waitFor(() => {
expect(queryByTestId('EmbeddablePanel')).not.toBeInTheDocument();
expect(queryByTestId('MapPanel')).not.toBeInTheDocument();
expect(getByTestId('IndexPatternsMissingPrompt')).toBeInTheDocument();
expect(queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
});
test('renders Loader', async () => {
mockCreateEmbeddable.mockResolvedValue(null);
const { getByTestId, queryByTestId } = render(
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
await waitFor(() => {
expect(queryByTestId('EmbeddablePanel')).not.toBeInTheDocument();
expect(queryByTestId('IndexPatternsMissingPrompt')).not.toBeInTheDocument();
expect(getByTestId('loading-spinner')).toBeInTheDocument();
});
});

View file

@ -10,23 +10,17 @@
import { EuiAccordion, EuiLink, EuiText } from '@elastic/eui';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { createHtmlPortalNode, InPortal } from 'react-reverse-portal';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import styled, { css } from 'styled-components';
import type { Filter, Query } from '@kbn/es-query';
import {
EmbeddablePanel,
isErrorEmbeddable,
type ErrorEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { MapEmbeddable } from '@kbn/maps-plugin/public/embeddable';
import { isEqual } from 'lodash/fp';
import type { MapApi, RenderTooltipContentParams } from '@kbn/maps-plugin/public';
import type { LayerDescriptor } from '@kbn/maps-plugin/common';
import { buildTimeRangeFilter } from '../../../../detections/components/alerts_table/helpers';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useIsFieldInIndexPattern } from '../../../containers/fields';
import { Loader } from '../../../../common/components/loader';
import type { GlobalTimeArgs } from '../../../../common/containers/use_global_time';
import { Embeddable } from './embeddable';
import { createEmbeddable } from './create_embeddable';
import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt';
import { MapToolTip } from './map_tool_tip/map_tool_tip';
import * as i18n from './translations';
@ -43,7 +37,7 @@ interface EmbeddableMapProps {
maintainRatio?: boolean;
}
const EmbeddableMap = styled.div.attrs(() => ({
const EmbeddableMapRatioHolder = styled.div.attrs(() => ({
className: 'siemEmbeddable__map',
}))<EmbeddableMapProps>`
.embPanel {
@ -89,7 +83,18 @@ const StyledEuiAccordion = styled(EuiAccordion)`
}
`;
EmbeddableMap.displayName = 'EmbeddableMap';
EmbeddableMapRatioHolder.displayName = 'EmbeddableMapRatioHolder';
const EmbeddableMapWrapper = styled.div`
position: relative;
`;
const EmbeddableMap = styled.div`
height: 100%;
width: 100%;
position: absolute;
top: 0;
`;
export interface EmbeddedMapProps {
query: Query;
@ -106,10 +111,6 @@ export const EmbeddedMapComponent = ({
setQuery,
startDate,
}: EmbeddedMapProps) => {
const [embeddable, setEmbeddable] = React.useState<MapEmbeddable | undefined | ErrorEmbeddable>(
undefined
);
const { services } = useKibana();
const { storage } = services;
@ -126,7 +127,7 @@ export const EmbeddedMapComponent = ({
const isFieldInIndexPattern = useIsFieldInIndexPattern();
const [mapDataViews, setMapDataViews] = useState<SourcererDataView[]>([]);
const [layerList, setLayerList] = useState<LayerDescriptor[]>([]);
const [availableDataViews, setAvailableDataViews] = useState<SourcererDataView[]>([]);
useEffect(() => {
@ -140,11 +141,11 @@ export const EmbeddedMapComponent = ({
// ensures only index patterns with maps fields are passed
const goodDataViews = availableDataViews.filter((_, i) => apiResponse[i] ?? false);
if (!canceled) {
setMapDataViews(goodDataViews);
setLayerList(getLayerList(goodDataViews));
}
} catch (e) {
if (!canceled) {
setMapDataViews([]);
setLayerList([]);
addError(e, { title: i18n.ERROR_CREATING_EMBEDDABLE });
setIsError(true);
}
@ -174,88 +175,9 @@ export const EmbeddedMapComponent = ({
// Search InPortal/OutPortal for implementation touch points
const portalNode = React.useMemo(() => createHtmlPortalNode(), []);
// Initial Load useEffect
useEffect(() => {
let isSubscribed = true;
async function setupEmbeddable() {
// Create & set Embeddable
try {
const embeddableObject = await createEmbeddable(
filters,
mapDataViews,
query,
startDate,
endDate,
setQuery,
portalNode,
services.embeddable
);
if (isSubscribed) {
setEmbeddable(embeddableObject);
}
} catch (e) {
if (isSubscribed) {
addError(e, { title: i18n.ERROR_CREATING_EMBEDDABLE });
setIsError(true);
}
}
}
if (embeddable == null && selectedPatterns.length > 0 && !isIndexError) {
setupEmbeddable();
}
return () => {
isSubscribed = false;
};
}, [
addError,
endDate,
embeddable,
filters,
mapDataViews,
query,
portalNode,
services.embeddable,
selectedPatterns,
setQuery,
startDate,
isIndexError,
]);
// update layer with new index patterns
useEffect(() => {
const setLayerList = async () => {
if (embeddable != null && mapDataViews.length) {
// @ts-expect-error
await embeddable.setLayerList(getLayerList(mapDataViews));
embeddable.reload();
}
};
if (embeddable != null && !isErrorEmbeddable(embeddable)) {
setLayerList();
}
}, [embeddable, mapDataViews]);
// queryExpression updated useEffect
useEffect(() => {
if (embeddable != null) {
embeddable.updateInput({ query });
}
}, [embeddable, query]);
const timeRangeFilter = useMemo(
() => buildTimeRangeFilter(startDate, endDate),
[startDate, endDate]
);
useEffect(() => {
if (embeddable != null) {
// pass time range as filter instead of via timeRange param
// if user's data view does not have a time field, the timeRange param is not applied
// using filter will always apply the time range
embeddable.updateInput({ filters: [...filters, ...timeRangeFilter] });
}
}, [embeddable, filters, timeRangeFilter]);
const appliedFilters = useMemo(() => {
return [...filters, ...buildTimeRangeFilter(startDate, endDate)];
}, [filters, startDate, endDate]);
const setDefaultMapVisibility = useCallback(
(isOpen: boolean) => {
@ -265,28 +187,40 @@ export const EmbeddedMapComponent = ({
[storage]
);
const content = useMemo(() => {
if (!storageValue) {
return null;
}
return (
<Embeddable>
<InPortal node={portalNode}>
<MapToolTip />
</InPortal>
<EmbeddableMap maintainRatio={!isIndexError}>
{isIndexError ? (
<IndexPatternsMissingPrompt data-test-subj="missing-prompt" />
) : embeddable != null ? (
<EmbeddablePanel embeddable={embeddable} />
) : (
<Loader data-test-subj="loading-panel" overlay size="xl" />
)}
</EmbeddableMap>
</Embeddable>
);
}, [embeddable, isIndexError, portalNode, storageValue]);
const content = !storageValue ? null : (
<Embeddable>
<InPortal node={portalNode}>
<MapToolTip />
</InPortal>
<EmbeddableMapWrapper>
<EmbeddableMapRatioHolder maintainRatio={!isIndexError} />
{isIndexError ? (
<IndexPatternsMissingPrompt data-test-subj="missing-prompt" />
) : (
<EmbeddableMap>
<services.maps.Map
// eslint-disable-next-line react/display-name
getTooltipRenderer={() => (tooltipProps: RenderTooltipContentParams) =>
<OutPortal node={portalNode} {...tooltipProps} />}
mapCenter={{ lon: -1.05469, lat: 15.96133, zoom: 1 }}
layerList={layerList}
filters={appliedFilters}
query={query}
onApiAvailable={(api: MapApi) => {
// Wire up to app refresh action
setQuery({
id: 'embeddedMap', // Scope to page type if using map elsewhere
inspect: null,
loading: false,
refetch: () => api.reload(),
});
}}
/>
</EmbeddableMap>
)}
</EmbeddableMapWrapper>
</Embeddable>
);
return isError ? null : (
<StyledEuiAccordion

View file

@ -7,6 +7,7 @@
import { v4 as uuidv4 } from 'uuid';
import { euiPaletteColorBlind } from '@elastic/eui';
import type { LayerDescriptor } from '@kbn/maps-plugin/common';
import { LAYER_TYPE, SCALING_TYPES, SOURCE_TYPES } from '@kbn/maps-plugin/common';
import type {
IndexPatternMapping,
@ -115,17 +116,6 @@ export const getRequiredMapsFields = (title: string): string[] => {
*/
export const getLayerList = (indexPatternIds: IndexPatternMapping[]) => {
return [
{
sourceDescriptor: { type: SOURCE_TYPES.EMS_TMS, isAutoSelect: true },
id: uuidv4(),
label: null,
minZoom: 0,
maxZoom: 24,
alpha: 1,
visible: true,
style: null,
type: LAYER_TYPE.EMS_VECTOR_TILE,
},
...indexPatternIds.reduce((acc: object[], { title, id }) => {
const layerGroupDescriptor = {
id: uuidv4(),
@ -152,7 +142,7 @@ export const getLayerList = (indexPatternIds: IndexPatternMapping[]) => {
layerGroupDescriptor,
];
}, []),
];
] as LayerDescriptor[];
};
/**

View file

@ -93,6 +93,9 @@ jest.mock('../../../common/lib/kibana', () => {
cases: {
...mockCasesContract(),
},
maps: {
Map: () => <div data-test-subj="MapPanel">{'mockMap'}</div>,
},
},
}),
useToasts: jest.fn().mockReturnValue({

View file

@ -59,6 +59,7 @@ import type { UpsellingService } from '@kbn/security-solution-upselling/service'
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public';
import type { PluginStartContract } from '@kbn/alerting-plugin/public/plugin';
import type { MapsStartApi } from '@kbn/maps-plugin/public';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { Detections } from './detections';
@ -130,6 +131,7 @@ export interface StartPlugins {
timelines: TimelinesUIStart;
sessionView: SessionViewStart;
uiActions: UiActionsStart;
maps: MapsStartApi;
ml?: MlPluginStart;
spaces?: SpacesPluginStart;
dataViewFieldEditor: IndexPatternFieldEditorStart;