diff --git a/public/app/features/plugins/admin/components/PluginList.tsx b/public/app/features/plugins/admin/components/PluginList.tsx index c4abe9d5836..236c9730d09 100644 --- a/public/app/features/plugins/admin/components/PluginList.tsx +++ b/public/app/features/plugins/admin/components/PluginList.tsx @@ -4,13 +4,13 @@ import { css } from '@emotion/css'; import { Card } from '../components/Card'; import { Grid } from '../components/Grid'; -import { Plugin } from '../types'; +import { Plugin, LocalPlugin } from '../types'; import { GrafanaTheme2 } from '@grafana/data'; import { isLocalPlugin } from '../guards'; import { PluginLogo } from './PluginLogo'; interface Props { - plugins: Plugin[]; + plugins: Array; } export const PluginList = ({ plugins }: Props) => { @@ -19,11 +19,13 @@ export const PluginList = ({ plugins }: Props) => { return ( {plugins.map((plugin) => { - const { name, orgName, typeCode } = plugin; + const id = getPluginId(plugin); + const { name } = plugin; + return ( { text={ <>
{name}
-
{orgName}
+
{getOrgName(plugin)}
} /> @@ -45,13 +47,20 @@ export const PluginList = ({ plugins }: Props) => { ); }; -function getPluginId(plugin: Plugin): string { +function getPluginId(plugin: Plugin | LocalPlugin): string { if (isLocalPlugin(plugin)) { return plugin.id; } return plugin.slug; } +function getOrgName(plugin: Plugin | LocalPlugin): string | undefined { + if (isLocalPlugin(plugin)) { + return plugin.info?.author?.name; + } + return plugin.orgName; +} + const getStyles = (theme: GrafanaTheme2) => ({ name: css` font-size: ${theme.typography.h4.fontSize}; diff --git a/public/app/features/plugins/admin/hooks/usePlugins.tsx b/public/app/features/plugins/admin/hooks/usePlugins.tsx index 5458f20e1c8..60cbb71751f 100644 --- a/public/app/features/plugins/admin/hooks/usePlugins.tsx +++ b/public/app/features/plugins/admin/hooks/usePlugins.tsx @@ -1,50 +1,37 @@ -import { useEffect, useMemo, useState } from 'react'; - +import { useMemo } from 'react'; +import { useAsync } from 'react-use'; import { Plugin, LocalPlugin } from '../types'; import { api } from '../api'; -type PluginsState = { - isLoading: boolean; - items: Plugin[]; - installedPlugins: any[]; -}; - export const usePlugins = () => { - const [state, setState] = useState({ isLoading: true, items: [], installedPlugins: [] }); - - useEffect(() => { - const fetchPluginData = async () => { - const items = await api.getRemotePlugins(); - const filteredPlugins = items.filter((plugin) => { - const isNotRenderer = plugin.typeCode !== 'renderer'; - const isSigned = Boolean(plugin.versionSignatureType); - const isNotEnterprise = plugin.status !== 'enterprise'; - - return isNotRenderer && isSigned && isNotEnterprise; - }); + const result = useAsync(async () => { + const items = await api.getRemotePlugins(); + const filteredPlugins = items.filter((plugin) => { + const isNotRenderer = plugin.typeCode !== 'renderer'; + const isSigned = Boolean(plugin.versionSignatureType); - const installedPlugins = await api.getInstalledPlugins(); + return isNotRenderer && isSigned; + }); - setState((state) => ({ ...state, items: filteredPlugins, installedPlugins, isLoading: false })); - }; + const installedPlugins = await api.getInstalledPlugins(); - fetchPluginData(); + return { items: filteredPlugins, installedPlugins }; }, []); - return state; + return result; }; type FilteredPluginsState = { isLoading: boolean; - items: Plugin[]; + items: Array; }; export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => { - const plugins = usePlugins(); + const { loading, value } = usePlugins(); const all = useMemo(() => { const combined: Plugin[] = []; - Array.prototype.push.apply(combined, plugins.items); - Array.prototype.push.apply(combined, plugins.installedPlugins); + Array.prototype.push.apply(combined, value?.items ?? []); + Array.prototype.push.apply(combined, value?.installedPlugins ?? []); const bySlug = combined.reduce((unique: Record, plugin) => { unique[plugin.slug] = plugin; @@ -52,17 +39,17 @@ export const usePluginsByFilter = (searchBy: string, filterBy: string): Filtered }, {}); return Object.values(bySlug); - }, [plugins.items, plugins.installedPlugins]); + }, [value?.items, value?.installedPlugins]); if (filterBy === 'installed') { return { - isLoading: plugins.isLoading, - items: applySearchFilter(searchBy, plugins.installedPlugins ?? []), + isLoading: loading, + items: applySearchFilter(searchBy, value?.installedPlugins ?? []), }; } return { - isLoading: plugins.isLoading, + isLoading: loading, items: applySearchFilter(searchBy, all), }; }; @@ -95,17 +82,12 @@ type PluginState = { }; export const usePlugin = (slug: string): PluginState => { - const [state, setState] = useState({ - isLoading: true, - }); - - useEffect(() => { - const fetchPluginData = async () => { - const plugin = await api.getPlugin(slug); - setState({ ...plugin, isLoading: false }); - }; - fetchPluginData(); + const { loading, value } = useAsync(async () => { + return await api.getPlugin(slug); }, [slug]); - return state; + return { + isLoading: loading, + ...value, + }; }; diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx new file mode 100644 index 00000000000..4caa3c57ed1 --- /dev/null +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Router } from 'react-router-dom'; +import { render, RenderResult, waitFor } from '@testing-library/react'; +import BrowsePage from './Browse'; +import { locationService } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { LocalPlugin, Plugin } from '../types'; +import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; + +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as object), + getBackendSrv: () => ({ + get: (path: string) => { + switch (path) { + case `${GRAFANA_API_ROOT}/plugins`: + return Promise.resolve({ items: remote }); + case API_ROOT: + return Promise.resolve(installed); + default: + return Promise.reject(); + } + }, + }), +})); + +function setup(path = '/plugins'): RenderResult { + const store = configureStore(); + locationService.push(path); + + return render( + + + + + + ); +} + +describe('Browse list of plugins', () => { + it('should list installed plugins by default', async () => { + const { getByText, queryByText } = setup('/plugins'); + + await waitFor(() => getByText('Installed')); + + for (const plugin of installed) { + expect(getByText(plugin.name)).toBeInTheDocument(); + } + + for (const plugin of remote) { + expect(queryByText(plugin.name)).toBeNull(); + } + }); + + it('should list all plugins by when filtering by all', async () => { + const plugins = [...installed, ...remote]; + const { getByText } = setup('/plugins?filterBy=all'); + + await waitFor(() => getByText('All')); + + for (const plugin of plugins) { + expect(getByText(plugin.name)).toBeInTheDocument(); + } + }); + + it('should only list plugins matching search', async () => { + const { getByText } = setup('/plugins?filterBy=all&q=zabbix'); + + await waitFor(() => getByText('All')); + + expect(getByText('Zabbix')).toBeInTheDocument(); + expect(getByText('1 result')).toBeInTheDocument(); + }); + + it('should list enterprise plugins', async () => { + const { getByText } = setup('/plugins?filterBy=all&q=wavefront'); + + await waitFor(() => getByText('All')); + + expect(getByText('Wavefront')).toBeInTheDocument(); + expect(getByText('1 result')).toBeInTheDocument(); + }); +}); + +const installed: LocalPlugin[] = [ + { + category: '', + defaultNavUrl: '/plugins/alertmanager/', + info: { + author: { + name: 'Prometheus alertmanager', + url: 'https://grafana.com', + }, + build: {}, + description: '', + links: [], + logos: { + small: '', + large: '', + }, + updated: '', + version: '', + }, + enabled: true, + hasUpdate: false, + id: 'alertmanager', + latestVersion: '', + name: 'Alert Manager', + pinned: false, + signature: 'internal', + signatureOrg: '', + signatureType: '', + state: 'alpha', + type: 'datasource', + dev: false, + }, +]; +const remote: Plugin[] = [ + { + createdAt: '2016-04-06T20:23:41.000Z', + description: 'Zabbix plugin for Grafana', + downloads: 33645089, + featured: 180, + internal: false, + links: [], + name: 'Zabbix', + orgName: 'Alexander Zobnin', + orgSlug: 'alexanderzobnin', + packages: {}, + popularity: 0.2111, + signatureType: 'community', + slug: 'alexanderzobnin-zabbix-app', + status: 'active', + typeCode: 'app', + updatedAt: '2021-05-18T14:53:01.000Z', + version: '4.1.5', + versionSignatureType: 'community', + readme: '', + }, + { + createdAt: '2020-09-01T13:02:57.000Z', + description: 'Wavefront Datasource', + downloads: 7283, + featured: 0, + internal: false, + links: [], + name: 'Wavefront', + orgName: 'Grafana Labs', + orgSlug: '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: '', + }, +]; diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index 546d663387f..0ea822a104f 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -39,7 +39,7 @@ export default function Browse(): ReactElement { }; const onSearch = (q: any) => { - history.push({ query: { filterBy: null, q } }); + history.push({ query: { filterBy: 'all', q } }); }; return ( diff --git a/public/app/features/plugins/admin/pages/Discover.tsx b/public/app/features/plugins/admin/pages/Discover.tsx deleted file mode 100644 index dc987ef8ae3..00000000000 --- a/public/app/features/plugins/admin/pages/Discover.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react'; -import { cx, css } from '@emotion/css'; - -import { dateTimeParse, GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, Legend, LinkButton } from '@grafana/ui'; -import { locationService } from '@grafana/runtime'; - -import { Card } from '../components/Card'; -import { Grid } from '../components/Grid'; -import { PluginList } from '../components/PluginList'; -import { SearchField } from '../components/SearchField'; -import { PluginTypeIcon } from '../components/PluginTypeIcon'; -import { usePlugins } from '../hooks/usePlugins'; -import { Plugin } from '../types'; -import { Page as PluginPage } from '../components/Page'; -import { Loader } from '../components/Loader'; -import { Page } from 'app/core/components/Page/Page'; - -export default function Discover(): JSX.Element | null { - const { items, isLoading } = usePlugins(); - const styles = useStyles2(getStyles); - - const onSearch = (q: string) => { - locationService.push({ - pathname: '/plugins/browse', - search: `?q=${q}`, - }); - }; - - const featuredPlugins = items.filter((_) => _.featured > 0); - featuredPlugins.sort((a: Plugin, b: Plugin) => { - return b.featured - a.featured; - }); - - const recentlyAdded = items.filter((_) => true); - recentlyAdded.sort((a: Plugin, b: Plugin) => { - const at = dateTimeParse(a.createdAt); - const bt = dateTimeParse(b.createdAt); - return bt.valueOf() - at.valueOf(); - }); - - const mostPopular = items.filter((_) => true); - mostPopular.sort((a: Plugin, b: Plugin) => { - return b.popularity - a.popularity; - }); - - if (isLoading) { - return ( - - - - - - ); - } - - return ( - - - - - {/* Featured */} - Featured - - - {/* Most popular */} -
- Most popular - See more -
- - - {/* Recently added */} -
- Recently added - See more -
- - - {/* Browse by type */} - Browse by type - - } - text={ Panels} - /> - } - text={ Data sources} - /> - } - text={ Apps} - /> - -
-
-
- ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - legend: css` - margin-top: ${theme.spacing(4)}; - `, - legendContainer: css` - align-items: baseline; - display: flex; - justify-content: space-between; - `, - typeLegend: css` - align-items: center; - display: flex; - font-size: ${theme.typography.h4.fontSize}; - `, - }; -}; diff --git a/public/app/features/plugins/admin/pages/Library.tsx b/public/app/features/plugins/admin/pages/Library.tsx deleted file mode 100644 index 755b6ee2d21..00000000000 --- a/public/app/features/plugins/admin/pages/Library.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { css } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; -import { PluginList } from '../components/PluginList'; -import { usePlugins } from '../hooks/usePlugins'; -import { Page as PluginPage } from '../components/Page'; -import { Loader } from '../components/Loader'; -import { CatalogTab, getCatalogNavModel } from './nav'; -import { Page } from 'app/core/components/Page/Page'; - -export default function Library(): JSX.Element | null { - const { isLoading, items, installedPlugins } = usePlugins(); - const styles = useStyles2(getStyles); - - const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug)); - - if (isLoading) { - return ( - - - - - - ); - } - - return ( - - - -

Library

- {filteredPlugins.length > 0 ? ( - - ) : ( -

- You haven't installed any plugins. Browse the{' '} - - catalog - {' '} - for plugins to install. -

- )} -
-
-
- ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - header: css` - margin-bottom: ${theme.spacing(3)}; - margin-top: ${theme.spacing(3)}; - `, - link: css` - text-decoration: underline; - `, - }; -}; diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx new file mode 100644 index 00000000000..58ce2954960 --- /dev/null +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { render, RenderResult, waitFor } from '@testing-library/react'; +import PluginDetailsPage from './PluginDetails'; +import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; +import { LocalPlugin, Plugin } from '../types'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; + +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + + return { + ...original, + getBackendSrv: () => ({ + get: (path: string) => { + switch (path) { + case `${GRAFANA_API_ROOT}/plugins/not-installed/versions`: + case `${GRAFANA_API_ROOT}/plugins/enterprise/versions`: + return Promise.resolve([]); + case API_ROOT: + return Promise.resolve([localPlugin()]); + case `${GRAFANA_API_ROOT}/plugins/not-installed`: + return Promise.resolve(remotePlugin()); + case `${GRAFANA_API_ROOT}/plugins/enterprise`: + return Promise.resolve(remotePlugin({ status: 'enterprise' })); + default: + return Promise.reject(); + } + }, + }), + config: { + ...original.config, + buildInfo: { + ...original.config.buildInfo, + version: 'v7.5.0', + }, + }, + }; +}); + +function setup(pluginId: string): RenderResult { + const props = getRouteComponentProps({ match: { params: { pluginId }, isExact: true, url: '', path: '' } }); + return render(); +} + +describe('Plugin details page', () => { + it('should display install button for uninstalled plugins', async () => { + const { getByText } = setup('not-installed'); + + const expected = 'Install'; + + await waitFor(() => getByText(expected)); + expect(getByText(expected)).toBeInTheDocument(); + }); + + it('should not display install button for enterprise plugins', async () => { + const { getByText } = setup('enterprise'); + + const expected = "Marketplace doesn't support installing Enterprise plugins yet. Stay tuned!"; + + await waitFor(() => getByText(expected)); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); + +function remotePlugin(plugin: Partial = {}): Plugin { + return { + createdAt: '2016-04-06T20:23:41.000Z', + description: 'Zabbix plugin for Grafana', + downloads: 33645089, + featured: 180, + internal: false, + links: [], + name: 'Zabbix', + orgName: 'Alexander Zobnin', + orgSlug: 'alexanderzobnin', + packages: {}, + popularity: 0.2111, + signatureType: 'community', + slug: 'alexanderzobnin-zabbix-app', + status: 'active', + typeCode: 'app', + updatedAt: '2021-05-18T14:53:01.000Z', + version: '4.1.5', + versionSignatureType: 'community', + readme: '', + json: { + dependencies: { + grafanaDependency: '>=7.3.0', + grafanaVersion: '7.3', + }, + info: { + links: [], + }, + }, + ...plugin, + }; +} + +function localPlugin(plugin: Partial = {}): LocalPlugin { + return { + category: '', + defaultNavUrl: '/plugins/alertmanager/', + info: { + author: { + name: 'Prometheus alertmanager', + url: 'https://grafana.com', + }, + build: {}, + description: '', + links: [], + logos: { + small: '', + large: '', + }, + updated: '', + version: '', + }, + enabled: true, + hasUpdate: false, + id: 'alertmanager', + latestVersion: '', + name: 'Alert Manager', + pinned: false, + signature: 'internal', + signatureOrg: '', + signatureType: '', + state: 'alpha', + type: 'datasource', + dev: false, + ...plugin, + }; +} diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index a3d83b03bff..ebcb45f9b7d 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -3,7 +3,6 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui'; -import { useParams } from 'react-router-dom'; import { VersionList } from '../components/VersionList'; import { InstallControls } from '../components/InstallControls'; @@ -12,16 +11,19 @@ import { Page as PluginPage } from '../components/Page'; import { Loader } from '../components/Loader'; import { Page } from 'app/core/components/Page/Page'; import { PluginLogo } from '../components/PluginLogo'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -export default function PluginDetails(): JSX.Element | null { - const { pluginId } = useParams<{ pluginId: string }>(); +type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>; + +export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null { + const { pluginId } = match.params; const [tabs, setTabs] = useState([ { label: 'Overview', active: true }, { label: 'Version history', active: false }, ]); - const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId); + const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId!); const styles = useStyles2(getStyles); const description = remote?.description ?? local?.info?.description; diff --git a/public/app/features/plugins/admin/pages/nav.ts b/public/app/features/plugins/admin/pages/nav.ts deleted file mode 100644 index 9f6e96ebb28..00000000000 --- a/public/app/features/plugins/admin/pages/nav.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NavModel, NavModelItem } from '@grafana/data'; - -export enum CatalogTab { - Browse = 'browse', - Discover = 'discover', - Library = 'library', -} - -export function getCatalogNavModel(tab: CatalogTab, baseURL: string): NavModel { - const pages: NavModelItem[] = []; - - if (!baseURL.endsWith('/')) { - baseURL += '/'; - } - - pages.push({ - text: 'Browse', - icon: 'icon-gf icon-gf-apps', - url: `${baseURL}${CatalogTab.Browse}`, - id: CatalogTab.Browse, - }); - - pages.push({ - text: 'Library', - icon: 'icon-gf icon-gf-apps', - url: `${baseURL}${CatalogTab.Library}`, - id: CatalogTab.Library, - }); - - const node: NavModelItem = { - text: 'Catalog', - icon: 'cog', - subTitle: 'Manage plugin installations', - breadcrumbs: [{ title: 'Plugins', url: 'plugins' }], - children: setActivePage(tab, pages, CatalogTab.Browse), - }; - - return { - node: node, - main: node, - }; -} - -function setActivePage(pageId: CatalogTab, pages: NavModelItem[], defaultPageId: CatalogTab): NavModelItem[] { - let found = false; - const selected = pageId || defaultPageId; - const changed = pages.map((p) => { - const active = !found && selected === p.id; - if (active) { - found = true; - } - return { ...p, active }; - }); - - if (!found) { - changed[0].active = true; - } - - return changed; -} diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 7b3f105b0d1..b36c37033a6 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -28,7 +28,7 @@ export interface Plugin { rel: string; href: string; }>; - json: { + json?: { dependencies: { grafanaDependency: string; grafanaVersion: string;