mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 02:09:32 -04:00
update copy styling (#79313)
This commit is contained in:
parent
745b5c1271
commit
4d4e53f7c7
8 changed files with 192 additions and 12 deletions
|
@ -4,7 +4,7 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
|
import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
|
||||||
|
import copy from 'copy-to-clipboard';
|
||||||
import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin';
|
import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin';
|
||||||
import { Simulator } from '../test_utilities/simulator';
|
import { Simulator } from '../test_utilities/simulator';
|
||||||
// Extend jest with a custom matcher
|
// Extend jest with a custom matcher
|
||||||
|
@ -14,6 +14,10 @@ import { urlSearch } from '../test_utilities/url_search';
|
||||||
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
|
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
|
||||||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||||
|
|
||||||
|
jest.mock('copy-to-clipboard', () => {
|
||||||
|
return jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
describe(`Resolver: when analyzing a tree with no ancestors and two children and two related registry event on the origin, and when the component instance ID is ${resolverComponentInstanceID}`, () => {
|
describe(`Resolver: when analyzing a tree with no ancestors and two children and two related registry event on the origin, and when the component instance ID is ${resolverComponentInstanceID}`, () => {
|
||||||
/**
|
/**
|
||||||
* Get (or lazily create and get) the simulator.
|
* Get (or lazily create and get) the simulator.
|
||||||
|
@ -112,6 +116,16 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
|
||||||
wordBreaks: 2,
|
wordBreaks: 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('should allow all node details to be copied', async () => {
|
||||||
|
const copyableFields = await simulator().resolve('resolver:panel:copyable-field');
|
||||||
|
|
||||||
|
copyableFields?.map((copyableField) => {
|
||||||
|
copyableField.simulate('mouseenter');
|
||||||
|
simulator().testSubject('clipboard').last().simulate('click');
|
||||||
|
expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object));
|
||||||
|
copyableField.simulate('mouseleave');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, {
|
const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, {
|
||||||
|
@ -158,6 +172,19 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
|
||||||
).toYieldEqualTo(3);
|
).toYieldEqualTo(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be able to copy the timestamps for all 3 nodes', async () => {
|
||||||
|
const copyableFields = await simulator().resolve('resolver:panel:copyable-field');
|
||||||
|
|
||||||
|
expect(copyableFields?.length).toBe(3);
|
||||||
|
|
||||||
|
copyableFields?.map((copyableField) => {
|
||||||
|
copyableField.simulate('mouseenter');
|
||||||
|
simulator().testSubject('clipboard').last().simulate('click');
|
||||||
|
expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object));
|
||||||
|
copyableField.simulate('mouseleave');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when there is an item in the node list and its text has been clicked', () => {
|
describe('when there is an item in the node list and its text has been clicked', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const nodeLinks = await simulator().resolve('resolver:node-list:node-link:title');
|
const nodeLinks = await simulator().resolve('resolver:node-list:node-link:title');
|
||||||
|
@ -239,6 +266,34 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
|
||||||
)
|
)
|
||||||
).toYieldEqualTo(2);
|
).toYieldEqualTo(2);
|
||||||
});
|
});
|
||||||
|
describe('and when the first event link is clicked', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const link = await simulator().resolve(
|
||||||
|
'resolver:panel:node-events-in-category:event-link'
|
||||||
|
);
|
||||||
|
const first = link?.first();
|
||||||
|
expect(first).toBeTruthy();
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
first.simulate('click', { button: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('should show the event detail view', async () => {
|
||||||
|
await expect(
|
||||||
|
simulator().map(() => simulator().testSubject('resolver:panel:event-detail').length)
|
||||||
|
).toYieldEqualTo(1);
|
||||||
|
});
|
||||||
|
it('should allow all fields to be copied', async () => {
|
||||||
|
const copyableFields = await simulator().resolve('resolver:panel:copyable-field');
|
||||||
|
|
||||||
|
copyableFields?.map((copyableField) => {
|
||||||
|
copyableField.simulate('mouseenter');
|
||||||
|
simulator().testSubject('clipboard').last().simulate('click');
|
||||||
|
expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object));
|
||||||
|
copyableField.simulate('mouseleave');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('and when the node list link has been clicked', () => {
|
describe('and when the node list link has been clicked', () => {
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable react/display-name */
|
||||||
|
|
||||||
|
import { EuiToolTip, EuiPopover } from '@elastic/eui';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import React, { memo, useState, useContext } from 'react';
|
||||||
|
import { WithCopyToClipboard } from '../../../common/lib/clipboard/with_copy_to_clipboard';
|
||||||
|
import { useColors } from '../use_colors';
|
||||||
|
import { ResolverPanelContext } from './panel_context';
|
||||||
|
|
||||||
|
interface StyledCopyableField {
|
||||||
|
readonly backgroundColor: string;
|
||||||
|
readonly activeBackgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledCopyableField = styled.div<StyledCopyableField>`
|
||||||
|
background-color: ${(props) => props.backgroundColor};
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${(props) => props.activeBackgroundColor};
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CopyablePanelField = memo(
|
||||||
|
({ textToCopy, content }: { textToCopy: string; content: JSX.Element | string }) => {
|
||||||
|
const { linkColor, copyableBackground } = useColors();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const panelContext = useContext(ResolverPanelContext);
|
||||||
|
|
||||||
|
const onMouseEnter = () => setIsOpen(true);
|
||||||
|
|
||||||
|
const ButtonContent = memo(() => (
|
||||||
|
<StyledCopyableField
|
||||||
|
backgroundColor={panelContext.isHoveringInPanel ? copyableBackground : 'transparent'}
|
||||||
|
data-test-subj="resolver:panel:copyable-field"
|
||||||
|
activeBackgroundColor={linkColor}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</StyledCopyableField>
|
||||||
|
));
|
||||||
|
|
||||||
|
const onMouseLeave = () => setIsOpen(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onMouseLeave={onMouseLeave}>
|
||||||
|
<EuiPopover
|
||||||
|
anchorPosition={'downCenter'}
|
||||||
|
button={<ButtonContent />}
|
||||||
|
closePopover={onMouseLeave}
|
||||||
|
hasArrow={false}
|
||||||
|
isOpen={isOpen}
|
||||||
|
panelPaddingSize="s"
|
||||||
|
>
|
||||||
|
<EuiToolTip
|
||||||
|
content={i18n.translate('xpack.securitySolution.resolver.panel.copyToClipboard', {
|
||||||
|
defaultMessage: 'Copy to Clipboard',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<WithCopyToClipboard
|
||||||
|
data-test-subj="resolver:panel:copy-to-clipboard"
|
||||||
|
text={textToCopy}
|
||||||
|
titleSummary={textToCopy}
|
||||||
|
/>
|
||||||
|
</EuiToolTip>
|
||||||
|
</EuiPopover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
|
@ -21,6 +21,7 @@ import {
|
||||||
GeneratedText,
|
GeneratedText,
|
||||||
noTimestampRetrievedText,
|
noTimestampRetrievedText,
|
||||||
} from './panel_content_utilities';
|
} from './panel_content_utilities';
|
||||||
|
import { CopyablePanelField } from './copyable_panel_field';
|
||||||
import { Breadcrumbs } from './breadcrumbs';
|
import { Breadcrumbs } from './breadcrumbs';
|
||||||
import * as eventModel from '../../../../common/endpoint/models/event';
|
import * as eventModel from '../../../../common/endpoint/models/event';
|
||||||
import * as selectors from '../../store/selectors';
|
import * as selectors from '../../store/selectors';
|
||||||
|
@ -156,7 +157,12 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) {
|
||||||
namespace: <GeneratedText>{key}</GeneratedText>,
|
namespace: <GeneratedText>{key}</GeneratedText>,
|
||||||
descriptions: deepObjectEntries(value).map(([path, fieldValue]) => ({
|
descriptions: deepObjectEntries(value).map(([path, fieldValue]) => ({
|
||||||
title: <GeneratedText>{path.join('.')}</GeneratedText>,
|
title: <GeneratedText>{path.join('.')}</GeneratedText>,
|
||||||
description: <GeneratedText>{String(fieldValue)}</GeneratedText>,
|
description: (
|
||||||
|
<CopyablePanelField
|
||||||
|
textToCopy={String(fieldValue)}
|
||||||
|
content={<GeneratedText>{String(fieldValue)}</GeneratedText>}
|
||||||
|
/>
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
returnValue.push(section);
|
returnValue.push(section);
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import * as selectors from '../../store/selectors';
|
import * as selectors from '../../store/selectors';
|
||||||
import { NodeEventsInCategory } from './node_events_of_type';
|
import { NodeEventsInCategory } from './node_events_of_type';
|
||||||
|
@ -15,33 +15,48 @@ import { NodeDetail } from './node_detail';
|
||||||
import { NodeList } from './node_list';
|
import { NodeList } from './node_list';
|
||||||
import { EventDetail } from './event_detail';
|
import { EventDetail } from './event_detail';
|
||||||
import { PanelViewAndParameters } from '../../types';
|
import { PanelViewAndParameters } from '../../types';
|
||||||
|
import { ResolverPanelContext } from './panel_context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search)
|
* Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PanelRouter = memo(function () {
|
export const PanelRouter = memo(function () {
|
||||||
const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters);
|
const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters);
|
||||||
|
const [isHoveringInPanel, updateIsHoveringInPanel] = useState(false);
|
||||||
|
|
||||||
|
const triggerPanelHover = () => updateIsHoveringInPanel(true);
|
||||||
|
const stopPanelHover = () => updateIsHoveringInPanel(false);
|
||||||
|
|
||||||
|
/* The default 'Event List' / 'List of all processes' view */
|
||||||
|
let panelViewToRender = <NodeList />;
|
||||||
|
|
||||||
if (params.panelView === 'nodeDetail') {
|
if (params.panelView === 'nodeDetail') {
|
||||||
return <NodeDetail nodeID={params.panelParameters.nodeID} />;
|
panelViewToRender = <NodeDetail nodeID={params.panelParameters.nodeID} />;
|
||||||
} else if (params.panelView === 'nodeEvents') {
|
} else if (params.panelView === 'nodeEvents') {
|
||||||
return <NodeEvents nodeID={params.panelParameters.nodeID} />;
|
panelViewToRender = <NodeEvents nodeID={params.panelParameters.nodeID} />;
|
||||||
} else if (params.panelView === 'nodeEventsInCategory') {
|
} else if (params.panelView === 'nodeEventsInCategory') {
|
||||||
return (
|
panelViewToRender = (
|
||||||
<NodeEventsInCategory
|
<NodeEventsInCategory
|
||||||
nodeID={params.panelParameters.nodeID}
|
nodeID={params.panelParameters.nodeID}
|
||||||
eventCategory={params.panelParameters.eventCategory}
|
eventCategory={params.panelParameters.eventCategory}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (params.panelView === 'eventDetail') {
|
} else if (params.panelView === 'eventDetail') {
|
||||||
return (
|
panelViewToRender = (
|
||||||
<EventDetail
|
<EventDetail
|
||||||
nodeID={params.panelParameters.nodeID}
|
nodeID={params.panelParameters.nodeID}
|
||||||
eventID={params.panelParameters.eventID}
|
eventID={params.panelParameters.eventID}
|
||||||
eventCategory={params.panelParameters.eventCategory}
|
eventCategory={params.panelParameters.eventCategory}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
/* The default 'Event List' / 'List of all processes' view */
|
|
||||||
return <NodeList />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResolverPanelContext.Provider value={{ isHoveringInPanel }}>
|
||||||
|
<div onMouseEnter={triggerPanelHover} onMouseLeave={stopPanelHover}>
|
||||||
|
{panelViewToRender}
|
||||||
|
</div>
|
||||||
|
</ResolverPanelContext.Provider>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { StyledDescriptionList, StyledTitle } from './styles';
|
||||||
import * as selectors from '../../store/selectors';
|
import * as selectors from '../../store/selectors';
|
||||||
import * as eventModel from '../../../../common/endpoint/models/event';
|
import * as eventModel from '../../../../common/endpoint/models/event';
|
||||||
import { GeneratedText } from './panel_content_utilities';
|
import { GeneratedText } from './panel_content_utilities';
|
||||||
|
import { CopyablePanelField } from './copyable_panel_field';
|
||||||
import { Breadcrumbs } from './breadcrumbs';
|
import { Breadcrumbs } from './breadcrumbs';
|
||||||
import { processPath, processPID } from '../../models/process_event';
|
import { processPath, processPID } from '../../models/process_event';
|
||||||
import { CubeForProcess } from './cube_for_process';
|
import { CubeForProcess } from './cube_for_process';
|
||||||
|
@ -131,7 +132,12 @@ const NodeDetailView = memo(function ({
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
description: <GeneratedText>{String(entry.description)}</GeneratedText>,
|
description: (
|
||||||
|
<CopyablePanelField
|
||||||
|
textToCopy={String(entry.description)}
|
||||||
|
content={<GeneratedText>{String(entry.description)}</GeneratedText>}
|
||||||
|
/>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,7 @@ import { SafeResolverEvent } from '../../../../common/endpoint/types';
|
||||||
import { ResolverAction } from '../../store/actions';
|
import { ResolverAction } from '../../store/actions';
|
||||||
import { useFormattedDate } from './use_formatted_date';
|
import { useFormattedDate } from './use_formatted_date';
|
||||||
import { getEmptyTagValue } from '../../../common/components/empty_value';
|
import { getEmptyTagValue } from '../../../common/components/empty_value';
|
||||||
|
import { CopyablePanelField } from './copyable_panel_field';
|
||||||
|
|
||||||
interface ProcessTableView {
|
interface ProcessTableView {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -214,5 +215,9 @@ function NodeDetailLink({
|
||||||
const NodeDetailTimestamp = memo(({ eventDate }: { eventDate: Date | undefined }) => {
|
const NodeDetailTimestamp = memo(({ eventDate }: { eventDate: Date | undefined }) => {
|
||||||
const formattedDate = useFormattedDate(eventDate);
|
const formattedDate = useFormattedDate(eventDate);
|
||||||
|
|
||||||
return formattedDate ? <>{formattedDate}</> : getEmptyTagValue();
|
return formattedDate ? (
|
||||||
|
<CopyablePanelField textToCopy={formattedDate} content={formattedDate} />
|
||||||
|
) : (
|
||||||
|
getEmptyTagValue()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const ResolverPanelContext = React.createContext({ isHoveringInPanel: false });
|
|
@ -10,10 +10,12 @@ import { useMemo } from 'react';
|
||||||
import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public';
|
import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public';
|
||||||
|
|
||||||
type ResolverColorNames =
|
type ResolverColorNames =
|
||||||
|
| 'copyableBackground'
|
||||||
| 'descriptionText'
|
| 'descriptionText'
|
||||||
| 'full'
|
| 'full'
|
||||||
| 'graphControls'
|
| 'graphControls'
|
||||||
| 'graphControlsBackground'
|
| 'graphControlsBackground'
|
||||||
|
| 'linkColor'
|
||||||
| 'resolverBackground'
|
| 'resolverBackground'
|
||||||
| 'resolverEdge'
|
| 'resolverEdge'
|
||||||
| 'resolverEdgeText'
|
| 'resolverEdgeText'
|
||||||
|
@ -31,6 +33,7 @@ export function useColors(): ColorMap {
|
||||||
const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight;
|
const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight;
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
copyableBackground: theme.euiColorLightShade,
|
||||||
descriptionText: theme.euiTextColor,
|
descriptionText: theme.euiTextColor,
|
||||||
full: theme.euiColorFullShade,
|
full: theme.euiColorFullShade,
|
||||||
graphControls: theme.euiColorDarkestShade,
|
graphControls: theme.euiColorDarkestShade,
|
||||||
|
@ -42,6 +45,7 @@ export function useColors(): ColorMap {
|
||||||
resolverEdgeText: isDarkMode ? theme.euiColorFullShade : theme.euiColorDarkShade,
|
resolverEdgeText: isDarkMode ? theme.euiColorFullShade : theme.euiColorDarkShade,
|
||||||
triggerBackingFill: `${theme.euiColorDanger}${isDarkMode ? '1F' : '0F'}`,
|
triggerBackingFill: `${theme.euiColorDanger}${isDarkMode ? '1F' : '0F'}`,
|
||||||
pillStroke: theme.euiColorLightShade,
|
pillStroke: theme.euiColorLightShade,
|
||||||
|
linkColor: theme.euiLinkColor,
|
||||||
};
|
};
|
||||||
}, [isDarkMode, theme]);
|
}, [isDarkMode, theme]);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue