mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Cloud Security] Asset Inventory - Universal Flyout Header Boxes, Tags, Fields Components (#211366)
This commit is contained in:
parent
bccbb933c0
commit
827219b82a
17 changed files with 730 additions and 103 deletions
|
@ -11,6 +11,9 @@
|
|||
export interface EntityEcs {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'universal' | 'user' | 'host' | 'service';
|
||||
timestamp: Date;
|
||||
type: 'container' | 'user' | 'host' | 'service';
|
||||
tags: string[];
|
||||
labels: Record<string, string>;
|
||||
criticality: 'low_impact' | 'medium_impact' | 'high_impact' | 'extreme_impact' | 'unassigned';
|
||||
category: string;
|
||||
}
|
||||
|
|
|
@ -115,7 +115,12 @@ export const FieldsSelectorTable = ({
|
|||
};
|
||||
const fields = useMemo<Field[]>(
|
||||
() =>
|
||||
filterFieldsBySearch(dataView.fields.getAll(), columns, searchQuery, isFilterSelectedEnabled),
|
||||
filterFieldsBySearch(
|
||||
dataView.fields?.getAll(),
|
||||
columns,
|
||||
searchQuery,
|
||||
isFilterSelectedEnabled
|
||||
),
|
||||
[dataView, columns, searchQuery, isFilterSelectedEnabled]
|
||||
);
|
||||
|
||||
|
@ -171,7 +176,7 @@ export const FieldsSelectorTable = ({
|
|||
];
|
||||
|
||||
const error = useMemo(() => {
|
||||
if (!dataView || dataView.fields.length === 0) {
|
||||
if (!dataView || dataView.fields?.length === 0) {
|
||||
return i18n.translate('xpack.securitySolution.assetInventory.allAssets.fieldsModalError', {
|
||||
defaultMessage: 'No fields found in the data view',
|
||||
});
|
||||
|
|
|
@ -30,11 +30,18 @@ jest.mock('../../flyout/shared/hooks/use_on_expandable_flyout_close', () => ({
|
|||
useOnExpandableFlyoutClose: jest.fn(),
|
||||
}));
|
||||
|
||||
const entity = {
|
||||
const entity: EntityEcs = {
|
||||
id: '123',
|
||||
name: 'test-entity',
|
||||
type: 'universal',
|
||||
timestamp: new Date(),
|
||||
type: 'container',
|
||||
tags: ['tag1', 'tag2'],
|
||||
labels: { label1: 'value1', label2: 'value2' },
|
||||
criticality: 'high_impact',
|
||||
category: 'test',
|
||||
};
|
||||
|
||||
const source = {
|
||||
'@timestamp': '2025-10-01T12:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('useDynamicEntityFlyout', () => {
|
||||
|
@ -66,16 +73,18 @@ describe('useDynamicEntityFlyout', () => {
|
|||
|
||||
act(() => {
|
||||
result.current.openDynamicFlyout({
|
||||
entity: { ...entity, type: 'universal', name: 'testUniversal' },
|
||||
entity: { ...entity, type: 'container', name: 'testUniversal' },
|
||||
source,
|
||||
scopeId: 'scope1',
|
||||
contextId: 'context1',
|
||||
});
|
||||
});
|
||||
|
||||
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
|
||||
expect(openFlyoutMock).toHaveBeenCalledWith({
|
||||
right: {
|
||||
id: UniversalEntityPanelKey,
|
||||
params: { entity: { ...entity, type: 'universal', name: 'testUniversal' } },
|
||||
params: { entity: { ...entity, type: 'container', name: 'testUniversal' }, source },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -88,11 +97,13 @@ describe('useDynamicEntityFlyout', () => {
|
|||
act(() => {
|
||||
result.current.openDynamicFlyout({
|
||||
entity: { ...entity, type: 'user', name: 'testUser' },
|
||||
source,
|
||||
scopeId: 'scope1',
|
||||
contextId: 'context1',
|
||||
});
|
||||
});
|
||||
|
||||
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
|
||||
expect(openFlyoutMock).toHaveBeenCalledWith({
|
||||
right: {
|
||||
id: UserPanelKey,
|
||||
|
@ -114,6 +125,7 @@ describe('useDynamicEntityFlyout', () => {
|
|||
});
|
||||
});
|
||||
|
||||
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
|
||||
expect(openFlyoutMock).toHaveBeenCalledWith({
|
||||
right: {
|
||||
id: HostPanelKey,
|
||||
|
@ -135,6 +147,7 @@ describe('useDynamicEntityFlyout', () => {
|
|||
});
|
||||
});
|
||||
|
||||
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
|
||||
expect(openFlyoutMock).toHaveBeenCalledWith({
|
||||
right: {
|
||||
id: ServicePanelKey,
|
||||
|
@ -149,7 +162,7 @@ describe('useDynamicEntityFlyout', () => {
|
|||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openDynamicFlyout({ entity: { type: 'user' } as EntityEcs });
|
||||
result.current.openDynamicFlyout({ entity: { type: 'user' } as EntityEcs, source });
|
||||
});
|
||||
|
||||
expect(toastsMock.addDanger).toHaveBeenCalledWith(
|
||||
|
@ -161,7 +174,7 @@ describe('useDynamicEntityFlyout', () => {
|
|||
expect(onFlyoutCloseMock).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.openDynamicFlyout({ entity: { type: 'host' } as EntityEcs });
|
||||
result.current.openDynamicFlyout({ entity: { type: 'host' } as EntityEcs, source });
|
||||
});
|
||||
|
||||
expect(toastsMock.addDanger).toHaveBeenCalledWith(
|
||||
|
@ -173,7 +186,7 @@ describe('useDynamicEntityFlyout', () => {
|
|||
expect(onFlyoutCloseMock).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.openDynamicFlyout({ entity: { type: 'service' } as EntityEcs });
|
||||
result.current.openDynamicFlyout({ entity: { type: 'service' } as EntityEcs, source });
|
||||
});
|
||||
|
||||
expect(toastsMock.addDanger).toHaveBeenCalledWith(
|
||||
|
@ -183,6 +196,8 @@ describe('useDynamicEntityFlyout', () => {
|
|||
})
|
||||
);
|
||||
expect(onFlyoutCloseMock).toHaveBeenCalled();
|
||||
|
||||
expect(openFlyoutMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should close the flyout when closeDynamicFlyout is called', () => {
|
||||
|
@ -195,5 +210,6 @@ describe('useDynamicEntityFlyout', () => {
|
|||
});
|
||||
|
||||
expect(closeFlyoutMock).toHaveBeenCalled();
|
||||
expect(openFlyoutMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { EsHitRecord } from '@kbn/discover-utils';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import {
|
||||
HostPanelKey,
|
||||
|
@ -25,53 +26,19 @@ import { useOnExpandableFlyoutClose } from '../../flyout/shared/hooks/use_on_exp
|
|||
|
||||
interface InventoryFlyoutProps {
|
||||
entity: EntityEcs;
|
||||
source?: EsHitRecord['_source'];
|
||||
scopeId?: string;
|
||||
contextId?: string;
|
||||
}
|
||||
|
||||
interface SecurityFlyoutPanelsCommonParams {
|
||||
scopeId?: string;
|
||||
contextId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type FlyoutParams =
|
||||
| {
|
||||
id: typeof UniversalEntityPanelKey;
|
||||
params: { entity: EntityEcs };
|
||||
}
|
||||
| { id: typeof UserPanelKey; params: { userName: string } & SecurityFlyoutPanelsCommonParams }
|
||||
| { id: typeof HostPanelKey; params: { hostName: string } & SecurityFlyoutPanelsCommonParams }
|
||||
| {
|
||||
id: typeof ServicePanelKey;
|
||||
params: { serviceName: string } & SecurityFlyoutPanelsCommonParams;
|
||||
};
|
||||
|
||||
const getFlyoutParamsByEntity = ({
|
||||
entity,
|
||||
scopeId,
|
||||
contextId,
|
||||
}: InventoryFlyoutProps): FlyoutParams => {
|
||||
const entitiesFlyoutParams: Record<EntityEcs['type'], FlyoutParams> = {
|
||||
universal: { id: UniversalEntityPanelKey, params: { entity } },
|
||||
user: { id: UserPanelKey, params: { userName: entity.name, scopeId, contextId } },
|
||||
host: { id: HostPanelKey, params: { hostName: entity.name, scopeId, contextId } },
|
||||
service: { id: ServicePanelKey, params: { serviceName: entity.name, scopeId, contextId } },
|
||||
} as const;
|
||||
|
||||
return entitiesFlyoutParams[entity.type];
|
||||
};
|
||||
|
||||
export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () => void }) => {
|
||||
const { openFlyout, closeFlyout } = useExpandableFlyoutApi();
|
||||
const { notifications } = useKibana().services;
|
||||
useOnExpandableFlyoutClose({ callback: onFlyoutClose });
|
||||
|
||||
const openDynamicFlyout = ({ entity, scopeId, contextId }: InventoryFlyoutProps) => {
|
||||
const entityFlyoutParams = getFlyoutParamsByEntity({ entity, scopeId, contextId });
|
||||
|
||||
const openDynamicFlyout = ({ entity, source, scopeId, contextId }: InventoryFlyoutProps) => {
|
||||
// User, Host, and Service entity flyouts rely on entity name to fetch required data
|
||||
if (entity.type !== 'universal' && !entity.name) {
|
||||
if (['user', 'host', 'service'].includes(entity.type) && !entity.name) {
|
||||
notifications.toasts.addDanger({
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.assetInventory.openFlyout.missingEntityNameTitle',
|
||||
|
@ -88,14 +55,29 @@ export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () =>
|
|||
return;
|
||||
}
|
||||
|
||||
uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS);
|
||||
switch (entity.type) {
|
||||
case 'user':
|
||||
openFlyout({
|
||||
right: { id: UserPanelKey, params: { userName: entity.name, scopeId, contextId } },
|
||||
});
|
||||
break;
|
||||
case 'host':
|
||||
openFlyout({
|
||||
right: { id: HostPanelKey, params: { hostName: entity.name, scopeId, contextId } },
|
||||
});
|
||||
break;
|
||||
case 'service':
|
||||
openFlyout({
|
||||
right: { id: ServicePanelKey, params: { serviceName: entity.name, scopeId, contextId } },
|
||||
});
|
||||
break;
|
||||
|
||||
openFlyout({
|
||||
right: {
|
||||
id: entityFlyoutParams.id || UniversalEntityPanelKey,
|
||||
params: entityFlyoutParams.params,
|
||||
},
|
||||
});
|
||||
default:
|
||||
openFlyout({ right: { id: UniversalEntityPanelKey, params: { entity, source } } });
|
||||
break;
|
||||
}
|
||||
|
||||
uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS);
|
||||
};
|
||||
|
||||
const closeDynamicFlyout = () => {
|
||||
|
|
|
@ -146,12 +146,19 @@ const getDefaultQuery = ({ query, filters }: AssetsBaseURLQuery): URLQuery => ({
|
|||
});
|
||||
|
||||
// TODO: Asset Inventory - adjust and remove type casting once we have real universal entity data
|
||||
const getEntity = (row: DataTableRecord): EntityEcs => {
|
||||
const getEntity = (record: DataTableRecord) => {
|
||||
const { _source } = record.raw;
|
||||
|
||||
const entityMock = {
|
||||
tags: ['infrastructure', 'linux', 'admin', 'active'],
|
||||
labels: { Group: 'cloud-sec-dev', Environment: 'Production' },
|
||||
id: 'mock-entity-id',
|
||||
criticality: 'low_impact',
|
||||
} as unknown as EntityEcs;
|
||||
|
||||
return {
|
||||
id: (row.flattened['asset.name'] as string) || '',
|
||||
name: (row.flattened['asset.name'] as string) || '',
|
||||
timestamp: row.flattened['@timestamp'] as Date,
|
||||
type: 'universal',
|
||||
entity: { ...(_source?.entity || {}), ...entityMock },
|
||||
source: _source || {},
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -181,12 +188,13 @@ export const AllAssets = () => {
|
|||
onFlyoutClose: () => setExpandedDoc(undefined),
|
||||
});
|
||||
|
||||
const onExpandDocClick = (doc?: DataTableRecord | undefined) => {
|
||||
if (doc) {
|
||||
const entity = getEntity(doc);
|
||||
setExpandedDoc(doc); // Table is expecting the same doc ref to highlight the selected row
|
||||
const onExpandDocClick = (record?: DataTableRecord | undefined) => {
|
||||
if (record) {
|
||||
const { entity, source } = getEntity(record);
|
||||
setExpandedDoc(record); // Table is expecting the same record ref to highlight the selected row
|
||||
openDynamicFlyout({
|
||||
entity,
|
||||
source,
|
||||
scopeId: ASSET_INVENTORY_TABLE_ID,
|
||||
contextId: ASSET_INVENTORY_TABLE_ID,
|
||||
});
|
||||
|
|
|
@ -235,7 +235,7 @@ const AssetCriticalityModal: React.FC<ModalProps> = ({
|
|||
<EuiModalBody>
|
||||
<EuiSuperSelect
|
||||
id={basicSelectId}
|
||||
options={options}
|
||||
options={assetCriticalityOptions}
|
||||
valueOfSelected={value}
|
||||
onChange={setNewValue}
|
||||
aria-label={PICK_ASSET_CRITICALITY}
|
||||
|
@ -280,13 +280,15 @@ const option = (
|
|||
<AssetCriticalityBadge criticalityLevel={level} style={{ lineHeight: 'inherit' }} />
|
||||
),
|
||||
});
|
||||
const options: Array<EuiSuperSelectOption<CriticalityLevelWithUnassigned>> = [
|
||||
option('unassigned'),
|
||||
option('low_impact'),
|
||||
option('medium_impact'),
|
||||
option('high_impact'),
|
||||
option('extreme_impact'),
|
||||
];
|
||||
|
||||
export const assetCriticalityOptions: Array<EuiSuperSelectOption<CriticalityLevelWithUnassigned>> =
|
||||
[
|
||||
option('unassigned'),
|
||||
option('low_impact'),
|
||||
option('medium_impact'),
|
||||
option('high_impact'),
|
||||
option('extreme_impact'),
|
||||
];
|
||||
|
||||
export const AssetCriticalityAccordion = React.memo(AssetCriticalityAccordionComponent);
|
||||
AssetCriticalityAccordion.displayName = 'AssetCriticalityAccordion';
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ExpandableBadgeGroup } from './expandable_badge_group';
|
||||
|
||||
const badgeProps = [
|
||||
{ color: 'hollow', children: 'Badge 1' },
|
||||
{ color: 'hollow', children: 'Badge 2' },
|
||||
{ color: 'hollow', children: 'Badge 3' },
|
||||
{ color: 'hollow', children: 'Badge 4' },
|
||||
];
|
||||
|
||||
const badgePropsWithElement = [
|
||||
{ color: 'hollow', children: <span>{'Badge 1 with element'}</span> },
|
||||
{ color: 'hollow', children: <span>{'Badge 2 with element'}</span> },
|
||||
{ color: 'hollow', children: <span>{'Badge 3 with element'}</span> },
|
||||
{ color: 'hollow', children: <span>{'Badge 4 with element'}</span> },
|
||||
];
|
||||
|
||||
describe('ExpandableBadgeGroup', () => {
|
||||
it('renders all badges when initialBadgeLimit is not set', () => {
|
||||
render(<ExpandableBadgeGroup badges={badgeProps} />);
|
||||
badgeProps.forEach((badge) => {
|
||||
expect(screen.getByText(badge.children)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders limited badges and expand button when initialBadgeLimit is set', () => {
|
||||
render(<ExpandableBadgeGroup badges={badgeProps} initialBadgeLimit={2} />);
|
||||
expect(screen.getByText('Badge 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Badge 2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Badge 3')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Badge 4')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands to show all badges when expand button is clicked', () => {
|
||||
render(<ExpandableBadgeGroup badges={badgeProps} initialBadgeLimit={2} />);
|
||||
fireEvent.click(screen.getByText('+2'));
|
||||
badgeProps.forEach((badge) => {
|
||||
expect(screen.getByText(badge.children)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies maxHeight style when maxHeight is set', () => {
|
||||
const { container } = render(<ExpandableBadgeGroup badges={badgeProps} maxHeight={100} />);
|
||||
expect(container.firstChild).toHaveStyle('max-height: 100px');
|
||||
});
|
||||
|
||||
it('renders badges with children as React elements', () => {
|
||||
render(<ExpandableBadgeGroup badges={badgePropsWithElement} />);
|
||||
badgePropsWithElement.forEach((badge) => {
|
||||
expect(screen.getByText(badge.children.props.children)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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, { useState, useMemo } from 'react';
|
||||
import { EuiBadge, EuiBadgeGroup } from '@elastic/eui';
|
||||
import type { EuiBadgeProps } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
interface ExpandableBadgeGroupProps {
|
||||
/** Array of EuiBadges to display */
|
||||
badges: EuiBadgeProps[];
|
||||
/** The initial number of badges to show before expanding. Defaults to 'all' if not set */
|
||||
initialBadgeLimit?: number | 'all';
|
||||
/** The maximum height of the badge group in pixels. If not set the expandable container will not have inner scrolling */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays a group of badges with a limited initial display and an expansion option.
|
||||
*
|
||||
* The component initially shows a limited number of badges (or all if `initialBadgeLimit` is not set) and provides a "+n" badge to expand and show all badges.
|
||||
* The badge group is scrollable if the badges exceed the `maxHeight`.
|
||||
*/
|
||||
export const ExpandableBadgeGroup = ({
|
||||
badges,
|
||||
initialBadgeLimit = 'all',
|
||||
maxHeight,
|
||||
}: ExpandableBadgeGroupProps) => {
|
||||
const [visibleBadgesCount, setVisibleBadgesCount] = useState<number | 'all'>(initialBadgeLimit);
|
||||
|
||||
// Calculate the number of remaining badges. If 'all' badges are shown, the remaining count is 0.
|
||||
const remainingCount = visibleBadgesCount === 'all' ? 0 : badges.length - visibleBadgesCount;
|
||||
const maxScrollHeight = maxHeight ? `${maxHeight}px` : 'initial';
|
||||
|
||||
const badgeElements = useMemo(
|
||||
() => badges.map((badge, index) => <EuiBadge key={index} {...badge} />),
|
||||
[badges]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiBadgeGroup
|
||||
gutterSize="s"
|
||||
style={{
|
||||
maxHeight: maxScrollHeight,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{
|
||||
// Show all badges if 'all' is set, otherwise show the first `badgesToShow` badges
|
||||
visibleBadgesCount === 'all' ? badgeElements : badgeElements.slice(0, visibleBadgesCount)
|
||||
}
|
||||
{
|
||||
// Show the expand badge if there are remaining badges to show
|
||||
remainingCount > 0 && visibleBadgesCount !== 'all' && (
|
||||
<EuiBadge
|
||||
color="hollow"
|
||||
onClick={() => setVisibleBadgesCount('all')}
|
||||
onClickAriaLabel={i18n.translate(
|
||||
'xpack.securitySolution.expandableBadgeGroup.expandBadgeAriaLabel',
|
||||
{ defaultMessage: 'Expand Remaining Badges' }
|
||||
)}
|
||||
>{`+${remainingCount}`}</EuiBadge>
|
||||
)
|
||||
}
|
||||
</EuiBadgeGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import { FieldsTable } from './fields_table';
|
||||
|
||||
const mockDocument = {
|
||||
field1: 'value1',
|
||||
field2: true,
|
||||
field3: null,
|
||||
field4: {
|
||||
nestedField1: 'nestedValue1',
|
||||
nestedField2: 123,
|
||||
},
|
||||
};
|
||||
|
||||
describe('FieldsTable', () => {
|
||||
it('renders the table with flattened fields and values', () => {
|
||||
render(<FieldsTable document={mockDocument} />);
|
||||
|
||||
expect(screen.getByText('field1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('field2')).toBeInTheDocument();
|
||||
expect(screen.getByText('true')).toBeInTheDocument();
|
||||
expect(screen.getByText('field3')).toBeInTheDocument();
|
||||
expect(screen.getByText('null')).toBeInTheDocument();
|
||||
expect(screen.getByText('field4.nestedField1')).toBeInTheDocument();
|
||||
expect(screen.getByText('nestedValue1')).toBeInTheDocument();
|
||||
expect(screen.getByText('field4.nestedField2')).toBeInTheDocument();
|
||||
expect(screen.getByText('123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders undefined values correctly', () => {
|
||||
const documentWithUndefined = { field1: undefined };
|
||||
render(<FieldsTable document={documentWithUndefined} />);
|
||||
|
||||
expect(screen.getByText('field1')).toBeInTheDocument();
|
||||
expect(screen.getByText('undefined')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders object values correctly', () => {
|
||||
const documentWithObject = { field1: { nestedField: 'nestedValue' } };
|
||||
render(<FieldsTable document={documentWithObject} />);
|
||||
|
||||
expect(screen.getByText('field1.nestedField')).toBeInTheDocument();
|
||||
expect(screen.getByText('nestedValue')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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 type { EuiInMemoryTableProps } from '@elastic/eui';
|
||||
import { EuiCode, EuiCodeBlock, EuiInMemoryTable, EuiText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { getFlattenedObject } from '@kbn/std';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
interface FlattenedItem {
|
||||
key: string; // flattened dot notation object path for an object;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const getDescriptionDisplay = (value: unknown) => {
|
||||
if (value === undefined) return 'undefined';
|
||||
if (typeof value === 'boolean' || value === null) {
|
||||
return <EuiCode>{JSON.stringify(value)}</EuiCode>;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return (
|
||||
<EuiCodeBlock isCopyable={true} overflowHeight={300}>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</EuiCodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return <EuiText size="s">{value as string}</EuiText>;
|
||||
};
|
||||
|
||||
const search: EuiInMemoryTableProps<FlattenedItem>['search'] = {
|
||||
box: {
|
||||
incremental: true,
|
||||
},
|
||||
};
|
||||
|
||||
const sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
|
||||
sort: {
|
||||
field: 'key',
|
||||
direction: 'asc',
|
||||
},
|
||||
};
|
||||
|
||||
const pagination: EuiInMemoryTableProps<FlattenedItem>['pagination'] = {
|
||||
initialPageSize: 100,
|
||||
showPerPageOptions: false,
|
||||
};
|
||||
|
||||
const columns: EuiInMemoryTableProps<FlattenedItem>['columns'] = [
|
||||
{
|
||||
field: 'key',
|
||||
name: i18n.translate('xpack.securitySolution.fieldsTable.fieldColumnLabel', {
|
||||
defaultMessage: 'Field',
|
||||
}),
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
field: 'value',
|
||||
name: i18n.translate('xpack.securitySolution.fieldsTable.valueColumnLabel', {
|
||||
defaultMessage: 'Value',
|
||||
}),
|
||||
render: (value: unknown) => <div style={{ width: '100%' }}>{getDescriptionDisplay(value)}</div>,
|
||||
},
|
||||
];
|
||||
|
||||
const getFlattenedItems = (resource: Record<string, unknown>) =>
|
||||
Object.entries(getFlattenedObject(resource)).map(([key, value]) => ({ key, value }));
|
||||
|
||||
/**
|
||||
* A component that displays a table of flattened fields and values from a resource object.
|
||||
*/
|
||||
export const FieldsTable = ({ document }: { document: Record<string, unknown> }) => (
|
||||
<EuiInMemoryTable
|
||||
items={getFlattenedItems(document)}
|
||||
columns={columns}
|
||||
sorting={sorting}
|
||||
search={search}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import { ResponsiveDataCards } from './responsive_data_cards';
|
||||
|
||||
const cards = [
|
||||
{ title: 'Card 1', description: 'Description 1' },
|
||||
{ title: 'Card 2', description: 'Description 2' },
|
||||
{ title: 'Card 3', description: 'Description 3' },
|
||||
{ title: 'Card 4', description: 'Description 4' },
|
||||
];
|
||||
|
||||
describe('ResponsiveDataCards', () => {
|
||||
it('renders the correct number of cards', () => {
|
||||
render(<ResponsiveDataCards cards={cards} />);
|
||||
const renderedCards = screen.getAllByTestId('responsive-data-card');
|
||||
expect(renderedCards).toHaveLength(cards.length);
|
||||
});
|
||||
|
||||
it('renders card titles and descriptions correctly', () => {
|
||||
render(<ResponsiveDataCards cards={cards} />);
|
||||
cards.forEach(({ title, description }) => {
|
||||
expect(screen.getByText(title)).toBeInTheDocument();
|
||||
expect(screen.getByText(description)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { css } from '@emotion/react';
|
||||
import { EuiCard, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
|
||||
import type { EuiCardProps } from '@elastic/eui/src/components/card/card';
|
||||
|
||||
interface ResponsiveDataCardsProps {
|
||||
/**
|
||||
* An array of EuiCardProps objects, defining the cards to be displayed.
|
||||
*/
|
||||
cards: Array<Pick<EuiCardProps, 'title' | 'description'>>;
|
||||
/**
|
||||
* The width (in pixels) at which the cards should collapse from a row layout to two columns layout.
|
||||
* Defaults to 750.
|
||||
*/
|
||||
collapseWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that displays a group of data cards in a responsive layout.
|
||||
* Depending on the width of the container, the cards will be displayed in a row layout or a two columns layout.
|
||||
*/
|
||||
export const ResponsiveDataCards = ({ cards, collapseWidth = 750 }: ResponsiveDataCardsProps) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
css={css`
|
||||
container-type: inline-size;
|
||||
`}
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
wrap
|
||||
>
|
||||
{cards.map((card, index) => (
|
||||
<EuiFlexItem
|
||||
key={index}
|
||||
css={css`
|
||||
flex-basis: calc(25% - ${euiTheme.size.s});
|
||||
width: calc(25% - ${euiTheme.size.s});
|
||||
|
||||
@container (max-width: ${collapseWidth}px) {
|
||||
flex: calc(50% - ${euiTheme.size.s});
|
||||
width: calc(50% - ${euiTheme.size.s});
|
||||
}
|
||||
`}
|
||||
>
|
||||
<EuiCard
|
||||
data-test-subj="responsive-data-card"
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
textAlign="left"
|
||||
titleSize="xs"
|
||||
hasBorder
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -6,13 +6,31 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
|
||||
import type { EsHitRecord } from '@kbn/discover-utils';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { FieldsTable } from './components/fields_table';
|
||||
import { ExpandableSection } from '../../document_details/right/components/expandable_section';
|
||||
import { FlyoutBody } from '../../shared/components/flyout_body';
|
||||
|
||||
interface UniversalEntityFlyoutContentProps {
|
||||
entity: EntityEcs;
|
||||
source: EsHitRecord['_source'];
|
||||
}
|
||||
|
||||
export const UniversalEntityFlyoutContent = ({ entity }: UniversalEntityFlyoutContentProps) => {
|
||||
return <FlyoutBody>{entity.type}</FlyoutBody>;
|
||||
export const UniversalEntityFlyoutContent = ({ source }: UniversalEntityFlyoutContentProps) => {
|
||||
return (
|
||||
<FlyoutBody>
|
||||
<ExpandableSection
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.universalEntityFlyout.flyoutContent.expandableSection.fieldsLabel"
|
||||
defaultMessage="Fields"
|
||||
/>
|
||||
}
|
||||
expanded
|
||||
localStorageKey={'universal_flyout:overview:fields_table'}
|
||||
>
|
||||
<FieldsTable document={source || {}} />
|
||||
</ExpandableSection>
|
||||
</FlyoutBody>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,34 +5,134 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiSpacer, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import type { IconType } from '@elastic/eui';
|
||||
import { EuiSpacer, EuiText, EuiFlexItem, EuiFlexGroup, useEuiTheme } from '@elastic/eui';
|
||||
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
|
||||
import type { EntityType } from '../../../../common/entity_analytics/types';
|
||||
import { ExpandableBadgeGroup } from './components/expandable_badge_group';
|
||||
import { HeaderDataCards } from './header_data_cards';
|
||||
import { EntityIconByType } from '../../../entity_analytics/components/entity_store/helpers';
|
||||
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
|
||||
import { FlyoutHeader } from '../../shared/components/flyout_header';
|
||||
import { FlyoutTitle } from '../../shared/components/flyout_title';
|
||||
|
||||
interface UniversalEntityFlyoutHeaderProps {
|
||||
entity: EntityEcs;
|
||||
}
|
||||
const initialBadgeLimit = 3;
|
||||
const maxBadgeContainerHeight = 180;
|
||||
|
||||
const HeaderTags = ({ tags, labels }: { tags: EntityEcs['tags']; labels: EntityEcs['labels'] }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const tagBadges = useMemo(
|
||||
() =>
|
||||
tags?.map((tag) => ({
|
||||
color: 'hollow',
|
||||
children: tag,
|
||||
})),
|
||||
[tags]
|
||||
);
|
||||
|
||||
const labelBadges = useMemo(
|
||||
() =>
|
||||
labels &&
|
||||
Object.entries(labels)?.map(([key, value]) => ({
|
||||
color: 'hollow',
|
||||
children: (
|
||||
<>
|
||||
<span
|
||||
css={css`
|
||||
color: ${euiTheme.colors.disabledText};
|
||||
border-right: ${euiTheme.border.thick};
|
||||
padding-right: ${euiTheme.size.xs};
|
||||
`}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<span
|
||||
css={css`
|
||||
padding-left: ${euiTheme.size.xs};
|
||||
`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
[labels, euiTheme.colors.disabledText, euiTheme.border.thick, euiTheme.size.xs]
|
||||
);
|
||||
|
||||
const allBadges = [...(tagBadges || []), ...(labelBadges || [])];
|
||||
|
||||
export const UniversalEntityFlyoutHeader = ({ entity }: UniversalEntityFlyoutHeaderProps) => {
|
||||
return (
|
||||
<FlyoutHeader data-test-subj="service-panel-header">
|
||||
<EuiFlexGroup gutterSize="s" responsive={false} direction="column">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs" data-test-subj={'service-panel-header-lastSeen'}>
|
||||
<PreferenceFormattedDate value={entity?.timestamp} />
|
||||
<EuiSpacer size="xs" />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{/* @ts-ignore Fix it once genetric entity is introduce*/}
|
||||
<FlyoutTitle title={entity?.id} iconType={EntityIconByType[entity?.type]} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</FlyoutHeader>
|
||||
<ExpandableBadgeGroup
|
||||
badges={allBadges}
|
||||
initialBadgeLimit={initialBadgeLimit}
|
||||
maxHeight={maxBadgeContainerHeight}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface UniversalEntityFlyoutHeaderProps {
|
||||
entity: EntityEcs;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
// TODO: Asset Inventory - move this to a shared location, for now it's here as a mock since we dont have generic entities yet
|
||||
enum GenericEntityType {
|
||||
container = 'container',
|
||||
}
|
||||
|
||||
export const UniversalEntityIconByType: Record<GenericEntityType | EntityType, IconType> = {
|
||||
...EntityIconByType,
|
||||
container: 'container',
|
||||
};
|
||||
|
||||
export const UniversalEntityFlyoutHeader = ({
|
||||
entity,
|
||||
timestamp,
|
||||
}: UniversalEntityFlyoutHeaderProps) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlyoutHeader>
|
||||
<EuiFlexGroup gutterSize="s" responsive={false} direction="column">
|
||||
{timestamp && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs">
|
||||
<PreferenceFormattedDate value={timestamp} />
|
||||
<EuiSpacer size="xs" />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<FlyoutTitle
|
||||
title={entity?.name}
|
||||
iconType={UniversalEntityIconByType[entity?.type] || 'globe'}
|
||||
iconColor="primary"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</FlyoutHeader>
|
||||
<div
|
||||
css={css`
|
||||
margin: ${euiTheme.size.s};
|
||||
`}
|
||||
>
|
||||
<HeaderDataCards
|
||||
id={entity.id}
|
||||
type={entity.type}
|
||||
category={entity.category}
|
||||
criticality={entity.criticality}
|
||||
/>
|
||||
{(entity.tags || entity.labels) && (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<HeaderTags tags={entity.tags} labels={entity.labels} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* 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 the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiCopy,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSuperSelect,
|
||||
EuiTextTruncate,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { CriticalityLevelWithUnassigned } from '../../../../common/entity_analytics/asset_criticality/types';
|
||||
import { assetCriticalityOptions } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector';
|
||||
import { ResponsiveDataCards } from './components/responsive_data_cards';
|
||||
|
||||
export const HeaderDataCards = ({
|
||||
criticality,
|
||||
id,
|
||||
category,
|
||||
type,
|
||||
}: {
|
||||
criticality?: CriticalityLevelWithUnassigned;
|
||||
id: string;
|
||||
category: string;
|
||||
type: string;
|
||||
}) => {
|
||||
const [selectValue, setSelectValue] = useState<CriticalityLevelWithUnassigned>(
|
||||
criticality || 'unassigned'
|
||||
);
|
||||
|
||||
const cards = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.criticalityLabel',
|
||||
{
|
||||
defaultMessage: 'Criticality',
|
||||
}
|
||||
),
|
||||
description: (
|
||||
<div
|
||||
css={css`
|
||||
width: fit-content;
|
||||
`}
|
||||
>
|
||||
<EuiSuperSelect
|
||||
popoverProps={{
|
||||
repositionOnScroll: true,
|
||||
panelMinWidth: 200,
|
||||
}}
|
||||
fullWidth={false}
|
||||
compressed
|
||||
hasDividers
|
||||
options={assetCriticalityOptions}
|
||||
valueOfSelected={selectValue}
|
||||
onChange={(newValue) => setSelectValue(newValue)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<EuiFlexGroup justifyContent={'spaceBetween'} wrap={false} responsive={false}>
|
||||
<EuiFlexItem grow={false}>{'ID'}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiCopy textToCopy={id}>
|
||||
{(copy) => <EuiButtonIcon onClick={copy} iconType="document" color="text" />}
|
||||
</EuiCopy>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
description: <EuiTextTruncate text={id} />,
|
||||
},
|
||||
{
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.categoryLabel',
|
||||
{
|
||||
defaultMessage: 'Category',
|
||||
}
|
||||
),
|
||||
description: <EuiTextTruncate text={category || ''} />,
|
||||
},
|
||||
{
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.typeLabel',
|
||||
{
|
||||
defaultMessage: 'Type',
|
||||
}
|
||||
),
|
||||
description: <EuiTextTruncate text={type || ''} />,
|
||||
},
|
||||
],
|
||||
[selectValue, id, category, type]
|
||||
);
|
||||
|
||||
return <ResponsiveDataCards cards={cards} collapseWidth={750} />;
|
||||
};
|
|
@ -13,12 +13,14 @@ import {
|
|||
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
|
||||
import type { EsHitRecord } from '@kbn/discover-utils';
|
||||
import { UniversalEntityFlyoutHeader } from './header';
|
||||
import { UniversalEntityFlyoutContent } from './content';
|
||||
import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
|
||||
|
||||
export interface UniversalEntityPanelProps {
|
||||
entity: EntityEcs;
|
||||
source: EsHitRecord['_source'];
|
||||
/** this is because FlyoutPanelProps defined params as Record<string, unknown> {@link FlyoutPanelProps#params} */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
@ -28,16 +30,21 @@ export interface UniversalEntityPanelExpandableFlyoutProps extends FlyoutPanelPr
|
|||
params: UniversalEntityPanelProps;
|
||||
}
|
||||
|
||||
export const UniversalEntityPanel = ({ entity }: UniversalEntityPanelProps) => {
|
||||
const isDate = (value: unknown): value is Date => value instanceof Date;
|
||||
|
||||
export const UniversalEntityPanel = ({ entity, source }: UniversalEntityPanelProps) => {
|
||||
useEffect(() => {
|
||||
uiMetricService.trackUiMetric(METRIC_TYPE.COUNT, UNIVERSAL_ENTITY_FLYOUT_OPENED);
|
||||
}, [entity]);
|
||||
|
||||
const docTimestamp = source?.['@timestamp'];
|
||||
const timestamp = isDate(docTimestamp) ? docTimestamp : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlyoutNavigation flyoutIsExpandable={false} />
|
||||
<UniversalEntityFlyoutHeader entity={entity} />
|
||||
<UniversalEntityFlyoutContent entity={entity} />
|
||||
<UniversalEntityFlyoutHeader entity={entity} timestamp={timestamp} />
|
||||
<UniversalEntityFlyoutContent source={source} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -28,6 +28,10 @@ export interface FlyoutTitleProps {
|
|||
* Optional icon type. If null, no icon is displayed
|
||||
*/
|
||||
iconType?: EuiButtonEmptyProps['iconType'];
|
||||
/**
|
||||
* Optional icon color
|
||||
*/
|
||||
iconColor?: EuiButtonEmptyProps['color'];
|
||||
/**
|
||||
* Optional boolean to indicate if title is a link. If true, a popout icon is appended
|
||||
* and the title text is changed to link color
|
||||
|
@ -43,12 +47,13 @@ export interface FlyoutTitleProps {
|
|||
* Title component with optional icon to indicate the type of document, can be used for text or a link
|
||||
*/
|
||||
export const FlyoutTitle: FC<FlyoutTitleProps> = memo(
|
||||
({ title, iconType, isLink = false, 'data-test-subj': dataTestSubj }) => {
|
||||
({ title, iconType, iconColor, isLink = false, 'data-test-subj': dataTestSubj }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const titleIcon = useMemo(() => {
|
||||
return iconType ? (
|
||||
<EuiIcon
|
||||
color={iconColor}
|
||||
type={iconType}
|
||||
size="m"
|
||||
className="eui-alignBaseline"
|
||||
|
@ -58,7 +63,7 @@ export const FlyoutTitle: FC<FlyoutTitleProps> = memo(
|
|||
`}
|
||||
/>
|
||||
) : null;
|
||||
}, [dataTestSubj, iconType, euiTheme.size.xs]);
|
||||
}, [iconType, iconColor, dataTestSubj, euiTheme.size.xs]);
|
||||
|
||||
const titleComponent = useMemo(() => {
|
||||
return (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue