diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.ts b/.buildkite/scripts/steps/storybooks/build_and_upload.ts
index d15e15800821a..f56e9bdab8529 100644
--- a/.buildkite/scripts/steps/storybooks/build_and_upload.ts
+++ b/.buildkite/scripts/steps/storybooks/build_and_upload.ts
@@ -14,6 +14,7 @@ import path from 'path';
const STORYBOOKS = [
'apm',
'canvas',
+ 'cases',
'ci_composite',
'cloud_chat',
'coloring',
diff --git a/packages/kbn-cases-components/.storybook/main.js b/packages/kbn-cases-components/.storybook/main.js
new file mode 100644
index 0000000000000..8dc3c5d1518f4
--- /dev/null
+++ b/packages/kbn-cases-components/.storybook/main.js
@@ -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;
diff --git a/packages/kbn-cases-components/README.md b/packages/kbn-cases-components/README.md
index bc60e2fba7fe5..cea88ceb6b37b 100644
--- a/packages/kbn-cases-components/README.md
+++ b/packages/kbn-cases-components/README.md
@@ -13,3 +13,34 @@ import { Status, CaseStatuses } from '@kbn/cases-components';
```
+
+### 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',
+};
+
+
+ This is a demo span
+
+```
diff --git a/packages/kbn-cases-components/index.ts b/packages/kbn-cases-components/index.ts
index 47a7a298f0e1d..9605b0267a25f 100644
--- a/packages/kbn-cases-components/index.ts
+++ b/packages/kbn-cases-components/index.ts
@@ -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';
diff --git a/packages/kbn-cases-components/src/__stories__/tooltip.stories.tsx b/packages/kbn-cases-components/src/__stories__/tooltip.stories.tsx
new file mode 100644
index 0000000000000..673e17db75bee
--- /dev/null
+++ b/packages/kbn-cases-components/src/__stories__/tooltip.stories.tsx
@@ -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 = () => (
+
+ {sampleText}
+
+);
+
+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) => (
+
+
+
+
+
+);
+
+export default {
+ title: 'CaseTooltip',
+ component: Template,
+} as ComponentMeta;
+
+export const Default: ComponentStory = Template.bind({});
+Default.args = { ...tooltipProps };
+
+export const LoadingState: ComponentStory = Template.bind({});
+LoadingState.args = { ...tooltipProps, loading: true };
+
+export const LongTitle: ComponentStory = Template.bind({});
+LongTitle.args = { ...tooltipProps, content: { ...tooltipContent, title: longTitle } };
+
+export const LongDescription: ComponentStory = Template.bind({});
+LongDescription.args = {
+ ...tooltipProps,
+ content: { ...tooltipContent, description: longDescription },
+};
+
+export const InProgressStatus: ComponentStory = Template.bind({});
+InProgressStatus.args = {
+ ...tooltipProps,
+ content: { ...tooltipContent, status: CaseStatuses['in-progress'] },
+};
+
+export const ClosedStatus: ComponentStory = Template.bind({});
+ClosedStatus.args = {
+ ...tooltipProps,
+ content: { ...tooltipContent, status: CaseStatuses.closed },
+};
+
+export const NoUserInfo: ComponentStory = Template.bind({});
+NoUserInfo.args = { ...tooltipProps, content: { ...tooltipContent, createdBy: {} } };
+
+export const FullName: ComponentStory = Template.bind({});
+FullName.args = {
+ ...tooltipProps,
+ content: { ...tooltipContent, createdBy: { fullName: 'Elastic User' } },
+};
+
+export const LongUserName: ComponentStory = Template.bind({});
+LongUserName.args = {
+ ...tooltipProps,
+ content: { ...tooltipContent, createdBy: { fullName: 'LoremIpsumElasticUser WithALongSurname' } },
+};
diff --git a/packages/kbn-cases-components/src/tooltip/icon_with_count.test.tsx b/packages/kbn-cases-components/src/tooltip/icon_with_count.test.tsx
new file mode 100644
index 0000000000000..99a777fd91bb9
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/icon_with_count.test.tsx
@@ -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();
+
+ expect(res.getByTestId('comment-count-icon')).toBeInTheDocument();
+ });
+
+ it('renders count correctly', () => {
+ const res = render();
+
+ expect(res.getByText(100)).toBeInTheDocument();
+ });
+});
diff --git a/packages/kbn-cases-components/src/tooltip/icon_with_count.tsx b/packages/kbn-cases-components/src/tooltip/icon_with_count.tsx
new file mode 100644
index 0000000000000..80e8c8c9a7520
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/icon_with_count.tsx
@@ -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 }) => (
+
+
+
+
+
+ {count}
+
+
+));
+
+IconWithCount.displayName = 'IconWithCount';
diff --git a/packages/kbn-cases-components/src/tooltip/skeleton.tsx b/packages/kbn-cases-components/src/tooltip/skeleton.tsx
new file mode 100644
index 0000000000000..f0c78e2a2e215
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/skeleton.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+};
+
+SkeletonComponent.displayName = 'Skeleton';
+
+export const Skeleton = SkeletonComponent;
diff --git a/packages/kbn-cases-components/src/tooltip/tooltip.test.tsx b/packages/kbn-cases-components/src/tooltip/tooltip.test.tsx
new file mode 100644
index 0000000000000..a4573db2f8ccd
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/tooltip.test.tsx
@@ -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 = () => {sampleText};
+
+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(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(await res.findByTestId('cases-components-tooltip')).toBeInTheDocument();
+ });
+
+ it('renders custom test subject correctly', async () => {
+ const res = render(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(await res.findByTestId('custom-data-test')).toBeInTheDocument();
+ });
+
+ it('renders loading state correctly', async () => {
+ const res = render(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(await res.findByTestId('tooltip-loading-content')).toBeInTheDocument();
+ });
+
+ it('renders title correctly', async () => {
+ const res = render(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(await res.findByText(tooltipContent.title)).toBeInTheDocument();
+ });
+
+ it('renders description correctly', async () => {
+ const res = render(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(await res.findByText(tooltipContent.description)).toBeInTheDocument();
+ });
+
+ it('renders icon', async () => {
+ const res = render(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(await res.findByTestId('comment-count-icon')).toBeInTheDocument();
+ });
+
+ it('renders comment count', async () => {
+ const res = render(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(await res.findByText(tooltipContent.totalComments)).toBeInTheDocument();
+ });
+
+ it('renders correct status', async () => {
+ const res = render(
+
+
+
+
+
+ );
+
+ 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(
+
+
+
+
+
+ );
+
+ 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(
+
+
+
+
+
+ );
+
+ fireEvent.mouseOver(res.getByTestId('sample-span'));
+ expect(res.queryByTestId('tooltip-username')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/kbn-cases-components/src/tooltip/tooltip.tsx b/packages/kbn-cases-components/src/tooltip/tooltip.tsx
new file mode 100644
index 0000000000000..7ac199ff58f24
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/tooltip.tsx
@@ -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((props) => {
+ const { dataTestSubj, children, loading = false, className = '', content } = props;
+
+ return (
+ : }
+ >
+ <>{children}>
+
+ );
+});
+
+CaseTooltipComponent.displayName = 'Tooltip';
+
+export const Tooltip = memo(CaseTooltipComponent);
diff --git a/packages/kbn-cases-components/src/tooltip/tooltip_content.tsx b/packages/kbn-cases-components/src/tooltip/tooltip_content.tsx
new file mode 100644
index 0000000000000..262365fba3ae4
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/tooltip_content.tsx
@@ -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(
+ ({ title, description, status, totalComments, createdAt, createdBy }) => (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {getTruncatedText(title, TITLE_TRUNCATE_LENGTH)}
+
+
+
+
+ {getTruncatedText(description, DESCRIPTION_TRUNCATE_LENGTH)}
+
+
+
+
+
+
+ {status === CaseStatuses.closed ? i18n.CLOSED : i18n.OPENED}{' '}
+ {' '}
+ {createdBy.username || createdBy.fullName ? (
+ <>
+ {i18n.BY}{' '}
+
+ {getTruncatedText(
+ createdBy.username ?? createdBy.fullName ?? '',
+ USER_TRUNCATE_LENGTH
+ )}
+
+ >
+ ) : null}
+
+ >
+ )
+);
+
+CaseTooltipContentComponent.displayName = 'TooltipContent';
+
+export const TooltipContent = memo(CaseTooltipContentComponent);
diff --git a/packages/kbn-cases-components/src/tooltip/translations.ts b/packages/kbn-cases-components/src/tooltip/translations.ts
new file mode 100644
index 0000000000000..b9223ba032206
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/translations.ts
@@ -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',
+});
diff --git a/packages/kbn-cases-components/src/tooltip/types.ts b/packages/kbn-cases-components/src/tooltip/types.ts
new file mode 100644
index 0000000000000..dea545ab8f40b
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/types.ts
@@ -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;
+}
diff --git a/packages/kbn-cases-components/src/tooltip/utils.test.ts b/packages/kbn-cases-components/src/tooltip/utils.test.ts
new file mode 100644
index 0000000000000..b9d5657ab0e3e
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/utils.test.ts
@@ -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);
+ });
+});
diff --git a/packages/kbn-cases-components/src/tooltip/utils.ts b/packages/kbn-cases-components/src/tooltip/utils.ts
new file mode 100644
index 0000000000000..95b43e62c36e8
--- /dev/null
+++ b/packages/kbn-cases-components/src/tooltip/utils.ts
@@ -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('...');
+};
diff --git a/packages/kbn-cases-components/tsconfig.json b/packages/kbn-cases-components/tsconfig.json
index 3d7519541dc8d..09abcbdb81a3e 100644
--- a/packages/kbn-cases-components/tsconfig.json
+++ b/packages/kbn-cases-components/tsconfig.json
@@ -14,6 +14,7 @@
],
"kbn_references": [
"@kbn/i18n",
+ "@kbn/i18n-react",
],
"exclude": [
"target/**/*",
diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts
index 05ae1c3048d17..99918241190df 100644
--- a/src/dev/storybook/aliases.ts
+++ b/src/dev/storybook/aliases.ts
@@ -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',