Plugins: Update Catalog Card UI (#37350)

* feat(catalog): lazy load and add alt text to plugin logos

* refactor(catalog): use plugin types, make sure data is available for new ui

* test(catalog): fix up tests after types refactor

* feat(catalog): introduce Tile and PluginBadge components for ui updates

* refactor(catalog): update PluginList to use new components, lazy load images, add creditcard icon

* test(catalog): update Browse.test types

* fix(catalog): if local and remote make sure to get the correct local plugin from array

* refactor(catalog): prefer grafana/ui components over custom Tile component

* chore(catalog): delete redundant components

* feat(catalog): introduce ascending descending name sort for Browse

* refactor(catalog): prefer theme spacing over hardcoded values

* refactor(catalog): update Local and Remote plugin types to match api responses

* fix(catalog): prefer local.hasUpdate and local.signature so updateable plugin signature is valid

* test(catalog): update test plugin mocks

* test(catalog): add tests for sorting and categorise

* test(catalog): add tests for plugin cards, remove grid component

* test(catalog): add tests for PluginBadges component

* refactor(catalog): change enterprise learn more link to open plugin page on website
This commit is contained in:
Jack Westbrook 2021-08-04 15:09:57 +02:00 committed by GitHub
parent e8e1a0b50b
commit cc7c54be0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 742 additions and 353 deletions

View File

@ -33,6 +33,7 @@ export enum PluginSignatureType {
commercial = 'commercial',
community = 'community',
private = 'private',
core = 'core',
}
/** Describes error code returned from Grafana plugins API call */

View File

@ -47,6 +47,7 @@ export const getAvailableIcons = () =>
'comments-alt',
'compass',
'copy',
'credit-card',
'cube',
'database',
'document-info',

View File

@ -1,8 +1,8 @@
import { getBackendSrv } from '@grafana/runtime';
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
import { Plugin, PluginDetails, Org, LocalPlugin } from './types';
import { PluginDetails, Org, LocalPlugin, RemotePlugin } from './types';
async function getRemotePlugins(): Promise<Plugin[]> {
async function getRemotePlugins(): Promise<RemotePlugin[]> {
const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`);
return res.items;
}
@ -23,7 +23,7 @@ async function getPlugin(slug: string): Promise<PluginDetails> {
};
}
async function getRemotePlugin(slug: string, local: LocalPlugin | undefined): Promise<Plugin | undefined> {
async function getRemotePlugin(slug: string, local: LocalPlugin | undefined): Promise<RemotePlugin | undefined> {
try {
return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${slug}`);
} catch (error) {

View File

@ -1,68 +0,0 @@
import React from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
interface Props {
href: string;
text: React.ReactNode;
image: React.ReactNode;
layout?: 'horizontal' | 'vertical';
}
export const Card = ({ href, text, image, layout = 'vertical' }: Props) => {
const styles = useStyles2(getCardStyles);
return (
<a href={href} className={styles.root}>
<div
className={cx(styles.container, {
[styles.containerHorizontal]: layout === 'horizontal',
})}
>
<div
className={cx(styles.imgContainer, {
[styles.imgContainerHorizontal]: layout === 'horizontal',
})}
>
{image}
</div>
{text}
</div>
</a>
);
};
const getCardStyles = (theme: GrafanaTheme2) => ({
root: css`
background-color: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius()};
cursor: pointer;
height: 100%;
padding: ${theme.spacing(2)};
&:hover {
background-color: ${theme.colors.action.hover};
}
`,
container: css`
display: flex;
flex-direction: column;
justify-content: space-around;
height: 100%;
`,
containerHorizontal: css`
flex-direction: row;
justify-content: flex-start;
`,
imgContainer: css`
align-items: center;
display: flex;
flex-grow: 1;
justify-content: center;
padding: ${theme.spacing()} 0;
`,
imgContainerHorizontal: css`
flex-grow: 0;
`,
});

View File

@ -1,23 +0,0 @@
import React from 'react';
import { css } from '@emotion/css';
import { useTheme2 } from '@grafana/ui';
interface Props {
children: React.ReactNode;
}
export const Grid = ({ children }: Props) => {
const theme = useTheme2();
return (
<div
className={css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(175px, 1fr));
grid-gap: ${theme.spacing.gridSize}px;
`}
>
{children}
</div>
);
};

View File

@ -0,0 +1,69 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PluginSignatureStatus } from '@grafana/data';
import { PluginBadges } from './PluginBadges';
import { CatalogPlugin } from '../types';
const runtimeMock = jest.requireMock('@grafana/runtime');
jest.mock('@grafana/runtime', () => ({
config: {
licenseInfo: {
hasValidLicense: false,
},
},
}));
describe('PluginBadges', () => {
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,
};
it('renders a plugin signature badge', () => {
render(<PluginBadges plugin={plugin} />);
expect(screen.getByText(/signed/i)).toBeVisible();
});
it('renders an installed badge', () => {
render(<PluginBadges plugin={{ ...plugin, isInstalled: true }} />);
expect(screen.getByText(/signed/i)).toBeVisible();
expect(screen.getByText(/installed/i)).toBeVisible();
});
it('renders an enterprise badge (when a license is valid)', () => {
runtimeMock.config.licenseInfo.hasValidLicense = true;
render(<PluginBadges plugin={{ ...plugin, isEnterprise: true }} />);
expect(screen.getByText(/enterprise/i)).toBeVisible();
expect(screen.queryByRole('button', { name: /learn more/i })).not.toBeInTheDocument();
});
it('renders an enterprise badge with icon and link (when a license is invalid)', () => {
runtimeMock.config.licenseInfo.hasValidLicense = false;
render(<PluginBadges plugin={{ ...plugin, isEnterprise: true }} />);
expect(screen.getByText(/enterprise/i)).toBeVisible();
expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /learn more/i })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,58 @@
import React from 'react';
import { css } from '@emotion/css';
import { Badge, Button, HorizontalGroup, PluginSignatureBadge, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CatalogPlugin } from '../types';
type PluginBadgeType = {
plugin: CatalogPlugin;
};
export function PluginBadges({ plugin }: PluginBadgeType) {
if (plugin.isEnterprise) {
return <EnterpriseBadge id={plugin.id} />;
}
return (
<HorizontalGroup>
<PluginSignatureBadge status={plugin.signature} />
{plugin.isInstalled && <InstalledBadge />}
</HorizontalGroup>
);
}
function EnterpriseBadge({ id }: { id: string }) {
const customBadgeStyles = useStyles2(getBadgeColor);
const onClick = (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
ev.preventDefault();
window.open(
`https://grafana.com/grafana/plugins/${id}?utm_source=grafana_catalog_learn_more`,
'_blank',
'noopener,noreferrer'
);
};
if (config.licenseInfo?.hasValidLicense) {
return <Badge text="Enterprise" color="blue" />;
}
return (
<HorizontalGroup>
<Badge icon="lock" aria-label="lock icon" text="Enterprise" color="blue" className={customBadgeStyles} />
<Button size="sm" fill="text" icon="external-link-alt" onClick={onClick}>
Learn more
</Button>
</HorizontalGroup>
);
}
function InstalledBadge() {
const customBadgeStyles = useStyles2(getBadgeColor);
return <Badge text="Installed" color="orange" className={customBadgeStyles} />;
}
const getBadgeColor = (theme: GrafanaTheme2) => css`
background: ${theme.colors.background.primary};
border-color: ${theme.colors.border.strong};
color: ${theme.colors.text.secondary};
`;

View File

@ -1,13 +1,10 @@
import React from 'react';
import { useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { useLocation } from 'react-router-dom';
import { Card } from '../components/Card';
import { Grid } from '../components/Grid';
import { CatalogPlugin } from '../types';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { PluginLogo } from './PluginLogo';
import { CatalogPlugin } from '../types';
import { PluginListCard } from './PluginListCard';
import { useLocation } from 'react-router-dom';
interface Props {
plugins: CatalogPlugin[];
@ -18,50 +15,16 @@ export const PluginList = ({ plugins }: Props) => {
const location = useLocation();
return (
<Grid>
{plugins.map((plugin) => {
const { name, id, orgName } = plugin;
return (
<Card
key={`${id}`}
href={`${location.pathname}/${id}`}
image={
<PluginLogo
src={plugin.info.logos.small}
className={css`
max-height: 64px;
`}
/>
}
text={
<>
<div className={styles.name}>{name}</div>
<div className={styles.orgName}>{orgName}</div>
</>
}
/>
);
})}
</Grid>
<div className={styles} data-testid="plugin-list">
{plugins.map((plugin) => (
<PluginListCard key={plugin.id} plugin={plugin} pathName={location.pathname} />
))}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
name: css`
font-size: ${theme.typography.h4.fontSize};
color: ${theme.colors.text};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
`,
orgName: css`
font-size: ${theme.typography.body.fontSize};
color: ${theme.colors.text.secondary};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
`,
});
const getStyles = (theme: GrafanaTheme2) => css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(288px, 1fr));
grid-gap: ${theme.spacing(3)};
`;

View File

@ -0,0 +1,67 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { PluginSignatureStatus, PluginType } from '@grafana/data';
import { PluginListCard } from './PluginListCard';
import { CatalogPlugin } from '../types';
describe('PluginCard', () => {
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,
};
it('renders a card with link, image, name, orgName and badges', () => {
render(<PluginListCard plugin={plugin} pathName="/plugins" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin');
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(<PluginListCard 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(<PluginListCard 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(<PluginListCard plugin={appPlugin} pathName="" />);
expect(screen.getByLabelText(/app plugin icon/i)).toBeVisible();
});
});

View File

@ -0,0 +1,81 @@
import React from 'react';
import { css } from '@emotion/css';
import { Icon, useStyles2, CardContainer, VerticalGroup } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { CatalogPlugin } from '../types';
import { PluginLogo } from './PluginLogo';
import { PluginBadges } from './PluginBadges';
const LOGO_SIZE = '48px';
enum IconName {
app = 'apps',
datasource = 'database',
panel = 'credit-card',
renderer = 'pen',
}
type PluginListCardProps = {
plugin: CatalogPlugin;
pathName: string;
};
export function PluginListCard({ plugin, pathName }: PluginListCardProps) {
const { name, id, orgName, type } = plugin;
const styles = useStyles2(getStyles);
return (
<CardContainer href={`${pathName}/${id}`} className={styles.cardContainer}>
<VerticalGroup spacing="md">
<div className={styles.headerWrap}>
<PluginLogo
src={plugin.info.logos.small}
alt={`${plugin.name} logo`}
className={styles.image}
height={LOGO_SIZE}
/>
<h3 className={styles.name}>{name}</h3>
{type && (
<div className={styles.icon}>
<Icon name={IconName[type]} aria-label={`${type} plugin icon`} />
</div>
)}
</div>
<p className={styles.orgName}>By {orgName}</p>
<PluginBadges plugin={plugin} />
</VerticalGroup>
</CardContainer>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
cardContainer: css`
margin-bottom: 0;
padding: ${theme.spacing()};
`,
headerWrap: css`
align-items: center;
display: grid;
grid-template-columns: ${LOGO_SIZE} 1fr ${theme.spacing(3)};
grid-gap: ${theme.spacing(2)};
width: 100%;
`,
name: css`
color: ${theme.colors.text.primary};
flex-grow: 1;
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};
margin-bottom: 0;
`,
});

View File

@ -1,10 +1,13 @@
import React from 'react';
type PluginLogoProps = {
src: string;
alt: string;
className?: string;
src: string;
height?: string | number;
};
export function PluginLogo({ src, className }: PluginLogoProps): React.ReactElement {
return <img src={src} className={className} />;
export function PluginLogo({ alt, className, src, height }: PluginLogoProps): React.ReactElement {
// @ts-ignore - react doesn't know about loading attr.
return <img src={src} className={className} alt={alt} loading="lazy" height={height} />;
}

View File

@ -1,7 +1,8 @@
import { config } from '@grafana/runtime';
import { gt } from 'semver';
import { PluginSignatureStatus } from '@grafana/data';
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, RemotePlugin, Version, PluginFilter } from './types';
import { contextSrv } from 'app/core/services/context_srv';
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, Plugin, Version, PluginFilter } from './types';
export function isGrafanaAdmin(): boolean {
return config.bootData.user.isGrafanaAdmin;
@ -11,7 +12,7 @@ export function isOrgAdmin() {
return contextSrv.hasRole('Admin');
}
export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin {
export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin {
const {
name,
slug: id,
@ -24,7 +25,11 @@ export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin {
updatedAt,
createdAt: publishedAt,
status,
versionSignatureType,
signatureType,
} = plugin;
const hasSignature = signatureType !== '' || versionSignatureType !== '';
const catalogPlugin = {
description,
downloads,
@ -39,6 +44,7 @@ export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin {
orgName,
popularity,
publishedAt,
signature: hasSignature ? PluginSignatureStatus.valid : PluginSignatureStatus.missing,
updatedAt,
version,
hasUpdate: false,
@ -69,6 +75,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
orgName: author.name,
popularity: 0,
publishedAt: '',
signature,
updatedAt: updated,
version,
hasUpdate: false,
@ -80,15 +87,12 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
};
}
export function getCatalogPluginDetails(
local: LocalPlugin | undefined,
remote: Plugin | undefined,
pluginVersions: Version[] | undefined
): CatalogPluginDetails {
export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin): CatalogPlugin {
const version = remote?.version || local?.info.version || '';
const hasUpdate = Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
const hasUpdate =
local?.hasUpdate || Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
const id = remote?.slug || local?.id || '';
const hasRemoteSignature = remote?.signatureType !== '' || remote?.versionSignatureType !== '';
let logos = {
small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small',
large: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/large',
@ -103,32 +107,43 @@ export function getCatalogPluginDetails(
logos = local.info.logos;
}
const plugin = {
return {
description: remote?.description || local?.info.description || '',
downloads: remote?.downloads || 0,
grafanaDependency: remote?.json?.dependencies?.grafanaDependency || '',
hasUpdate,
id,
info: {
logos,
},
isCore: Boolean(remote?.internal || local?.signature === 'internal'),
isCore: Boolean(remote?.internal || local?.signature === PluginSignatureStatus.internal),
isDev: Boolean(local?.dev),
isEnterprise: remote?.status === 'enterprise' || false,
isEnterprise: remote?.status === 'enterprise',
isInstalled: Boolean(local),
links: remote?.json?.info.links || local?.info.links || [],
name: remote?.name || local?.name || '',
orgName: remote?.orgName || local?.info.author.name || '',
popularity: remote?.popularity || 0,
publishedAt: remote?.createdAt || '',
readme: remote?.readme || 'No plugin help or readme markdown file was found',
type: remote?.typeCode || local?.type || '',
type: remote?.typeCode || local?.type,
signature: local?.signature || hasRemoteSignature ? PluginSignatureStatus.valid : PluginSignatureStatus.missing,
updatedAt: remote?.updatedAt || local?.info.updated || '',
version,
versions: pluginVersions || [],
};
}
return plugin;
export function getCatalogPluginDetails(
local: LocalPlugin | undefined,
remote: RemotePlugin | undefined,
pluginVersions: Version[] = []
): CatalogPluginDetails {
const plugin = mapToCatalogPlugin(local, remote);
return {
...plugin,
grafanaDependency: remote?.json?.dependencies?.grafanaDependency || '',
links: remote?.json?.info.links || local?.info.links || [],
readme: remote?.readme || 'No plugin help or readme markdown file was found',
versions: pluginVersions,
};
}
export const isInstalled: PluginFilter = (plugin, query) =>

View File

@ -2,7 +2,14 @@ import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { CatalogPlugin, CatalogPluginsState, PluginsByFilterType, FilteredPluginsState } from '../types';
import { api } from '../api';
import { mapLocalToCatalog, mapRemoteToCatalog, isInstalled, isType, matchesKeyword } from '../helpers';
import {
mapLocalToCatalog,
mapRemoteToCatalog,
mapToCatalogPlugin,
isInstalled,
isType,
matchesKeyword,
} from '../helpers';
export function usePlugins(): CatalogPluginsState {
const { loading, value, error } = useAsync(async () => {
@ -21,10 +28,6 @@ export function usePlugins(): CatalogPluginsState {
}
for (const plugin of remote) {
if (unique[plugin.slug]) {
continue;
}
if (plugin.typeCode === 'renderer') {
continue;
}
@ -33,9 +36,15 @@ export function usePlugins(): CatalogPluginsState {
continue;
}
unique[plugin.slug] = mapRemoteToCatalog(plugin);
if (unique[plugin.slug]) {
unique[plugin.slug] = mapToCatalogPlugin(
installed.find((installedPlugin) => installedPlugin.id === plugin.slug),
plugin
);
} else {
unique[plugin.slug] = mapRemoteToCatalog(plugin);
}
}
return Object.values(unique);
}, [value?.installed, value?.remote]);

View File

@ -1,12 +1,13 @@
import React from 'react';
import { Router } from 'react-router-dom';
import { render, RenderResult, waitFor } from '@testing-library/react';
import { render, RenderResult, waitFor, within } from '@testing-library/react';
import { Provider } from 'react-redux';
import { locationService } from '@grafana/runtime';
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
import BrowsePage from './Browse';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { configureStore } from 'app/store/configureStore';
import { LocalPlugin, Plugin, PluginAdminRoutes } from '../types';
import { LocalPlugin, RemotePlugin, PluginAdminRoutes } from '../types';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
jest.mock('@grafana/runtime', () => ({
@ -42,135 +43,221 @@ function setup(path = '/plugins'): RenderResult {
}
describe('Browse list of plugins', () => {
it('should list installed plugins by default', async () => {
const { queryByText } = setup('/plugins');
describe('when filtering', () => {
it('should list installed plugins by default', async () => {
const { queryByText } = setup('/plugins');
await waitFor(() => queryByText('Installed'));
await waitFor(() => queryByText('Installed'));
for (const plugin of installed) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of installed) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeNull();
}
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeNull();
}
});
it('should list all plugins (except core plugins) when filtering by all', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=all');
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
});
it('should list installed plugins (including core plugins) when filtering by installed', async () => {
const { queryByText } = setup('/plugins?filterBy=installed');
await waitFor(() => queryByText('Installed'));
for (const plugin of installed) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of remote) {
expect(queryByText(plugin.name)).not.toBeInTheDocument();
}
});
it('should list enterprise plugins', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=wavefront');
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
});
it('should list only datasource plugins when filtering by datasource', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=datasource');
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
expect(queryByText('ACE.SVG')).not.toBeInTheDocument();
});
it('should list only panel plugins when filtering by panel', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=panel');
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
expect(queryByText('ACE.SVG')).toBeInTheDocument();
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
});
it('should list only app plugins when filtering by app', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=app');
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('ACE.SVG')).not.toBeInTheDocument();
});
});
describe('when searching', () => {
it('should only list plugins matching search', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=zabbix');
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('Redis Application')).not.toBeInTheDocument();
});
});
it('should list all plugins (except core plugins) when filtering by all', async () => {
const { queryByText } = setup('/plugins?filterBy=all?filterByType=all');
describe('when sorting', () => {
it('should sort plugins by name in ascending alphabetical order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all');
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
});
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'ACE.SVG',
'Diagram',
'Redis Application',
'Wavefront',
'Zabbix',
]);
});
it('should list installed plugins (including core plugins) when filtering by installed', async () => {
const { queryByText } = setup('/plugins?filterBy=installed');
it('should sort plugins by name in descending alphabetical order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=nameDesc');
await waitFor(() => queryByText('Installed'));
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
for (const plugin of installed) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Zabbix',
'Wavefront',
'Redis Application',
'Diagram',
'ACE.SVG',
]);
});
for (const plugin of remote) {
expect(queryByText(plugin.name)).not.toBeInTheDocument();
}
});
it('should sort plugins by date in ascending updated order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=updated');
it('should list enterprise plugins', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=wavefront');
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
});
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Diagram',
'Wavefront',
'Redis Application',
'ACE.SVG',
'Zabbix',
]);
});
it('should list only datasource plugins when filtering by datasource', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=datasource');
it('should sort plugins by date in ascending published order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=published');
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
});
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Diagram',
'Redis Application',
'ACE.SVG',
'Wavefront',
'Zabbix',
]);
});
it('should list only panel plugins when filtering by panel', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=panel');
it('should sort plugins by number of downloads in ascending order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=downloads');
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
});
it('should list only app plugins when filtering by app', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=app');
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
});
it('should only list plugins matching search', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=zabbix');
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Zabbix',
'ACE.SVG',
'Wavefront',
'Diagram',
'Redis Application',
]);
});
});
});
const installed: LocalPlugin[] = [
{
category: '',
defaultNavUrl: '/plugins/alertmanager/',
name: 'Alert Manager',
type: PluginType.datasource,
id: 'alertmanager',
enabled: true,
pinned: false,
info: {
author: {
name: 'Prometheus alertmanager',
url: 'https://grafana.com',
},
build: {},
description: '',
links: [],
links: [
{
name: 'Learn more',
url: 'https://prometheus.io/docs/alerting/latest/alertmanager/',
},
],
logos: {
small: '',
large: '',
small: 'public/app/plugins/datasource/alertmanager/img/logo.svg',
large: 'public/app/plugins/datasource/alertmanager/img/logo.svg',
},
updated: '',
build: {},
screenshots: null,
version: '',
updated: '',
},
enabled: true,
hasUpdate: false,
id: 'alertmanager',
latestVersion: '',
name: 'Alert Manager',
pinned: false,
signature: 'internal',
signatureOrg: '',
signatureType: '',
hasUpdate: false,
defaultNavUrl: '/plugins/alertmanager/',
category: '',
state: 'alpha',
type: 'datasource',
dev: false,
signature: PluginSignatureStatus.internal,
signatureType: '',
signatureOrg: '',
},
{
name: 'Diagram',
type: 'panel',
type: PluginType.panel,
id: 'jdbranham-diagram-panel',
enabled: true,
pinned: false,
info: {
author: {
name: 'Jeremy Branham',
url: 'https://savantly.net',
},
author: { name: 'Jeremy Branham', url: 'https://savantly.net' },
description: 'Display diagrams and charts with colored metric indicators',
links: [
{
@ -187,61 +274,153 @@ const installed: LocalPlugin[] = [
large: 'public/plugins/jdbranham-diagram-panel/img/logo.svg',
},
build: {},
version: '1.7.1',
updated: '2021-05-26',
screenshots: [],
version: '1.7.3',
updated: '2021-07-20',
},
latestVersion: '1.7.3',
hasUpdate: true,
defaultNavUrl: '/plugins/jdbranham-diagram-panel/',
category: '',
state: '',
signature: 'unsigned',
signature: PluginSignatureStatus.missing,
signatureType: '',
signatureOrg: '',
dev: false,
},
{
name: 'Redis Application',
type: PluginType.app,
id: 'redis-app',
enabled: false,
pinned: false,
info: {
author: {
name: 'RedisGrafana',
url: 'https://redisgrafana.github.io',
},
description: 'Provides Application pages and custom panels for Redis Data Source.',
links: [
{ name: 'Website', url: 'https://redisgrafana.github.io' },
{
name: 'License',
url: 'https://github.com/RedisGrafana/grafana-redis-app/blob/master/LICENSE',
},
],
logos: {
small: 'public/plugins/redis-app/img/logo.svg',
large: 'public/plugins/redis-app/img/logo.svg',
},
build: {},
screenshots: [],
version: '2.0.1',
updated: '2021-07-07',
},
latestVersion: '2.0.1',
hasUpdate: false,
defaultNavUrl: '/plugins/redis-app/',
category: '',
state: '',
signature: PluginSignatureStatus.valid,
signatureType: PluginSignatureType.commercial,
signatureOrg: 'RedisGrafana',
},
];
const remote: Plugin[] = [
const remote: RemotePlugin[] = [
{
createdAt: '2016-04-06T20:23:41.000Z',
description: 'Zabbix plugin for Grafana',
downloads: 33645089,
featured: 180,
internal: false,
links: [],
status: 'active',
id: 74,
typeId: 1,
typeName: 'Application',
typeCode: PluginType.app,
slug: 'alexanderzobnin-zabbix-app',
name: 'Zabbix',
description: 'Zabbix plugin for Grafana',
version: '4.1.5',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.community,
versionSignedByOrg: 'alexanderzobnin',
versionSignedByOrgName: 'Alexander Zobnin',
userId: 0,
orgId: 13056,
orgName: 'Alexander Zobnin',
orgSlug: 'alexanderzobnin',
packages: {},
popularity: 0.2111,
signatureType: 'community',
slug: 'alexanderzobnin-zabbix-app',
status: 'active',
typeCode: 'app',
orgUrl: 'https://github.com/alexanderzobnin',
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
createdAt: '2016-04-06T20:23:41.000Z',
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
versionSignatureType: 'community',
readme: '',
downloads: 34387994,
verified: false,
featured: 180,
internal: false,
downloadSlug: 'alexanderzobnin-zabbix-app',
popularity: 0.2019,
signatureType: PluginSignatureType.community,
packages: {},
links: [],
},
{
createdAt: '2020-09-01T13:02:57.000Z',
description: 'Wavefront Datasource',
downloads: 7283,
featured: 0,
internal: false,
links: [],
status: 'enterprise',
id: 658,
typeId: 2,
typeName: 'Data Source',
typeCode: PluginType.datasource,
slug: 'grafana-wavefront-datasource',
name: 'Wavefront',
description: 'Wavefront Datasource',
version: '1.0.8',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.grafana,
versionSignedByOrg: 'grafana',
versionSignedByOrgName: 'Grafana Labs',
userId: 0,
orgId: 5000,
orgName: 'Grafana Labs',
orgSlug: 'grafana',
orgUrl: 'https://grafana.org',
url: 'https://github.com/grafana/wavefront-datasource/',
createdAt: '2020-09-01T13:02:57.000Z',
updatedAt: '2021-07-12T18:41:03.000Z',
downloads: 7818,
verified: false,
featured: 0,
internal: false,
downloadSlug: 'grafana-wavefront-datasource',
popularity: 0.0107,
signatureType: PluginSignatureType.grafana,
packages: {},
popularity: 0.0133,
signatureType: 'grafana',
slug: 'grafana-wavefront-datasource',
status: 'enterprise',
typeCode: 'datasource',
updatedAt: '2021-06-23T12:45:13.000Z',
version: '1.0.7',
versionSignatureType: 'grafana',
readme: '',
links: [],
},
{
status: 'active',
id: 659,
typeId: 3,
typeName: 'Panel',
typeCode: PluginType.panel,
slug: 'aceiot-svg-panel',
name: 'ACE.SVG',
description: 'SVG Visualization Panel',
version: '0.0.10',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.community,
versionSignedByOrg: 'aceiot',
versionSignedByOrgName: 'Andrew Rodgers',
userId: 0,
orgId: 409764,
orgName: 'Andrew Rodgers',
orgSlug: 'aceiot',
orgUrl: '',
url: 'https://github.com/ACE-IoT-Solutions/ace-svg-react',
createdAt: '2020-09-01T14:46:44.000Z',
updatedAt: '2021-06-28T14:01:36.000Z',
downloads: 101569,
verified: false,
featured: 0,
internal: false,
downloadSlug: 'aceiot-svg-panel',
popularity: 0.0134,
signatureType: PluginSignatureType.community,
packages: {},
links: [],
},
];

View File

@ -27,7 +27,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
const q = (query.q as string) ?? '';
const filterBy = (query.filterBy as string) ?? 'installed';
const filterByType = (query.filterByType as string) ?? 'all';
const sortBy = (query.sortBy as string) ?? 'name';
const sortBy = (query.sortBy as string) ?? 'nameAsc';
const { plugins, isLoading, error } = usePluginsByFilter({ searchBy: q, filterBy, filterByType });
const sortedPlugins = plugins.sort(sorters[sortBy]);
@ -90,7 +90,8 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
value={sortBy}
onChange={onSortByChange}
options={[
{ value: 'name', label: 'Sort by name (A-Z)' },
{ value: 'nameAsc', label: 'Sort by name (A-Z)' },
{ value: 'nameDesc', label: 'Sort by name (Z-A)' },
{ value: 'updated', label: 'Sort by updated date' },
{ value: 'published', label: 'Sort by published date' },
{ value: 'downloads', label: 'Sort by downloads' },
@ -139,7 +140,8 @@ const getNavModelId = (routeName?: string) => {
};
const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = {
name: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name),
nameAsc: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name),
nameDesc: (a: CatalogPlugin, b: CatalogPlugin) => b.name.localeCompare(a.name),
updated: (a: CatalogPlugin, b: CatalogPlugin) =>
dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(),
published: (a: CatalogPlugin, b: CatalogPlugin) =>

View File

@ -1,8 +1,9 @@
import React from 'react';
import { render, RenderResult, waitFor } from '@testing-library/react';
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
import PluginDetailsPage from './PluginDetails';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
import { LocalPlugin, Plugin } from '../types';
import { LocalPlugin, RemotePlugin } from '../types';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
jest.mock('@grafana/runtime', () => {
@ -74,26 +75,38 @@ describe('Plugin details page', () => {
});
});
function remotePlugin(plugin: Partial<Plugin> = {}): Plugin {
function remotePlugin(plugin: Partial<RemotePlugin> = {}): RemotePlugin {
return {
createdAt: '2016-04-06T20:23:41.000Z',
description: 'Zabbix plugin for Grafana',
downloads: 33645089,
featured: 180,
id: 74,
typeId: 1,
typeName: 'Application',
internal: false,
links: [],
name: 'Zabbix',
orgId: 13056,
orgName: 'Alexander Zobnin',
orgSlug: 'alexanderzobnin',
orgUrl: 'https://github.com/alexanderzobnin',
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
verified: false,
downloadSlug: 'alexanderzobnin-zabbix-app',
packages: {},
popularity: 0.2111,
signatureType: 'community',
signatureType: PluginSignatureType.community,
slug: 'alexanderzobnin-zabbix-app',
status: 'active',
typeCode: 'app',
typeCode: PluginType.app,
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
versionSignatureType: 'community',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.community,
versionSignedByOrg: 'alexanderzobnin',
versionSignedByOrgName: 'Alexander Zobnin',
userId: 0,
readme: '',
json: {
dependencies: {
@ -133,12 +146,11 @@ function localPlugin(plugin: Partial<LocalPlugin> = {}): LocalPlugin {
latestVersion: '',
name: 'Alert Manager',
pinned: false,
signature: 'internal',
signature: PluginSignatureStatus.internal,
signatureOrg: '',
signatureType: '',
state: 'alpha',
type: 'datasource',
dev: false,
type: PluginType.datasource,
...plugin,
};
}
@ -147,7 +159,6 @@ function corePlugin(plugin: Partial<LocalPlugin> = {}): LocalPlugin {
return {
category: 'sql',
defaultNavUrl: '/plugins/postgres/',
dev: false,
enabled: true,
hasUpdate: false,
id: 'core',
@ -166,11 +177,11 @@ function corePlugin(plugin: Partial<LocalPlugin> = {}): LocalPlugin {
latestVersion: '',
name: 'PostgreSQL',
pinned: false,
signature: 'internal',
signature: PluginSignatureStatus.internal,
signatureOrg: '',
signatureType: '',
state: '',
type: 'datasource',
type: PluginType.datasource,
...plugin,
};
}

View File

@ -49,6 +49,7 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
<PluginPage>
<div className={styles.headerContainer}>
<PluginLogo
alt={`${plugin.name} logo`}
src={plugin.info.logos.small}
className={css`
object-fit: contain;

View File

@ -1,4 +1,4 @@
import { GrafanaPlugin, PluginMeta } from '@grafana/data';
import { GrafanaPlugin, PluginMeta, PluginType, PluginSignatureStatus, PluginSignatureType } from '@grafana/data';
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
export enum PluginAdminRoutes {
@ -22,9 +22,10 @@ export interface CatalogPlugin {
isInstalled: boolean;
name: string;
orgName: string;
signature: PluginSignatureStatus;
popularity: number;
publishedAt: string;
type: string;
type?: PluginType;
updatedAt: string;
version: string;
}
@ -46,34 +47,14 @@ export interface CatalogPluginInfo {
};
}
export interface Plugin {
name: string;
description: string;
slug: string;
orgName: string;
orgSlug: string;
signatureType: string;
version: string;
status: string;
popularity: number;
downloads: number;
updatedAt: string;
export type RemotePlugin = {
createdAt: string;
typeCode: string;
description: string;
downloads: number;
downloadSlug: string;
featured: number;
readme: string;
id: number;
internal: boolean;
versionSignatureType: string;
packages: {
[arch: string]: {
packageName: string;
downloadUrl: string;
};
};
links: Array<{
rel: string;
href: string;
}>;
json?: {
dependencies: {
grafanaDependency: string;
@ -86,50 +67,89 @@ export interface Plugin {
}>;
};
};
}
links: Array<{ rel: string; href: string }>;
name: string;
orgId: number;
orgName: string;
orgSlug: string;
orgUrl: string;
packages: {
[arch: string]: {
packageName: string;
downloadUrl: string;
};
};
popularity: number;
readme?: string;
signatureType: PluginSignatureType | '';
slug: string;
status: string;
typeCode: PluginType;
typeId: number;
typeName: string;
updatedAt: string;
url: string;
userId: number;
verified: boolean;
version: string;
versionSignatureType: PluginSignatureType | '';
versionSignedByOrg: string;
versionSignedByOrgName: string;
versionStatus: string;
};
export type LocalPlugin = {
category: string;
defaultNavUrl: string;
dev?: boolean;
enabled: boolean;
hasUpdate: boolean;
id: string;
info: {
author: {
name: string;
url: string;
};
build: {};
author: Rel;
description: string;
links: Array<{
name: string;
url: string;
}>;
links?: Rel[];
logos: {
large: string;
small: string;
large: string;
};
updated: string;
build: Build;
screenshots?: Array<{
path: string;
name: string;
}> | null;
version: string;
updated: string;
};
latestVersion: string;
name: string;
pinned: boolean;
signature: string;
signature: PluginSignatureStatus;
signatureOrg: string;
signatureType: string;
state: string;
type: string;
dev: boolean | undefined;
type: PluginType;
};
interface Rel {
name: string;
url: string;
}
export interface Build {
time?: number;
repo?: string;
branch?: string;
hash?: string;
}
export interface Version {
version: string;
createdAt: string;
}
export interface PluginDetails {
remote?: Plugin;
remote?: RemotePlugin;
remoteVersions?: Version[];
local?: LocalPlugin;
}