mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
dc9f2732a1
commit
289107aaac
12 changed files with 508 additions and 266 deletions
|
@ -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', () => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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"]';
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue