[Security Solution] Hover actions on overflow items (#131816)

* init fix

* More items

* more containers in tables

* fix types

* apply design

* unit test

* apply changes from main

* update unit tests

* styling

* unit tests

* review

* issue-129441

* types

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2022-05-23 19:28:04 +01:00 committed by GitHub
parent dc9f2732a1
commit 289107aaac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 508 additions and 266 deletions

View file

@ -16,6 +16,7 @@ import {
clickOnFilterIn,
clickOnFilterOut,
clickOnShowTopN,
mouseoverOnToOverflowItem,
openHoverActions,
} from '../../tasks/network/flows';
import { openTimelineUsingToggle } from '../../tasks/security_main';
@ -41,6 +42,7 @@ describe('Hover actions', () => {
beforeEach(() => {
visit(NETWORK_URL, onBeforeLoadCallback);
openHoverActions();
mouseoverOnToOverflowItem();
});
it('Adds global filter - filter in', () => {

View file

@ -16,7 +16,7 @@ import {
import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
import { login, visit } from '../../tasks/login';
import { openHoverActions } from '../../tasks/network/flows';
import { mouseoverOnToOverflowItem, openHoverActions } from '../../tasks/network/flows';
import { NETWORK_URL } from '../../urls/navigation';
@ -29,8 +29,7 @@ describe('Overflow items', () => {
esArchiverLoad('network');
login();
visit(NETWORK_URL);
cy.get(DESTINATION_DOMAIN(testDomainOne)).should('not.exist');
cy.get(DESTINATION_DOMAIN(testDomainTwo)).should('not.exist');
cy.get(DESTINATION_DOMAIN).should('not.exist');
cy.get(FILTER_IN).should('not.exist');
cy.get(FILTER_OUT).should('not.exist');
cy.get(ADD_TO_TIMELINE).should('not.exist');
@ -38,6 +37,7 @@ describe('Overflow items', () => {
cy.get(COPY).should('not.exist');
openHoverActions();
mouseoverOnToOverflowItem();
});
after(() => {
@ -45,8 +45,8 @@ describe('Overflow items', () => {
});
it('Shows more items in the popover', () => {
cy.get(DESTINATION_DOMAIN(testDomainOne)).should('exist');
cy.get(DESTINATION_DOMAIN(testDomainTwo)).should('exist');
cy.get(DESTINATION_DOMAIN).eq(0).should('have.text', testDomainOne);
cy.get(DESTINATION_DOMAIN).eq(1).should('have.text', testDomainTwo);
});
it('Shows Hover actions for more items in the popover', () => {

View file

@ -23,5 +23,7 @@ export const TOP_N_CONTAINER = '[data-test-subj="topN-container"]';
export const CLOSE_TOP_N = '[data-test-subj="close"]';
export const DESTINATION_DOMAIN = (testDomain: string) =>
`[data-test-subj="destination.domain-${testDomain}"]`;
export const DESTINATION_DOMAIN = `[data-test-subj="more-container"] [data-test-subj="render-content-destination.domain"]`;
export const OVERFLOW_ITEM =
'[data-test-subj="more-container"] [data-test-subj="render-content-destination.domain"]';

View file

@ -13,6 +13,7 @@ import {
IPS_TABLE_LOADED,
SHOW_TOP_FIELD,
EXPAND_OVERFLOW_ITEMS,
OVERFLOW_ITEM,
} from '../../screens/network/flows';
export const waitForIpsTableToBeLoaded = () => {
@ -23,6 +24,10 @@ export const openHoverActions = () => {
cy.get(EXPAND_OVERFLOW_ITEMS).first().click({ scrollBehavior: 'center' });
};
export const mouseoverOnToOverflowItem = () => {
cy.get(OVERFLOW_ITEM).first().trigger('mouseover');
};
export const clickOnFilterIn = () => {
cy.get(FILTER_IN).first().click();
};

View file

@ -0,0 +1,136 @@
/*
* 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 { render, fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { DEFAULT_MORE_MAX_HEIGHT } from '.';
import { TestProviders } from '../../mock';
import {
MoreReputationLinksContainer,
ReputationLinkSetting,
ReputationLinksOverflow,
} from './helpers';
const rowItems = [
{ name: 'item1', url_template: 'https://www.virustotal.com/gui/search/{{ip}' },
{
name: 'item2',
url_template: 'https://talosintelligence.com/reputation_center/lookup?search={{ip}}',
},
{ name: 'item3', url_template: 'https://www.virustotal.com/gui/search/{{ip}' },
{
name: 'item4',
url_template: 'https://talosintelligence.com/reputation_center/lookup?search={{ip}}',
},
{ name: 'item5', url_template: 'https://www.virustotal.com/gui/search/{{ip}' },
{
name: 'item6',
url_template: 'https://talosintelligence.com/reputation_center/lookup?search={{ip}}',
},
];
describe('MoreReputationLinksContainer', () => {
test('it should only render the items after overflowIndexStart', () => {
render(
<MoreReputationLinksContainer
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
render={(item: ReputationLinkSetting) => <a href={item.url_template}>{item.name}</a>}
/>
);
expect(screen.getByRole('link')).toHaveTextContent('item6');
});
test('it should render all the items when overflowIndexStart is zero', () => {
render(
<MoreReputationLinksContainer
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={0}
rowItems={rowItems}
render={(item: ReputationLinkSetting) => <a href={item.url_template}>{item.name}</a>}
/>
);
expect(screen.getAllByRole('link')).toHaveLength(6);
});
test('it should have the eui-yScroll to enable scrolling', () => {
const wrapper = render(
<MoreReputationLinksContainer
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
render={(item: ReputationLinkSetting) => <a href={item.url_template}>{item.name}</a>}
/>
);
expect(wrapper.getByTestId('more-container')).toHaveClass('eui-yScroll');
});
test('it should use the moreMaxHeight prop as the value for the max-height style', () => {
const { getByTestId } = render(
<MoreReputationLinksContainer
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
render={(item: ReputationLinkSetting) => <a href={item.url_template}>{item.name}</a>}
/>
);
expect(getByTestId('more-container')).toHaveStyle({ maxHeight: '200px' });
});
test('it should only invoke the optional render function, when provided, for the items after overflowIndexStart', () => {
const mockRender = jest.fn(() => <></>);
render(
<MoreReputationLinksContainer
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
render={mockRender}
/>
);
expect(mockRender).toHaveBeenCalledTimes(1);
});
});
describe('ReputationLinksOverflow', () => {
test('it should render the length of items after the overflowIndexStart', () => {
const wrapper = render(
<TestProviders>
<ReputationLinksOverflow
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
render={(item: ReputationLinkSetting) => <a href={item.url_template}>{item.name}</a>}
/>
</TestProviders>
);
expect(wrapper.getByRole('button')).toHaveTextContent('+1 More');
expect(wrapper.queryByTestId('more-container')).toBeNull();
});
test('it should render the items after overflowIndexStart in the popover', () => {
const wrapper = render(
<TestProviders>
<ReputationLinksOverflow
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
render={(item: ReputationLinkSetting) => <a href={item.url_template}>{item.name}</a>}
/>
</TestProviders>
);
fireEvent.click(wrapper.getByRole('button'));
expect(wrapper.queryByTestId('more-container')).toHaveTextContent('item6');
});
});

View file

@ -4,19 +4,27 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { SyntheticEvent } from 'react';
import React, { SyntheticEvent, useCallback, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonIcon,
EuiButtonProps,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiLinkProps,
EuiPopover,
PropsForAnchor,
PropsForButton,
} from '@elastic/eui';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import { defaultToEmptyTag } from '../empty_value';
export interface ReputationLinkSetting {
name: string;
url_template: string;
}
export const LinkButton: React.FC<
PropsForButton<EuiButtonProps> | PropsForAnchor<EuiButtonProps>
> = ({ children, ...props }) => <EuiButton {...props}>{children}</EuiButton>;
@ -70,3 +78,88 @@ export const PortContainer = styled.div`
top: -1px;
}
`;
interface ReputationLinkOverflowProps {
rowItems: ReputationLinkSetting[];
render?: (item: ReputationLinkSetting) => React.ReactNode;
overflowIndexStart?: number;
moreMaxHeight: string;
}
export const ReputationLinksOverflow = React.memo<ReputationLinkOverflowProps>(
({ moreMaxHeight, overflowIndexStart = 5, render, rowItems }) => {
const [isOpen, setIsOpen] = useState(false);
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
const button = useMemo(
() => (
<>
{' ,'}
<EuiButtonEmpty size="xs" onClick={togglePopover}>
{`+${rowItems.length - overflowIndexStart} `}
<FormattedMessage
id="xpack.securitySolution.reputationLinks.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"
>
<MoreReputationLinksContainer
render={render}
rowItems={rowItems}
moreMaxHeight={moreMaxHeight}
overflowIndexStart={overflowIndexStart}
/>
</EuiPopover>
)}
</EuiFlexItem>
);
}
);
ReputationLinksOverflow.displayName = 'ReputationLinksOverflow';
export const MoreReputationLinksContainer = React.memo<ReputationLinkOverflowProps>(
({ moreMaxHeight, overflowIndexStart, render, rowItems }) => {
const moreItems = useMemo(
() =>
rowItems.slice(overflowIndexStart).map((rowItem, index) => {
return (
<EuiFlexItem grow={1} key={`${rowItem}-${index}`}>
{(render && render(rowItem)) ?? defaultToEmptyTag(rowItem)}
</EuiFlexItem>
);
}),
[overflowIndexStart, render, rowItems]
);
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">
{moreItems}
</EuiFlexGroup>
</div>
);
}
);
MoreReputationLinksContainer.displayName = 'MoreReputationLinksContainer';

View file

@ -16,10 +16,6 @@ import {
import React, { useMemo, useCallback, SyntheticEvent, MouseEventHandler, MouseEvent } from 'react';
import { isArray, isNil } from 'lodash/fp';
import { IP_REPUTATION_LINKS_SETTING, APP_UI_ID } from '../../../../common/constants';
import {
DefaultFieldRendererOverflow,
DEFAULT_MORE_MAX_HEIGHT,
} from '../../../timelines/components/field_renderers/field_renderers';
import { encodeIpv6 } from '../../lib/helpers';
import {
getCaseDetailsUrl,
@ -40,7 +36,15 @@ import { isUrlInvalid } from '../../utils/validators';
import * as i18n from './translations';
import { SecurityPageName } from '../../../app/types';
import { getTabsOnUsersDetailsUrl, getUsersDetailsUrl } from '../link_to/redirect_to_users';
import { LinkAnchor, GenericLinkButton, PortContainer, Comma, LinkButton } from './helpers';
import {
LinkAnchor,
GenericLinkButton,
PortContainer,
Comma,
LinkButton,
ReputationLinkSetting,
ReputationLinksOverflow,
} from './helpers';
import { HostsTableType } from '../../../hosts/store/model';
import { UsersTableType } from '../../../users/store/model';
@ -48,6 +52,9 @@ export { LinkButton, LinkAnchor } from './helpers';
export const DEFAULT_NUMBER_OF_LINK = 5;
/** The default max-height of the Reputation Links popover used to show "+n More" items (e.g. `+9 More`) */
export const DEFAULT_MORE_MAX_HEIGHT = '200px';
// Internal Links
const UserDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
@ -402,11 +409,6 @@ enum DefaultReputationLink {
'talosIntelligence.com' = 'talosIntelligence.com',
}
export interface ReputationLinkSetting {
name: string;
url_template: string;
}
function isDefaultReputationLink(name: string): name is DefaultReputationLink {
return (
name === DefaultReputationLink['virustotal.com'] ||
@ -500,9 +502,8 @@ const ReputationLinkComponent: React.FC<{
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DefaultFieldRendererOverflow
<ReputationLinksOverflow
rowItems={ipReputationLinks}
idPrefix="moreReputationLink"
render={renderCallback}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={overflowIndexStart}

View file

@ -9,36 +9,19 @@ exports[`Table Helpers #RowItemOverflow it returns correctly against snapshot 1`
<EuiText
size="xs"
>
<EuiFlexGroup
data-test-subj="overflow-items"
direction="column"
gutterSize="none"
>
<EuiFlexItem
key="idPrefix-idPrefix-attrName-item2-0"
>
<Memo(OverflowItemComponent)
dataProvider={
Object {
"and": Array [],
"enabled": true,
"excluded": false,
"id": "idPrefix-attrName-item2-0",
"kqlQuery": "",
"name": "item2",
"queryMatch": Object {
"displayValue": "item2",
"field": "attrName",
"operator": ":",
"value": "item2",
},
}
}
field="attrName"
rowItem="item2"
/>
</EuiFlexItem>
</EuiFlexGroup>
<MoreContainer
attrName="attrName"
idPrefix="idPrefix"
moreMaxHeight="none"
overflowIndexStart={1}
rowItems={
Array [
"item1",
"item2",
"item3",
]
}
/>
<p
data-test-subj="popover-additional-overflow"
>

View file

@ -14,12 +14,10 @@ import {
RowItemOverflowComponent,
getRowItemDraggable,
OverflowFieldComponent,
OverflowItemComponent,
} from './helpers';
import { TestProviders } from '../../mock';
import { getEmptyValue } from '../empty_value';
import { useMountAppended } from '../../utils/use_mount_appended';
import { IS_OPERATOR, QueryOperator } from '../../../../common/types';
jest.mock('../../lib/kibana');
@ -211,17 +209,22 @@ describe('Table Helpers', () => {
});
test('it shows correct number of overflow items when maxOverflowItems are not exceeded', () => {
const wrapper = shallow(
<RowItemOverflowComponent
rowItems={items}
attrName="attrName"
idPrefix="idPrefix"
maxOverflowItems={5}
overflowIndexStart={1}
/>
const wrapper = mount(
<TestProviders>
<RowItemOverflowComponent
rowItems={items}
attrName="attrName"
idPrefix="idPrefix"
maxOverflowItems={5}
overflowIndexStart={1}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="overflow-button"]').first().simulate('click');
expect(
wrapper.find('[data-test-subj="overflow-items"]').prop<JSX.Element[]>('children')?.length
wrapper.find('[data-test-subj="overflow-items"]').last().prop<JSX.Element[]>('children')
?.length
).toEqual(2);
});
@ -260,34 +263,4 @@ describe('Table Helpers', () => {
expect(wrapper.text()).toBe('This string is exact');
});
});
describe('OverflowItemComponent', () => {
const id = 'mock id';
const rowItem = 'endpoint-dev-es.app.elstc.co';
const field = 'destination.ip';
const dataProvider = {
and: [],
enabled: true,
id,
name: rowItem,
excluded: false,
kqlQuery: '',
queryMatch: {
field,
value: rowItem,
displayValue: rowItem,
operator: IS_OPERATOR as QueryOperator,
},
};
const props = {
dataProvider,
field,
rowItem,
};
test('Renders Hover Actions', () => {
const wrapper = shallow(<OverflowItemComponent {...props} />);
expect(wrapper.find('[data-test-subj="hover-actions"]').exists()).toBeTruthy();
});
});
});

View file

@ -4,28 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useContext, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiLink,
EuiPopover,
EuiToolTip,
EuiText,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { EuiLink, EuiPopover, EuiToolTip, EuiText, EuiTextColor } from '@elastic/eui';
import styled from 'styled-components';
import { TimelineContext } from '@kbn/timelines-plugin/public';
import { DragEffects, DraggableWrapper } from '../drag_and_drop/draggable_wrapper';
import { escapeDataProviderId } from '../drag_and_drop/helpers';
import { defaultToEmptyTag, getEmptyTagValue } from '../empty_value';
import { MoreRowItems } from '../page';
import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider';
import { Provider } from '../../../timelines/components/timeline/data_providers/provider';
import { HoverActions } from '../hover_actions';
import { DataProvider, QueryOperator } from '../../../../common/types';
import { MoreContainer } from '../../../timelines/components/field_renderers/field_renderers';
const Subtext = styled.div`
font-size: ${(props) => props.theme.eui.euiFontSizeXS};
@ -170,68 +161,6 @@ export const getRowItemDraggables = ({
}
};
interface OverflowItemProps {
dataProvider?: DataProvider | DataProvider[] | undefined;
dragDisplayValue?: string;
field: string;
rowItem: string;
fieldType?: string;
isAggregatable?: boolean;
}
export const OverflowItemComponent: React.FC<OverflowItemProps> = ({
dataProvider,
dragDisplayValue,
field,
fieldType = '',
isAggregatable = false,
rowItem,
}) => {
const [showTopN, setShowTopN] = useState<boolean>(false);
const { timelineId: timelineIdFind } = useContext(TimelineContext);
const [hoverActionsOwnFocus] = useState<boolean>(false);
const toggleTopN = useCallback(() => {
setShowTopN((prevShowTopN) => {
const newShowTopN = !prevShowTopN;
return newShowTopN;
});
}, []);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
return (
<EuiFlexGroup
gutterSize="none"
justifyContent="spaceBetween"
direction="row"
data-test-subj={`${field}-${dragDisplayValue ?? rowItem}`}
>
<EuiFlexItem grow={1}>{defaultToEmptyTag(rowItem)} </EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj="hover-actions">
<HoverActions
closeTopN={closeTopN}
dataProvider={dataProvider}
field={field}
fieldType={fieldType}
isAggregatable={isAggregatable}
isObjectArray={false}
ownFocus={hoverActionsOwnFocus}
showOwnFocus={false}
showTopN={showTopN}
timelineId={timelineIdFind}
toggleTopN={toggleTopN}
values={dragDisplayValue ?? rowItem}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
OverflowItemComponent.displayName = 'OverflowItemComponent';
export const OverflowItem = React.memo(OverflowItemComponent);
interface RowItemOverflowProps {
attrName: string;
dragDisplayValue?: string;
@ -253,59 +182,21 @@ export const RowItemOverflowComponent: React.FC<RowItemOverflowProps> = ({
fieldType,
isAggregatable,
}) => {
const overflowItems = useMemo(
() =>
rowItems
.slice(overflowIndexStart, overflowIndexStart + maxOverflowItems)
.map((rowItem, index) => {
const id = escapeDataProviderId(`${idPrefix}-${attrName}-${rowItem}-${index}`);
const dataProvider = {
and: [],
enabled: true,
id,
name: rowItem,
excluded: false,
kqlQuery: '',
queryMatch: {
field: attrName,
value: rowItem,
displayValue: dragDisplayValue || rowItem,
operator: IS_OPERATOR as QueryOperator,
},
};
return (
<EuiFlexItem key={`${idPrefix}-${id}`}>
<OverflowItem
dataProvider={dataProvider}
dragDisplayValue={dragDisplayValue}
rowItem={rowItem}
field={attrName}
fieldType={fieldType}
isAggregatable={isAggregatable}
/>
</EuiFlexItem>
);
}),
[
attrName,
dragDisplayValue,
idPrefix,
maxOverflowItems,
overflowIndexStart,
rowItems,
fieldType,
isAggregatable,
]
);
return (
<>
{rowItems.length > overflowIndexStart && (
<Popover count={rowItems.length - overflowIndexStart} idPrefix={idPrefix}>
<EuiText size="xs">
<EuiFlexGroup gutterSize="none" direction="column" data-test-subj="overflow-items">
{overflowItems}
</EuiFlexGroup>
<MoreContainer
attrName={attrName}
dragDisplayValue={dragDisplayValue}
idPrefix={idPrefix}
overflowIndexStart={overflowIndexStart}
rowItems={rowItems}
moreMaxHeight="none"
fieldType={fieldType}
isAggregatable={isAggregatable}
/>
{rowItems.length > overflowIndexStart + maxOverflowItems && (
<p data-test-subj="popover-additional-overflow">

View file

@ -28,6 +28,7 @@ import { mockData } from '../../../network/components/details/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
import { AutonomousSystem, FlowTarget } from '../../../../common/search_strategy';
import { HostEcs } from '../../../../common/ecs/host';
import { DataProvider } from '../../../../common/types';
jest.mock('../../../common/lib/kibana');
@ -273,10 +274,12 @@ describe('Field Renderers', () => {
test('it should only render the items after overflowIndexStart', () => {
const wrapper = mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
rowItems={rowItems}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
/>
);
@ -286,38 +289,44 @@ describe('Field Renderers', () => {
test('it should render all the items when overflowIndexStart is zero', () => {
const wrapper = mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
rowItems={rowItems}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={0}
rowItems={rowItems}
/>
);
expect(wrapper.text()).toEqual('item1item2item3item4item5item6item7');
});
test('it should have the overflow `auto` style to enable scrolling when necessary', () => {
test('it should have the eui-yScroll to enable scrolling when necessary', () => {
const wrapper = mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
rowItems={rowItems}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
/>
);
expect(
wrapper.find('[data-test-subj="more-container"]').first().props().style?.overflow
).toEqual('auto');
expect(wrapper.find('[data-test-subj="more-container"]').first().props().className).toEqual(
'eui-yScroll'
);
});
test('it should use the moreMaxHeight prop as the value for the max-height style', () => {
const wrapper = mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
rowItems={rowItems}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
/>
);
@ -326,16 +335,70 @@ describe('Field Renderers', () => {
).toEqual(DEFAULT_MORE_MAX_HEIGHT);
});
test('it should render with correct attrName prop', () => {
const wrapper = mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
attrName="mock.attr"
/>
);
expect(
wrapper.find('DraggableWrapper').first().prop<DataProvider>('dataProvider').queryMatch.field
).toEqual('mock.attr');
});
test('it should render with correct fieldType prop', () => {
const wrapper = mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
attrName="mock.attr"
/>
);
expect(wrapper.find('DraggableWrapper').first().prop<string>('fieldType')).toEqual('keyword');
});
test('it should render with correct isAggregatable prop', () => {
const wrapper = mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
attrName="mock.attr"
/>
);
expect(wrapper.find('DraggableWrapper').first().prop<boolean>('isAggregatable')).toEqual(
true
);
});
test('it should only invoke the optional render function, when provided, for the items after overflowIndexStart', () => {
const render = jest.fn();
mount(
<MoreContainer
fieldType="keyword"
idPrefix={idPrefix}
render={render}
rowItems={rowItems}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
render={render}
rowItems={rowItems}
/>
);
@ -351,10 +414,12 @@ describe('Field Renderers', () => {
const wrapper = mount(
<TestProviders>
<DefaultFieldRendererOverflow
fieldType="keyword"
idPrefix={idPrefix}
rowItems={rowItems}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
/>
</TestProviders>
);
@ -367,10 +432,12 @@ describe('Field Renderers', () => {
const wrapper = mount(
<TestProviders>
<DefaultFieldRendererOverflow
fieldType="keyword"
idPrefix={idPrefix}
rowItems={rowItems}
isAggregatable={true}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
rowItems={rowItems}
/>
</TestProviders>
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiText } from '@elastic/eui';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { getOr } from 'lodash/fp';
import React, { useCallback, Fragment, useMemo, useState } from 'react';
@ -19,16 +19,13 @@ import {
} from '../../../../common/search_strategy';
import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers';
import { DefaultDraggable } from '../../../common/components/draggables';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { defaultToEmptyTag, getEmptyTagValue } from '../../../common/components/empty_value';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import {
HostDetailsLink,
ReputationLink,
WhoIsLink,
ReputationLinkSetting,
} from '../../../common/components/links';
import { HostDetailsLink, ReputationLink, WhoIsLink } from '../../../common/components/links';
import { Spacer } from '../../../common/components/page';
import * as i18n from '../../../network/components/details/translations';
import { IS_OPERATOR, QueryOperator } from '../../../../common/types';
import { DraggableWrapper } from '../../../common/components/drag_and_drop/draggable_wrapper';
const DraggableContainerFlexGroup = styled(EuiFlexGroup)`
flex-grow: unset;
@ -190,17 +187,15 @@ export const reputationRenderer = (ip: string): React.ReactElement => (
);
interface DefaultFieldRendererProps {
rowItems: string[] | null | undefined;
attrName: string;
displayCount?: number;
idPrefix: string;
isDraggable?: boolean;
render?: (item: string) => JSX.Element;
displayCount?: number;
moreMaxHeight?: string;
render?: (item: string) => React.ReactNode;
rowItems: string[] | null | undefined;
}
type OverflowRenderer = (item: string | ReputationLinkSetting) => JSX.Element;
export const DefaultFieldRendererComponent: React.FC<DefaultFieldRendererProps> = ({
attrName,
displayCount = 1,
@ -244,11 +239,14 @@ export const DefaultFieldRendererComponent: React.FC<DefaultFieldRendererProps>
<EuiFlexItem grow={false}>{draggables} </EuiFlexItem>
<EuiFlexItem grow={false}>
<DefaultFieldRendererOverflow
rowItems={rowItems}
attrName={attrName}
fieldType="keyword"
idPrefix={idPrefix}
render={render as OverflowRenderer}
overflowIndexStart={displayCount}
isAggregatable={true}
moreMaxHeight={moreMaxHeight}
overflowIndexStart={displayCount}
render={render}
rowItems={rowItems}
/>
</EuiFlexItem>
</DraggableContainerFlexGroup>
@ -264,47 +262,134 @@ export const DefaultFieldRenderer = React.memo(DefaultFieldRendererComponent);
DefaultFieldRenderer.displayName = 'DefaultFieldRenderer';
type RowItemTypes = string | ReputationLinkSetting;
interface DefaultFieldRendererOverflowProps {
rowItems: string[] | ReputationLinkSetting[];
attrName?: string;
fieldType?: string;
rowItems: string[];
idPrefix: string;
render?: (item: RowItemTypes) => React.ReactNode;
isAggregatable?: boolean;
render?: (item: string) => React.ReactNode;
overflowIndexStart?: number;
moreMaxHeight: string;
}
interface MoreContainerProps {
attrName?: string;
dragDisplayValue?: string;
fieldType?: string;
idPrefix: string;
render?: (item: RowItemTypes) => React.ReactNode;
rowItems: RowItemTypes[];
isAggregatable?: boolean;
moreMaxHeight: string;
overflowIndexStart: number;
render?: (item: string) => React.ReactNode;
rowItems: string[];
}
/** A container (with overflow) for showing "More" items in a popover */
export const MoreContainer = React.memo<MoreContainerProps>(
({ idPrefix, render, rowItems, moreMaxHeight, overflowIndexStart }) => (
<div
data-test-subj="more-container"
style={{
maxHeight: moreMaxHeight,
overflow: 'auto',
paddingRight: '2px',
}}
>
{rowItems.slice(overflowIndexStart).map((rowItem, i) => (
<EuiText key={`${idPrefix}-${rowItem}-${i}`} size="s">
{render ? render(rowItem) : rowItem}
</EuiText>
))}
</div>
)
);
({
attrName,
dragDisplayValue,
fieldType,
idPrefix,
isAggregatable,
moreMaxHeight,
overflowIndexStart,
render,
rowItems,
}) => {
const moreItemsWithHoverActions = useMemo(
() =>
rowItems.slice(overflowIndexStart).reduce<React.ReactElement[]>((acc, rowItem, index) => {
const id = escapeDataProviderId(`${idPrefix}-${attrName}-${rowItem}-${index}`);
const dataProvider =
typeof rowItem === 'string' && attrName != null
? {
and: [],
enabled: true,
id,
name: rowItem,
excluded: false,
kqlQuery: '',
queryMatch: {
field: attrName,
value: rowItem,
displayValue: dragDisplayValue ?? rowItem,
operator: IS_OPERATOR as QueryOperator,
},
}
: undefined;
if (dataProvider != null) {
acc.push(
<EuiFlexItem key={`${idPrefix}-${id}`}>
<DraggableWrapper
dataProvider={dataProvider}
isDraggable={false}
render={() => (render && render(rowItem)) ?? defaultToEmptyTag(rowItem)}
timelineId={undefined}
fieldType={fieldType}
isAggregatable={isAggregatable}
/>
</EuiFlexItem>
);
}
return acc;
}, []),
[
attrName,
dragDisplayValue,
fieldType,
idPrefix,
isAggregatable,
overflowIndexStart,
render,
rowItems,
]
);
const moreItems = useMemo(
() =>
rowItems.slice(overflowIndexStart).map((rowItem, index) => {
return (
<EuiFlexItem grow={1} key={`${rowItem}-${index}`}>
{(render && render(rowItem)) ?? defaultToEmptyTag(rowItem)}
</EuiFlexItem>
);
}),
[overflowIndexStart, render, rowItems]
);
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">
{attrName != null ? moreItemsWithHoverActions : moreItems}
</EuiFlexGroup>
</div>
);
}
);
MoreContainer.displayName = 'MoreContainer';
export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverflowProps>(
({ idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems }) => {
({
attrName,
idPrefix,
moreMaxHeight,
overflowIndexStart = 5,
render,
rowItems,
fieldType,
isAggregatable,
}) => {
const [isOpen, setIsOpen] = useState(false);
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
const button = useMemo(
@ -332,13 +417,17 @@ export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverf
isOpen={isOpen}
closePopover={togglePopover}
repositionOnScroll
panelClassName="withHoverActions__popover"
>
<MoreContainer
attrName={attrName}
idPrefix={idPrefix}
render={render}
rowItems={rowItems}
moreMaxHeight={moreMaxHeight}
overflowIndexStart={overflowIndexStart}
fieldType={fieldType}
isAggregatable={isAggregatable}
/>
</EuiPopover>
)}