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',