Skip to content

Commit

Permalink
[Cloud Security] Improve graph node label ellipsis logic (elastic#204580
Browse files Browse the repository at this point in the history
)

## Summary

This PR improves the logic of node's label truncation. It follows the
guidelines from design:

> - Set the maximum width for names to 160px
> - Truncate names to max two lines
> - I recommend truncating names in the middle, as entity IDs often
differ only in their last characters while starting similarly. This
approach will make it easier to identify differences between names
> - Display the full name in a tooltip on hover for better visibility
 

Before:

<img width="150"
src="https://github.com/user-attachments/assets/4bc18b98-1e1b-41df-ac69-79b2adf1f127"
/>
<img width="150"
src="https://github.com/user-attachments/assets/69c335f6-3952-44e8-8836-19eab3bff28f"
/>
<img width="150"
src="https://github.com/user-attachments/assets/4ba65932-5af5-4382-b721-41c6ee7388a8"
/>


After:


https://github.com/user-attachments/assets/2ab04809-7599-45ab-9aaa-ae1fcabdf969


**How to test**

To test this PR you can run

```
yarn storybook cloud_security_posture_packages
```

### Checklist

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Sean Rathier <[email protected]>
Co-authored-by: Brad White <[email protected]>
  • Loading branch information
3 people authored Dec 19, 2024
1 parent dec31c5 commit f316612
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { Handle, Position } from '@xyflow/react';
import type { EntityNodeViewModel, NodeProps } from '../types';
import {
NodeShapeContainer,
NodeLabel,
NodeShapeOnHoverSvg,
NodeShapeSvg,
NodeIcon,
Expand All @@ -20,6 +19,7 @@ import {
} from './styles';
import { DiamondHoverShape, DiamondShape } from './shapes/diamond_shape';
import { NodeExpandButton } from './node_expand_button';
import { Label } from './label';

const NODE_WIDTH = 99;
const NODE_HEIGHT = 98;
Expand Down Expand Up @@ -81,7 +81,7 @@ export const DiamondNode: React.FC<NodeProps> = memo((props: NodeProps) => {
style={HandleStyleOverride}
/>
</NodeShapeContainer>
<NodeLabel>{label ? label : id}</NodeLabel>
<Label text={label ? label : id} />
</>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,18 @@ import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui';
import { Handle, Position } from '@xyflow/react';
import {
NodeShapeContainer,
NodeLabel,
NodeShapeOnHoverSvg,
NodeShapeSvg,
NodeIcon,
NodeButton,
HandleStyleOverride,
NODE_WIDTH,
NODE_HEIGHT,
} from './styles';
import type { EntityNodeViewModel, NodeProps } from '../types';
import { EllipseHoverShape, EllipseShape } from './shapes/ellipse_shape';
import { NodeExpandButton } from './node_expand_button';

const NODE_WIDTH = 90;
const NODE_HEIGHT = 90;
import { Label } from './label';

// eslint-disable-next-line react/display-name
export const EllipseNode: React.FC<NodeProps> = memo((props: NodeProps) => {
Expand Down Expand Up @@ -81,7 +80,7 @@ export const EllipseNode: React.FC<NodeProps> = memo((props: NodeProps) => {
style={HandleStyleOverride}
/>
</NodeShapeContainer>
<NodeLabel>{label ? label : id}</NodeLabel>
<Label text={label ? label : id} />
</>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui';
import { Handle, Position } from '@xyflow/react';
import {
NodeShapeContainer,
NodeLabel,
NodeShapeOnHoverSvg,
NodeShapeSvg,
NodeIcon,
Expand All @@ -20,6 +19,7 @@ import {
import type { EntityNodeViewModel, NodeProps } from '../types';
import { HexagonHoverShape, HexagonShape } from './shapes/hexagon_shape';
import { NodeExpandButton } from './node_expand_button';
import { Label } from './label';

const NODE_WIDTH = 87;
const NODE_HEIGHT = 96;
Expand Down Expand Up @@ -81,7 +81,7 @@ export const HexagonNode: React.FC<NodeProps> = memo((props: NodeProps) => {
style={HandleStyleOverride}
/>
</NodeShapeContainer>
<NodeLabel>{label ? label : id}</NodeLabel>
<Label text={label ? label : id} />
</>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { ThemeProvider } from '@emotion/react';
import { pick } from 'lodash';
import { ReactFlow, Controls, Background } from '@xyflow/react';
import { Story } from '@storybook/react';
import { NodeViewModel } from '../types';
import { HexagonNode, PentagonNode, EllipseNode, RectangleNode, DiamondNode, LabelNode } from '.';

import '@xyflow/react/dist/style.css';

export default {
title: 'Components/Graph Components/Labels',
description: 'CDR - Graph visualization',
argTypes: {
color: {
options: ['primary', 'danger', 'warning'],
control: { type: 'radio' },
},
shape: {
options: ['ellipse', 'hexagon', 'pentagon', 'rectangle', 'diamond', 'label'],
control: { type: 'radio' },
},
expandButtonClick: { action: 'expandButtonClick' },
},
};

const nodeTypes = {
hexagon: HexagonNode,
pentagon: PentagonNode,
ellipse: EllipseNode,
rectangle: RectangleNode,
diamond: DiamondNode,
label: LabelNode,
};

const Template: Story<NodeViewModel> = (args: NodeViewModel) => (
<ThemeProvider theme={{ darkMode: false }}>
<ReactFlow
fitView
attributionPosition={undefined}
nodeTypes={nodeTypes}
nodes={[
{
id: args.id,
type: args.shape,
data: pick(args, ['id', 'label', 'color', 'icon', 'interactive', 'expandButtonClick']),
position: { x: 0, y: 0 },
},
]}
>
<Controls />
<Background />
</ReactFlow>
</ThemeProvider>
);

export const ShortLabel = Template.bind({});

ShortLabel.args = {
id: 'siem-windows',
label: '',
color: 'primary',
shape: 'hexagon',
icon: 'okta',
interactive: true,
};

export const ArnLabel = Template.bind({});

ArnLabel.args = {
id: 'siem-windows',
label: 'arn:aws:iam::1234567890:user/lorem-ipsumdol-sitamet-user-1234',
color: 'primary',
shape: 'hexagon',
icon: 'okta',
interactive: true,
};

export const DashedLabel = Template.bind({});

DashedLabel.args = {
id: 'siem-windows',
label: 'lore-ipsumdol-sitameta-consectetu-adipis342',
color: 'primary',
shape: 'hexagon',
icon: 'okta',
interactive: true,
};

export const NoSpacesLabel = Template.bind({});

NoSpacesLabel.args = {
id: 'siem-windows',
label: 'LoremIpsumDolorSitAmetConsectetur123',
color: 'primary',
shape: 'hexagon',
icon: 'okta',
interactive: true,
};

export const NoSpacesAllLoweredLabel = Template.bind({});

NoSpacesAllLoweredLabel.args = {
id: 'siem-windows',
label: 'loremipsumdolorsitametconsectetur123',
color: 'primary',
shape: 'hexagon',
icon: 'okta',
interactive: true,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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, { memo, type PropsWithChildren } from 'react';
import { EuiText, EuiTextTruncate, EuiToolTip } from '@elastic/eui';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { NODE_LABEL_WIDTH, NODE_WIDTH } from './styles';

const WORD_BOUNDARIES_REGEX = /\b/;
const FORCE_BREAK_REGEX = /(.{10})/;

/**
* A component that renders an element with breaking opportunities (`<wbr>`s)
* spliced into text children at word boundaries.
* Copied from x-pack/plugins/security_solution/public/resolver/view/generated_text.tsx
*/
const GeneratedText = memo<PropsWithChildren<{}>>(function ({ children }) {
return <>{processedValue()}</>;

function processedValue() {
return React.Children.map(children, (child) => {
if (typeof child === 'string') {
let valueSplitByWordBoundaries = child.split(WORD_BOUNDARIES_REGEX);

if (valueSplitByWordBoundaries.length < 2) {
valueSplitByWordBoundaries = child.split(FORCE_BREAK_REGEX);

if (valueSplitByWordBoundaries.length < 2) {
return valueSplitByWordBoundaries[0];
}
}

return [
valueSplitByWordBoundaries[0],
...valueSplitByWordBoundaries
.splice(1)
.reduce((generatedTextMemo: Array<string | JSX.Element>, value) => {
if (
generatedTextMemo.length > 0 &&
typeof generatedTextMemo[generatedTextMemo.length - 1] === 'object'
) {
return [...generatedTextMemo, value];
}
return [...generatedTextMemo, value, <wbr />];
}, []),
];
} else {
return child;
}
});
}
});

GeneratedText.displayName = 'GeneratedText';

export interface LabelProps {
text?: string;
}

const LabelComponent: React.FC<LabelProps> = ({ text = '' }: LabelProps) => {
const [isTruncated, setIsTruncated] = React.useState(false);

return (
<EuiText
size="xs"
textAlign="center"
css={css`
width: ${NODE_LABEL_WIDTH}px;
margin-left: ${-(NODE_LABEL_WIDTH - NODE_WIDTH) / 2}px;
overflow: hidden;
text-overflow: ellipsis;
max-height: 32px;
`}
>
<EuiToolTip content={isTruncated ? text : ''} position="bottom">
<EuiTextTruncate
truncation="end"
truncationOffset={20}
text={text}
width={NODE_LABEL_WIDTH * 1.5}
>
{(truncatedText) => (
<>
{setIsTruncated(truncatedText.length !== text.length)}
{<GeneratedText>{truncatedText}</GeneratedText>}
</>
)}
</EuiTextTruncate>
</EuiToolTip>
</EuiText>
);
};

export const Label = styled(LabelComponent)`
width: ${NODE_LABEL_WIDTH}px;
margin-left: ${-(NODE_LABEL_WIDTH - NODE_WIDTH) / 2}px;
text-overflow: ellipsis;
overflow: hidden;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import {
NodeShapeContainer,
NodeLabel,
NodeShapeOnHoverSvg,
NodeShapeSvg,
NodeIcon,
Expand All @@ -21,6 +20,7 @@ import {
import type { EntityNodeViewModel, NodeProps } from '../types';
import { PentagonHoverShape, PentagonShape } from './shapes/pentagon_shape';
import { NodeExpandButton } from './node_expand_button';
import { Label } from './label';

const PentagonShapeOnHover = styled(NodeShapeOnHoverSvg)`
transform: translate(-50%, -51.5%);
Expand Down Expand Up @@ -86,7 +86,7 @@ export const PentagonNode: React.FC<NodeProps> = memo((props: NodeProps) => {
style={HandleStyleOverride}
/>
</NodeShapeContainer>
<NodeLabel>{label ? label : id}</NodeLabel>
<Label text={label ? label : id} />
</>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui';
import { Handle, Position } from '@xyflow/react';
import {
NodeShapeContainer,
NodeLabel,
NodeShapeOnHoverSvg,
NodeShapeSvg,
NodeIcon,
Expand All @@ -20,6 +19,7 @@ import {
import type { EntityNodeViewModel, NodeProps } from '../types';
import { RectangleHoverShape, RectangleShape } from './shapes/rectangle_shape';
import { NodeExpandButton } from './node_expand_button';
import { Label } from './label';

const NODE_WIDTH = 81;
const NODE_HEIGHT = 80;
Expand Down Expand Up @@ -81,7 +81,7 @@ export const RectangleNode: React.FC<NodeProps> = memo((props: NodeProps) => {
style={HandleStyleOverride}
/>
</NodeShapeContainer>
<NodeLabel>{label ? label : id}</NodeLabel>
<Label text={label ? label : id} />
</>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const LABEL_PADDING_X = 15;
export const LABEL_BORDER_WIDTH = 1;
export const NODE_WIDTH = 90;
export const NODE_HEIGHT = 90;
const NODE_LABEL_WIDTH = 120;
export const NODE_LABEL_WIDTH = 160;

export const LabelNodeContainer = styled.div`
text-wrap: nowrap;
Expand Down Expand Up @@ -185,19 +185,6 @@ export const NodeIcon = ({ icon, color, x, y }: NodeIconProps) => {
);
};

export const NodeLabel = styled(EuiText)`
width: ${NODE_LABEL_WIDTH}px;
margin-left: ${-(NODE_LABEL_WIDTH - NODE_WIDTH) / 2}px;
text-overflow: ellipsis;
// white-space: nowrap;
overflow: hidden;
`;

NodeLabel.defaultProps = {
size: 'xs',
textAlign: 'center',
};

export const ExpandButtonSize = 18;

export const RoundEuiButtonIcon = styled(EuiButtonIcon)`
Expand Down

0 comments on commit f316612

Please sign in to comment.