UI/Card: Refactor Card component for improved accessibility (#41890)

* UI/Card: Improve accessibility of Card component

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
kay delaney 2022-01-06 15:48:12 +00:00 committed by GitHub
parent d55d2d3da7
commit 890c43adf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 619 additions and 417 deletions

View File

@ -29,7 +29,9 @@ export const Pages = {
},
AddDataSource: {
url: '/datasources/new',
/** @deprecated Use dataSourcePluginsV2 */
dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`,
dataSourcePluginsV2: (pluginName: string) => `Add data source ${pluginName}`,
},
ConfirmModal: {
delete: 'Confirm Modal Danger Button',

View File

@ -47,7 +47,7 @@ export const addDataSource = (config?: Partial<AddDataSourceConfig>) => {
e2e().logToConsole('Adding data source with name:', name);
e2e.pages.AddDataSource.visit();
e2e.pages.AddDataSource.dataSourcePlugins(type)
e2e.pages.AddDataSource.dataSourcePluginsV2(type)
.scrollIntoView()
.should('be.visible') // prevents flakiness
.click();

View File

@ -69,18 +69,17 @@ export const Variants: Story<ButtonProps> = () => {
<Button icon="angle-down" />
</ButtonGroup>
</HorizontalGroup>
<Card heading="Button inside card">
<Card>
<Card.Heading>Button inside card</Card.Heading>
<Card.Actions>
<>
{allButtonVariants.map((variant) => (
<Button variant={variant} key={variant}>
{variant}
</Button>
))}
<Button variant="primary" disabled>
Disabled
{allButtonVariants.map((variant) => (
<Button variant={variant} key={variant}>
{variant}
</Button>
</>
))}
<Button variant="primary" disabled>
Disabled
</Button>
</Card.Actions>
</Card>
</VerticalGroup>

View File

@ -17,11 +17,17 @@ export const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png'
A basic `Card` component expects at least a heading, used as a title.
```jsx
<Card heading="Filter by name" description="Filter data by query." />
<Card>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>Filter data by query.</Card.Description>
</Card>
```
<Preview>
<Card heading="Filter by name" description="Filter data by query." />
<Card>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>Filter data by query.</Card.Description>
</Card>
</Preview>
### Multiple metadata elements
@ -29,13 +35,15 @@ A basic `Card` component expects at least a heading, used as a title.
For providing metadata elements, which can be any extra information for the card, `Card.Meta` component should be used. If metadata consists of multiple strings, each of them has to be escaped (wrapped in brackets `{}`) or better passed in as an array.
```jsx
<Card heading="Test dashboard">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Meta>{['Folder: Test', 'Views: 100']}</Card.Meta>
</Card>
```
<Preview>
<Card heading="Test dashboard">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Meta>{['Folder: Test', 'Views: 100']}</Card.Meta>
</Card>
</Preview>
@ -43,7 +51,8 @@ For providing metadata elements, which can be any extra information for the card
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
<Card heading="Test dashboard">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -54,7 +63,8 @@ Metadata also accepts HTML elements, which could be links, for example. For elem
```
<Preview>
<Card heading="Test dashboard">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -67,7 +77,8 @@ Metadata also accepts HTML elements, which could be links, for example. For elem
The separator for multiple metadata elements defaults to a vertical line `|`, but can be customised.
```jsx
<Card heading="Test dashboard">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Meta separator={'-'}>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -78,7 +89,8 @@ The separator for multiple metadata elements defaults to a vertical line `|`, bu
```
<Preview>
<Card heading="Test dashboard">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Meta separator={'-'}>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -93,7 +105,9 @@ The separator for multiple metadata elements defaults to a vertical line `|`, bu
Tags can be rendered inside the Card, by being wrapped in `Card.Tags` component. Note that this component does not provide any tag styling and that should be handled by the children. It is recommended to use it with Grafana-UI's `TagList` component.
```jsx
<Card heading="Test dashboard" description="Card with a list of tags">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Description>Card with a list of tags</Card.Description>
<Card.Tags>
<TagList tags={['tag1', 'tag2', 'tag3']} onClick={(tag) => console.log(tag)} />
</Card.Tags>
@ -101,7 +115,9 @@ Tags can be rendered inside the Card, by being wrapped in `Card.Tags` component.
```
<Preview>
<Card heading="Test dashboard" description="Card with a list of tags">
<Card>
<Card.Heading>Test dashboard</Card.Heading>
<Card.Description>Card with a list of tags</Card.Description>
<Card.Tags>
<TagList tags={['tag1', 'tag2', 'tag3']} onClick={(tag) => console.log(tag)} />
</Card.Tags>
@ -113,19 +129,17 @@ Tags can be rendered inside the Card, by being wrapped in `Card.Tags` component.
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
<Card
heading="Redirect to Grafana"
description="Clicking this card will redirect to grafana website"
href="https://grafana.com"
/>
<Card href="https://grafana.com">
<Card.Heading>Redirect to Grafana</Card.Heading>
<Card.Description>Clicking this card will redirect to grafana website</Card.Description>
</Card>
```
<Preview>
<Card
heading="Redirect to Grafana"
description="Clicking this card will redirect to grafana website"
href="https://grafana.com"
/>
<Card href="https://grafana.com">
<Card.Heading>Redirect to Grafana</Card.Heading>
<Card.Description>Clicking this card will redirect to grafana website</Card.Description>
</Card>
</Preview>
### Inside a list item
@ -135,33 +149,57 @@ To render cards in a list, it is possible to nest them inside `li` items.
```jsx
<ul>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
</ul>
```
<Preview>
<ul style={{ padding: '20px', maxWidth: '800px', listStyle: 'none' }}>
<ul style={{ padding: '20px', maxWidth: '800px', listStyle: 'none', display: 'grid' }}>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
<li>
<Card heading="List card item" description="Card that is rendered inside li element." />
<Card>
<Card.Heading>List card item</Card.Heading>
<Card.Description>Card that is rendered inside li element.</Card.Description>
</Card>
</li>
</ul>
</Preview>
@ -171,9 +209,10 @@ To render cards in a list, it is possible to nest them inside `li` items.
Cards can also be rendered with media content such icons or images. Such elements need to be wrapped in `Card.Figure` component.
```jsx
<Card heading="1-ops-tools1-fallback">
<Card>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
<Card.Meta>
Grafana
@ -185,9 +224,10 @@ Cards can also be rendered with media content such icons or images. Such element
```
<Preview>
<Card heading="1-ops-tools1-fallback">
<Card>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
<Card.Meta>
Grafana
@ -203,7 +243,8 @@ Cards can also be rendered with media content such icons or images. Such element
Cards also accept primary and secondary actions. Usually the primary actions are displayed as buttons while secondary actions are displayed as icon buttons. The actions need to be wrappd in `Card.Actions` and `Card.SecondaryActions` components respectively.
```jsx
<Card heading="1-ops-tools1-fallback">
<Card>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -211,7 +252,7 @@ Cards also accept primary and secondary actions. Usually the primary actions are
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
<Card.Actions>
<Button key="settings" variant="secondary">
@ -229,7 +270,8 @@ Cards also accept primary and secondary actions. Usually the primary actions are
```
<Preview>
<Card heading="1-ops-tools1-fallback">
<Card>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -237,7 +279,7 @@ Cards also accept primary and secondary actions. Usually the primary actions are
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
<Card.Actions>
<Button key="settings" variant="secondary">
@ -259,7 +301,8 @@ Cards also accept primary and secondary actions. Usually the primary actions are
Card can have a disabled state, effectively making it and its actions non-clickable. If there are any actions, they will be disabled instead of the whole card.
```jsx
<Card heading="1-ops-tools1-fallback" disabled>
<Card disabled>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -267,13 +310,14 @@ Card can have a disabled state, effectively making it and its actions non-clicka
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
</Card>
```
<Preview>
<Card heading="1-ops-tools1-fallback" disabled>
<Card disabled>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -281,13 +325,14 @@ Card can have a disabled state, effectively making it and its actions non-clicka
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
</Card>
</Preview>
```jsx
<Card heading="1-ops-tools1-fallback" disabled>
<Card disabled>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -295,7 +340,7 @@ Card can have a disabled state, effectively making it and its actions non-clicka
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
<Card.Actions>
<Button key="settings" variant="secondary">
@ -313,7 +358,8 @@ Card can have a disabled state, effectively making it and its actions non-clicka
```
<Preview>
<Card heading="1-ops-tools1-fallback" disabled>
<Card disabled>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Grafana
<a key="prom-link" href="https://ops-us-east4.grafana.net/api/prom">
@ -321,7 +367,7 @@ Card can have a disabled state, effectively making it and its actions non-clicka
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Grafana Logo" />
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
<Card.Actions>
<Button key="settings" variant="secondary">

View File

@ -26,30 +26,35 @@ export default {
export const Basic: Story<Props> = ({ disabled }) => {
return (
<Card
heading="Filter by name"
description="Filter data by query. This is useful if you are sharing the results from a different panel that has many queries and you want to only visualize a subset of that in this panel."
disabled={disabled}
/>
<Card disabled={disabled}>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>
Filter data by query. This is useful if you are sharing the results from a different panel that has many queries
and you want to only visualize a subset of that in this panel.
</Card.Description>
</Card>
);
};
export const AsLink: Story<Props> = ({ disabled }) => {
return (
<VerticalGroup>
<Card
href="https://grafana.com"
heading="Filter by name"
description="Filter data by query. This is useful if you are sharing the results from a different panel that has many queries and you want to only visualize a subset of that in this panel."
disabled={disabled}
/>
<Card
href="https://grafana.com"
heading="Filter by name2"
description="Filter data by query. This is useful if you are sharing the results from a different panel that has many queries and you want to only visualize a subset of that in this panel."
disabled={disabled}
/>
<Card href="https://grafana.com" heading="Production system overview" disabled={disabled}>
<Card href="https://grafana.com" disabled={disabled}>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>
Filter data by query. This is useful if you are sharing the results from a different panel that has many
queries and you want to only visualize a subset of that in this panel.
</Card.Description>
</Card>
<Card href="https://grafana.com" disabled={disabled}>
<Card.Heading>Filter by name2</Card.Heading>
<Card.Description>
Filter data by query. This is useful if you are sharing the results from a different panel that has many
queries and you want to only visualize a subset of that in this panel.
</Card.Description>
</Card>
<Card href="https://grafana.com" disabled={disabled}>
<Card.Heading>Production system overview</Card.Heading>
<Card.Meta>Meta tags</Card.Meta>
</Card>
</VerticalGroup>
@ -58,7 +63,8 @@ export const AsLink: Story<Props> = ({ disabled }) => {
export const WithTags: Story<Props> = ({ disabled }) => {
return (
<Card heading="Elasticsearch Custom Templated Query" disabled={disabled}>
<Card disabled={disabled}>
<Card.Heading>Elasticsearch Custom Templated Query</Card.Heading>
<Card.Meta>Elastic Search</Card.Meta>
<Card.Tags>
<TagList tags={['elasticsearch', 'test', 'testdata']} onClick={(tag) => console.log('tag', tag)} />
@ -69,7 +75,8 @@ export const WithTags: Story<Props> = ({ disabled }) => {
export const WithMedia: Story<Props> = ({ disabled }) => {
return (
<Card heading="1-ops-tools1-fallback" disabled={disabled}>
<Card disabled={disabled}>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Prometheus
<a key="link2" href="https://ops-us-east4.grafana.net/api/prom">
@ -77,14 +84,15 @@ export const WithMedia: Story<Props> = ({ disabled }) => {
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Prometheus Logo" />
<img src={logo} alt="Prometheus Logo" height="40" width="40" />
</Card.Figure>
</Card>
);
};
export const WithActions: Story<Props> = ({ disabled }) => {
return (
<Card heading="1-ops-tools1-fallback" disabled={disabled}>
<Card disabled={disabled}>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Prometheus
<a key="link2" href="https://ops-us-east4.grafana.net/api/prom">
@ -92,7 +100,7 @@ export const WithActions: Story<Props> = ({ disabled }) => {
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Prometheus Logo" />
<img src={logo} alt="Prometheus Logo" height="40" width="40" />
</Card.Figure>
<Card.Actions>
<Button key="settings" variant="secondary">
@ -112,11 +120,13 @@ export const WithActions: Story<Props> = ({ disabled }) => {
export const Full: Story<Props> = ({ disabled }) => {
return (
<Card
heading="Card title"
disabled={disabled}
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."
>
<Card disabled={disabled}>
<Card.Heading>Card title</Card.Heading>
<Card.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.
</Card.Description>
<Card.Meta>
{['Subtitle', 'Meta info 1', 'Meta info 2']}
<a key="link" href="https://ops-us-east4.grafana.net/api/prom">
@ -124,7 +134,7 @@ export const Full: Story<Props> = ({ disabled }) => {
</a>
</Card.Meta>
<Card.Figure>
<img src={logo} alt="Prometheus Logo" />
<img src={logo} alt="Prometheus Logo" height="40" width="40" />
</Card.Figure>
<Card.Actions>
<Button key="settings" variant="secondary">

View File

@ -7,7 +7,11 @@ import { IconButton } from '../IconButton/IconButton';
describe('Card', () => {
it('should execute callback when clicked', () => {
const callback = jest.fn();
render(<Card heading="Test Heading" onClick={callback} />);
render(
<Card onClick={callback}>
<Card.Heading>Test Heading</Card.Heading>
</Card>
);
fireEvent.click(screen.getByText('Test Heading'));
expect(callback).toBeCalledTimes(1);
});
@ -15,7 +19,8 @@ describe('Card', () => {
describe('Card Actions', () => {
it('Children should be disabled or enabled according to Card disabled prop', () => {
const { rerender } = render(
<Card heading="Test Heading">
<Card>
<Card.Heading>Test Heading</Card.Heading>
<Card.Actions>
<Button>Click Me</Button>
</Card.Actions>
@ -29,7 +34,8 @@ describe('Card', () => {
expect(screen.getByRole('button', { name: 'Delete' })).not.toBeDisabled();
rerender(
<Card heading="Test Heading" disabled>
<Card disabled>
<Card.Heading>Test Heading</Card.Heading>
<Card.Actions>
<Button>Click Me</Button>
</Card.Actions>
@ -45,7 +51,8 @@ describe('Card', () => {
it('Children should be independently enabled or disabled if explicitly set', () => {
const { rerender } = render(
<Card heading="Test Heading">
<Card>
<Card.Heading>Test Heading</Card.Heading>
<Card.Actions>
<Button disabled>Click Me</Button>
</Card.Actions>
@ -59,7 +66,8 @@ describe('Card', () => {
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
rerender(
<Card heading="Test Heading" disabled>
<Card disabled>
<Card.Heading>Test Heading</Card.Heading>
<Card.Actions>
<Button disabled={false}>Click Me</Button>
</Card.Actions>
@ -76,7 +84,8 @@ describe('Card', () => {
it('Children should be conditional', () => {
const shouldNotRender = false;
render(
<Card heading="Test Heading">
<Card>
<Card.Heading>Test Heading</Card.Heading>
<Card.Actions>
<Button>Click Me</Button>
{shouldNotRender && <Button>Delete</Button>}

View File

@ -1,225 +1,230 @@
import React, { memo, cloneElement, FC, ReactNode } from 'react';
import React, { memo, cloneElement, FC, useMemo, useContext, ReactNode } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2, stylesFactory } from '../../themes';
import { CardContainer, CardContainerProps } from './CardContainer';
import { useStyles2, useTheme2 } from '../../themes';
import { CardContainer, CardContainerProps, getCardContainerStyles } from './CardContainer';
import { getFocusStyles } from '../../themes/mixins';
/**
* @public
*/
export interface Props extends Omit<CardContainerProps, 'disableEvents' | 'disableHover'> {
/** Main heading for the Card **/
heading: ReactNode;
/** Card description text */
description?: string;
/** Indicates if the card and all its actions can be interacted with */
disabled?: boolean;
/** 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;
/** @deprecated Use `Card.Heading` instead */
heading?: ReactNode;
/** @deprecated Use `Card.Description` instead */
description?: string;
}
export interface CardInterface extends FC<Props> {
Heading: typeof Heading;
Tags: typeof Tags;
Figure: typeof Figure;
Meta: typeof Meta;
Actions: typeof Actions;
SecondaryActions: typeof SecondaryActions;
Description: typeof Description;
}
const CardContext = React.createContext<{
href?: string;
onClick?: () => void;
disabled?: boolean;
} | null>(null);
/**
* Generic card component
*
* @public
*/
export const Card: CardInterface = ({ heading, description, disabled, href, onClick, children, ...htmlProps }) => {
const theme = useTheme2();
const styles = getCardStyles(theme);
const [tags, figure, meta, actions, secondaryActions] = ['Tags', 'Figure', 'Meta', 'Actions', 'SecondaryActions'].map(
(item) => {
const found = React.Children.toArray(children as React.ReactElement[]).find((child) => {
return React.isValidElement(child) && child?.type && (child.type as any).displayName === item;
});
if (found && React.isValidElement(found)) {
return React.cloneElement(found, { disabled, styles, ...found.props });
}
return found;
}
export const Card: CardInterface = ({
disabled,
href,
onClick,
children,
heading: deprecatedHeading,
description: deprecatedDescription,
className,
...htmlProps
}) => {
const hasHeadingComponent = useMemo(
() =>
React.Children.toArray(children).some(
(c) => React.isValidElement(c) && (c.type as any).displayName === Heading.displayName
),
[children]
);
const hasActions = Boolean(actions || secondaryActions);
const disableHover = disabled || (!onClick && !href);
const disableEvents = disabled && !actions;
const onCardClick = onClick && !disabled ? onClick : undefined;
const onEnterKey = onClick && !disabled ? getEnterKeyHandler(onClick) : undefined;
const theme = useTheme2();
const styles = getCardContainerStyles(theme, disabled, disableHover);
return (
<CardContainer
tabIndex={disableHover ? undefined : 0}
onClick={onCardClick}
onKeyDown={onEnterKey}
disableEvents={disableEvents}
disableEvents={disabled}
disableHover={disableHover}
href={href}
className={cx(styles.container, className)}
{...htmlProps}
>
{figure}
<div className={styles.inner}>
<div className={styles.info}>
<div>
<h2 className={styles.heading}>{heading}</h2>
{meta}
{description && <p className={styles.description}>{description}</p>}
</div>
{tags}
</div>
{hasActions && (
<div className={styles.actionRow}>
{actions}
{secondaryActions}
</div>
)}
</div>
<CardContext.Provider value={{ href, onClick: onCardClick, disabled }}>
{!hasHeadingComponent && <Heading />}
{deprecatedHeading && <Heading>{deprecatedHeading}</Heading>}
{deprecatedDescription && <Description>{deprecatedDescription}</Description>}
{children}
</CardContext.Provider>
</CardContainer>
);
};
function getEnterKeyHandler(onClick: () => void) {
return (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onClick();
}
};
interface ChildProps {
className?: string;
disabled?: boolean;
children?: React.ReactNode;
/** @deprecated Use `className` to add new styles */
styles?: ReturnType<typeof getCardStyles>;
}
/**
* @public
*/
export const getCardStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
inner: css`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
flex-wrap: wrap;
`,
heading: css`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 0;
font-size: ${theme.typography.size.md};
letter-spacing: inherit;
line-height: ${theme.typography.body.lineHeight};
color: ${theme.colors.text.primary};
font-weight: ${theme.typography.fontWeightMedium};
`,
info: css`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
`,
metadata: css`
display: flex;
align-items: center;
width: 100%;
font-size: ${theme.typography.size.sm};
color: ${theme.colors.text.secondary};
margin: ${theme.spacing(0.5, 0, 0)};
line-height: ${theme.typography.bodySmall.lineHeight};
overflow-wrap: anywhere;
`,
description: css`
width: 100%;
margin: ${theme.spacing(1, 0, 0)};
color: ${theme.colors.text.secondary};
line-height: ${theme.typography.body.lineHeight};
`,
media: css`
margin-right: ${theme.spacing(2)};
width: 40px;
/** Main heading for the card */
const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps & { 'aria-label'?: string }) => {
const context = useContext(CardContext);
const styles = useStyles2(getHeadingStyles);
& > * {
width: 100%;
}
const { href, onClick } = context ?? { href: undefined, onClick: undefined };
&:empty {
display: none;
}
`,
actionRow: css`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: ${theme.spacing(2)};
`,
actions: css`
& > * {
margin-right: ${theme.spacing(1)};
}
`,
secondaryActions: css`
display: flex;
align-items: center;
color: ${theme.colors.text.secondary};
// align to the right
margin-left: auto;
& > * {
margin-right: ${theme.spacing(1)} !important;
}
`,
separator: css`
margin: 0 ${theme.spacing(1)};
`,
tagList: css`
max-width: 50%;
`,
};
return (
<h2 className={cx(styles.heading, className)}>
{href ? (
<a href={href} className={styles.linkHack} aria-label={ariaLabel}>
{children}
</a>
) : onClick ? (
<button onClick={onClick} className={styles.linkHack} aria-label={ariaLabel}>
{children}
</button>
) : (
<>{children}</>
)}
</h2>
);
};
Heading.displayName = 'Heading';
const getHeadingStyles = (theme: GrafanaTheme2) => ({
heading: css({
gridArea: 'Heading',
justifySelf: 'start',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
marginBottom: 0,
fontSize: theme.typography.size.md,
letterSpacing: 'inherit',
lineHeight: theme.typography.body.lineHeight,
color: theme.colors.text.primary,
fontWeight: theme.typography.fontWeightMedium,
}),
linkHack: css({
all: 'unset',
'&::after': {
position: 'absolute',
content: '""',
top: 0,
bottom: 0,
left: 0,
right: 0,
borderRadius: theme.shape.borderRadius(1),
},
'&:focus-visible': {
outline: 'none',
outlineOffset: 0,
boxShadow: 'none',
'&::after': {
...getFocusStyles(theme),
zIndex: 1,
},
},
}),
});
interface ChildProps {
styles?: ReturnType<typeof getCardStyles>;
disabled?: boolean;
}
const Tags: FC<ChildProps> = ({ children, styles }) => {
return <div className={styles?.tagList}>{children}</div>;
const Tags = ({ children, className }: ChildProps) => {
const styles = useStyles2(getTagStyles);
return <div className={cx(styles.tagList, className)}>{children}</div>;
};
Tags.displayName = 'Tags';
const Figure: FC<ChildProps & { align?: 'top' | 'center'; className?: string }> = ({
children,
styles,
align = 'top',
className,
}) => {
const getTagStyles = (theme: GrafanaTheme2) => ({
tagList: css({
position: 'relative',
gridArea: 'Tags',
alignSelf: 'center',
}),
});
/** Card description text */
const Description = ({ children, className }: ChildProps) => {
const styles = useStyles2(getDescriptionStyles);
return <p className={cx(styles.description, className)}>{children}</p>;
};
Description.displayName = 'Description';
const getDescriptionStyles = (theme: GrafanaTheme2) => ({
description: css({
width: '100%',
gridArea: 'Description',
margin: theme.spacing(1, 0, 0),
color: theme.colors.text.secondary,
lineHeight: theme.typography.body.lineHeight,
}),
});
const Figure = ({ children, align = 'start', className }: ChildProps & { align?: 'start' | 'center' }) => {
const styles = useStyles2(getFigureStyles);
return (
<div
className={cx(
styles?.media,
styles.media,
className,
align === 'center' &&
css`
display: flex;
align-items: center;
`
css`
align-self: ${align};
`
)}
>
{children}
</div>
);
};
Figure.displayName = 'Figure';
const Meta: FC<ChildProps & { separator?: string }> = memo(({ children, styles, separator = '|' }) => {
const getFigureStyles = (theme: GrafanaTheme2) => ({
media: css({
position: 'relative',
gridArea: 'Figure',
marginRight: theme.spacing(2),
width: '40px',
'> img': {
width: '100%',
},
'&:empty': {
display: 'none',
},
}),
});
const Meta = memo(({ children, className, separator = '|' }: ChildProps & { separator?: string }) => {
const styles = useStyles2(getMetaStyles);
let meta = children;
// Join meta data elements by separator
@ -230,55 +235,118 @@ const Meta: FC<ChildProps & { separator?: string }> = memo(({ children, styles,
}
meta = filtered.reduce((prev, curr, i) => [
prev,
<span key={`separator_${i}`} className={styles?.separator}>
<span key={`separator_${i}`} className={styles.separator}>
{separator}
</span>,
curr,
]);
}
return <div className={styles?.metadata}>{meta}</div>;
return <div className={cx(styles.metadata, className)}>{meta}</div>;
});
Meta.displayName = 'Meta';
const getMetaStyles = (theme: GrafanaTheme2) => ({
metadata: css({
gridArea: 'Meta',
display: 'flex',
alignItems: 'center',
width: '100%',
fontSize: theme.typography.size.sm,
color: theme.colors.text.secondary,
margin: theme.spacing(0.5, 0, 0),
lineHeight: theme.typography.bodySmall.lineHeight,
overflowWrap: 'anywhere',
}),
separator: css({
margin: `0 ${theme.spacing(1)}`,
}),
});
interface ActionsProps extends ChildProps {
children?: React.ReactNode;
variant?: 'primary' | 'secondary';
}
const BaseActions: FC<ActionsProps> = ({ children, styles, disabled, variant }) => {
const css = variant === 'primary' ? styles?.actions : styles?.secondaryActions;
const BaseActions = ({ children, disabled, variant, className }: ActionsProps) => {
const styles = useStyles2(getActionStyles);
const context = useContext(CardContext);
const isDisabled = context?.disabled || disabled;
const css = variant === 'primary' ? styles.actions : styles.secondaryActions;
return (
<div className={css}>
<div className={cx(css, className)}>
{React.Children.map(children, (child) => {
return React.isValidElement(child) ? cloneElement(child, { disabled, ...child.props }) : null;
return React.isValidElement(child) ? cloneElement(child, { disabled: isDisabled, ...child.props }) : null;
})}
</div>
);
};
const Actions: FC<ActionsProps> = ({ children, styles, disabled }) => {
const getActionStyles = (theme: GrafanaTheme2) => ({
actions: css({
gridArea: 'Actions',
marginTop: theme.spacing(2),
'& > *': {
marginRight: theme.spacing(1),
},
}),
secondaryActions: css({
display: 'flex',
gridArea: 'Secondary',
alignSelf: 'center',
color: theme.colors.text.secondary,
marginTtop: theme.spacing(2),
'& > *': {
marginRight: `${theme.spacing(1)} !important`,
},
}),
});
const Actions = ({ children, disabled, className }: ChildProps) => {
return (
<BaseActions variant="primary" disabled={disabled} styles={styles}>
<BaseActions variant="primary" disabled={disabled} className={className}>
{children}
</BaseActions>
);
};
Actions.displayName = 'Actions';
const SecondaryActions: FC<ActionsProps> = ({ children, styles, disabled }) => {
const SecondaryActions = ({ children, disabled, className }: ChildProps) => {
return (
<BaseActions variant="secondary" disabled={disabled} styles={styles}>
<BaseActions variant="secondary" disabled={disabled} className={className}>
{children}
</BaseActions>
);
};
SecondaryActions.displayName = 'SecondaryActions';
/**
* @public
* @deprecated Use `className` on respective components to modify styles
*/
export const getCardStyles = (theme: GrafanaTheme2) => {
return {
inner: css`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
flex-wrap: wrap;
`,
...getHeadingStyles(theme),
...getMetaStyles(theme),
...getDescriptionStyles(theme),
...getFigureStyles(theme),
...getActionStyles(theme),
...getTagStyles(theme),
};
};
Card.Heading = Heading;
Card.Tags = Tags;
Card.Figure = Figure;
Card.Meta = Meta;
Card.Actions = Actions;
Card.SecondaryActions = SecondaryActions;
Card.Description = Description;

View File

@ -1,28 +1,36 @@
import React, { HTMLAttributes, ReactNode } from 'react';
import React, { HTMLAttributes } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { styleMixins, stylesFactory, useTheme2 } from '../../themes';
import { styleMixins, stylesFactory, useStyles2, useTheme2 } from '../../themes';
/**
* @public
*/
export interface CardInnerProps {
href?: string;
children?: ReactNode;
children?: React.ReactNode;
}
/** @deprecated This component will be removed in a future release */
const CardInner = ({ children, href }: CardInnerProps) => {
const theme = useTheme2();
const { inner } = getCardContainerStyles(theme);
const { inner } = useStyles2(getCardInnerStyles);
return href ? (
<a className={inner} href={href}>
{children}
</a>
) : (
<div className={inner}>{children}</div>
<>{children}</>
);
};
const getCardInnerStyles = (theme: GrafanaTheme2) => ({
inner: css({
display: 'flex',
width: '100%',
padding: theme.spacing(2),
}),
});
/**
* @public
*/
@ -35,26 +43,58 @@ export interface CardContainerProps extends HTMLAttributes<HTMLOrSVGElement>, Ca
className?: string;
}
/** @deprecated Using `CardContainer` directly is discouraged and should be replaced with `Card` */
export const CardContainer = ({
href,
children,
disableEvents,
disableHover,
className,
href,
...props
}: CardContainerProps) => {
const theme = useTheme2();
const { container } = getCardContainerStyles(theme, disableEvents, disableHover);
const { oldContainer } = getCardContainerStyles(theme, disableEvents, disableHover);
return (
<div {...props} className={cx(container, className)}>
<div {...props} className={cx(oldContainer, className)}>
<CardInner href={href}>{children}</CardInner>
</div>
);
};
const getCardContainerStyles = stylesFactory((theme: GrafanaTheme2, disabled = false, disableHover = false) => {
export const getCardContainerStyles = stylesFactory((theme: GrafanaTheme2, disabled = false, disableHover = false) => {
return {
container: css({
display: 'grid',
position: 'relative',
gridTemplateColumns: 'auto 1fr auto',
gridTemplateRows: '1fr auto auto auto',
gridAutoColumns: '1fr',
gridAutoFlow: 'row',
gridTemplateAreas: `
"Figure Heading Tags"
"Figure Meta Tags"
"Figure Description Tags"
"Figure Actions Secondary"`,
width: '100%',
padding: theme.spacing(2),
background: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
marginBottom: '8px',
pointerEvents: disabled ? 'none' : 'auto',
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
}),
...(!disableHover && {
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),
cursor: 'pointer',
zIndex: 1,
},
'&:focus': styleMixins.getFocusStyles(theme),
}),
}),
oldContainer: css({
display: 'flex',
width: '100%',
background: theme.colors.background.secondary,
@ -75,10 +115,5 @@ const getCardContainerStyles = stylesFactory((theme: GrafanaTheme2, disabled = f
'&:focus': styleMixins.getFocusStyles(theme),
}),
}),
inner: css({
display: 'flex',
width: '100%',
padding: theme.spacing(2),
}),
};
});

View File

@ -190,18 +190,17 @@ export const ThemeDemo = () => {
Disabled
</Button>
</HorizontalGroup>
<Card heading="Button inside card">
<Card>
<Card.Heading>Button inside card</Card.Heading>
<Card.Actions>
<>
{allButtonVariants.map((variant) => (
<Button variant={variant} key={variant}>
{variant}
</Button>
))}
<Button variant="primary" disabled>
Disabled
{allButtonVariants.map((variant) => (
<Button variant={variant} key={variant}>
{variant}
</Button>
</>
))}
<Button variant="primary" disabled>
Disabled
</Button>
</Card.Actions>
</Card>
</VerticalGroup>

View File

@ -1,40 +0,0 @@
import React from 'react';
import { cx } from '@emotion/css';
export interface CardProps {
logoUrl?: string;
logoAlt?: string;
title: string;
description?: string;
labels?: React.ReactNode;
actions?: React.ReactNode;
onClick?: () => void;
ariaLabel?: string;
className?: string;
}
export const Card: React.FC<CardProps> = ({
logoUrl,
logoAlt,
title,
description,
labels,
actions,
onClick,
ariaLabel,
className,
}) => {
const mainClassName = cx('add-data-source-item', className);
return (
<div className={mainClassName} onClick={onClick} aria-label={ariaLabel}>
{logoUrl && <img className="add-data-source-item-logo" src={logoUrl} alt={logoAlt ?? ''} />}
<div className="add-data-source-item-text-wrapper">
<span className="add-data-source-item-text">{title}</span>
{description && <span className="add-data-source-item-desc">{description}</span>}
{labels && <div className="add-data-source-item-badge">{labels}</div>}
</div>
{actions && <div className="add-data-source-item-actions">{actions}</div>}
</div>
);
};

View File

@ -25,7 +25,8 @@ const AlertRuleItem = ({ rule, search, onTogglePause }: Props) => {
);
return (
<Card heading={<a href={ruleUrl}>{renderText(rule.name)}</a>}>
<Card href={ruleUrl}>
<Card.Heading>{renderText(rule.name)}</Card.Heading>
<Card.Figure>
<Icon size="xl" name={rule.stateIcon as IconName} className={`alert-rule-item__icon ${rule.stateClass}`} />
</Card.Figure>

View File

@ -70,11 +70,8 @@ export function RedirectToRuleViewer(props: RedirectToRuleViewerProps): JSX.Elem
<div className={styles.rules}>
{rules.map((rule, index) => {
return (
<Card
key={`${rule.name}-${index}`}
heading={rule.name}
href={createViewLink(rulesSource, rule, '/alerting/list')}
>
<Card key={`${rule.name}-${index}`} href={createViewLink(rulesSource, rule, '/alerting/list')}>
<Card.Heading>{rule.name}</Card.Heading>
<Card.Meta separator={''}>
<Icon name="folder" />
<span className={styles.namespace}>{`${rule.namespace.name} / ${rule.group.name}`}</span>

View File

@ -375,10 +375,10 @@ function TransformationCard({ transform, onClick }: TransformationCardProps) {
return (
<Card
className={styles.card}
heading={transform.name}
aria-label={selectors.components.TransformTab.newTransform(transform.name)}
onClick={onClick}
>
<Card.Heading>{transform.name}</Card.Heading>
<Card.Meta>{transform.description}</Card.Meta>
{transform.state && (
<Card.Tags>
@ -393,10 +393,7 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
card: css`
margin: 0;
> div {
padding: ${theme.spacing(1)};
}
padding: ${theme.spacing(1)};
`,
};
};

View File

@ -23,7 +23,6 @@ describe('DataSourcesList', () => {
it('should render all elements in the list item', () => {
setup();
expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'dataSource-0 dataSource-0' })).toBeInTheDocument();
expect(screen.getByAltText('dataSource-0')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'dataSource-0' })).toBeInTheDocument();
});
});

View File

@ -16,12 +16,13 @@ export const DataSourcesList: FC<Props> = ({ dataSources, layoutMode }) => {
return (
<ul className={styles.list}>
{dataSources.map((dataSource, index) => {
{dataSources.map((dataSource) => {
return (
<li key={dataSource.id}>
<Card heading={dataSource.name} href={`datasources/edit/${dataSource.uid}`}>
<Card href={`datasources/edit/${dataSource.uid}`}>
<Card.Heading>{dataSource.name}</Card.Heading>
<Card.Figure>
<img src={dataSource.typeLogoUrl} alt={dataSource.name} />
<img src={dataSource.typeLogoUrl} alt="" height="40px" width="40px" className={styles.logo} />
</Card.Figure>
<Card.Meta>
{[
@ -42,8 +43,13 @@ export default DataSourcesList;
const getStyles = () => {
return {
list: css`
list-style: none;
`,
list: css({
listStyle: 'none',
display: 'grid',
// gap: '8px', Add back when legacy support for old Card interface is dropped
}),
logo: css({
objectFit: 'contain',
}),
};
};

View File

@ -1,7 +1,8 @@
import React, { FC, PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { DataSourcePluginMeta, NavModel } from '@grafana/data';
import { Button, LinkButton, List, PluginSignatureBadge, FilterInput } from '@grafana/ui';
import { DataSourcePluginMeta, GrafanaTheme2, NavModel } from '@grafana/data';
import { Card, LinkButton, List, PluginSignatureBadge, FilterInput, useStyles2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import { selectors } from '@grafana/e2e-selectors';
import Page from 'app/core/components/Page/Page';
@ -9,7 +10,6 @@ import { StoreState } from 'app/types';
import { addDataSource, loadDataSourcePlugins } from './state/actions';
import { getDataSourcePlugins } from './state/selectors';
import { setDataSourceTypeSearchQuery } from './state/reducers';
import { Card } from 'app/core/components/Card/Card';
import { PluginsErrorsInfo } from '../plugins/components/PluginsErrorsInfo';
function mapStateToProps(state: StoreState) {
@ -45,7 +45,7 @@ class NewDataSourcePage extends PureComponent<Props> {
this.props.setDataSourceTypeSearchQuery(value);
};
renderPlugins(plugins: DataSourcePluginMeta[]) {
renderPlugins(plugins: DataSourcePluginMeta[], id?: string) {
if (!plugins || !plugins.length) {
return null;
}
@ -53,6 +53,11 @@ class NewDataSourcePage extends PureComponent<Props> {
return (
<List
items={plugins}
className={css`
> li {
margin-bottom: 2px;
}
`}
getItemKey={(item) => item.id.toString()}
renderItem={(item) => (
<DataSourceTypeCard
@ -61,6 +66,7 @@ class NewDataSourcePage extends PureComponent<Props> {
onLearnMoreClick={this.onLearnMoreClick}
/>
)}
aria-labelledby={id}
/>
);
}
@ -76,8 +82,10 @@ class NewDataSourcePage extends PureComponent<Props> {
<>
{categories.map((category) => (
<div className="add-data-source-category" key={category.id}>
<div className="add-data-source-category__header">{category.title}</div>
{this.renderPlugins(category.plugins)}
<div className="add-data-source-category__header" id={category.id}>
{category.title}
</div>
{this.renderPlugins(category.plugins, category.id)}
</div>
))}
<div className="add-data-source-more">
@ -131,37 +139,91 @@ const DataSourceTypeCard: FC<DataSourceTypeCardProps> = (props) => {
// find first plugin info link
const learnMoreLink = plugin.info?.links?.length > 0 ? plugin.info.links[0] : null;
const styles = useStyles2(getStyles);
return (
<Card
title={plugin.name}
description={plugin.info.description}
ariaLabel={selectors.pages.AddDataSource.dataSourcePlugins(plugin.name)}
logoUrl={plugin.info.logos.small}
actions={
<>
{learnMoreLink && (
<LinkButton
variant="secondary"
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
target="_blank"
rel="noopener"
onClick={onLearnMoreClick}
icon="external-link-alt"
>
{learnMoreLink.name}
</LinkButton>
)}
{!isPhantom && <Button disabled={plugin.unlicensed}>Select</Button>}
</>
}
labels={!isPhantom && <PluginSignatureBadge status={plugin.signature} />}
className={isPhantom ? 'add-data-source-item--phantom' : ''}
onClick={onClick}
aria-label={selectors.pages.AddDataSource.dataSourcePlugins(plugin.name)}
/>
<Card className={cx(styles.card, 'card-parent')} onClick={onClick}>
<Card.Heading
className={styles.heading}
aria-label={selectors.pages.AddDataSource.dataSourcePluginsV2(plugin.name)}
>
{plugin.name}
</Card.Heading>
<Card.Figure align="center" className={styles.figure}>
<img className={styles.logo} src={plugin.info.logos.small} alt="" />
</Card.Figure>
<Card.Description className={styles.description}>{plugin.info.description}</Card.Description>
{!isPhantom && (
<Card.Meta className={styles.meta}>
<PluginSignatureBadge status={plugin.signature} />
</Card.Meta>
)}
<Card.Actions className={styles.actions}>
{learnMoreLink && (
<LinkButton
variant="secondary"
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
target="_blank"
rel="noopener"
onClick={onLearnMoreClick}
icon="external-link-alt"
aria-label={`${plugin.name}, learn more.`}
>
{learnMoreLink.name}
</LinkButton>
)}
</Card.Actions>
</Card>
);
};
function getStyles(theme: GrafanaTheme2) {
return {
heading: css({
fontSize: theme.v1.typography.heading.h5,
fontWeight: 'inherit',
}),
figure: css({
width: 'inherit',
marginRight: '0px',
'> img': {
width: theme.spacing(7),
},
}),
meta: css({
marginTop: '6px',
position: 'relative',
}),
description: css({
margin: '0px',
fontSize: theme.typography.size.sm,
}),
actions: css({
position: 'relative',
alignSelf: 'center',
marginTop: '0px',
opacity: 0,
'.card-parent:hover &, .card-parent:focus-within &': {
opacity: 1,
},
}),
card: css({
gridTemplateAreas: `
"Figure Heading Actions"
"Figure Description Actions"
"Figure Meta Actions"
"Figure - Actions"`,
}),
logo: css({
marginRight: theme.v1.spacing.lg,
marginLeft: theme.v1.spacing.sm,
width: theme.spacing(7),
maxHeight: theme.spacing(7),
}),
};
}
export function getNavModel(): NavModel {
const main = {
icon: 'database',

View File

@ -1,7 +1,9 @@
import React from 'react';
import { PlaylistDTO } from './types';
import { Button, Card, LinkButton } from '@grafana/ui';
import { Button, Card, LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
interface Props {
setStartPlaylist: (playlistItem: PlaylistDTO) => void;
@ -10,32 +12,47 @@ interface Props {
}
export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDelete }: Props) => {
const styles = useStyles2(getStyles);
return (
<>
<ul className={styles.list}>
{playlists!.map((playlist: PlaylistDTO) => (
<Card heading={playlist.name} key={playlist.id.toString()}>
<Card.Actions>
<Button variant="secondary" icon="play" onClick={() => setStartPlaylist(playlist)}>
Start playlist
</Button>
{contextSrv.isEditor && (
<>
<LinkButton key="edit" variant="secondary" href={`/playlists/edit/${playlist.id}`} icon="cog">
Edit playlist
</LinkButton>
<Button
disabled={false}
onClick={() => setPlaylistToDelete({ id: playlist.id, name: playlist.name })}
icon="trash-alt"
variant="destructive"
>
Delete playlist
</Button>
</>
)}
</Card.Actions>
</Card>
<li className={styles.listItem} key={playlist.id.toString()}>
<Card>
<Card.Heading>{playlist.name}</Card.Heading>
<Card.Actions>
<Button variant="secondary" icon="play" onClick={() => setStartPlaylist(playlist)}>
Start playlist
</Button>
{contextSrv.isEditor && (
<>
<LinkButton key="edit" variant="secondary" href={`/playlists/edit/${playlist.id}`} icon="cog">
Edit playlist
</LinkButton>
<Button
disabled={false}
onClick={() => setPlaylistToDelete({ id: playlist.id, name: playlist.name })}
icon="trash-alt"
variant="destructive"
>
Delete playlist
</Button>
</>
)}
</Card.Actions>
</Card>
</li>
))}
</>
</ul>
);
};
function getStyles(theme: GrafanaTheme2) {
return {
list: css({
display: 'grid',
}),
listItem: css({
listStyle: 'none',
}),
};
}

View File

@ -52,11 +52,11 @@ export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSe
return (
<Card
data-testid={selectors.dashboardItem(item.title)}
heading={item.title}
href={item.url}
style={{ minHeight: SEARCH_ITEM_HEIGHT }}
className={styles.container}
>
<Card.Heading>{item.title}</Card.Heading>
<Card.Figure align={'center'} className={styles.checkbox}>
<SearchCheckbox
aria-label="Select dashboard"
@ -67,7 +67,7 @@ export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSe
</Card.Figure>
<Card.Meta separator={''}>
<span className={styles.metaContainer}>
<Icon name={'folder'} />
<Icon name={'folder'} aria-hidden />
{folderTitle}
</span>
{item.sortMetaName && (
@ -88,10 +88,7 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
margin-bottom: ${theme.spacing(0.75)};
a {
padding: ${theme.spacing(1)} ${theme.spacing(2)};
}
padding: ${theme.spacing(1)} ${theme.spacing(2)};
`,
metaContainer: css`
display: flex;

View File

@ -145,11 +145,8 @@ export function AlertList(props: PanelProps<AlertListOptions>) {
currentAlertState.value &&
currentAlertState.value!.map((alert) => (
<li className={styles.alertRuleItem} key={`alert-${alert.id}`}>
<Card
heading={alert.name}
href={`${alert.url}?viewPanel=${alert.panelId}`}
className={styles.cardContainer}
>
<Card href={`${alert.url}?viewPanel=${alert.panelId}`} className={styles.cardContainer}>
<Card.Heading>{alert.name}</Card.Heading>
<Card.Figure className={cx(styles.alertRuleItemIcon, alert.stateModel.stateClass)}>
<Icon name={alert.stateModel.iconClass} size="xl" className={styles.alertIcon} />
</Card.Figure>
@ -166,7 +163,8 @@ export function AlertList(props: PanelProps<AlertListOptions>) {
recentStateChanges.value &&
recentStateChanges.value.map((alert) => (
<li className={styles.alertRuleItem} key={`alert-${alert.id}`}>
<Card heading={alert.alertName} className={styles.cardContainer}>
<Card className={styles.cardContainer}>
<Card.Heading>{alert.alertName}</Card.Heading>
<Card.Figure className={cx(styles.alertRuleItemIcon, alert.stateModel.stateClass)}>
<Icon name={alert.stateModel.iconClass} size="xl" />
</Card.Figure>