[Security Solution] Fix: host popover issue 187338 (#187848)

## Summary

When clicking on the "+6 more" icon in the "Last Command" column, a
popover appears showing six items, which is as expected. However, there
is also a text label stating "1 more not shown." This seems confusing
because all six items are already displayed.

Solution: Remove the `"n more not shown."` label at the end of the list
rendered in the popover component. For cases where there are a lot of
items to render, set a max height value for the list so it becomes
scrollable.

<img width="410" alt="Screenshot 2024-07-02 at 12 08 20"
src="c4c29607-ba7d-4060-9d0a-4531d9e48614">

<img width="284" alt="Screenshot 2024-07-09 at 12 42 31"
src="c0a2c1e1-b44b-46b9-a84f-173213f267cd">


### Checklist

Delete any items that are not applicable to this PR.

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Agustina Nahir Ruidiaz 2024-07-18 12:48:13 +02:00 committed by GitHub
parent 3370d80754
commit 3ac173e140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 632 additions and 559 deletions

View file

@ -18,25 +18,36 @@ exports[`Table Helpers #RowItemOverflow it returns correctly against snapshot 1`
Array [
"item1",
"item2",
"item3",
]
}
/>
<p
data-test-subj="popover-additional-overflow"
>
<EuiTextColor
color="subdued"
>
1
<MemoizedFormattedMessage
defaultMessage="more not shown"
id="xpack.securitySolution.tables.rowItemHelper.moreDescription"
/>
</EuiTextColor>
</p>
</EuiText>
<EuiFlexGroup
css={
Object {
"paddingTop": "12px",
}
}
data-test-subj="popover-additional-overflow"
>
<EuiFlexItem>
<EuiText
size="xs"
>
<EuiTextColor
color="subdued"
>
1
<MemoizedFormattedMessage
data-test-subj="popover-additional-overflow-text"
defaultMessage="more not shown"
id="xpack.securitySolution.tables.rowItemHelper.moreDescription"
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</Popover>
</Fragment>
`;

View file

@ -107,8 +107,8 @@ describe('Table Helpers', () => {
values={items}
fieldName="attrName"
idPrefix="idPrefix"
maxOverflowItems={1}
overflowIndexStart={1}
maxOverflowItems={1}
/>
);
expect(wrapper).toMatchSnapshot();
@ -159,6 +159,26 @@ describe('Table Helpers', () => {
);
expect(wrapper.find('[data-test-subj="popover-additional-overflow"]').length).toBe(1);
});
test('it shows correct number of overflow items when maxOverflowItems are exceeded', () => {
const wrapper = mount(
<TestProviders>
<RowItemOverflowComponent
values={items}
fieldName="attrName"
idPrefix="idPrefix"
maxOverflowItems={1}
overflowIndexStart={1}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="overflow-button"]').first().find('button').simulate('click');
expect(
wrapper.find('[data-test-subj="overflow-items"]').last().prop<JSX.Element[]>('children')
?.length
).toBe(1);
});
});
describe('OverflowField', () => {

View file

@ -4,15 +4,24 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiPopover, EuiToolTip, EuiText, EuiTextColor } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import {
EuiLink,
EuiPopover,
EuiToolTip,
EuiText,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import styled from 'styled-components';
import { SecurityCellActions, CellActionsMode, SecurityCellActionsTrigger } from '../cell_actions';
import { escapeDataProviderId } from '../drag_and_drop/helpers';
import { defaultToEmptyTag, getEmptyTagValue } from '../empty_value';
import { MoreRowItems } from '../page';
import { MoreContainer } from '../../../timelines/components/field_renderers/field_renderers';
import { MoreContainer } from '../../../timelines/components/field_renderers/more_container';
const Subtext = styled.div`
font-size: ${(props) => props.theme.eui.euiFontSizeXS};
@ -62,8 +71,8 @@ export const getRowItemsWithActions = ({
fieldName={fieldName}
values={values}
idPrefix={idPrefix}
maxOverflowItems={maxOverflow}
overflowIndexStart={displayCount}
maxOverflowItems={maxOverflow}
/>
</>
) : (
@ -78,17 +87,21 @@ interface RowItemOverflowProps {
fieldName: string;
values: string[];
idPrefix: string;
maxOverflowItems: number;
overflowIndexStart: number;
maxOverflowItems: number;
}
export const RowItemOverflowComponent: React.FC<RowItemOverflowProps> = ({
fieldName,
values,
idPrefix,
maxOverflowItems = 5,
overflowIndexStart = 5,
maxOverflowItems = 5,
}) => {
const maxVisibleValues = useMemo(
() => values.slice(0, maxOverflowItems + 1),
[values, maxOverflowItems]
);
return (
<>
{values.length > overflowIndexStart && (
@ -97,23 +110,30 @@ export const RowItemOverflowComponent: React.FC<RowItemOverflowProps> = ({
<MoreContainer
fieldName={fieldName}
idPrefix={idPrefix}
values={values}
values={maxVisibleValues}
overflowIndexStart={overflowIndexStart}
moreMaxHeight="none"
/>
{values.length > overflowIndexStart + maxOverflowItems && (
<p data-test-subj="popover-additional-overflow">
<EuiTextColor color="subdued">
{values.length - overflowIndexStart - maxOverflowItems}{' '}
<FormattedMessage
id="xpack.securitySolution.tables.rowItemHelper.moreDescription"
defaultMessage="more not shown"
/>
</EuiTextColor>
</p>
)}
</EuiText>
{values.length > overflowIndexStart + maxOverflowItems && (
<EuiFlexGroup
css={{ paddingTop: euiThemeVars.euiSizeM }}
data-test-subj="popover-additional-overflow"
>
<EuiFlexItem>
<EuiText size="xs">
<EuiTextColor color="subdued">
{values.length - overflowIndexStart - maxOverflowItems}{' '}
<FormattedMessage
data-test-subj="popover-additional-overflow-text"
id="xpack.securitySolution.tables.rowItemHelper.moreDescription"
defaultMessage="more not shown"
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
</Popover>
)}
</>

View file

@ -14,7 +14,7 @@ import {
} from '../../../../../common/components/empty_value';
import { DescriptionListStyled } from '../../../../../common/components/page';
import { HostDetailsLink, NetworkDetailsLink } from '../../../../../common/components/links';
import { DefaultFieldRenderer } from '../../../../../timelines/components/field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../../../../timelines/components/field_renderers/default_renderer';
import type { FlowTarget } from '../../../../../../common/search_strategy';
interface PointToolTipContentProps {

View file

@ -34,7 +34,7 @@ import { InspectButton, InspectButtonContainer } from '../../../../common/compon
import { NetworkDetailsLink } from '../../../../common/components/links';
import { RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskScoreLevel } from '../../../../entity_analytics/components/severity/common';
import { DefaultFieldRenderer } from '../../../../timelines/components/field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../../../timelines/components/field_renderers/default_renderer';
import { InputsModelId } from '../../../../common/store/inputs/constants';
import { CellActions } from './cell_actions';
import { useGlobalTime } from '../../../../common/containers/use_global_time';

View file

@ -34,7 +34,7 @@ import { InspectButton, InspectButtonContainer } from '../../../../common/compon
import { NetworkDetailsLink } from '../../../../common/components/links';
import { RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskScoreLevel } from '../../../../entity_analytics/components/severity/common';
import { DefaultFieldRenderer } from '../../../../timelines/components/field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../../../timelines/components/field_renderers/default_renderer';
import { CellActions } from './cell_actions';
import { InputsModelId } from '../../../../common/store/inputs/constants';
import { useGlobalTime } from '../../../../common/containers/use_global_time';

View file

@ -9,7 +9,7 @@ import { css } from '@emotion/react';
import React from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import { FormattedMessage } from '@kbn/i18n-react';
import { DefaultFieldRenderer } from '../../../../../timelines/components/field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../../../../timelines/components/field_renderers/default_renderer';
import { getEmptyTagValue } from '../../../../../common/components/empty_value';
import type { BasicEntityData, EntityTableColumns } from './types';

View file

@ -13,7 +13,7 @@ import { AgentStatus } from '../../../../common/components/endpoint/agents/agent
import { OverviewDescriptionList } from '../../../../common/components/overview_description_list';
import type { DescriptionList } from '../../../../../common/utility_types';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
import { DefaultFieldRenderer } from '../../../../timelines/components/field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../../../timelines/components/field_renderers/default_renderer';
import * as i18n from './translations';
import type { EndpointFields } from '../../../../../common/search_strategy/security_solution/hosts';
import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy/security_solution/hosts';

View file

@ -19,10 +19,8 @@ import { buildHostNamesFilter, RiskScoreEntity } from '../../../../common/search
import type { DescriptionList } from '../../../../common/utility_types';
import { useDarkMode } from '../../../common/lib/kibana';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import {
DefaultFieldRenderer,
hostIdRenderer,
} from '../../../timelines/components/field_renderers/field_renderers';
import { hostIdRenderer } from '../../../timelines/components/field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/default_renderer';
import {
FirstLastSeen,
FirstLastSeenType,

View file

@ -18,7 +18,7 @@ import { buildUserNamesFilter, RiskScoreEntity } from '../../../../common/search
import type { DescriptionList } from '../../../../common/utility_types';
import { useDarkMode } from '../../../common/lib/kibana';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/default_renderer';
import {
FirstLastSeen,
FirstLastSeenType,

View file

@ -84,16 +84,6 @@ exports[`Field Renderers #autonomousSystemRenderer it renders correctly against
</DocumentFragment>
`;
exports[`Field Renderers #dateRenderer it renders correctly against snapshot 1`] = `
<DocumentFragment>
<span
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
>
Feb 7, 2019 @ 17:19:41.636
</span>
</DocumentFragment>
`;
exports[`Field Renderers #hostIdRenderer it renders correctly against snapshot 1`] = `
<DocumentFragment>
.c0 > span.euiToolTipAnchor {

View file

@ -0,0 +1,128 @@
/*
* 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 userEvent from '@testing-library/user-event';
import { TestProviders } from '../../../../common/mock';
import { mockGetUrlForApp } from '@kbn/security-solution-navigation/mocks/context';
import { DefaultFieldRenderer, DefaultFieldRendererOverflow } from '.';
jest.mock('../../../../common/lib/kibana');
jest.mock('@kbn/security-solution-navigation/src/context');
mockGetUrlForApp.mockImplementation(
(appId: string, options?: { path?: string; deepLinkId?: boolean }) =>
`${appId}/${options?.deepLinkId ?? ''}${options?.path ?? ''}`
);
jest.mock('../../../../common/hooks/use_get_field_spec');
export const DEFAULT_MORE_MAX_HEIGHT = '200px';
describe('Field Renderers', () => {
describe('DefaultFieldRenderer', () => {
test('it should render a single item', () => {
render(
<TestProviders>
<DefaultFieldRenderer rowItems={['item1']} attrName={'item1'} idPrefix={'prefix-1'} />
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual('item1 ');
});
test('it should render two items', () => {
render(
<TestProviders>
<DefaultFieldRenderer
displayCount={5}
rowItems={['item1', 'item2']}
attrName={'item1'}
idPrefix={'prefix-1'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual(
'item1,item2 '
);
});
test('it should render all items when the item count exactly equals displayCount', () => {
render(
<TestProviders>
<DefaultFieldRenderer
displayCount={5}
rowItems={['item1', 'item2', 'item3', 'item4', 'item5']}
attrName={'item1'}
idPrefix={'prefix-1'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual(
'item1,item2,item3,item4,item5 '
);
});
test('it should render all items up to displayCount and the expected "+ n More" popover anchor text for items greater than displayCount', () => {
render(
<TestProviders>
<DefaultFieldRenderer
displayCount={5}
rowItems={['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7']}
attrName={'item1'}
idPrefix={'prefix-1'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual(
'item1,item2,item3,item4,item5 ,+2 More'
);
});
});
describe('DefaultFieldRendererOverflow', () => {
const idPrefix = 'prefix-1';
const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7'];
test('it should render the length of items after the overflowIndexStart', () => {
render(
<TestProviders>
<DefaultFieldRendererOverflow
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
attrName={'mock.attr'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererOverflow-button').textContent).toEqual(
'+2 More'
);
expect(screen.queryByTestId('more-container')).not.toBeInTheDocument();
});
test('it should render the items after overflowIndexStart in the popover', () => {
render(
<TestProviders>
<DefaultFieldRendererOverflow
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
attrName={'mock.attr'}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId('DefaultFieldRendererOverflow-button'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByTestId('more-container').textContent).toEqual('item6item7');
});
});
});

View file

@ -0,0 +1,166 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiPopover } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { Spacer } from '../../../../common/components/page';
import { DefaultDraggable } from '../../../../common/components/draggables';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
import { escapeDataProviderId } from '../../../../common/components/drag_and_drop/helpers';
import { MoreContainer } from '../more_container';
interface DefaultFieldRendererProps {
attrName: string;
displayCount?: number;
idPrefix: string;
isDraggable?: boolean;
moreMaxHeight?: string;
render?: (item: string) => React.ReactNode;
rowItems: string[] | null | undefined;
scopeId?: string;
}
export const IpOverviewId = 'ip-overview';
/** The default max-height of the popover used to show "+n More" items (e.g. `+9 More`) */
export const DEFAULT_MORE_MAX_HEIGHT = '200px';
export const DefaultFieldRendererComponent: React.FC<DefaultFieldRendererProps> = ({
attrName,
displayCount = 1,
idPrefix,
isDraggable = false,
moreMaxHeight = DEFAULT_MORE_MAX_HEIGHT,
render,
rowItems,
scopeId,
}) => {
if (rowItems != null && rowItems.length > 0) {
const draggables = rowItems.slice(0, displayCount).map((rowItem, index) => {
const id = escapeDataProviderId(
`default-field-renderer-default-draggable-${idPrefix}-${attrName}-${rowItem}`
);
return (
<EuiFlexItem key={id} grow={false}>
{index !== 0 && (
<>
{','}
<Spacer />
</>
)}
{typeof rowItem === 'string' && (
<DefaultDraggable
id={id}
isDraggable={isDraggable}
field={attrName}
value={rowItem}
isAggregatable={true}
scopeId={scopeId}
fieldType={'keyword'}
>
{render ? render(rowItem) : rowItem}
</DefaultDraggable>
)}
</EuiFlexItem>
);
});
return draggables.length > 0 ? (
<EuiFlexGroup
css={{ flexGrow: 'unset' }}
alignItems="center"
gutterSize="none"
component="span"
data-test-subj="DefaultFieldRendererComponent"
>
<EuiFlexItem grow={false}>{draggables} </EuiFlexItem>
<EuiFlexItem grow={false}>
<DefaultFieldRendererOverflow
attrName={attrName}
idPrefix={idPrefix}
moreMaxHeight={moreMaxHeight}
overflowIndexStart={displayCount}
render={render}
rowItems={rowItems}
scopeId={scopeId}
/>
</EuiFlexItem>
</EuiFlexGroup>
) : (
getEmptyTagValue()
);
} else {
return getEmptyTagValue();
}
};
export const DefaultFieldRenderer = React.memo(DefaultFieldRendererComponent);
DefaultFieldRenderer.displayName = 'DefaultFieldRenderer';
interface DefaultFieldRendererOverflowProps {
attrName: string;
rowItems: string[];
idPrefix: string;
render?: (item: string) => React.ReactNode;
overflowIndexStart?: number;
moreMaxHeight: string;
scopeId?: string;
}
export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverflowProps>(
({ attrName, idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems, scopeId }) => {
const [isOpen, setIsOpen] = useState(false);
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
const button = useMemo(
() => (
<>
{' ,'}
<EuiButtonEmpty
size="xs"
onClick={togglePopover}
data-test-subj="DefaultFieldRendererOverflow-button"
>
{`+${rowItems.length - overflowIndexStart} `}
<FormattedMessage
id="xpack.securitySolution.fieldRenderers.moreLabel"
defaultMessage="More"
/>
</EuiButtonEmpty>
</>
),
[togglePopover, overflowIndexStart, rowItems.length]
);
return (
<EuiFlexItem grow={false}>
{rowItems.length > overflowIndexStart && (
<EuiPopover
id="popover"
button={button}
isOpen={isOpen}
closePopover={togglePopover}
repositionOnScroll
panelClassName="withHoverActions__popover"
>
<MoreContainer
fieldName={attrName}
idPrefix={idPrefix}
render={render}
values={rowItems}
overflowIndexStart={overflowIndexStart}
scopeId={scopeId}
/>
</EuiPopover>
)}
</EuiFlexItem>
);
}
);
DefaultFieldRendererOverflow.displayName = 'DefaultFieldRendererOverflow';

View file

@ -7,22 +7,14 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProviders } from '../../../common/mock';
import { getEmptyValue } from '../../../common/components/empty_value';
import {
autonomousSystemRenderer,
dateRenderer,
hostNameRenderer,
locationRenderer,
whoisRenderer,
reputationRenderer,
DefaultFieldRenderer,
DEFAULT_MORE_MAX_HEIGHT,
DefaultFieldRendererOverflow,
MoreContainer,
} from './field_renderers';
import { mockData } from '../../../explore/network/components/details/mock';
import type { AutonomousSystem } from '../../../../common/search_strategy';
@ -65,21 +57,6 @@ describe('Field Renderers', () => {
});
});
describe('#dateRenderer', () => {
test('it renders correctly against snapshot', () => {
const { asFragment } = render(dateRenderer(mockData.complete.source?.firstSeen), {
wrapper: TestProviders,
});
expect(asFragment()).toMatchSnapshot();
});
test('it renders emptyTagValue when invalid field provided', () => {
render(<TestProviders>{dateRenderer(null)}</TestProviders>);
expect(screen.getByText(getEmptyValue())).toBeInTheDocument();
});
});
describe('#autonomousSystemRenderer', () => {
const emptyMock: AutonomousSystem = { organization: { name: null }, number: null };
const halfEmptyMock: AutonomousSystem = { organization: { name: 'Test Org' }, number: null };
@ -108,17 +85,23 @@ describe('Field Renderers', () => {
});
});
const emptyIdHost: Partial<HostEcs> = {
name: ['test'],
id: undefined,
ip: ['10.10.10.10'],
};
const emptyIpHost: Partial<HostEcs> = {
name: ['test'],
id: ['test'],
ip: undefined,
};
const emptyNameHost: Partial<HostEcs> = {
name: undefined,
id: ['test'],
ip: ['10.10.10.10'],
};
describe('#hostIdRenderer', () => {
const emptyIdHost: Partial<HostEcs> = {
name: ['test'],
id: undefined,
ip: ['10.10.10.10'],
};
const emptyIpHost: Partial<HostEcs> = {
name: ['test'],
id: ['test'],
ip: undefined,
};
test('it renders correctly against snapshot', () => {
const { asFragment } = render(
<TestProviders>{hostNameRenderer(mockData.complete.host, '10.10.10.10')}</TestProviders>
@ -144,21 +127,6 @@ describe('Field Renderers', () => {
});
describe('#hostNameRenderer', () => {
const emptyIdHost: Partial<HostEcs> = {
name: ['test'],
id: undefined,
ip: ['10.10.10.10'],
};
const emptyIpHost: Partial<HostEcs> = {
name: ['test'],
id: ['test'],
ip: undefined,
};
const emptyNameHost: Partial<HostEcs> = {
name: undefined,
id: ['test'],
ip: ['10.10.10.10'],
};
test('it renders correctly against snapshot', () => {
const { asFragment } = render(
<TestProviders>{hostNameRenderer(mockData.complete.host, '10.10.10.10')}</TestProviders>
@ -205,222 +173,4 @@ describe('Field Renderers', () => {
expect(asFragment()).toMatchSnapshot();
});
});
describe('DefaultFieldRenderer', () => {
test('it should render a single item', () => {
render(
<TestProviders>
<DefaultFieldRenderer rowItems={['item1']} attrName={'item1'} idPrefix={'prefix-1'} />
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual('item1 ');
});
test('it should render two items', () => {
render(
<TestProviders>
<DefaultFieldRenderer
displayCount={5}
rowItems={['item1', 'item2']}
attrName={'item1'}
idPrefix={'prefix-1'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual(
'item1,item2 '
);
});
test('it should render all items when the item count exactly equals displayCount', () => {
render(
<TestProviders>
<DefaultFieldRenderer
displayCount={5}
rowItems={['item1', 'item2', 'item3', 'item4', 'item5']}
attrName={'item1'}
idPrefix={'prefix-1'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual(
'item1,item2,item3,item4,item5 '
);
});
test('it should render all items up to displayCount and the expected "+ n More" popover anchor text for items greater than displayCount', () => {
render(
<TestProviders>
<DefaultFieldRenderer
displayCount={5}
rowItems={['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7']}
attrName={'item1'}
idPrefix={'prefix-1'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererComponent').textContent).toEqual(
'item1,item2,item3,item4,item5 ,+2 More'
);
});
});
describe('MoreContainer', () => {
const idPrefix = 'prefix-1';
const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7'];
test('it should only render the items after overflowIndexStart', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container').textContent).toEqual('item6item7');
});
test('it should render all the items when overflowIndexStart is zero', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={0}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container').textContent).toEqual(
'item1item2item3item4item5item6item7'
);
});
test('it should have the eui-yScroll to enable scrolling when necessary', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container')).toHaveClass('eui-yScroll');
});
test('it should use the moreMaxHeight prop as the value for the max-height style', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container')).toHaveStyle(
`max-height: ${DEFAULT_MORE_MAX_HEIGHT}`
);
});
test('it should render with correct attrName prop', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
screen
.getAllByTestId('cellActions-renderContent-mock.attr')
.forEach((element) => expect(element).toBeInTheDocument());
});
test('it should only invoke the optional render function when provided', () => {
const renderFn = jest.fn();
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
render={renderFn}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(renderFn).toHaveBeenCalledTimes(2);
});
});
describe('DefaultFieldRendererOverflow', () => {
const idPrefix = 'prefix-1';
const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7'];
test('it should render the length of items after the overflowIndexStart', () => {
render(
<TestProviders>
<DefaultFieldRendererOverflow
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
attrName={'mock.attr'}
/>
</TestProviders>
);
expect(screen.getByTestId('DefaultFieldRendererOverflow-button').textContent).toEqual(
'+2 More'
);
expect(screen.queryByTestId('more-container')).not.toBeInTheDocument();
});
test('it should render the items after overflowIndexStart in the popover', () => {
render(
<TestProviders>
<DefaultFieldRendererOverflow
idPrefix={idPrefix}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
attrName={'mock.attr'}
/>
</TestProviders>
);
userEvent.click(screen.getByTestId('DefaultFieldRendererOverflow-button'));
expect(
screen.getByText(
'You are in a dialog. Press Escape, or tap/click outside the dialog to close.'
)
).toBeInTheDocument();
expect(screen.getByTestId('more-container').textContent).toEqual('item6item7');
});
});
});

View file

@ -5,44 +5,23 @@
* 2.0.
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { getOr } from 'lodash/fp';
import React, { useCallback, Fragment, useMemo, useState, useContext } from 'react';
import styled from 'styled-components';
import React, { Fragment } from 'react';
import type { HostEcs } from '@kbn/securitysolution-ecs';
import { getSourcererScopeId } from '../../../helpers';
import {
SecurityCellActions,
CellActionsMode,
SecurityCellActionsTrigger,
} from '../../../common/components/cell_actions';
import type {
AutonomousSystem,
FlowTarget,
FlowTargetSourceDest,
NetworkDetailsStrategyResponse,
} from '../../../../common/search_strategy';
import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers';
import { DefaultDraggable } from '../../../common/components/draggables';
import { defaultToEmptyTag, getEmptyTagValue } from '../../../common/components/empty_value';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { HostDetailsLink, ReputationLink, WhoIsLink } from '../../../common/components/links';
import { Spacer } from '../../../common/components/page';
import * as i18n from '../../../explore/network/components/details/translations';
import { SourcererScopeName } from '../../../sourcerer/store/model';
import { TimelineContext } from '../timeline';
const DraggableContainerFlexGroup = styled(EuiFlexGroup)`
flex-grow: unset;
`;
export const IpOverviewId = 'ip-overview';
/** The default max-height of the popover used to show "+n More" items (e.g. `+9 More`) */
export const DEFAULT_MORE_MAX_HEIGHT = '200px';
export const locationRenderer = (
fieldNames: string[],
data: NetworkDetailsStrategyResponse['networkDetails'],
@ -76,10 +55,6 @@ export const locationRenderer = (
getEmptyTagValue()
);
export const dateRenderer = (timestamp?: string | null): React.ReactElement => (
<FormattedRelativePreferenceDate value={timestamp} />
);
export const autonomousSystemRenderer = (
as: AutonomousSystem,
flowTarget: FlowTarget | FlowTargetSourceDest,
@ -192,212 +167,3 @@ export const whoisRenderer = (ip: string) => <WhoIsLink domain={ip}>{i18n.VIEW_W
export const reputationRenderer = (ip: string): React.ReactElement => (
<ReputationLink domain={ip} direction="column" />
);
interface DefaultFieldRendererProps {
attrName: string;
displayCount?: number;
idPrefix: string;
isDraggable?: boolean;
moreMaxHeight?: string;
render?: (item: string) => React.ReactNode;
rowItems: string[] | null | undefined;
scopeId?: string;
}
export const DefaultFieldRendererComponent: React.FC<DefaultFieldRendererProps> = ({
attrName,
displayCount = 1,
idPrefix,
isDraggable = false,
moreMaxHeight = DEFAULT_MORE_MAX_HEIGHT,
render,
rowItems,
scopeId,
}) => {
if (rowItems != null && rowItems.length > 0) {
const draggables = rowItems.slice(0, displayCount).map((rowItem, index) => {
const id = escapeDataProviderId(
`default-field-renderer-default-draggable-${idPrefix}-${attrName}-${rowItem}`
);
return (
<EuiFlexItem key={id} grow={false}>
{index !== 0 && (
<>
{','}
<Spacer />
</>
)}
{typeof rowItem === 'string' && (
<DefaultDraggable
id={id}
isDraggable={isDraggable}
field={attrName}
value={rowItem}
isAggregatable={true}
scopeId={scopeId}
fieldType={'keyword'}
>
{render ? render(rowItem) : rowItem}
</DefaultDraggable>
)}
</EuiFlexItem>
);
});
return draggables.length > 0 ? (
<DraggableContainerFlexGroup
alignItems="center"
gutterSize="none"
component="span"
data-test-subj="DefaultFieldRendererComponent"
>
<EuiFlexItem grow={false}>{draggables} </EuiFlexItem>
<EuiFlexItem grow={false}>
<DefaultFieldRendererOverflow
attrName={attrName}
idPrefix={idPrefix}
moreMaxHeight={moreMaxHeight}
overflowIndexStart={displayCount}
render={render}
rowItems={rowItems}
scopeId={scopeId}
/>
</EuiFlexItem>
</DraggableContainerFlexGroup>
) : (
getEmptyTagValue()
);
} else {
return getEmptyTagValue();
}
};
export const DefaultFieldRenderer = React.memo(DefaultFieldRendererComponent);
DefaultFieldRenderer.displayName = 'DefaultFieldRenderer';
interface DefaultFieldRendererOverflowProps {
attrName: string;
rowItems: string[];
idPrefix: string;
render?: (item: string) => React.ReactNode;
overflowIndexStart?: number;
moreMaxHeight: string;
scopeId?: string;
}
interface MoreContainerProps {
fieldName: string;
values: string[];
idPrefix: string;
moreMaxHeight: string;
overflowIndexStart: number;
render?: (item: string) => React.ReactNode;
scopeId?: string;
}
export const MoreContainer = React.memo<MoreContainerProps>(
({ fieldName, idPrefix, moreMaxHeight, overflowIndexStart, render, values, scopeId }) => {
const { timelineId } = useContext(TimelineContext);
const defaultedScopeId = scopeId ?? timelineId;
const sourcererScopeId = getSourcererScopeId(defaultedScopeId ?? '');
const moreItemsWithHoverActions = useMemo(
() =>
values.slice(overflowIndexStart).reduce<React.ReactElement[]>((acc, value, index) => {
const id = escapeDataProviderId(`${idPrefix}-${fieldName}-${value}-${index}`);
if (typeof value === 'string' && fieldName != null) {
acc.push(
<EuiFlexItem key={id}>
<SecurityCellActions
key={id}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}
data={{ value, field: fieldName }}
sourcererScopeId={sourcererScopeId ?? SourcererScopeName.default}
metadata={{ scopeId: defaultedScopeId ?? undefined }}
>
<>{render ? render(value) : defaultToEmptyTag(value)}</>
</SecurityCellActions>
</EuiFlexItem>
);
}
return acc;
}, []),
[values, overflowIndexStart, idPrefix, fieldName, sourcererScopeId, defaultedScopeId, render]
);
return (
<div
data-test-subj="more-container"
className="eui-yScroll"
style={{
maxHeight: moreMaxHeight,
paddingRight: '2px',
}}
>
<EuiFlexGroup gutterSize="s" direction="column" data-test-subj="overflow-items">
{moreItemsWithHoverActions}
</EuiFlexGroup>
</div>
);
}
);
MoreContainer.displayName = 'MoreContainer';
export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverflowProps>(
({ attrName, idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems, scopeId }) => {
const [isOpen, setIsOpen] = useState(false);
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
const button = useMemo(
() => (
<>
{' ,'}
<EuiButtonEmpty
size="xs"
onClick={togglePopover}
data-test-subj="DefaultFieldRendererOverflow-button"
>
{`+${rowItems.length - overflowIndexStart} `}
<FormattedMessage
id="xpack.securitySolution.fieldRenderers.moreLabel"
defaultMessage="More"
/>
</EuiButtonEmpty>
</>
),
[togglePopover, overflowIndexStart, rowItems.length]
);
return (
<EuiFlexItem grow={false}>
{rowItems.length > overflowIndexStart && (
<EuiPopover
id="popover"
button={button}
isOpen={isOpen}
closePopover={togglePopover}
repositionOnScroll
panelClassName="withHoverActions__popover"
>
<MoreContainer
fieldName={attrName}
idPrefix={idPrefix}
render={render}
values={rowItems}
moreMaxHeight={moreMaxHeight}
overflowIndexStart={overflowIndexStart}
scopeId={scopeId}
/>
</EuiPopover>
)}
</EuiFlexItem>
);
}
);
DefaultFieldRendererOverflow.displayName = 'DefaultFieldRendererOverflow';

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; 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 { TestProviders } from '../../../../common/mock';
import { MoreContainer } from '.';
import { mockGetUrlForApp } from '@kbn/security-solution-navigation/mocks/context';
jest.mock('../../../../common/lib/kibana');
jest.mock('@kbn/security-solution-navigation/src/context');
mockGetUrlForApp.mockImplementation(
(appId: string, options?: { path?: string; deepLinkId?: boolean }) =>
`${appId}/${options?.deepLinkId ?? ''}${options?.path ?? ''}`
);
jest.mock('../../../../common/hooks/use_get_field_spec');
const DEFAULT_MORE_MAX_HEIGHT = '100px';
describe('Field Renderers', () => {
describe('MoreContainer', () => {
const idPrefix = 'prefix-1';
const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7'];
test('it should only render the items after overflowIndexStart', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
overflowIndexStart={5}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container').textContent).toEqual('item6item7');
});
test('it should render all the items when overflowIndexStart is zero', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
overflowIndexStart={0}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container').textContent).toEqual(
'item1item2item3item4item5item6item7'
);
});
test('it should have the eui-yScroll to enable scrolling when necessary', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
overflowIndexStart={5}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container')).toHaveClass('eui-yScroll');
});
test('it should use the moreMaxHeight prop as the value for the max-height style', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
overflowIndexStart={5}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(screen.getByTestId('more-container')).toHaveStyle(
`max-height: ${DEFAULT_MORE_MAX_HEIGHT}`
);
});
test('it should render with correct attrName prop', () => {
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
overflowIndexStart={5}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
screen
.getAllByTestId('cellActions-renderContent-mock.attr')
.forEach((element) => expect(element).toBeInTheDocument());
});
test('it should only invoke the optional render function when provided', () => {
const renderFn = jest.fn();
render(
<TestProviders>
<MoreContainer
idPrefix={idPrefix}
overflowIndexStart={5}
render={renderFn}
values={rowItems}
fieldName="mock.attr"
/>
</TestProviders>
);
expect(renderFn).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -0,0 +1,94 @@
/*
* 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, { useContext, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/css';
import classNames from 'classnames';
import { TimelineContext } from '../../timeline';
import { getSourcererScopeId } from '../../../../helpers';
import { escapeDataProviderId } from '../../../../common/components/drag_and_drop/helpers';
import { defaultToEmptyTag } from '../../../../common/components/empty_value';
import { SourcererScopeName } from '../../../../sourcerer/store/model';
import {
SecurityCellActions,
CellActionsMode,
SecurityCellActionsTrigger,
} from '../../../../common/components/cell_actions';
interface MoreContainerProps {
fieldName: string;
values: string[];
idPrefix: string;
moreMaxHeight?: string;
overflowIndexStart: number;
render?: (item: string) => React.ReactNode;
scopeId?: string;
}
/** The default max-height of the popover used to show "+n More" items (e.g. `+9 More`) */
export const DEFAULT_MORE_MAX_HEIGHT = '200px';
export const MoreContainer = React.memo<MoreContainerProps>(
({
fieldName,
idPrefix,
moreMaxHeight = DEFAULT_MORE_MAX_HEIGHT,
overflowIndexStart,
render,
values,
scopeId,
}) => {
const { timelineId } = useContext(TimelineContext);
const defaultedScopeId = scopeId ?? timelineId;
const sourcererScopeId = getSourcererScopeId(defaultedScopeId ?? '');
const moreItemsWithHoverActions = useMemo(
() =>
values.slice(overflowIndexStart).reduce<React.ReactElement[]>((acc, value, index) => {
const id = escapeDataProviderId(`${idPrefix}-${fieldName}-${value}-${index}`);
if (typeof value === 'string' && fieldName != null) {
acc.push(
<EuiFlexItem key={id}>
<SecurityCellActions
key={id}
mode={CellActionsMode.HOVER_DOWN}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}
data={{ value, field: fieldName }}
sourcererScopeId={sourcererScopeId ?? SourcererScopeName.default}
metadata={{ scopeId: defaultedScopeId ?? undefined }}
>
<>{render ? render(value) : defaultToEmptyTag(value)}</>
</SecurityCellActions>
</EuiFlexItem>
);
}
return acc;
}, []),
[values, overflowIndexStart, idPrefix, fieldName, sourcererScopeId, defaultedScopeId, render]
);
const moreContainerStyles = () => css`
max-height: ${moreMaxHeight};
padding-right: 2px;
`;
const moreContainerClasses = classNames(moreContainerStyles(), 'eui-yScroll');
return (
<div data-test-subj="more-container" className={moreContainerClasses}>
<EuiFlexGroup gutterSize="s" direction="column" data-test-subj="overflow-items">
{moreItemsWithHoverActions}
</EuiFlexGroup>
</div>
);
}
);
MoreContainer.displayName = 'MoreContainer';

View file

@ -9,7 +9,7 @@ import { css } from '@emotion/react';
import React from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { DefaultFieldRenderer } from '../../field_renderers/field_renderers';
import { DefaultFieldRenderer } from '../../field_renderers/default_renderer';
import type { ManagedUsersTableColumns, ManagedUserTable } from './types';
import * as i18n from './translations';
import { defaultToEmptyTag } from '../../../../common/components/empty_value';