mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d55d2d3da7
commit
890c43adf1
@ -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',
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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>}
|
||||
|
@ -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;
|
||||
|
@ -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),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user