[Security Solution] flyout UI adjustment (#108192)

* styling

* fix hover actions

* init overflow button for flyout

* init overflow button

* topN btn

* remove popover from topN

* fix tests

* fix unit test

* add use hover action items hook

* fix for code review

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2021-08-16 13:02:42 +01:00 committed by GitHub
parent 7f5c1b43ad
commit fe0322ac1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 741 additions and 227 deletions

View file

@ -52,6 +52,8 @@ export const ID_FIELD = '[data-test-subj="timeline"] [data-test-subj="field-name
export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]';
export const ID_HOVER_ACTION_OVERFLOW_BTN = '[data-test-subj="more-actions-_id"]';
export const LOCKED_ICON = '[data-test-subj="timeline-date-picker-lock-button"]';
export const UNLOCKED_ICON = '[data-test-subj="timeline-date-picker-unlock-button"]';
@ -266,3 +268,5 @@ export const TIMELINE_TAB_CONTENT_PINNED = '[data-test-subj="timeline-tab-conten
export const TIMELINE_TAB_CONTENT_GRAPHS_NOTES =
'[data-test-subj="timeline-tab-content-graph-notes"]';
export const TIMESTAMP_HOVER_ACTION_OVERFLOW_BTN = '[data-test-subj="more-actions-@timestamp"]';

View file

@ -24,6 +24,7 @@ import {
ID_FIELD,
ID_HEADER_FIELD,
ID_TOGGLE_FIELD,
ID_HOVER_ACTION_OVERFLOW_BTN,
NOTES_TAB_BUTTON,
NOTES_TEXT_AREA,
OPEN_TIMELINE_ICON,
@ -63,6 +64,7 @@ import {
TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN,
TIMELINE_COLLAPSED_ITEMS_BTN,
TIMELINE_TAB_CONTENT_EQL,
TIMESTAMP_HOVER_ACTION_OVERFLOW_BTN,
} from '../screens/timeline';
import { REFRESH_BUTTON, TIMELINE } from '../screens/timelines';
@ -188,7 +190,14 @@ export const attachTimelineToExistingCase = () => {
cy.get(ATTACH_TIMELINE_TO_EXISTING_CASE_ICON).click({ force: true });
};
const clickIdHoverActionOverflowButton = () => {
cy.get(ID_HOVER_ACTION_OVERFLOW_BTN).should('exist');
cy.get(ID_HOVER_ACTION_OVERFLOW_BTN).click({ force: true });
};
export const clickIdToggleField = () => {
clickIdHoverActionOverflowButton();
cy.get(ID_HEADER_FIELD).should('not.exist');
cy.get(ID_TOGGLE_FIELD).click({
@ -293,7 +302,15 @@ export const unpinFirstEvent = () => {
cy.get(PIN_EVENT).first().click({ force: true });
};
const clickTimestampHoverActionOverflowButton = () => {
cy.get(TIMESTAMP_HOVER_ACTION_OVERFLOW_BTN).should('exist');
cy.get(TIMESTAMP_HOVER_ACTION_OVERFLOW_BTN).click({ force: true });
};
export const clickTimestampToggleField = () => {
clickTimestampHoverActionOverflowButton();
cy.get(TIMESTAMP_TOGGLE_FIELD).should('exist');
cy.get(TIMESTAMP_TOGGLE_FIELD).click({ force: true });

View file

@ -11,6 +11,7 @@ import { TestProviders } from '../../mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { mockBrowserFields } from '../../containers/source/mock';
import { EventFieldsData } from './types';
import { get } from 'lodash/fp';
jest.mock('../../lib/kibana');
interface Column {
@ -78,13 +79,13 @@ describe('getColumns', () => {
});
});
describe('add to timeline', () => {
test('it renders an add to timeline button', () => {
describe('overflow button', () => {
test('it renders an overflow button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(wrapper.find('[data-test-subj="hover-actions-add-timeline"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="more-actions-agent.id"]').exists()).toBeTruthy();
});
});
@ -95,8 +96,32 @@ describe('getColumns', () => {
) as ReactWrapper;
expect(
wrapper.find('[data-test-subj="hover-actions-toggle-column"]').exists()
).toBeTruthy();
get(['items', 0, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-toggle-column');
});
});
describe('add to timeline', () => {
test('it renders an add to timeline button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(
get(['items', 1, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-add-timeline');
});
});
describe('topN', () => {
test('it renders a show topN button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(
get(['items', 2, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-show-top-n');
});
});
@ -106,7 +131,9 @@ describe('getColumns', () => {
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(wrapper.find('[data-test-subj="hover-actions-copy-button"]').exists()).toBeTruthy();
expect(
get(['items', 3, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-copy-button');
});
});
});

View file

@ -14,6 +14,7 @@ import { EventFieldsBrowser } from './event_fields_browser';
import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { TimelineTabs } from '../../../../common/types/timeline';
import { get } from 'lodash/fp';
jest.mock('../../lib/kibana');
@ -116,7 +117,7 @@ describe('EventFieldsBrowser', () => {
expect(wrapper.find('[data-test-subj="hover-actions-filter-out"]').exists()).toBeTruthy();
});
test('it renders an add to timeline button', () => {
test('it renders an overflow button', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
@ -129,7 +130,7 @@ describe('EventFieldsBrowser', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="hover-actions-add-timeline"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="more-actions-@timestamp"]').exists()).toBeTruthy();
});
test('it renders a column toggle button', () => {
@ -146,8 +147,26 @@ describe('EventFieldsBrowser', () => {
);
expect(
wrapper.find('[data-test-subj="hover-actions-toggle-column"]').first().exists()
).toBeTruthy();
get(['items', 0, 'key'], wrapper.find('[data-test-subj="more-actions-@timestamp"]').props())
).toEqual('hover-actions-toggle-column');
});
test('it renders an add to timeline button', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
browserFields={mockBrowserFields}
data={mockDetailItemData}
eventId={eventId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
expect(
get(['items', 1, 'key'], wrapper.find('[data-test-subj="more-actions-@timestamp"]').props())
).toEqual('hover-actions-add-timeline');
});
test('it renders a copy button', () => {
@ -163,7 +182,9 @@ describe('EventFieldsBrowser', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="hover-actions-copy-button"]').exists()).toBeTruthy();
expect(
get(['items', 2, 'key'], wrapper.find('[data-test-subj="more-actions-@timestamp"]').props())
).toEqual('hover-actions-copy-button');
});
});

View file

@ -89,14 +89,13 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-family: ${({ theme }) => theme.eui.euiCodeFontFamily};
.eventFieldsTable__hoverActionButtons {
&:focus-within {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
@ -110,9 +109,6 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
opacity: 0;
} */
opacity: 0;
&:focus {
opacity: 1;
}
}
}

View file

@ -223,6 +223,7 @@ export const getSummaryColumns = (
name: '',
},
{
className: 'flyoutOverviewDescription',
field: 'description',
truncateText: false,
render: DescriptionComponent,

View file

@ -24,6 +24,22 @@ export const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
.euiTableHeaderCell .euiTableCellContent {
padding: 0;
}
.flyoutOverviewDescription {
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
}
`;
export const SummaryViewComponent: React.FC<{

View file

@ -65,10 +65,16 @@ export const ActionCell: React.FC<Props> = React.memo(
});
}, []);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
return (
<HoverActions
closeTopN={closeTopN}
dataType={data.type}
dataProvider={actionCellConfig?.dataProvider}
enableOverflowButton={true}
field={data.field}
goGetTimelineId={setGoGetTimelineId}
isObjectArray={data.isObjectArray}

View file

@ -6,7 +6,7 @@
*/
import React, { useMemo } from 'react';
import { EuiButtonEmpty, EuiButtonIcon, EuiPopover, EuiToolTip } from '@elastic/eui';
import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { StatefulTopN } from '../../top_n';
import { TimelineId } from '../../../../../common/types/timeline';
@ -24,7 +24,7 @@ const SHOW_TOP = (fieldName: string) =>
interface Props {
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem;
field: string;
onClick: () => void;
onFilterAdded?: () => void;
@ -64,6 +64,7 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
<Component
aria-label={SHOW_TOP(field)}
data-test-subj="show-top-field"
icon="visBarVertical"
iconType="visBarVertical"
onClick={onClick}
title={SHOW_TOP(field)}
@ -84,17 +85,15 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
);
return showTopN ? (
<EuiPopover button={button} isOpen={showTopN} closePopover={onClick}>
<StatefulTopN
browserFields={browserFields}
field={field}
indexPattern={indexPattern}
onFilterAdded={onFilterAdded}
timelineId={timelineId ?? undefined}
toggleTopN={onClick}
value={value}
/>
</EuiPopover>
<StatefulTopN
browserFields={browserFields}
field={field}
indexPattern={indexPattern}
onFilterAdded={onFilterAdded}
timelineId={timelineId ?? undefined}
toggleTopN={onClick}
value={value}
/>
) : showTooltip ? (
<EuiToolTip
content={

View file

@ -6,23 +6,15 @@
*/
import { EuiFocusTrap, EuiScreenReaderOnly } from '@elastic/eui';
import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { DraggableId } from 'react-beautiful-dnd';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { useKibana } from '../../lib/kibana';
import { getAllFieldsByName } from '../../containers/source';
import { allowTopN } from './utils';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { ColumnHeaderOptions, DataProvider, TimelineId } from '../../../../common/types/timeline';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { useSourcererScope } from '../../containers/sourcerer';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { ColumnHeaderOptions, DataProvider } from '../../../../common/types/timeline';
import { stopPropagationAndPreventDefault } from '../../../../../timelines/public';
import { ShowTopNButton } from './actions/show_top_n';
import { SHOW_TOP_N_KEYBOARD_SHORTCUT } from './keyboard_shortcut_constants';
import { useHoverActionItems } from './use_hover_action_items';
export const YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS = (fieldName: string) =>
i18n.translate(
@ -39,11 +31,27 @@ export const AdditionalContent = styled.div`
AdditionalContent.displayName = 'AdditionalContent';
const StyledHoverActionsContainer = styled.div<{ $showTopN: boolean; $showOwnFocus: boolean }>`
const StyledHoverActionsContainer = styled.div<{
$showTopN: boolean;
$showOwnFocus: boolean;
$isActive: boolean;
}>`
min-width: 138px;
padding: ${(props) => `0 ${props.theme.eui.paddingSizes.s}`};
display: flex;
${(props) =>
props.$isActive
? `
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
}
`
: ''}
${(props) =>
props.$showOwnFocus
? `
@ -75,10 +83,12 @@ const StyledHoverActionsContainer = styled.div<{ $showTopN: boolean; $showOwnFoc
interface Props {
additionalContent?: React.ReactNode;
closeTopN?: () => void;
closePopOver?: () => void;
dataProvider?: DataProvider | DataProvider[];
dataType?: string;
draggableId?: DraggableId;
enableOverflowButton?: boolean;
field: string;
goGetTimelineId?: (args: boolean) => void;
isObjectArray: boolean;
@ -110,9 +120,12 @@ const isFocusTrapDisabled = ({
export const HoverActions: React.FC<Props> = React.memo(
({
additionalContent = null,
closePopOver,
closeTopN,
dataProvider,
dataType,
draggableId,
enableOverflowButton = false,
field,
goGetTimelineId,
isObjectArray,
@ -125,45 +138,25 @@ export const HoverActions: React.FC<Props> = React.memo(
toggleTopN,
values,
}) => {
const kibana = useKibana();
const { timelines } = kibana.services;
// Common actions used by the alert table and alert flyout
const {
getAddToTimelineButton,
getColumnToggleButton,
getCopyButton,
getFilterForValueButton,
getFilterOutValueButton,
} = timelines.getHoverActions();
const [stKeyboardEvent, setStKeyboardEvent] = useState<React.KeyboardEvent>();
const filterManagerBackup = useMemo(() => kibana.services.data.query.filterManager, [
kibana.services.data.query.filterManager,
]);
const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []);
const { filterManager: activeFilterMananager } = useDeepEqualSelector((state) =>
getManageTimeline(state, timelineId ?? '')
);
const filterManager = useMemo(
() => (timelineId === TimelineId.active ? activeFilterMananager : filterManagerBackup),
[timelineId, activeFilterMananager, filterManagerBackup]
);
const [isActive, setIsActive] = useState(false);
const [isOverflowPopoverOpen, setIsOverflowPopoverOpen] = useState(false);
const onOverflowButtonClick = useCallback(() => {
setIsActive((prev) => !prev);
setIsOverflowPopoverOpen(!isOverflowPopoverOpen);
}, [isOverflowPopoverOpen, setIsOverflowPopoverOpen]);
// Regarding data from useManageTimeline:
// * `indexToAdd`, which enables the alerts index to be appended to
// the `indexPattern` returned by `useWithSource`, may only be populated when
// this component is rendered in the context of the active timeline. This
// behavior enables the 'All events' view by appending the alerts index
// to the index pattern.
const activeScope: SourcererScopeName =
timelineId === TimelineId.active
? SourcererScopeName.timeline
: timelineId != null &&
[TimelineId.detectionsPage, TimelineId.detectionsRulesDetailsPage].includes(
timelineId as TimelineId
)
? SourcererScopeName.detections
: SourcererScopeName.default;
const { browserFields } = useSourcererScope(activeScope);
const handleHoverActionClicked = useCallback(() => {
if (closeTopN) {
closeTopN();
}
setIsActive(false);
setIsOverflowPopoverOpen(false);
if (closePopOver) {
closePopOver();
}
}, [closePopOver, closeTopN]);
const isInit = useRef(true);
const defaultFocusedButtonRef = useRef<HTMLButtonElement | null>(null);
@ -206,7 +199,26 @@ export const HoverActions: React.FC<Props> = React.memo(
[ownFocus, toggleTopN]
);
const showFilters = values != null;
const { overflowActionItems, allActionItems } = useHoverActionItems({
dataProvider,
dataType,
defaultFocusedButtonRef,
draggableId,
enableOverflowButton,
field,
handleHoverActionClicked,
isObjectArray,
isOverflowPopoverOpen,
onFilterAdded,
onOverflowButtonClick,
ownFocus,
showTopN,
stKeyboardEvent,
timelineId,
toggleColumn,
toggleTopN,
values,
});
return (
<EuiFocusTrap
@ -219,6 +231,8 @@ export const HoverActions: React.FC<Props> = React.memo(
onKeyDown={onKeyDown}
$showTopN={showTopN}
$showOwnFocus={showOwnFocus}
$isActive={isActive}
className={isActive ? 'hoverActions-active' : ''}
>
<EuiScreenReaderOnly>
<p>{YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS(field)}</p>
@ -226,88 +240,7 @@ export const HoverActions: React.FC<Props> = React.memo(
{additionalContent != null && <AdditionalContent>{additionalContent}</AdditionalContent>}
{showFilters && (
<>
<div data-test-subj="hover-actions-filter-for">
{getFilterForValueButton({
defaultFocusedButtonRef,
field,
filterManager,
keyboardEvent: stKeyboardEvent,
onFilterAdded,
ownFocus,
showTooltip: true,
value: values,
})}
</div>
<div data-test-subj="hover-actions-filter-out">
{getFilterOutValueButton({
field,
filterManager,
keyboardEvent: stKeyboardEvent,
onFilterAdded,
ownFocus,
showTooltip: true,
value: values,
})}
</div>
</>
)}
{toggleColumn && (
<div data-test-subj="hover-actions-toggle-column">
{getColumnToggleButton({
field,
isDisabled: isObjectArray && dataType !== 'geo_point',
isObjectArray,
keyboardEvent: stKeyboardEvent,
ownFocus,
showTooltip: true,
toggleColumn,
value: values,
})}
</div>
)}
{showFilters && (draggableId != null || !isEmpty(dataProvider)) && (
<div data-test-subj="hover-actions-add-timeline">
{getAddToTimelineButton({
dataProvider,
draggableId,
field,
keyboardEvent: stKeyboardEvent,
ownFocus,
showTooltip: true,
value: values,
})}
</div>
)}
{allowTopN({
browserField: getAllFieldsByName(browserFields)[field],
fieldName: field,
}) && (
<ShowTopNButton
data-test-subj="hover-actions-show-top-n"
field={field}
onClick={toggleTopN}
onFilterAdded={onFilterAdded}
ownFocus={ownFocus}
showTopN={showTopN}
timelineId={timelineId}
value={values}
/>
)}
{field != null && (
<div data-test-subj="hover-actions-copy-button">
{getCopyButton({
field,
isHoverAction: true,
keyboardEvent: stKeyboardEvent,
ownFocus,
showTooltip: true,
value: values,
})}
</div>
)}
{enableOverflowButton ? overflowActionItems : allActionItems}
</StyledHoverActionsContainer>
</EuiFocusTrap>
);

View file

@ -0,0 +1,314 @@
/*
* 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 { EuiContextMenuItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import { DraggableId } from 'react-beautiful-dnd';
import { isEmpty } from 'lodash';
import { useKibana } from '../../lib/kibana';
import { getAllFieldsByName } from '../../containers/source';
import { allowTopN } from './utils';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { ColumnHeaderOptions, DataProvider, TimelineId } from '../../../../common/types/timeline';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { useSourcererScope } from '../../containers/sourcerer';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { ShowTopNButton } from './actions/show_top_n';
interface UseHoverActionItemsProps {
dataProvider?: DataProvider | DataProvider[];
dataType?: string;
defaultFocusedButtonRef: React.MutableRefObject<HTMLButtonElement | null>;
draggableId?: DraggableId;
enableOverflowButton?: boolean;
field: string;
handleHoverActionClicked: () => void;
isObjectArray: boolean;
isOverflowPopoverOpen?: boolean;
itemsToShow?: number;
onFilterAdded?: () => void;
onOverflowButtonClick?: () => void;
ownFocus: boolean;
showTopN: boolean;
stKeyboardEvent: React.KeyboardEvent<Element> | undefined;
timelineId?: string | null;
toggleColumn?: (column: ColumnHeaderOptions) => void;
toggleTopN: () => void;
values?: string[] | string | null;
}
interface UseHoverActionItems {
overflowActionItems: JSX.Element[];
allActionItems: JSX.Element[];
}
export const useHoverActionItems = ({
dataProvider,
dataType,
defaultFocusedButtonRef,
draggableId,
enableOverflowButton,
field,
handleHoverActionClicked,
isObjectArray,
isOverflowPopoverOpen,
itemsToShow = 2,
onFilterAdded,
onOverflowButtonClick,
ownFocus,
showTopN,
stKeyboardEvent,
timelineId,
toggleColumn,
toggleTopN,
values,
}: UseHoverActionItemsProps): UseHoverActionItems => {
const kibana = useKibana();
const { timelines } = kibana.services;
// Common actions used by the alert table and alert flyout
const {
getAddToTimelineButton,
getColumnToggleButton,
getCopyButton,
getFilterForValueButton,
getFilterOutValueButton,
getOverflowButton,
} = timelines.getHoverActions();
const filterManagerBackup = useMemo(() => kibana.services.data.query.filterManager, [
kibana.services.data.query.filterManager,
]);
const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []);
const { filterManager: activeFilterMananager } = useDeepEqualSelector((state) =>
getManageTimeline(state, timelineId ?? '')
);
const filterManager = useMemo(
() => (timelineId === TimelineId.active ? activeFilterMananager : filterManagerBackup),
[timelineId, activeFilterMananager, filterManagerBackup]
);
// Regarding data from useManageTimeline:
// * `indexToAdd`, which enables the alerts index to be appended to
// the `indexPattern` returned by `useWithSource`, may only be populated when
// this component is rendered in the context of the active timeline. This
// behavior enables the 'All events' view by appending the alerts index
// to the index pattern.
const activeScope: SourcererScopeName =
timelineId === TimelineId.active
? SourcererScopeName.timeline
: timelineId != null &&
[TimelineId.detectionsPage, TimelineId.detectionsRulesDetailsPage].includes(
timelineId as TimelineId
)
? SourcererScopeName.detections
: SourcererScopeName.default;
const { browserFields } = useSourcererScope(activeScope);
/*
* In the case of `DisableOverflowButton`, we show filters only when topN is NOT opened. As after topN button is clicked, the chart panel replace current hover actions in the hover actions' popover, so we have to hide all the actions.
* in the case of `EnableOverflowButton`, we only need to hide all the items in the overflow popover as the chart's panel opens in the overflow popover, so non-overflowed actions are not affected.
*/
const showFilters =
values != null && (enableOverflowButton || (!showTopN && !enableOverflowButton));
const allItems = useMemo(
() =>
[
showFilters ? (
<div data-test-subj="hover-actions-filter-for" key="hover-actions-filter-for">
{getFilterForValueButton({
defaultFocusedButtonRef,
field,
filterManager,
keyboardEvent: stKeyboardEvent,
onClick: handleHoverActionClicked,
onFilterAdded,
ownFocus,
showTooltip: enableOverflowButton ? false : true,
value: values,
})}
</div>
) : null,
showFilters ? (
<div data-test-subj="hover-actions-filter-out" key="hover-actions-filter-out">
{getFilterOutValueButton({
field,
filterManager,
keyboardEvent: stKeyboardEvent,
onFilterAdded,
ownFocus,
onClick: handleHoverActionClicked,
showTooltip: enableOverflowButton ? false : true,
value: values,
})}
</div>
) : null,
toggleColumn ? (
<div data-test-subj="hover-actions-toggle-column" key="hover-actions-toggle-column">
{getColumnToggleButton({
Component: enableOverflowButton ? EuiContextMenuItem : undefined,
field,
isDisabled: isObjectArray && dataType !== 'geo_point',
isObjectArray,
keyboardEvent: stKeyboardEvent,
ownFocus,
onClick: handleHoverActionClicked,
showTooltip: enableOverflowButton ? false : true,
toggleColumn,
value: values,
})}
</div>
) : null,
values != null && (draggableId != null || !isEmpty(dataProvider)) ? (
<div data-test-subj="hover-actions-add-timeline" key="hover-actions-add-timeline">
{getAddToTimelineButton({
Component: enableOverflowButton ? EuiContextMenuItem : undefined,
dataProvider,
draggableId,
field,
keyboardEvent: stKeyboardEvent,
ownFocus,
onClick: handleHoverActionClicked,
showTooltip: enableOverflowButton ? false : true,
value: values,
})}
</div>
) : null,
allowTopN({
browserField: getAllFieldsByName(browserFields)[field],
fieldName: field,
}) ? (
<ShowTopNButton
Component={enableOverflowButton ? EuiContextMenuItem : undefined}
data-test-subj="hover-actions-show-top-n"
field={field}
key="hover-actions-show-top-n"
onClick={toggleTopN}
onFilterAdded={onFilterAdded}
ownFocus={ownFocus}
showTopN={showTopN}
showTooltip={enableOverflowButton ? false : true}
timelineId={timelineId}
value={values}
/>
) : null,
field != null ? (
<div data-test-subj="hover-actions-copy-button" key="hover-actions-copy-button">
{getCopyButton({
Component: enableOverflowButton ? EuiContextMenuItem : undefined,
field,
isHoverAction: true,
keyboardEvent: stKeyboardEvent,
ownFocus,
onClick: handleHoverActionClicked,
showTooltip: enableOverflowButton ? false : true,
value: values,
})}
</div>
) : null,
].filter((item) => {
return item != null;
}),
[
browserFields,
dataProvider,
dataType,
defaultFocusedButtonRef,
draggableId,
enableOverflowButton,
field,
filterManager,
getAddToTimelineButton,
getColumnToggleButton,
getCopyButton,
getFilterForValueButton,
getFilterOutValueButton,
handleHoverActionClicked,
isObjectArray,
onFilterAdded,
ownFocus,
showFilters,
showTopN,
stKeyboardEvent,
timelineId,
toggleColumn,
toggleTopN,
values,
]
) as JSX.Element[];
const overflowBtn = useMemo(
() => (
<ShowTopNButton
Component={enableOverflowButton ? EuiContextMenuItem : undefined}
data-test-subj="hover-actions-show-top-n"
field={field}
key="hover-actions-show-top-n"
onClick={toggleTopN}
onFilterAdded={onFilterAdded}
ownFocus={ownFocus}
showTopN={showTopN}
showTooltip={enableOverflowButton ? false : true}
timelineId={timelineId}
value={values}
/>
),
[enableOverflowButton, field, onFilterAdded, ownFocus, showTopN, timelineId, toggleTopN, values]
);
const overflowActionItems = useMemo(
() =>
[
...allItems.slice(0, itemsToShow),
...(enableOverflowButton && itemsToShow > 0
? [
getOverflowButton({
closePopOver: handleHoverActionClicked,
field,
keyboardEvent: stKeyboardEvent,
ownFocus,
onClick: onOverflowButtonClick,
showTooltip: enableOverflowButton ? false : true,
value: values,
items: showTopN ? [overflowBtn] : allItems.slice(itemsToShow),
isOverflowPopoverOpen: !!isOverflowPopoverOpen,
}),
]
: []),
].filter((item) => {
return item != null;
}),
[
allItems,
enableOverflowButton,
field,
getOverflowButton,
handleHoverActionClicked,
isOverflowPopoverOpen,
itemsToShow,
onOverflowButtonClick,
overflowBtn,
ownFocus,
showTopN,
stKeyboardEvent,
values,
]
);
const allActionItems = useMemo(() => (showTopN ? [overflowBtn] : allItems), [
allItems,
overflowBtn,
showTopN,
]);
return {
overflowActionItems,
allActionItems,
};
};

View file

@ -77,6 +77,10 @@ export const useHoverActions = ({
});
}, [handleClosePopOverTrigger]);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
const hoverContent = useMemo(() => {
// display links as additional content in the hover menu to enable keyboard
// navigation of links (when the draggable contains them):
@ -92,6 +96,7 @@ export const useHoverActions = ({
return (
<HoverActions
additionalContent={additionalContent}
closeTopN={closeTopN}
closePopOver={handleClosePopOverTrigger}
dataProvider={dataProvider}
draggableId={isDraggable ? getDraggableId(dataProvider.id) : undefined}
@ -112,6 +117,7 @@ export const useHoverActions = ({
/>
);
}, [
closeTopN,
dataProvider,
handleClosePopOverTrigger,
hoverActionsOwnFocus,

View file

@ -108,9 +108,14 @@ export const FieldName = React.memo<{
});
}, [handleClosePopOverTrigger]);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
const hoverContent = useMemo(
() => (
<HoverActions
closeTopN={closeTopN}
closePopOver={handleClosePopOverTrigger}
field={fieldId}
isObjectArray={false}
@ -122,6 +127,7 @@ export const FieldName = React.memo<{
/>
),
[
closeTopN,
fieldId,
handleClosePopOverTrigger,
hoverActionsOwnFocus,

View file

@ -261,7 +261,7 @@ Array [
-ms-flex: 1;
flex: 1;
overflow: hidden;
padding: 16px;
padding: 0 16px 16px;
}
<EuiFlyout
@ -295,10 +295,10 @@ Array [
timelineId="test"
>
<EuiFlyoutHeader
hasBorder={true}
hasBorder={false}
>
<div
className="euiFlyoutHeader euiFlyoutHeader--hasBorder"
className="euiFlyoutHeader"
>
<ExpandableEventTitle
isAlert={false}
@ -528,7 +528,7 @@ Array [
-ms-flex: 1;
flex: 1;
overflow: hidden;
padding: 16px;
padding: 0 16px 16px;
}
<div
@ -556,10 +556,10 @@ Array [
timelineId="test"
>
<EuiFlyoutHeader
hasBorder={true}
hasBorder={false}
>
<div
className="euiFlyoutHeader euiFlyoutHeader--hasBorder"
className="euiFlyoutHeader"
>
<ExpandableEventTitle
isAlert={false}

View file

@ -43,7 +43,7 @@ const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflowContent {
flex: 1;
overflow: hidden;
padding: ${({ theme }) => `${theme.eui.paddingSizes.m}`};
padding: ${({ theme }) => `0 ${theme.eui.paddingSizes.m} ${theme.eui.paddingSizes.m}`};
}
}
`;
@ -154,7 +154,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
return isFlyoutView ? (
<>
<EuiFlyoutHeader hasBorder>
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
{isHostIsolationPanelOpen ? (
backToAlertDetailsLink
) : (

View file

@ -6,7 +6,7 @@
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { EuiContextMenuItem, EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { DraggableId } from 'react-beautiful-dnd';
import { useDispatch } from 'react-redux';
@ -45,7 +45,7 @@ const useGetHandleStartDragToTimeline = ({
export interface AddToTimelineButtonProps extends HoverActionComponentProps {
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem;
draggableId?: DraggableId;
dataProvider?: DataProvider[] | DataProvider;
}
@ -53,13 +53,13 @@ export interface AddToTimelineButtonProps extends HoverActionComponentProps {
const AddToTimelineButton: React.FC<AddToTimelineButtonProps> = React.memo(
({
Component,
closePopOver,
dataProvider,
defaultFocusedButtonRef,
draggableId,
field,
keyboardEvent,
ownFocus,
onClick,
showTooltip = false,
value,
}) => {
@ -84,10 +84,10 @@ const AddToTimelineButton: React.FC<AddToTimelineButtonProps> = React.memo(
});
}
if (closePopOver != null) {
closePopOver();
if (onClick != null) {
onClick();
}
}, [addSuccess, closePopOver, dataProvider, dispatch, draggableId, startDragToTimeline]);
}, [addSuccess, onClick, dataProvider, dispatch, draggableId, startDragToTimeline]);
useEffect(() => {
if (!ownFocus) {
@ -106,6 +106,7 @@ const AddToTimelineButton: React.FC<AddToTimelineButtonProps> = React.memo(
aria-label={i18n.ADD_TO_TIMELINE}
buttonRef={defaultFocusedButtonRef}
data-test-subj="add-to-timeline"
icon="timeline"
iconType="timeline"
onClick={handleStartDragToTimeline}
title={i18n.ADD_TO_TIMELINE}

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import React, { useCallback, useEffect } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo } from 'react';
import { EuiContextMenuItem, EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { stopPropagationAndPreventDefault } from '../../../../common';
@ -33,6 +33,7 @@ export const NESTED_COLUMN = (field: string) =>
export const COLUMN_TOGGLE_KEYBOARD_SHORTCUT = 'i';
export interface ColumnToggleProps extends HoverActionComponentProps {
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem;
isDisabled: boolean;
isObjectArray: boolean;
toggleColumn: (column: ColumnHeaderOptions) => void;
@ -40,13 +41,14 @@ export interface ColumnToggleProps extends HoverActionComponentProps {
const ColumnToggleButton: React.FC<ColumnToggleProps> = React.memo(
({
closePopOver,
Component,
defaultFocusedButtonRef,
field,
isDisabled,
isObjectArray,
keyboardEvent,
ownFocus,
onClick,
showTooltip = false,
toggleColumn,
value,
@ -59,10 +61,10 @@ const ColumnToggleButton: React.FC<ColumnToggleProps> = React.memo(
id: field,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
});
if (closePopOver != null) {
closePopOver();
if (onClick != null) {
onClick();
}
}, [closePopOver, field, toggleColumn]);
}, [onClick, field, toggleColumn]);
useEffect(() => {
if (!ownFocus) {
@ -74,6 +76,36 @@ const ColumnToggleButton: React.FC<ColumnToggleProps> = React.memo(
}
}, [handleToggleColumn, keyboardEvent, ownFocus]);
const button = useMemo(
() =>
Component ? (
<Component
aria-label={label}
data-test-subj={`toggle-field-${field}`}
icon="listAdd"
iconType="listAdd"
onClick={handleToggleColumn}
title={label}
>
{label}
</Component>
) : (
<EuiButtonIcon
aria-label={label}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj={`toggle-field-${field}`}
data-colindex={1}
disabled={isDisabled}
id={field}
iconSize="s"
iconType="listAdd"
onClick={handleToggleColumn}
/>
),
[Component, defaultFocusedButtonRef, field, handleToggleColumn, isDisabled, label]
);
return showTooltip ? (
<EuiToolTip
content={
@ -88,32 +120,10 @@ const ColumnToggleButton: React.FC<ColumnToggleProps> = React.memo(
/>
}
>
<EuiButtonIcon
aria-label={label}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj={`toggle-field-${field}`}
data-colindex={1}
disabled={isDisabled}
id={field}
iconSize="s"
iconType="listAdd"
onClick={handleToggleColumn}
/>
{button}
</EuiToolTip>
) : (
<EuiButtonIcon
aria-label={label}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj={`toggle-field-${field}`}
data-colindex={1}
disabled={isDisabled}
id={field}
iconSize="s"
iconType="listAdd"
onClick={handleToggleColumn}
/>
button
);
}
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { EuiContextMenuItem, EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import copy from 'copy-to-clipboard';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
@ -26,12 +26,12 @@ export const COPY_TO_CLIPBOARD_KEYBOARD_SHORTCUT = 'c';
export interface CopyProps extends HoverActionComponentProps {
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem;
isHoverAction?: boolean;
}
const CopyButton: React.FC<CopyProps> = React.memo(
({ Component, closePopOver, field, isHoverAction, keyboardEvent, ownFocus, value }) => {
({ Component, field, isHoverAction, onClick, keyboardEvent, ownFocus, value }) => {
const { addSuccess } = useAppToasts();
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@ -46,28 +46,32 @@ const CopyButton: React.FC<CopyProps> = React.memo(
if (copyToClipboardButton != null) {
copyToClipboardButton.click();
}
if (closePopOver != null) {
closePopOver();
if (onClick != null) {
onClick();
}
}
}, [closePopOver, keyboardEvent, ownFocus]);
}, [onClick, keyboardEvent, ownFocus]);
const text = useMemo(() => `${field}${value != null ? `: "${value}"` : ''}`, [field, value]);
const onClick = useCallback(() => {
const handleOnClick = useCallback(() => {
const isSuccess = copy(text, { debug: true });
if (onClick != null) {
onClick();
}
if (isSuccess) {
addSuccess(SUCCESS_TOAST_TITLE(field), { toastLifeTimeMs: 800 });
}
}, [addSuccess, field, text]);
}, [addSuccess, field, onClick, text]);
return Component ? (
<Component
aria-label={COPY_TO_CLIPBOARD}
data-test-subj="copy-to-clipboard"
icon="copyClipboard"
iconType="copyClipboard"
onClick={onClick}
onClick={handleOnClick}
title={COPY_TO_CLIPBOARD}
>
{COPY_TO_CLIPBOARD}

View file

@ -24,13 +24,13 @@ export type FilterForValueProps = HoverActionComponentProps & FilterValueFnArgs;
const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
({
Component,
closePopOver,
defaultFocusedButtonRef,
field,
filterManager,
keyboardEvent,
onFilterAdded,
ownFocus,
onClick,
showTooltip = false,
value,
}) => {
@ -49,10 +49,11 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
onFilterAdded();
}
}
if (closePopOver != null) {
closePopOver();
if (onClick != null) {
onClick();
}
}, [closePopOver, field, filterManager, onFilterAdded, value]);
}, [field, filterManager, onClick, onFilterAdded, value]);
useEffect(() => {
if (!ownFocus) {

View file

@ -23,13 +23,13 @@ export const FILTER_OUT_VALUE_KEYBOARD_SHORTCUT = 'o';
const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnArgs> = React.memo(
({
Component,
closePopOver,
defaultFocusedButtonRef,
field,
filterManager,
keyboardEvent,
onFilterAdded,
ownFocus,
onClick,
showTooltip = false,
value,
}) => {
@ -50,10 +50,10 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
onFilterAdded();
}
}
if (closePopOver != null) {
closePopOver();
if (onClick != null) {
onClick();
}
}, [closePopOver, field, filterManager, onFilterAdded, value]);
}, [field, filterManager, onClick, onFilterAdded, value]);
useEffect(() => {
if (!ownFocus) {

View file

@ -0,0 +1,135 @@
/*
* 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, { useEffect, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonIcon,
EuiButtonEmpty,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import { stopPropagationAndPreventDefault } from '../../../../common';
import { TooltipWithKeyboardShortcut } from '../../tooltip_with_keyboard_shortcut';
import { getAdditionalScreenReaderOnlyContext } from '../utils';
import { HoverActionComponentProps } from './types';
export const MORE_ACTIONS = i18n.translate('xpack.timelines.hoverActions.moreActions', {
defaultMessage: 'More actions',
});
export const FILTER_OUT_VALUE_KEYBOARD_SHORTCUT = 'm';
export interface OverflowButtonProps extends HoverActionComponentProps {
closePopOver: () => void;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem;
items: JSX.Element[];
isOverflowPopoverOpen: boolean;
}
const OverflowButton: React.FC<OverflowButtonProps> = React.memo(
({
closePopOver,
Component,
defaultFocusedButtonRef,
field,
items,
isOverflowPopoverOpen,
keyboardEvent,
ownFocus,
onClick,
showTooltip = false,
value,
}) => {
useEffect(() => {
if (!ownFocus) {
return;
}
if (keyboardEvent?.key === FILTER_OUT_VALUE_KEYBOARD_SHORTCUT) {
stopPropagationAndPreventDefault(keyboardEvent);
if (onClick != null) {
onClick();
}
}
}, [keyboardEvent, onClick, ownFocus]);
const popover = useMemo(
() => (
<EuiPopover
button={
Component ? (
<Component
aria-label={MORE_ACTIONS}
buttonRef={defaultFocusedButtonRef}
data-test-subj={`more-actions-${field}`}
icon="boxesHorizontal"
iconType="boxesHorizontal"
onClick={onClick}
title={MORE_ACTIONS}
>
{MORE_ACTIONS}
</Component>
) : (
<EuiButtonIcon
aria-label={MORE_ACTIONS}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj={`more-actions-${field}`}
iconSize="s"
iconType="boxesHorizontal"
onClick={onClick}
/>
)
}
isOpen={isOverflowPopoverOpen}
closePopover={closePopOver}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
),
[
Component,
defaultFocusedButtonRef,
field,
onClick,
isOverflowPopoverOpen,
closePopOver,
items,
]
);
return showTooltip ? (
<EuiToolTip
content={
<TooltipWithKeyboardShortcut
additionalScreenReaderOnlyContext={getAdditionalScreenReaderOnlyContext({
field,
value,
})}
content={MORE_ACTIONS}
shortcut={FILTER_OUT_VALUE_KEYBOARD_SHORTCUT}
showShortcut={ownFocus}
/>
}
>
{popover}
</EuiToolTip>
) : (
popover
);
}
);
OverflowButton.displayName = 'OverflowButton';
// eslint-disable-next-line import/no-default-export
export { OverflowButton as default };

View file

@ -18,11 +18,11 @@ export interface FilterValueFnArgs {
}
export interface HoverActionComponentProps {
closePopOver?: () => void;
defaultFocusedButtonRef?: EuiButtonIconPropsForButton['buttonRef'];
field: string;
keyboardEvent?: React.KeyboardEvent;
ownFocus: boolean;
onClick?: () => void;
showTooltip?: boolean;
value?: string[] | string | null;
}

View file

@ -13,6 +13,7 @@ import type { AddToTimelineButtonProps } from './actions/add_to_timeline';
import type { ColumnToggleProps } from './actions/column_toggle';
import type { CopyProps } from './actions/copy';
import type { HoverActionComponentProps, FilterValueFnArgs } from './actions/types';
import type { OverflowButtonProps } from './actions/overflow';
export interface HoverActionsConfig {
getAddToTimelineButton: (
@ -26,6 +27,7 @@ export interface HoverActionsConfig {
getFilterOutValueButton: (
props: HoverActionComponentProps & FilterValueFnArgs
) => ReactElement<HoverActionComponentProps & FilterValueFnArgs>;
getOverflowButton: (props: OverflowButtonProps) => ReactElement<HoverActionComponentProps>;
}
const AddToTimelineButtonLazy = React.lazy(() => import('./actions/add_to_timeline'));
@ -77,10 +79,20 @@ const getFilterOutValueButtonLazy = (props: HoverActionComponentProps & FilterVa
);
};
const OverflowButtonLazy = React.lazy(() => import('./actions/overflow'));
const getOverflowButtonLazy = (props: OverflowButtonProps) => {
return (
<React.Suspense fallback={<EuiLoadingSpinner />}>
<OverflowButtonLazy {...props} />
</React.Suspense>
);
};
export const getHoverActions = (store?: Store): HoverActionsConfig => ({
getAddToTimelineButton: getAddToTimelineButtonLazy.bind(null, store!),
getColumnToggleButton: getColumnToggleButtonLazy,
getCopyButton: getCopyButtonLazy,
getFilterForValueButton: getFilterForValueButtonLazy,
getFilterOutValueButton: getFilterOutValueButtonLazy,
getOverflowButton: getOverflowButtonLazy,
});

View file

@ -13,4 +13,9 @@ export const mockHoverActions = {
getCopyButton: () => <>{'Copy button'}</>,
getFilterForValueButton: () => <>{'Filter button'}</>,
getFilterOutValueButton: () => <>{'Filter out button'}</>,
getOverflowButton: (props: { field: string }) => (
<div data-test-subj={`more-actions-${props.field}`} {...props}>
{'Overflow button'}
</div>
),
};