Chore/PluginList: Consolidate PluginListItemRow & PluginListItemCard (#40271)

* Chore/PluginList: Consolidate PluginListItemRow & PluginListItemCard
This commit is contained in:
kay delaney
2021-10-12 11:06:22 +01:00
committed by GitHub
parent e1dfec49f9
commit 9fb6dfe11d
7 changed files with 207 additions and 309 deletions
@@ -1,5 +1,5 @@
import React from 'react';
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { useLocation } from 'react-router-dom';
@@ -12,26 +12,28 @@ interface Props {
}
export const PluginList = ({ plugins, displayMode }: Props) => {
const styles = useStyles2((theme) => getStyles(theme, displayMode));
const isList = displayMode === PluginListDisplayMode.List;
const styles = useStyles2(getStyles);
const location = useLocation();
return (
<div className={styles.container} data-testid="plugin-list">
<div className={cx(styles.container, { [styles.list]: isList })} data-testid="plugin-list">
{plugins.map((plugin) => (
<PluginListItem key={plugin.id} plugin={plugin} pathName={location.pathname} />
<PluginListItem key={plugin.id} plugin={plugin} pathName={location.pathname} displayMode={displayMode} />
))}
</div>
);
};
const getStyles = (theme: GrafanaTheme2, display: PluginListDisplayMode) => {
const isList = display === PluginListDisplayMode.List;
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
display: grid;
grid-template-columns: ${isList ? '1fr' : 'repeat(auto-fill, minmax(288px, 1fr))'};
grid-gap: ${theme.spacing(3)};
grid-template-columns: repeat(auto-fill, minmax(288px, 1fr));
gap: ${theme.spacing(3)};
`,
list: css`
grid-template-columns: 1fr;
`,
};
};
@@ -0,0 +1,118 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data';
import { PluginListItem } from './PluginListItem';
import { CatalogPlugin, PluginListDisplayMode } from '../types';
describe('PluginListItem', () => {
const plugin: CatalogPlugin = {
description: 'The test plugin',
downloads: 5,
id: 'test-plugin',
info: {
logos: {
small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small',
large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large',
},
},
name: 'Testing Plugin',
orgName: 'Test',
popularity: 0,
signature: PluginSignatureStatus.valid,
publishedAt: '2020-09-01',
updatedAt: '2021-06-28',
version: '1.0.0',
hasUpdate: false,
isInstalled: false,
isCore: false,
isDev: false,
isEnterprise: false,
isDisabled: false,
};
/** As Grid */
it('renders a card with link, image, name, orgName and badges', () => {
render(<PluginListItem plugin={plugin} pathName="/plugins" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview');
const logo = screen.getByRole('img');
expect(logo).toHaveAttribute('src', plugin.info.logos.small);
expect(screen.getByRole('heading', { name: /testing plugin/i })).toBeVisible();
expect(screen.getByText(`By ${plugin.orgName}`)).toBeVisible();
expect(screen.getByText(/signed/i)).toBeVisible();
expect(screen.queryByLabelText(/icon/i)).not.toBeInTheDocument();
});
it('renders a datasource plugin with correct icon', () => {
const datasourcePlugin = { ...plugin, type: PluginType.datasource };
render(<PluginListItem plugin={datasourcePlugin} pathName="" />);
expect(screen.getByLabelText(/datasource plugin icon/i)).toBeVisible();
});
it('renders a panel plugin with correct icon', () => {
const panelPlugin = { ...plugin, type: PluginType.panel };
render(<PluginListItem plugin={panelPlugin} pathName="" />);
expect(screen.getByLabelText(/panel plugin icon/i)).toBeVisible();
});
it('renders an app plugin with correct icon', () => {
const appPlugin = { ...plugin, type: PluginType.app };
render(<PluginListItem plugin={appPlugin} pathName="" />);
expect(screen.getByLabelText(/app plugin icon/i)).toBeVisible();
});
it('renders a disabled plugin with a badge to indicate its error', () => {
const pluginWithError = { ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature };
render(<PluginListItem plugin={pluginWithError} pathName="" />);
expect(screen.getByText(/disabled/i)).toBeVisible();
});
/** As List */
it('renders a row with link, image, name, orgName and badges', () => {
render(<PluginListItem plugin={plugin} pathName="/plugins" displayMode={PluginListDisplayMode.List} />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview');
const logo = screen.getByRole('img');
expect(logo).toHaveAttribute('src', plugin.info.logos.small);
expect(screen.getByRole('heading', { name: /testing plugin/i })).toBeVisible();
expect(screen.getByText(`By ${plugin.orgName}`)).toBeVisible();
expect(screen.getByText(/signed/i)).toBeVisible();
expect(screen.queryByLabelText(/icon/i)).not.toBeInTheDocument();
});
it('renders a datasource plugin with correct icon', () => {
const datasourcePlugin = { ...plugin, type: PluginType.datasource };
render(<PluginListItem plugin={datasourcePlugin} pathName="" displayMode={PluginListDisplayMode.List} />);
expect(screen.getByLabelText(/datasource plugin icon/i)).toBeVisible();
});
it('renders a panel plugin with correct icon', () => {
const panelPlugin = { ...plugin, type: PluginType.panel };
render(<PluginListItem plugin={panelPlugin} pathName="" displayMode={PluginListDisplayMode.List} />);
expect(screen.getByLabelText(/panel plugin icon/i)).toBeVisible();
});
it('renders an app plugin with correct icon', () => {
const appPlugin = { ...plugin, type: PluginType.app };
render(<PluginListItem plugin={appPlugin} pathName="" displayMode={PluginListDisplayMode.List} />);
expect(screen.getByLabelText(/app plugin icon/i)).toBeVisible();
});
it('renders a disabled plugin with a badge to indicate its error', () => {
const pluginWithError = { ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature };
render(<PluginListItem plugin={pluginWithError} pathName="" displayMode={PluginListDisplayMode.List} />);
expect(screen.getByText(/disabled/i)).toBeVisible();
});
});
@@ -1,78 +1,102 @@
import React from 'react';
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useDisplayMode } from '../state/hooks';
import { CatalogPlugin, PluginListDisplayMode } from '../types';
import { PluginListItemRow } from './PluginListItemRow';
import { PluginListItemCard } from './PluginListItemCard';
import { CatalogPlugin, PluginIconName, PluginListDisplayMode, PluginTabIds } from '../types';
import { PluginListItemBadges } from './PluginListItemBadges';
import { PluginLogo } from './PluginLogo';
import { Icon, useStyles2 } from '@grafana/ui';
export const LOGO_SIZE = '48px';
type Props = {
plugin: CatalogPlugin;
pathName: string;
displayMode?: PluginListDisplayMode;
};
export function PluginListItem({ plugin, pathName }: Props) {
const { displayMode } = useDisplayMode();
export function PluginListItem({ plugin, pathName, displayMode = PluginListDisplayMode.Grid }: Props) {
const styles = useStyles2(getStyles);
const isList = displayMode === PluginListDisplayMode.List;
if (isList) {
return <PluginListItemRow plugin={plugin} pathName={pathName} />;
}
return <PluginListItemCard plugin={plugin} pathName={pathName} />;
return (
<a
href={`${pathName}/${plugin.id}?page=${PluginTabIds.OVERVIEW}`}
className={cx(styles.container, { [styles.list]: isList })}
>
<PluginLogo src={plugin.info.logos.small} className={styles.pluginLogo} height={LOGO_SIZE} alt="" />
<h2 className={cx(styles.name, 'plugin-name')}>{plugin.name}</h2>
<div className={cx(styles.content, 'plugin-content')}>
<p>By {plugin.orgName}</p>
<PluginListItemBadges plugin={plugin} />
</div>
<div className={styles.pluginType}>
{plugin.type && <Icon name={PluginIconName[plugin.type]} aria-label={`${plugin.type} plugin icon`} />}
</div>
</a>
);
}
// Styles shared between the different type of list items
export const getStyles = (theme: GrafanaTheme2, displayMode: PluginListDisplayMode) => {
const isRow = displayMode === PluginListDisplayMode.List;
const isCard = displayMode === PluginListDisplayMode.Grid;
export const getStyles = (theme: GrafanaTheme2) => {
return {
cardContainer: css`
margin-bottom: 0;
padding: ${theme.spacing()};
`,
headerWrap: css`
container: css`
display: grid;
grid-template-columns: ${LOGO_SIZE} 1fr ${theme.spacing(3)};
grid-gap: ${theme.spacing(2)};
width: 100%;
${isCard &&
css`
align-items: center;
`}
grid-template-rows: auto;
gap: ${theme.spacing(2)};
grid-auto-flow: row;
background: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius()};
padding: ${theme.spacing(3)};
transition: ${theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
})};
&:hover {
background: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
}
`,
list: css`
row-gap: 0px;
> img {
align-self: start;
}
> .plugin-content {
min-height: 0px;
grid-area: 2 / 2 / 4 / 3;
> p {
margin: ${theme.spacing(0, 0, 0.5, 0)};
}
}
> .plugin-name {
align-self: center;
grid-area: 1 / 2 / 2 / 3;
}
`,
pluginType: css`
grid-area: 1 / 3 / 2 / 4;
color: ${theme.colors.text.secondary};
`,
pluginLogo: css`
grid-area: 1 / 1 / 3 / 2;
max-width: 100%;
align-self: center;
object-fit: contain;
`,
content: css`
grid-area: 3 / 1 / 4 / 3;
color: ${theme.colors.text.secondary};
`,
name: css`
color: ${theme.colors.text.primary};
flex-grow: 1;
grid-area: 1 / 2 / 3 / 3;
align-self: center;
font-size: ${theme.typography.h4.fontSize};
margin-bottom: 0;
`,
image: css`
object-fit: contain;
max-width: 100%;
`,
icon: css`
align-self: flex-start;
color: ${theme.colors.text.secondary};
`,
orgName: css`
color: ${theme.colors.text.secondary};
${isRow &&
css`
margin: ${theme.spacing(0, 0, 0.5, 0)};
`}
${isCard &&
css`
margin-bottom: 0;
`};
`,
hasUpdate: css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.bodySmall.fontSize};
margin-bottom: 0;
color: ${theme.colors.text.primary};
margin: 0;
`,
};
};
@@ -1,75 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data';
import { PluginListItemCard } from './PluginListItemCard';
import { CatalogPlugin } from '../types';
describe('PluginListItemCard', () => {
const plugin: CatalogPlugin = {
description: 'The test plugin',
downloads: 5,
id: 'test-plugin',
info: {
logos: {
small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small',
large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large',
},
},
name: 'Testing Plugin',
orgName: 'Test',
popularity: 0,
signature: PluginSignatureStatus.valid,
publishedAt: '2020-09-01',
updatedAt: '2021-06-28',
version: '1.0.0',
hasUpdate: false,
isInstalled: false,
isCore: false,
isDev: false,
isEnterprise: false,
isDisabled: false,
};
it('renders a card with link, image, name, orgName and badges', () => {
render(<PluginListItemCard plugin={plugin} pathName="/plugins" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview');
const logo = screen.getByRole('img');
expect(logo).toHaveAttribute('src', plugin.info.logos.small);
expect(logo).toHaveAttribute('alt', `${plugin.name} logo`);
expect(screen.getByRole('heading', { name: /testing plugin/i })).toBeVisible();
expect(screen.getByText(`By ${plugin.orgName}`)).toBeVisible();
expect(screen.getByText(/signed/i)).toBeVisible();
expect(screen.queryByLabelText(/icon/i)).not.toBeInTheDocument();
});
it('renders a datasource plugin with correct icon', () => {
const datasourcePlugin = { ...plugin, type: PluginType.datasource };
render(<PluginListItemCard plugin={datasourcePlugin} pathName="" />);
expect(screen.getByTestId(/datasource plugin icon/i)).toBeVisible();
});
it('renders a panel plugin with correct icon', () => {
const panelPlugin = { ...plugin, type: PluginType.panel };
render(<PluginListItemCard plugin={panelPlugin} pathName="" />);
expect(screen.getByTestId(/panel plugin icon/i)).toBeVisible();
});
it('renders an app plugin with correct icon', () => {
const appPlugin = { ...plugin, type: PluginType.app };
render(<PluginListItemCard plugin={appPlugin} pathName="" />);
expect(screen.getByTestId(/app plugin icon/i)).toBeVisible();
});
it('renders a disabled plugin with a badge to indicate its error', () => {
const pluginWithError = { ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature };
render(<PluginListItemCard plugin={pluginWithError} pathName="" />);
expect(screen.getByText(/disabled/i)).toBeVisible();
});
});
@@ -1,47 +0,0 @@
import React from 'react';
import { Icon, useStyles2, CardContainer, VerticalGroup } from '@grafana/ui';
import { CatalogPlugin, PluginIconName, PluginListDisplayMode, PluginTabIds } from '../types';
import { PluginLogo } from './PluginLogo';
import { PluginListItemBadges } from './PluginListItemBadges';
import { getStyles, LOGO_SIZE } from './PluginListItem';
type Props = {
plugin: CatalogPlugin;
pathName: string;
};
export function PluginListItemCard({ plugin, pathName }: Props) {
const styles = useStyles2((theme) => getStyles(theme, PluginListDisplayMode.Grid));
return (
<CardContainer href={`${pathName}/${plugin.id}?page=${PluginTabIds.OVERVIEW}`} className={styles.cardContainer}>
<VerticalGroup spacing="md">
<div className={styles.headerWrap}>
{/* Logo */}
<PluginLogo
src={plugin.info.logos.small}
alt={`${plugin.name} logo`}
className={styles.image}
height={LOGO_SIZE}
/>
{/* Name */}
<h2 className={styles.name}>{plugin.name}</h2>
{/* Type Icon */}
{plugin.type && (
<div className={styles.icon} data-testid={`${plugin.type} plugin icon`}>
<Icon name={PluginIconName[plugin.type]} />
</div>
)}
</div>
{/* Org */}
<p className={styles.orgName}>By {plugin.orgName}</p>
{/* Badges */}
<PluginListItemBadges plugin={plugin} />
</VerticalGroup>
</CardContainer>
);
}
@@ -1,75 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data';
import { PluginListItemRow } from './PluginListItemRow';
import { CatalogPlugin } from '../types';
describe('PluginListItemRow', () => {
const plugin: CatalogPlugin = {
description: 'The test plugin',
downloads: 5,
id: 'test-plugin',
info: {
logos: {
small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small',
large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large',
},
},
name: 'Testing Plugin',
orgName: 'Test',
popularity: 0,
signature: PluginSignatureStatus.valid,
publishedAt: '2020-09-01',
updatedAt: '2021-06-28',
version: '1.0.0',
hasUpdate: false,
isInstalled: false,
isCore: false,
isDev: false,
isEnterprise: false,
isDisabled: false,
};
it('renders a row with link, image, name, orgName and badges', () => {
render(<PluginListItemRow plugin={plugin} pathName="/plugins" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview');
const logo = screen.getByRole('img');
expect(logo).toHaveAttribute('src', plugin.info.logos.small);
expect(logo).toHaveAttribute('alt', `${plugin.name} logo`);
expect(screen.getByRole('heading', { name: /testing plugin/i })).toBeVisible();
expect(screen.getByText(`By ${plugin.orgName}`)).toBeVisible();
expect(screen.getByText(/signed/i)).toBeVisible();
expect(screen.queryByLabelText(/icon/i)).not.toBeInTheDocument();
});
it('renders a datasource plugin with correct icon', () => {
const datasourcePlugin = { ...plugin, type: PluginType.datasource };
render(<PluginListItemRow plugin={datasourcePlugin} pathName="" />);
expect(screen.getByLabelText(/datasource plugin icon/i)).toBeVisible();
});
it('renders a panel plugin with correct icon', () => {
const panelPlugin = { ...plugin, type: PluginType.panel };
render(<PluginListItemRow plugin={panelPlugin} pathName="" />);
expect(screen.getByLabelText(/panel plugin icon/i)).toBeVisible();
});
it('renders an app plugin with correct icon', () => {
const appPlugin = { ...plugin, type: PluginType.app };
render(<PluginListItemRow plugin={appPlugin} pathName="" />);
expect(screen.getByLabelText(/app plugin icon/i)).toBeVisible();
});
it('renders a disabled plugin with a badge to indicate its error', () => {
const pluginWithError = { ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature };
render(<PluginListItemRow plugin={pluginWithError} pathName="" />);
expect(screen.getByText(/disabled/i)).toBeVisible();
});
});
@@ -1,49 +0,0 @@
import React from 'react';
import { Icon, useStyles2, CardContainer, VerticalGroup } from '@grafana/ui';
import { CatalogPlugin, PluginIconName, PluginListDisplayMode, PluginTabIds } from '../types';
import { PluginLogo } from './PluginLogo';
import { PluginListItemBadges } from './PluginListItemBadges';
import { getStyles, LOGO_SIZE } from './PluginListItem';
type Props = {
plugin: CatalogPlugin;
pathName: string;
};
export function PluginListItemRow({ plugin, pathName }: Props) {
const styles = useStyles2((theme) => getStyles(theme, PluginListDisplayMode.List));
return (
<CardContainer href={`${pathName}/${plugin.id}?page=${PluginTabIds.OVERVIEW}`} className={styles.cardContainer}>
<VerticalGroup spacing="md">
<div className={styles.headerWrap}>
{/* Logo */}
<PluginLogo
src={plugin.info.logos.small}
alt={`${plugin.name} logo`}
className={styles.image}
height={LOGO_SIZE}
/>
<div>
{/* Name */}
<h3 className={styles.name}>{plugin.name}</h3>
{/* Org */}
<p className={styles.orgName}>By {plugin.orgName}</p>
{/* Badges */}
<PluginListItemBadges plugin={plugin} />
</div>
{/* Type Icon */}
{plugin.type && (
<div className={styles.icon}>
<Icon name={PluginIconName[plugin.type]} aria-label={`${plugin.type} plugin icon`} />
</div>
)}
</div>
</VerticalGroup>
</CardContainer>
);
}