diff --git a/packages/grafana-ui/src/components/Card/Card.mdx b/packages/grafana-ui/src/components/Card/Card.mdx new file mode 100644 index 00000000000..d98f13e84ce --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.mdx @@ -0,0 +1,282 @@ +import { Meta, Preview, Props } from "@storybook/addon-docs/blocks"; +import { Card } from "./Card"; +import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; + +export const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png' + + + +# Card + +## Usage + +### Basic +A basic Card component expects at least a heading to be used as title. Optionally a `metadata` prop is accepted, to provide some secondary information for the card. Multiple meta data elements can be provided as an array, in which case they will be separated by a horizontal line: `|`. +```jsx + +``` + + + + + +### Multiple metadata elements + +```jsx + +``` + + + console.log('clicked tag:', tag) } + /> + + +Metadata also accepts html elements, which could be links, for example. For elements, that are not strings, a `key` prop has to be manually specified. + +```jsx +https://ops-us-east4.grafana.net/api/prom, + ]} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + /> + + +### As a link +Card can be used as a clickable link item by specifying `href` prop. In this case the Card's content will be rendered inside `a`. + +```jsx + +``` + + + + +### Inside a list item + +To render cards in a list, it is possible to nest them inside `li` items. +```jsx +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+``` + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ +### With media elements + +Cards can also be rendered with media content such icons or images. + +```jsx +https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + /> + + +### Action Cards + +Cards also accept primary and secondary actions. Usually the primary actions are displayed as buttons while secondary actions are displayed as icon buttons. + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, + , + ]} + secondaryActions={[ + , + , + ]} + /> + + +### Disabled state + +Card can have a disabled state, effectively making it and its actions non-clickable. If there is more than one primary action, disabled state will disable them instead of the whole card. + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + disabled +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + disabled + /> + + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} + disabled +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} + disabled + /> + + + +### Props + + diff --git a/packages/grafana-ui/src/components/Card/Card.story.internal.tsx b/packages/grafana-ui/src/components/Card/Card.story.internal.tsx new file mode 100644 index 00000000000..533c08da559 --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.story.internal.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { boolean } from '@storybook/addon-knobs'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { Card } from './Card'; +import mdx from './Card.mdx'; +import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; + +const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png'; + +export default { + title: 'General/Card', + component: Card, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const getKnobs = () => { + const disabled = boolean('Disabled', false, 'Style props'); + + return { disabled }; +}; + +export const Basic = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const AsLink = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithTooltip = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithTags = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithMedia = () => { + const { disabled } = getKnobs(); + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + /> + ); +}; +export const WithActions = () => { + const { disabled } = getKnobs(); + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + actions={[ + , + , + ]} + secondaryActions={[ + , + , + ]} + /> + ); +}; + +export const Full = () => { + const { disabled } = getKnobs(); + + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + tags={['firing', 'active', 'test', 'testdata', 'prometheus']} + description="Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + actions={[ + , + , + ]} + secondaryActions={[ + , + , + , + , + , + ]} + /> + ); +}; diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx new file mode 100644 index 00000000000..45bc6e43be8 --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -0,0 +1,245 @@ +import React, { cloneElement, FC, HTMLAttributes, ReactElement, ReactNode, useCallback, useMemo } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useTheme, styleMixins, stylesFactory } from '../../themes'; +import { Tooltip, PopoverContent } from '../Tooltip/Tooltip'; +import { OnTagClick } from '../Tags/Tag'; +import { TagList } from '../Tags/TagList'; + +/** + * @alpha + */ +export interface ContainerProps extends HTMLAttributes { + /** Content for the card's tooltip */ + tooltip?: PopoverContent; +} + +const CardContainer: FC = ({ children, tooltip, ...props }) => { + return tooltip ? ( + +
{children}
+
+ ) : ( +
{children}
+ ); +}; + +/** + * @alpha + */ +export interface CardInnerProps { + href?: string; +} + +const CardInner: FC = ({ children, href }) => { + const theme = useTheme(); + const styles = getCardStyles(theme); + return href ? ( + + {children} + + ) : ( + <>{children} + ); +}; + +/** + * @alpha + */ +export interface Props extends ContainerProps { + /** Main heading for the Card **/ + heading: ReactNode; + /** Additional data about the card. If array is supplied, elements will be rendered with vertical line separator */ + metadata?: ReactNode | ReactNode[]; + /** Card description text */ + description?: string; + /** List of tags to display in the card */ + tags?: string[]; + /** Optional callback for tag onclick event */ + onTagClick?: OnTagClick; + /** Indicates if the card and all its actions can be interacted with */ + disabled?: boolean; + /** Image or icon to be displayed on the let side of the card */ + image?: ReactNode; + /** Main card actions **/ + actions?: ReactElement[]; + /** Right-side actions */ + secondaryActions?: ReactElement[]; + /** Link to redirect to on card click. If provided, the Card inner content will be rendered inside `a` */ + href?: string; + /** On click handler for the Card */ + onClick?: () => void; +} + +/** + * Generic card component + * + * @alpha + */ +export const Card: FC = ({ + heading, + description, + metadata, + tags = [], + onTagClick, + disabled, + image, + actions = [], + tooltip, + secondaryActions = [], + href, + onClick, + className, + ...htmlProps +}) => { + const hasActions = Boolean(actions.length || secondaryActions.length); + const disableHover = disabled || actions.length > 1 || !onClick; + const disableEvents = disabled && !actions.length; + const theme = useTheme(); + const styles = getCardStyles(theme, disableEvents, disableHover); + // Join meta data elements by '|' + const meta = useMemo( + () => + Array.isArray(metadata) + ? (metadata as ReactNode[]).filter(Boolean).reduce((prev, curr, i) => [ + prev, + + | + , + curr, + ]) + : metadata, + [metadata, styles.separator] + ); + const onCardClick = useCallback(() => (disableHover ? () => {} : onClick), [disableHover, onClick]); + + return ( + + + {image &&
{image}
} +
+
{heading}
+ {meta &&
{meta}
} + {!!tags.length && } + {description &&

{description}

} + {hasActions && ( +
+ {!!actions.length && ( +
{actions.map(action => cloneElement(action, { disabled }))}
+ )} + {!!secondaryActions.length && ( +
+ {secondaryActions.map(action => cloneElement(action, { disabled }))} +
+ )} +
+ )} +
+
+
+ ); +}; + +/** + * @alpha + */ +export const getCardStyles = stylesFactory((theme: GrafanaTheme, disabled = false, disableHover = false) => { + return { + container: css` + display: flex; + width: 100%; + color: ${theme.colors.textStrong}; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.sm}; + padding: ${theme.spacing.md}; + position: relative; + pointer-events: ${disabled ? 'none' : 'auto'}; + margin-bottom: ${theme.spacing.sm}; + + &::after { + content: ''; + display: ${disabled ? 'block' : 'none'}; + position: absolute; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + background: linear-gradient(180deg, rgba(75, 79, 84, 0.5) 0%, rgba(82, 84, 92, 0.5) 100%); + width: calc(100% - 2px); + height: calc(100% - 2px); + border-radius: ${theme.border.radius.sm}; + } + + &:hover { + background: ${disableHover ? theme.colors.bg2 : styleMixins.hoverColor(theme.colors.bg2, theme)}; + cursor: ${disableHover ? 'default' : 'pointer'}; + } + + &:focus { + ${styleMixins.focusCss(theme)}; + } + `, + inner: css` + width: 100%; + `, + heading: css` + margin-bottom: 0; + font-size: ${theme.typography.size.md}; + line-height: ${theme.typography.lineHeight.xs}; + `, + metadata: css` + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textSemiWeak}; + margin: ${theme.spacing.sm} 0 0; + line-height: ${theme.typography.lineHeight.xs}; + `, + description: css` + margin: ${theme.spacing.sm} 0 0; + color: ${theme.colors.textSemiWeak}; + line-height: ${theme.typography.lineHeight.md}; + `, + media: css` + margin-right: ${theme.spacing.md}; + max-width: 40px; + & > * { + width: 100%; + } + `, + actionRow: css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: ${theme.spacing.md}; + `, + actions: css` + & > * { + margin-right: ${theme.spacing.sm}; + } + `, + secondaryActions: css` + display: flex; + align-items: center; + color: ${theme.colors.textSemiWeak}; + & > * { + margin-right: ${theme.spacing.sm} !important; + } + `, + separator: css` + margin: 0 ${theme.spacing.sm}; + `, + innerLink: css` + display: flex; + width: 100%; + `, + tagList: css` + margin-top: ${theme.spacing.sm}; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/Tags/Tag.tsx b/packages/grafana-ui/src/components/Tags/Tag.tsx index 275d14d0764..9d18a88565a 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.tsx @@ -4,6 +4,9 @@ import { GrafanaTheme } from '@grafana/data'; import { useTheme } from '../../themes'; import { getTagColor, getTagColorsFromName } from '../../utils'; +/** + * @public + */ export type OnTagClick = (name: string, event: React.MouseEvent) => any; export interface Props extends Omit, 'onClick'> { diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index 844342ee18e..cb3ca0b486d 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -29,7 +29,9 @@ const getStyles = () => { flex-wrap: wrap; `, tag: css` - margin-left: 6px; + &:not(:first-child) { + margin-left: 6px; + } `, }; }; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 172a854d48a..a42b11cbf90 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -29,7 +29,7 @@ export { TimeZonePicker } from './TimePicker/TimeZonePicker'; export { List } from './List/List'; export { TagsInput } from './TagsInput/TagsInput'; export { Pagination } from './Pagination/Pagination'; -export { Tag } from './Tags/Tag'; +export { Tag, OnTagClick } from './Tags/Tag'; export { TagList } from './Tags/TagList'; export { FilterPill } from './FilterPill/FilterPill'; @@ -169,6 +169,7 @@ export { Checkbox } from './Forms/Checkbox'; export { TextArea } from './TextArea/TextArea'; export { FileUpload } from './FileUpload/FileUpload'; export { TimeRangeInput } from './TimePicker/TimeRangeInput'; +export { Card, Props as CardProps, ContainerProps, CardInnerProps, getCardStyles } from './Card/Card'; // Legacy forms diff --git a/public/app/features/alerting/AlertRuleItem.tsx b/public/app/features/alerting/AlertRuleItem.tsx index c01c6105e8f..532a82f101f 100644 --- a/public/app/features/alerting/AlertRuleItem.tsx +++ b/public/app/features/alerting/AlertRuleItem.tsx @@ -1,8 +1,9 @@ -import React, { PureComponent } from 'react'; +import React, { useCallback } from 'react'; // @ts-ignore import Highlighter from 'react-highlight-words'; +import { css } from 'emotion'; +import { Icon, IconName, Button, LinkButton, Card } from '@grafana/ui'; import { AlertRule } from '../../types'; -import { Icon, IconName, Button, Tooltip, LinkButton, HorizontalGroup } from '@grafana/ui'; export interface Props { rule: AlertRule; @@ -10,56 +11,51 @@ export interface Props { onTogglePause: () => void; } -class AlertRuleItem extends PureComponent { - renderText(text: string) { - return ( +const AlertRuleItem = ({ rule, search, onTogglePause }: Props) => { + const ruleUrl = `${rule.url}?editPanel=${rule.panelId}&tab=alert`; + const renderText = useCallback( + text => ( - ); - } + ), + [search] + ); - render() { - const { rule, onTogglePause } = this.props; - - const ruleUrl = `${rule.url}?editPanel=${rule.panelId}&tab=alert`; - - return ( -
  • - -
    -
    - -
    - {this.renderText(rule.stateText)} - for {rule.stateAge} -
    -
    - {rule.info &&
    {this.renderText(rule.info)}
    } -
    - -
    - - -
    -
  • - ); - } -} + return ( +
  • + {renderText(rule.name)}} + image={ + + } + metadata={[ + + + {renderText(rule.stateText)}{' '} + + for {rule.stateAge} + , + rule.info ? renderText(rule.info) : null, + ]} + actions={[ + , + + Edit alert + , + ]} + /> +
  • + ); +}; export default AlertRuleItem; diff --git a/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap index 4a588d10caf..2e77c00710a 100644 --- a/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap +++ b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap @@ -2,90 +2,72 @@ exports[`Render should render component 1`] = `
  • - -
    -
    -
    - - - -
    -
    - - - - - for - age - -
    -
    -
    -
    - - + - - + > + Pause + , + Edit alert + , + ] + } + heading={ + + - - -
    + + } + image={ + + } + metadata={ + Array [ + + + + + + for + age + , + null, + ] + } + />
  • `;