From a0bcc44b63543a8221bd5358ce9c48b0f71fb20b Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 22 Mar 2024 14:46:32 +0000 Subject: [PATCH] GrafanaUI: Add new `EmptyState` component (#84891) * add empty search state component * extract translations * nicer prop spreading * actually expose component in grafana-ui * use requestAnimationFrame * add rollup-plugin-svg-import * move to a single EmptyState component with variants * update docs * remove CTA * add some more prop documentation * just use 1 svg --- packages/grafana-ui/package.json | 1 + packages/grafana-ui/rollup.config.ts | 2 + .../src/components/EmptyState/EmptyState.mdx | 43 +++++++++++ .../EmptyState/EmptyState.story.tsx | 33 +++++++++ .../src/components/EmptyState/EmptyState.tsx | 47 ++++++++++++ .../EmptyState/GrotNotFound/GrotNotFound.tsx | 72 +++++++++++++++++++ .../GrotNotFound/grot-not-found.svg | 62 ++++++++++++++++ packages/grafana-ui/src/components/index.ts | 1 + public/locales/en-US/grafana.json | 3 + public/locales/pseudo-LOCALE/grafana.json | 3 + yarn.lock | 24 ++++++- 11 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 packages/grafana-ui/src/components/EmptyState/EmptyState.mdx create mode 100644 packages/grafana-ui/src/components/EmptyState/EmptyState.story.tsx create mode 100644 packages/grafana-ui/src/components/EmptyState/EmptyState.tsx create mode 100644 packages/grafana-ui/src/components/EmptyState/GrotNotFound/GrotNotFound.tsx create mode 100644 packages/grafana-ui/src/components/EmptyState/GrotNotFound/grot-not-found.svg diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index dca13e21b3a..097e5c3adb9 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -176,6 +176,7 @@ "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", + "rollup-plugin-svg-import": "1.6.0", "sass-loader": "14.1.1", "storybook": "7.4.5", "storybook-addon-turbo-build": "2.0.1", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 3659a42c58b..76891bf60c7 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -4,6 +4,7 @@ import copy from 'rollup-plugin-copy'; import dts from 'rollup-plugin-dts'; import esbuild from 'rollup-plugin-esbuild'; import { externals } from 'rollup-plugin-node-externals'; +import svg from 'rollup-plugin-svg-import'; const icons = require('../../public/app/core/icons/cached.json'); @@ -18,6 +19,7 @@ export default [ input: 'src/index.ts', plugins: [ externals({ deps: true, packagePath: './package.json' }), + svg({ stringify: true }), resolve(), copy({ targets: [{ src: iconSrcPaths, dest: './dist/public/' }], diff --git a/packages/grafana-ui/src/components/EmptyState/EmptyState.mdx b/packages/grafana-ui/src/components/EmptyState/EmptyState.mdx new file mode 100644 index 00000000000..bf57bfc37ed --- /dev/null +++ b/packages/grafana-ui/src/components/EmptyState/EmptyState.mdx @@ -0,0 +1,43 @@ +import { ArgTypes } from '@storybook/blocks'; +import { EmptyState } from './EmptyState'; + +# EmptyState + +Use an empty state to communicate to the user that there is no data to display, or that a search query returned no results. + +## variant="search" + +### When to use + +Use in place of a results table or list when a search query or filter returns no results. + +There are sensible defaults for the image and message, so in most cases you can use it without any additional props. + +```jsx +import { EmptyState } from '@grafana/ui'; + +; +``` + +### Providing custom overrides + +You can optionally override the message or image, and add additional information or a button (e.g. to clear the search query) + +```jsx +import { Button, EmptyState } from '@grafana/ui'; + +} + image={} + message="No playlists found" +> + Optionally provide some additional information here. Maybe even a link to{' '} + + documentation. + +; +``` + +## Props + + diff --git a/packages/grafana-ui/src/components/EmptyState/EmptyState.story.tsx b/packages/grafana-ui/src/components/EmptyState/EmptyState.story.tsx new file mode 100644 index 00000000000..f9fc1912120 --- /dev/null +++ b/packages/grafana-ui/src/components/EmptyState/EmptyState.story.tsx @@ -0,0 +1,33 @@ +import { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; + +import { EmptyState } from './EmptyState'; +import mdx from './EmptyState.mdx'; + +const meta: Meta = { + title: 'General/EmptyState', + component: EmptyState, + parameters: { + docs: { + page: mdx, + }, + controls: { + exclude: ['button', 'image', 'variant'], + }, + }, + argTypes: { + children: { + type: 'string', + }, + }, +}; + +export const Basic: StoryFn = (args) => { + return ; +}; + +Basic.args = { + children: 'Use this space to add any additional information', +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/EmptyState/EmptyState.tsx b/packages/grafana-ui/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 00000000000..f7aed1dc5a9 --- /dev/null +++ b/packages/grafana-ui/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,47 @@ +import React, { ReactNode } from 'react'; + +import { t } from '../../utils/i18n'; +import { Box } from '../Layout/Box/Box'; +import { Stack } from '../Layout/Stack/Stack'; +import { Text } from '../Text/Text'; + +import { GrotNotFound } from './GrotNotFound/GrotNotFound'; + +interface Props { + /** + * Provide a button to render below the message + */ + button?: ReactNode; + hideImage?: boolean; + /** + * Override the default image for the variant + */ + image?: ReactNode; + /** + * Message to display to the user + */ + message?: string; + /** + * Empty state variant. Possible values are 'search'. + */ + variant: 'search'; +} + +export const EmptyState = ({ + button, + children, + image = , + message = t('grafana-ui.empty-state.search-message', 'No results found'), + hideImage = false, +}: React.PropsWithChildren) => { + return ( + + {!hideImage && image} + + {message} + {children && {children}} + + {button} + + ); +}; diff --git a/packages/grafana-ui/src/components/EmptyState/GrotNotFound/GrotNotFound.tsx b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/GrotNotFound.tsx new file mode 100644 index 00000000000..1f09105dbf6 --- /dev/null +++ b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/GrotNotFound.tsx @@ -0,0 +1,72 @@ +import { css } from '@emotion/css'; +import React, { SVGProps, useEffect, useRef } from 'react'; +import SVG from 'react-inlinesvg'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../../themes'; + +import notFoundSvg from './grot-not-found.svg'; + +const MIN_ARM_ROTATION = -20; +const MAX_ARM_ROTATION = 5; +const MIN_ARM_TRANSLATION = -5; +const MAX_ARM_TRANSLATION = 5; + +export interface Props { + width?: SVGProps['width']; + height?: SVGProps['height']; +} + +export const GrotNotFound = ({ width = 'auto', height }: Props) => { + const svgRef = useRef(null); + const styles = useStyles2(getStyles); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + const grotArm = svgRef.current?.querySelector('#grot-not-found-arm'); + const grotMagnifier = svgRef.current?.querySelector('#grot-not-found-magnifier'); + + const { clientX, clientY } = event; + const { innerWidth, innerHeight } = window; + const heightRatio = clientY / innerHeight; + const widthRatio = clientX / innerWidth; + const rotation = getIntermediateValue(heightRatio, MIN_ARM_ROTATION, MAX_ARM_ROTATION); + const translation = getIntermediateValue(widthRatio, MIN_ARM_TRANSLATION, MAX_ARM_TRANSLATION); + + window.requestAnimationFrame(() => { + grotArm?.setAttribute('style', `transform: rotate(${rotation}deg) translateX(${translation}%)`); + grotMagnifier?.setAttribute('style', `transform: rotate(${rotation}deg) translateX(${translation}%)`); + }); + }; + + window.addEventListener('mousemove', handleMouseMove); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + + return ; +}; + +GrotNotFound.displayName = 'GrotNotFound'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + svg: css({ + '#grot-not-found-arm, #grot-not-found-magnifier': { + transformOrigin: 'center', + }, + }), + }; +}; + +/** + * Given a start value, end value, and a ratio, return the intermediate value + * Works with negative and inverted start/end values + */ +const getIntermediateValue = (ratio: number, start: number, end: number) => { + const value = ratio * (end - start) + start; + return value; +}; diff --git a/packages/grafana-ui/src/components/EmptyState/GrotNotFound/grot-not-found.svg b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/grot-not-found.svg new file mode 100644 index 00000000000..f5574a95946 --- /dev/null +++ b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/grot-not-found.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 13d8a7b604b..ca48dd03bc3 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -30,6 +30,7 @@ export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker'; export { ColorPickerInput } from './ColorPicker/ColorPickerInput'; export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover'; export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult'; +export { EmptyState } from './EmptyState/EmptyState'; export { UnitPicker } from './UnitPicker/UnitPicker'; export { StatsPicker } from './StatsPicker/StatsPicker'; export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker'; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index ffbcd86e32b..fa91f787e58 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -563,6 +563,9 @@ "drawer": { "close": "Close" }, + "empty-state": { + "search-message": "No results found" + }, "modal": { "close-tooltip": "Close" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index bc54a2fbd02..2ed5a8a6414 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -563,6 +563,9 @@ "drawer": { "close": "Cľőşę" }, + "empty-state": { + "search-message": "Ńő řęşūľŧş ƒőūʼnđ" + }, "modal": { "close-tooltip": "Cľőşę" }, diff --git a/yarn.lock b/yarn.lock index cfc3ace0f14..6b7b990b9a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4341,6 +4341,7 @@ __metadata: rollup-plugin-dts: "npm:^5.0.0" rollup-plugin-esbuild: "npm:5.0.0" rollup-plugin-node-externals: "npm:^5.0.0" + rollup-plugin-svg-import: "npm:1.6.0" rxjs: "npm:7.8.1" sass-loader: "npm:14.1.1" slate: "npm:0.47.9" @@ -7140,6 +7141,16 @@ __metadata: languageName: node linkType: hard +"@rollup/pluginutils@npm:^4.1.1": + version: 4.2.1 + resolution: "@rollup/pluginutils@npm:4.2.1" + dependencies: + estree-walker: "npm:^2.0.1" + picomatch: "npm:^2.2.2" + checksum: 10/503a6f0a449e11a2873ac66cfdfb9a3a0b77ffa84c5cad631f5e4bc1063c850710e8d5cd5dab52477c0d66cda2ec719865726dbe753318cd640bab3fff7ca476 + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.1.0": version: 5.1.0 resolution: "@rollup/pluginutils@npm:5.1.0" @@ -16978,7 +16989,7 @@ __metadata: languageName: node linkType: hard -"estree-walker@npm:^2.0.2": +"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2": version: 2.0.2 resolution: "estree-walker@npm:2.0.2" checksum: 10/b02109c5d46bc2ed47de4990eef770f7457b1159a229f0999a09224d2b85ffeed2d7679cffcff90aeb4448e94b0168feb5265b209cdec29aad50a3d6e93d21e2 @@ -27877,6 +27888,17 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-svg-import@npm:1.6.0": + version: 1.6.0 + resolution: "rollup-plugin-svg-import@npm:1.6.0" + dependencies: + "@rollup/pluginutils": "npm:^4.1.1" + peerDependencies: + rollup: ">=1.29.0 <3.0.0" + checksum: 10/453862c39d2301563d9d07f6647c295377ff66cf3174d2a0612389fda4bfd9fa72718d90279feb782d3525f1d6f9710e1dc78641cd9d4044360a0179f88054b0 + languageName: node + linkType: hard + "rollup@npm:2.79.1": version: 2.79.1 resolution: "rollup@npm:2.79.1"