[Cases] add tooltip component to kbn-cases-components package (#148561)

## Summary

This PR adds a tooltip component (High OrderComponent) to
@kbn/cases-components package. #146864

**Details of tooltip**


![image](https://user-images.githubusercontent.com/117571355/211531519-55c68e15-00ce-410d-9cd5-d23d4eb45287.png)

**status: Open, tooltip position : Top**


![image](https://user-images.githubusercontent.com/117571355/211530420-d0c96461-1ce5-4344-8fcf-17907a7efe61.png)

**status: In-progress, tooltip position: bottom, long title and
description**


![image](https://user-images.githubusercontent.com/117571355/211530905-2df9b768-3181-481b-8234-43875301cbb4.png)

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [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
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### How to Test:

- run `yarn storybook cases` and test on  http://localhost:9001/

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2023-01-17 14:24:50 +01:00 committed by GitHub
parent 066ee1c9e8
commit ed32d89848
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 618 additions and 0 deletions

View file

@ -14,6 +14,7 @@ import path from 'path';
const STORYBOOKS = [
'apm',
'canvas',
'cases',
'ci_composite',
'cloud_chat',
'coloring',

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = require('@kbn/storybook').defaultConfig;

View file

@ -13,3 +13,34 @@ import { Status, CaseStatuses } from '@kbn/cases-components';
<Status status={CaseStatuses.open} />
```
### Tooltip
The component renders the tooltip with case details on hover of an Element. Usage:
```
import { Tooltip, CaseStatuses } from '@kbn/cases-components';
import type { CaseTooltipContentProps, CaseTooltipProps } from '@kbn/cases-components';
const tooltipContent: CaseTooltipContentProps = {
title: 'Case title',
description: 'Case description',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
totalComments: 1,
status: CaseStatuses.open,
}
const tooltipProps: CaseTooltipProps = {
loading: false,
content: tooltipContent,
className: 'customClass',
};
<Tooltip {...tooltipProps}>
<span>This is a demo span</span>
</Tooltip>
```

View file

@ -9,3 +9,5 @@
export { Status } from './src/status/status';
export { CaseStatuses } from './src/status/types';
export { getStatusConfiguration } from './src/status/config';
export { Tooltip } from './src/tooltip/tooltip';
export type { CaseTooltipProps, CaseTooltipContentProps } from './src/tooltip/types';

View file

@ -0,0 +1,103 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { CaseStatuses } from '../status/types';
import { Tooltip } from '../tooltip/tooltip';
import type { CaseTooltipProps, CaseTooltipContentProps } from '../tooltip/types';
const sampleText = 'This is a test span element!!';
const TestSpan = () => (
<a href="https://www.elastic.co/">
<span data-test-subj="sample-span">{sampleText}</span>
</a>
);
const tooltipContent: CaseTooltipContentProps = {
title: 'Unusual process identified',
description: 'There was an unusual process while adding alerts to existing case.',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
totalComments: 10,
status: CaseStatuses.open,
};
const tooltipProps: CaseTooltipProps = {
children: TestSpan,
loading: false,
content: tooltipContent,
};
const longTitle = `Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry standard dummy text ever since the 1500s!! Lorem!!!`;
const longDescription = `Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer
took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries,
but also the leap into electronic typesetting, remaining essentially unchanged.`;
const Template = (args: CaseTooltipProps) => (
<I18nProvider>
<Tooltip {...args}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
export default {
title: 'CaseTooltip',
component: Template,
} as ComponentMeta<typeof Template>;
export const Default: ComponentStory<typeof Template> = Template.bind({});
Default.args = { ...tooltipProps };
export const LoadingState: ComponentStory<typeof Template> = Template.bind({});
LoadingState.args = { ...tooltipProps, loading: true };
export const LongTitle: ComponentStory<typeof Template> = Template.bind({});
LongTitle.args = { ...tooltipProps, content: { ...tooltipContent, title: longTitle } };
export const LongDescription: ComponentStory<typeof Template> = Template.bind({});
LongDescription.args = {
...tooltipProps,
content: { ...tooltipContent, description: longDescription },
};
export const InProgressStatus: ComponentStory<typeof Template> = Template.bind({});
InProgressStatus.args = {
...tooltipProps,
content: { ...tooltipContent, status: CaseStatuses['in-progress'] },
};
export const ClosedStatus: ComponentStory<typeof Template> = Template.bind({});
ClosedStatus.args = {
...tooltipProps,
content: { ...tooltipContent, status: CaseStatuses.closed },
};
export const NoUserInfo: ComponentStory<typeof Template> = Template.bind({});
NoUserInfo.args = { ...tooltipProps, content: { ...tooltipContent, createdBy: {} } };
export const FullName: ComponentStory<typeof Template> = Template.bind({});
FullName.args = {
...tooltipProps,
content: { ...tooltipContent, createdBy: { fullName: 'Elastic User' } },
};
export const LongUserName: ComponentStory<typeof Template> = Template.bind({});
LongUserName.args = {
...tooltipProps,
content: { ...tooltipContent, createdBy: { fullName: 'LoremIpsumElasticUser WithALongSurname' } },
};

View file

@ -0,0 +1,26 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { IconWithCount } from './icon_with_count';
describe('IconWithCount', () => {
it('renders component correctly', () => {
const res = render(<IconWithCount count={5} icon={'editorComment'} />);
expect(res.getByTestId('comment-count-icon')).toBeInTheDocument();
});
it('renders count correctly', () => {
const res = render(<IconWithCount count={100} icon={'editorComment'} />);
expect(res.getByText(100)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import React from 'react';
export const IconWithCount = React.memo<{
count: number;
icon: string;
}>(({ count, icon }) => (
<EuiFlexGroup alignItems="center" gutterSize="none" css={{ marginLeft: 'auto', flexGrow: 0 }}>
<EuiFlexItem grow={false}>
<EuiIcon
css={{ marginRight: '4px' }}
size="s"
type={icon}
data-test-subj="comment-count-icon"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">{count}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
));
IconWithCount.displayName = 'IconWithCount';

View file

@ -0,0 +1,25 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiFlexItem, EuiLoadingContent, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
const SkeletonComponent: React.FC = () => {
return (
<EuiFlexItem css={{ width: 240 }} data-test-subj="tooltip-loading-content">
<EuiLoadingContent lines={1} css={{ width: 70, marginBottom: '12px' }} />
<EuiLoadingContent lines={3} />
<EuiHorizontalRule margin="xs" />
<EuiSpacer size="s" />
</EuiFlexItem>
);
};
SkeletonComponent.displayName = 'Skeleton';
export const Skeleton = SkeletonComponent;

View file

@ -0,0 +1,175 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { I18nProvider } from '@kbn/i18n-react';
import { Tooltip } from './tooltip';
import { CaseStatuses } from '../status/types';
import type { CaseTooltipContentProps, CaseTooltipProps } from './types';
const elasticUser = {
fullName: 'Elastic User',
username: 'elastic',
};
const sampleText = 'This is a test span element!!';
const TestSpan = () => <span data-test-subj="sample-span">{sampleText}</span>;
const tooltipContent: CaseTooltipContentProps = {
title: 'Another horrible breach!!',
description: 'Demo case banana Issue',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: elasticUser,
totalComments: 1,
status: CaseStatuses.open,
};
const tooltipProps: CaseTooltipProps = {
children: TestSpan,
loading: false,
content: tooltipContent,
};
describe('Tooltip', () => {
it('renders correctly', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByTestId('cases-components-tooltip')).toBeInTheDocument();
});
it('renders custom test subject correctly', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps} dataTestSubj="custom-data-test">
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByTestId('custom-data-test')).toBeInTheDocument();
});
it('renders loading state correctly', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps} loading={true}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByTestId('tooltip-loading-content')).toBeInTheDocument();
});
it('renders title correctly', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByText(tooltipContent.title)).toBeInTheDocument();
});
it('renders description correctly', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByText(tooltipContent.description)).toBeInTheDocument();
});
it('renders icon', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByTestId('comment-count-icon')).toBeInTheDocument();
});
it('renders comment count', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByText(tooltipContent.totalComments)).toBeInTheDocument();
});
it('renders correct status', async () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps} content={{ ...tooltipContent, status: CaseStatuses.closed }}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByText('Closed')).toBeInTheDocument();
});
it('renders full name when no username available', async () => {
const newUser = {
fullName: 'New User',
};
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps} content={{ ...tooltipContent, createdBy: newUser }}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(await res.findByTestId('tooltip-username')).toBeInTheDocument();
expect(await res.findByText(newUser.fullName)).toBeInTheDocument();
});
it('does not render username when no username or full name available', () => {
const res = render(
<I18nProvider>
<Tooltip {...tooltipProps} content={{ ...tooltipContent, createdBy: {} }}>
<TestSpan />
</Tooltip>
</I18nProvider>
);
fireEvent.mouseOver(res.getByTestId('sample-span'));
expect(res.queryByTestId('tooltip-username')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { memo } from 'react';
import { EuiToolTip } from '@elastic/eui';
import { TooltipContent } from './tooltip_content';
import type { CaseTooltipProps } from './types';
import { Skeleton } from './skeleton';
const CaseTooltipComponent = React.memo<CaseTooltipProps>((props) => {
const { dataTestSubj, children, loading = false, className = '', content } = props;
return (
<EuiToolTip
data-test-subj={dataTestSubj ? dataTestSubj : 'cases-components-tooltip'}
anchorClassName={className}
content={loading ? <Skeleton /> : <TooltipContent {...content} />}
>
<>{children}</>
</EuiToolTip>
);
});
CaseTooltipComponent.displayName = 'Tooltip';
export const Tooltip = memo(CaseTooltipComponent);

View file

@ -0,0 +1,69 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { memo } from 'react';
import { FormattedRelative } from '@kbn/i18n-react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiHorizontalRule } from '@elastic/eui';
import { Status } from '../status/status';
import { CaseStatuses } from '../status/types';
import { IconWithCount } from './icon_with_count';
import { getTruncatedText } from './utils';
import * as i18n from './translations';
import type { CaseTooltipContentProps } from './types';
const TITLE_TRUNCATE_LENGTH = 35;
const DESCRIPTION_TRUNCATE_LENGTH = 80;
const USER_TRUNCATE_LENGTH = 15;
const CaseTooltipContentComponent = React.memo<CaseTooltipContentProps>(
({ title, description, status, totalComments, createdAt, createdBy }) => (
<>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexGroup gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<Status status={status} />
</EuiFlexItem>
<IconWithCount count={totalComments} icon={'editorComment'} />
</EuiFlexGroup>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<EuiText size="relative">
<strong>{getTruncatedText(title, TITLE_TRUNCATE_LENGTH)}</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="relative">
{getTruncatedText(description, DESCRIPTION_TRUNCATE_LENGTH)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<EuiText size="relative">
{status === CaseStatuses.closed ? i18n.CLOSED : i18n.OPENED}{' '}
<FormattedRelative value={createdAt} />{' '}
{createdBy.username || createdBy.fullName ? (
<>
{i18n.BY}{' '}
<strong data-test-subj="tooltip-username">
{getTruncatedText(
createdBy.username ?? createdBy.fullName ?? '',
USER_TRUNCATE_LENGTH
)}
</strong>
</>
) : null}
</EuiText>
</>
)
);
CaseTooltipContentComponent.displayName = 'TooltipContent';
export const TooltipContent = memo(CaseTooltipContentComponent);

View file

@ -0,0 +1,21 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const OPENED = i18n.translate('cases.components.tooltip.opened', {
defaultMessage: 'Opened',
});
export const CLOSED = i18n.translate('cases.components.tooltip.closed', {
defaultMessage: 'Closed',
});
export const BY = i18n.translate('cases.components.tooltip.by', {
defaultMessage: 'by',
});

View file

@ -0,0 +1,25 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CaseStatuses } from '../status/types';
export interface CaseTooltipContentProps {
title: string;
description: string;
status: CaseStatuses;
totalComments: number;
createdAt: string;
createdBy: { username?: string; fullName?: string };
}
export interface CaseTooltipProps {
children: React.ReactNode;
content: CaseTooltipContentProps;
dataTestSubj?: string;
className?: string;
loading?: boolean;
}

View file

@ -0,0 +1,51 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getTruncatedText } from './utils';
describe('getTruncatedText', () => {
it('should return truncated text correctly', () => {
const sampleText = 'This is a sample text!!';
const res = getTruncatedText(sampleText, 4);
expect(res).toEqual('This...');
});
it('should return original text if text is empty', () => {
const res = getTruncatedText('', 4);
expect(res).toEqual('');
});
it('should return empty text if text is empty', () => {
const res = getTruncatedText('', 10);
expect(res).toEqual('');
});
it('should return original text if truncate length is negative', () => {
const sampleText = 'This is a sample text!!';
const res = getTruncatedText(sampleText, -4);
expect(res).toEqual(sampleText);
});
it('should return original text if truncate length is zero', () => {
const sampleText = 'This is a sample text!!';
const res = getTruncatedText(sampleText, 0);
expect(res).toEqual(sampleText);
});
it('should return original text if text is smaller than truncate length number', () => {
const sampleText = 'This is a sample text!!';
const res = getTruncatedText(sampleText, 50);
expect(res).toEqual(sampleText);
});
});

View file

@ -0,0 +1,15 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const getTruncatedText = (text: string, truncateLength: number): string => {
if (truncateLength <= 0 || text.length <= truncateLength) {
return text;
}
return text.slice(0, truncateLength).trim().concat('...');
};

View file

@ -14,6 +14,7 @@
],
"kbn_references": [
"@kbn/i18n",
"@kbn/i18n-react",
],
"exclude": [
"target/**/*",

View file

@ -10,6 +10,7 @@
export const storybookAliases = {
apm: 'x-pack/plugins/apm/.storybook',
canvas: 'x-pack/plugins/canvas/storybook',
cases: 'packages/kbn-cases-components/.storybook',
ci_composite: '.ci/.storybook',
cloud_chat: 'x-pack/plugins/cloud_integrations/cloud_chat/.storybook',
coloring: 'packages/kbn-coloring/.storybook',