mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
3370d80754
commit
3ac173e140
18 changed files with 632 additions and 559 deletions
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue