mirror of
https://github.com/grafana/grafana.git
synced 2024-11-29 04:04:00 -06:00
Plugins: Combine local and remote plugins into one structure (#36859)
* adding some structure to combine the local and remote into one type. * feat(catalog): map local and remote responses to catalog plugin * feat(catalog): render CatalogPlugins in list * refactor(catalog): update usePluginsByFilter to work with new data structure * refactor(catalog): move helper functions into helpers file. delete redundent usePlugins hook * feat(catalog): create CatalogPluginDetails and pass to PluginDetails * feat(catalog): update types and components for plugin installation * chore(catalog): comment so not to forget to move code out of api layer * fix(catalog): make sure all filter shows gcom and installed * fix(catalog): fix up getCatalogPlugin logic for only locally available plugins * refactor(catalog): create getCatalogPluginDetails helper. Move usage to hook * revert(catalog): put back small logos in PluginList * revert(catalog): put back small logo for PluginDetails page * fix(catalog): prevent useDebounce from triggering when SearchField mounts * chore(catalog): add coment explaining reason for usedebouncewithoutfirstrender * refactor(catalog): replace reduce with filter to remove duplicate array of all plugins * refactor(catalog): update types for useDebounceWithoutFirstRender * chore(catalog): remove commented out import Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
parent
79747b419b
commit
ff56ea6ea6
@ -1,5 +1,4 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { PluginMeta } from '@grafana/data';
|
||||
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
|
||||
import { Plugin, PluginDetails, Org, LocalPlugin } from './types';
|
||||
|
||||
@ -43,7 +42,7 @@ async function getPluginVersions(id: string): Promise<any[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getInstalledPlugins(): Promise<any> {
|
||||
async function getInstalledPlugins(): Promise<LocalPlugin[]> {
|
||||
const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 });
|
||||
return installed;
|
||||
}
|
||||
@ -63,16 +62,6 @@ async function uninstallPlugin(id: string) {
|
||||
return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`);
|
||||
}
|
||||
|
||||
async function updatePlugin(pluginId: string, data: Partial<PluginMeta>) {
|
||||
const response = await getBackendSrv().datasourceRequest({
|
||||
url: `/api/plugins/${pluginId}/settings`,
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getRemotePlugins,
|
||||
getPlugin,
|
||||
@ -80,5 +69,4 @@ export const api = {
|
||||
getOrg,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
updatePlugin,
|
||||
};
|
||||
|
@ -1,44 +1,42 @@
|
||||
import React, { useState } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { gt, satisfies } from 'semver';
|
||||
import { satisfies } from 'semver';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { LocalPlugin, Plugin } from '../types';
|
||||
import { api } from '../api';
|
||||
|
||||
// This isn't exported in the sdk yet
|
||||
// @ts-ignore
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { CatalogPluginDetails } from '../types';
|
||||
import { api } from '../api';
|
||||
import { isGrafanaAdmin } from '../helpers';
|
||||
|
||||
interface Props {
|
||||
localPlugin?: LocalPlugin;
|
||||
remotePlugin: Plugin;
|
||||
plugin: CatalogPluginDetails;
|
||||
}
|
||||
|
||||
export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
||||
export const InstallControls = ({ plugin }: Props) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isInstalled, setIsInstalled] = useState(Boolean(localPlugin));
|
||||
const [shouldUpdate, setShouldUpdate] = useState(
|
||||
remotePlugin?.version && localPlugin?.info.version && gt(remotePlugin?.version!, localPlugin?.info.version!)
|
||||
);
|
||||
const [isInstalled, setIsInstalled] = useState(plugin.isInstalled || false);
|
||||
const [shouldUpdate, setShouldUpdate] = useState(plugin.hasUpdate || false);
|
||||
const [hasInstalledPanel, setHasInstalledPanel] = useState(false);
|
||||
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
|
||||
const externalManageLink = getExternalManageLink(remotePlugin);
|
||||
const externalManageLink = getExternalManageLink(plugin);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!plugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onInstall = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
|
||||
appEvents.emit(AppEvents.alertSuccess, [`Installed ${remotePlugin?.name}`]);
|
||||
await api.installPlugin(plugin.id, plugin.version);
|
||||
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]);
|
||||
setLoading(false);
|
||||
setIsInstalled(true);
|
||||
setHasInstalledPanel(remotePlugin.typeCode === 'panel');
|
||||
setHasInstalledPanel(plugin.type === 'panel');
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -47,8 +45,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
||||
const onUninstall = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.uninstallPlugin(remotePlugin.slug);
|
||||
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${remotePlugin?.name}`]);
|
||||
await api.uninstallPlugin(plugin.id);
|
||||
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
|
||||
setLoading(false);
|
||||
setIsInstalled(false);
|
||||
} catch (error) {
|
||||
@ -59,8 +57,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
||||
const onUpdate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
|
||||
appEvents.emit(AppEvents.alertSuccess, [`Updated ${remotePlugin?.name}`]);
|
||||
await api.installPlugin(plugin.id, plugin.version);
|
||||
appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]);
|
||||
setLoading(false);
|
||||
setShouldUpdate(false);
|
||||
} catch (error) {
|
||||
@ -68,7 +66,7 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const grafanaDependency = remotePlugin?.json?.dependencies?.grafanaDependency;
|
||||
const grafanaDependency = plugin.grafanaDependency;
|
||||
const unsupportedGrafanaVersion = grafanaDependency
|
||||
? !satisfies(config.buildInfo.version, grafanaDependency, {
|
||||
// needed for when running against master
|
||||
@ -76,9 +74,9 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
||||
})
|
||||
: false;
|
||||
|
||||
const isDevelopmentBuild = Boolean(localPlugin?.dev);
|
||||
const isEnterprise = remotePlugin?.status === 'enterprise';
|
||||
const isCore = remotePlugin?.internal || localPlugin?.signature === 'internal';
|
||||
const isDevelopmentBuild = Boolean(plugin.isDev);
|
||||
const isEnterprise = plugin.isEnterprise;
|
||||
const isCore = plugin.isCore;
|
||||
const hasPermission = isGrafanaAdmin();
|
||||
|
||||
if (isCore) {
|
||||
@ -161,8 +159,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function getExternalManageLink(plugin: Plugin): string {
|
||||
return `https://grafana.com/grafana/plugins/${plugin.slug}`;
|
||||
function getExternalManageLink(plugin: CatalogPluginDetails): string {
|
||||
return `https://grafana.com/grafana/plugins/${plugin.id}`;
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
@ -173,26 +171,5 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
messageMargin: css`
|
||||
margin-left: ${theme.spacing()};
|
||||
`,
|
||||
readme: css`
|
||||
margin: ${theme.spacing(3)} 0;
|
||||
|
||||
& img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin-top: ${theme.spacing(3)};
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
}
|
||||
|
||||
li {
|
||||
margin-left: ${theme.spacing(2)};
|
||||
& > p {
|
||||
margin: ${theme.spacing()} 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -4,13 +4,12 @@ import { css } from '@emotion/css';
|
||||
import { Card } from '../components/Card';
|
||||
import { Grid } from '../components/Grid';
|
||||
|
||||
import { Plugin, LocalPlugin } from '../types';
|
||||
import { CatalogPlugin } from '../types';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { isLocalPlugin } from '../guards';
|
||||
import { PluginLogo } from './PluginLogo';
|
||||
|
||||
interface Props {
|
||||
plugins: Array<Plugin | LocalPlugin>;
|
||||
plugins: CatalogPlugin[];
|
||||
}
|
||||
|
||||
export const PluginList = ({ plugins }: Props) => {
|
||||
@ -19,8 +18,7 @@ export const PluginList = ({ plugins }: Props) => {
|
||||
return (
|
||||
<Grid>
|
||||
{plugins.map((plugin) => {
|
||||
const id = getPluginId(plugin);
|
||||
const { name } = plugin;
|
||||
const { name, id, orgName } = plugin;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@ -28,7 +26,7 @@ export const PluginList = ({ plugins }: Props) => {
|
||||
href={`/plugins/${id}`}
|
||||
image={
|
||||
<PluginLogo
|
||||
plugin={plugin}
|
||||
src={plugin.info.logos.small}
|
||||
className={css`
|
||||
max-height: 64px;
|
||||
`}
|
||||
@ -37,7 +35,7 @@ export const PluginList = ({ plugins }: Props) => {
|
||||
text={
|
||||
<>
|
||||
<div className={styles.name}>{name}</div>
|
||||
<div className={styles.orgName}>{getOrgName(plugin)}</div>
|
||||
<div className={styles.orgName}>{orgName}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@ -47,20 +45,6 @@ export const PluginList = ({ plugins }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
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};
|
||||
|
@ -1,24 +1,10 @@
|
||||
import React from 'react';
|
||||
import { isLocalPlugin } from '../guards';
|
||||
import { LocalPlugin, Plugin } from '../types';
|
||||
|
||||
type PluginLogoProps = {
|
||||
plugin: Plugin | LocalPlugin | undefined;
|
||||
src: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PluginLogo({ plugin, className }: PluginLogoProps): React.ReactElement | null {
|
||||
return <img src={getImageSrc(plugin)} className={className} />;
|
||||
}
|
||||
|
||||
function getImageSrc(plugin: Plugin | LocalPlugin | undefined): string {
|
||||
if (!plugin) {
|
||||
return 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small';
|
||||
}
|
||||
|
||||
if (isLocalPlugin(plugin)) {
|
||||
return plugin?.info?.logos?.large;
|
||||
}
|
||||
|
||||
return `https://grafana.com/api/plugins/${plugin.slug}/versions/${plugin.version}/logos/small`;
|
||||
export function PluginLogo({ src, className }: PluginLogoProps): React.ReactElement {
|
||||
return <img src={src} className={className} />;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@ -9,11 +9,30 @@ interface Props {
|
||||
onSearch: (value: string) => void;
|
||||
}
|
||||
|
||||
// useDebounce has a bug which causes it to fire on first render. This wrapper prevents that.
|
||||
// https://github.com/streamich/react-use/issues/759
|
||||
const useDebounceWithoutFirstRender = (callBack: () => any, delay = 0, deps: React.DependencyList = []) => {
|
||||
const isFirstRender = useRef(true);
|
||||
const debounceDeps = [...deps, isFirstRender];
|
||||
|
||||
return useDebounce(
|
||||
() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
return callBack();
|
||||
},
|
||||
delay,
|
||||
debounceDeps
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchField = ({ value, onSearch }: Props) => {
|
||||
const [query, setQuery] = useState(value);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useDebounce(() => onSearch(query ?? ''), 500, [query]);
|
||||
useDebounceWithoutFirstRender(() => onSearch(query ?? ''), 500, [query]);
|
||||
|
||||
return (
|
||||
<input
|
||||
|
@ -1,5 +1,147 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { gt } from 'semver';
|
||||
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, Plugin, Version } from './types';
|
||||
|
||||
export function isGrafanaAdmin(): boolean {
|
||||
return config.bootData.user.isGrafanaAdmin;
|
||||
}
|
||||
|
||||
export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin {
|
||||
const {
|
||||
name,
|
||||
slug: id,
|
||||
description,
|
||||
version,
|
||||
orgName,
|
||||
popularity,
|
||||
downloads,
|
||||
typeCode,
|
||||
updatedAt,
|
||||
createdAt: publishedAt,
|
||||
status,
|
||||
} = plugin;
|
||||
const catalogPlugin = {
|
||||
description,
|
||||
downloads,
|
||||
id,
|
||||
info: {
|
||||
logos: {
|
||||
small: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/small`,
|
||||
large: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/large`,
|
||||
},
|
||||
},
|
||||
name,
|
||||
orgName,
|
||||
popularity,
|
||||
publishedAt,
|
||||
updatedAt,
|
||||
version,
|
||||
hasUpdate: false,
|
||||
isInstalled: false,
|
||||
isCore: plugin.internal,
|
||||
isDev: false,
|
||||
isEnterprise: status === 'enterprise',
|
||||
type: typeCode,
|
||||
};
|
||||
return catalogPlugin;
|
||||
}
|
||||
|
||||
export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
|
||||
const {
|
||||
name,
|
||||
info: { description, version, logos, updated, author },
|
||||
id,
|
||||
signature,
|
||||
dev,
|
||||
type,
|
||||
} = plugin;
|
||||
return {
|
||||
description,
|
||||
downloads: 0,
|
||||
id,
|
||||
info: { logos },
|
||||
name,
|
||||
orgName: author.name,
|
||||
popularity: 0,
|
||||
publishedAt: '',
|
||||
updatedAt: updated,
|
||||
version,
|
||||
hasUpdate: false,
|
||||
isInstalled: true,
|
||||
isCore: signature === 'internal',
|
||||
isDev: Boolean(dev),
|
||||
isEnterprise: false,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCatalogPluginDetails(
|
||||
local: LocalPlugin | undefined,
|
||||
remote: Plugin | undefined,
|
||||
pluginVersions: Version[] | undefined
|
||||
): CatalogPluginDetails {
|
||||
const version = remote?.version || local?.info.version || '';
|
||||
const hasUpdate = Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
|
||||
const id = remote?.slug || local?.id || '';
|
||||
|
||||
let logos = {
|
||||
small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small',
|
||||
large: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/large',
|
||||
};
|
||||
|
||||
if (remote) {
|
||||
logos = {
|
||||
small: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/small`,
|
||||
large: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/large`,
|
||||
};
|
||||
} else if (local && local.info.logos) {
|
||||
logos = local.info.logos;
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
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'),
|
||||
isDev: Boolean(local?.dev),
|
||||
isEnterprise: remote?.status === 'enterprise' || false,
|
||||
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 || '',
|
||||
updatedAt: remote?.updatedAt || local?.info.updated || '',
|
||||
version,
|
||||
versions: pluginVersions || [],
|
||||
};
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export function applySearchFilter(searchBy: string | undefined, plugins: CatalogPlugin[]): CatalogPlugin[] {
|
||||
if (!searchBy) {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
return plugins.filter((plugin) => {
|
||||
const fields: String[] = [];
|
||||
|
||||
if (plugin.name) {
|
||||
fields.push(plugin.name.toLowerCase());
|
||||
}
|
||||
|
||||
if (plugin.orgName) {
|
||||
fields.push(plugin.orgName.toLowerCase());
|
||||
}
|
||||
|
||||
return fields.some((f) => f.includes(searchBy.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
@ -1,84 +1,86 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Plugin, LocalPlugin } from '../types';
|
||||
import { CatalogPlugin, CatalogPluginDetails } from '../types';
|
||||
import { api } from '../api';
|
||||
import { mapLocalToCatalog, mapRemoteToCatalog, getCatalogPluginDetails, applySearchFilter } from '../helpers';
|
||||
|
||||
export const usePlugins = () => {
|
||||
const result = useAsync(async () => {
|
||||
const items = await api.getRemotePlugins();
|
||||
const filteredPlugins = items.filter((plugin) => {
|
||||
const isNotRenderer = plugin.typeCode !== 'renderer';
|
||||
const isSigned = Boolean(plugin.versionSignatureType);
|
||||
type CatalogPluginsState = {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
plugins: CatalogPlugin[];
|
||||
};
|
||||
|
||||
return isNotRenderer && isSigned;
|
||||
});
|
||||
|
||||
const installedPlugins = await api.getInstalledPlugins();
|
||||
|
||||
return { items: filteredPlugins, installedPlugins };
|
||||
export function usePlugins(): CatalogPluginsState {
|
||||
const { loading, value, error } = useAsync(async () => {
|
||||
const remote = await api.getRemotePlugins();
|
||||
const installed = await api.getInstalledPlugins();
|
||||
return { remote, installed };
|
||||
}, []);
|
||||
|
||||
return result;
|
||||
};
|
||||
const plugins = useMemo(() => {
|
||||
const installed = value?.installed || [];
|
||||
const remote = value?.remote || [];
|
||||
const unique: Record<string, CatalogPlugin> = {};
|
||||
|
||||
for (const plugin of installed) {
|
||||
unique[plugin.id] = mapLocalToCatalog(plugin);
|
||||
}
|
||||
|
||||
for (const plugin of remote) {
|
||||
if (unique[plugin.slug]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (plugin.typeCode === 'renderer') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Boolean(plugin.versionSignatureType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique[plugin.slug] = mapRemoteToCatalog(plugin);
|
||||
}
|
||||
|
||||
return Object.values(unique);
|
||||
}, [value?.installed, value?.remote]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
type FilteredPluginsState = {
|
||||
isLoading: boolean;
|
||||
items: Array<Plugin | LocalPlugin>;
|
||||
error?: Error;
|
||||
plugins: CatalogPlugin[];
|
||||
};
|
||||
|
||||
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
|
||||
const { loading, value } = usePlugins();
|
||||
const all = useMemo(() => {
|
||||
const combined: Plugin[] = [];
|
||||
Array.prototype.push.apply(combined, value?.items ?? []);
|
||||
Array.prototype.push.apply(combined, value?.installedPlugins ?? []);
|
||||
const { loading, error, plugins } = usePlugins();
|
||||
|
||||
const bySlug = combined.reduce((unique: Record<string, Plugin>, plugin) => {
|
||||
unique[plugin.slug] = plugin;
|
||||
return unique;
|
||||
}, {});
|
||||
|
||||
return Object.values(bySlug);
|
||||
}, [value?.items, value?.installedPlugins]);
|
||||
const installed = useMemo(() => plugins.filter((plugin) => plugin.isInstalled), [plugins]);
|
||||
|
||||
if (filterBy === 'installed') {
|
||||
return {
|
||||
isLoading: loading,
|
||||
items: applySearchFilter(searchBy, value?.installedPlugins ?? []),
|
||||
error,
|
||||
plugins: applySearchFilter(searchBy, installed),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: loading,
|
||||
items: applySearchFilter(searchBy, all),
|
||||
error,
|
||||
plugins: applySearchFilter(searchBy, plugins),
|
||||
};
|
||||
};
|
||||
|
||||
function applySearchFilter(searchBy: string | undefined, plugins: Plugin[]): Plugin[] {
|
||||
if (!searchBy) {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
return plugins.filter((plugin) => {
|
||||
const fields: String[] = [];
|
||||
|
||||
if (plugin.name) {
|
||||
fields.push(plugin.name.toLowerCase());
|
||||
}
|
||||
|
||||
if (plugin.orgName) {
|
||||
fields.push(plugin.orgName.toLowerCase());
|
||||
}
|
||||
|
||||
return fields.some((f) => f.includes(searchBy.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
type PluginState = {
|
||||
isLoading: boolean;
|
||||
remote?: Plugin;
|
||||
remoteVersions?: Array<{ version: string; createdAt: string }>;
|
||||
local?: LocalPlugin;
|
||||
plugin?: CatalogPluginDetails;
|
||||
};
|
||||
|
||||
export const usePlugin = (slug: string): PluginState => {
|
||||
@ -86,8 +88,10 @@ export const usePlugin = (slug: string): PluginState => {
|
||||
return await api.getPlugin(slug);
|
||||
}, [slug]);
|
||||
|
||||
const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions);
|
||||
|
||||
return {
|
||||
isLoading: loading,
|
||||
...value,
|
||||
plugin,
|
||||
};
|
||||
};
|
||||
|
@ -52,7 +52,7 @@ describe('Browse list of plugins', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should list all plugins by when filtering by all', async () => {
|
||||
it('should list all plugins when filtering by all', async () => {
|
||||
const plugins = [...installed, ...remote];
|
||||
const { getByText } = setup('/plugins?filterBy=all');
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { PluginList } from '../components/PluginList';
|
||||
import { SearchField } from '../components/SearchField';
|
||||
import { HorizontalGroup } from '../components/HorizontalGroup';
|
||||
import { useHistory } from '../hooks/useHistory';
|
||||
import { Plugin } from '../types';
|
||||
import { CatalogPlugin } from '../types';
|
||||
import { Page as PluginPage } from '../components/Page';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { usePluginsByFilter } from '../hooks/usePlugins';
|
||||
@ -17,7 +17,7 @@ import { useSelector } from 'react-redux';
|
||||
import { StoreState } from 'app/types/store';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
|
||||
export default function Browse(): ReactElement {
|
||||
export default function Browse(): ReactElement | null {
|
||||
const location = useLocation();
|
||||
const query = locationSearchToObject(location.search);
|
||||
const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, 'plugins'));
|
||||
@ -26,8 +26,8 @@ export default function Browse(): ReactElement {
|
||||
const filterBy = (query.filterBy as string) ?? 'installed';
|
||||
const sortBy = (query.sortBy as string) ?? 'name';
|
||||
|
||||
const plugins = usePluginsByFilter(q, filterBy);
|
||||
const sortedPlugins = plugins.items.sort(sorters[sortBy]);
|
||||
const { plugins, isLoading, error } = usePluginsByFilter(q, filterBy);
|
||||
const sortedPlugins = plugins.sort(sorters[sortBy]);
|
||||
const history = useHistory();
|
||||
|
||||
const onSortByChange = (value: SelectableValue<string>) => {
|
||||
@ -42,6 +42,12 @@ export default function Browse(): ReactElement {
|
||||
history.push({ query: { filterBy: 'all', q } });
|
||||
};
|
||||
|
||||
// How should we handle errors?
|
||||
if (error) {
|
||||
console.error(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents>
|
||||
@ -49,7 +55,7 @@ export default function Browse(): ReactElement {
|
||||
<SearchField value={q} onSearch={onSearch} />
|
||||
<HorizontalGroup>
|
||||
<div>
|
||||
{plugins.isLoading ? (
|
||||
{isLoading ? (
|
||||
<LoadingPlaceholder
|
||||
className={css`
|
||||
margin-bottom: 0;
|
||||
@ -87,17 +93,19 @@ export default function Browse(): ReactElement {
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
|
||||
{!plugins.isLoading && <PluginList plugins={sortedPlugins} />}
|
||||
{!isLoading && <PluginList plugins={sortedPlugins} />}
|
||||
</PluginPage>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const sorters: { [name: string]: (a: Plugin, b: Plugin) => number } = {
|
||||
name: (a: Plugin, b: Plugin) => a.name.localeCompare(b.name),
|
||||
updated: (a: Plugin, b: Plugin) => dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(),
|
||||
published: (a: Plugin, b: Plugin) => dateTimeParse(b.createdAt).valueOf() - dateTimeParse(a.createdAt).valueOf(),
|
||||
downloads: (a: Plugin, b: Plugin) => b.downloads - a.downloads,
|
||||
popularity: (a: Plugin, b: Plugin) => b.popularity - a.popularity,
|
||||
const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = {
|
||||
name: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name),
|
||||
updated: (a: CatalogPlugin, b: CatalogPlugin) =>
|
||||
dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(),
|
||||
published: (a: CatalogPlugin, b: CatalogPlugin) =>
|
||||
dateTimeParse(b.publishedAt).valueOf() - dateTimeParse(a.publishedAt).valueOf(),
|
||||
downloads: (a: CatalogPlugin, b: CatalogPlugin) => b.downloads - a.downloads,
|
||||
popularity: (a: CatalogPlugin, b: CatalogPlugin) => b.popularity - a.popularity,
|
||||
};
|
||||
|
@ -23,15 +23,9 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
||||
{ label: 'Version history', active: false },
|
||||
]);
|
||||
|
||||
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId!);
|
||||
const { isLoading, plugin } = usePlugin(pluginId!);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const description = remote?.description ?? local?.info?.description;
|
||||
const readme = remote?.readme;
|
||||
const version = local?.info?.version || remote?.version;
|
||||
const links = (local?.info?.links || remote?.json?.info?.links) ?? [];
|
||||
const downloads = remote?.downloads;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Page>
|
||||
@ -40,67 +34,75 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PluginPage>
|
||||
<div className={styles.headerContainer}>
|
||||
<PluginLogo
|
||||
plugin={remote ?? local}
|
||||
className={css`
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
max-width: 68px;
|
||||
`}
|
||||
/>
|
||||
if (plugin) {
|
||||
return (
|
||||
<Page>
|
||||
<PluginPage>
|
||||
<div className={styles.headerContainer}>
|
||||
<PluginLogo
|
||||
src={plugin.info.logos.small}
|
||||
className={css`
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
max-width: 68px;
|
||||
`}
|
||||
/>
|
||||
|
||||
<div className={styles.headerWrapper}>
|
||||
<h1>{remote?.name ?? local?.name}</h1>
|
||||
<div className={styles.headerLinks}>
|
||||
<a className={styles.headerOrgName} href={'/plugins'}>
|
||||
{remote?.orgName ?? local?.info?.author?.name}
|
||||
</a>
|
||||
{links.map((link: any) => (
|
||||
<a key={link.name} href={link.url}>
|
||||
{link.name}
|
||||
<div className={styles.headerWrapper}>
|
||||
<h1>{plugin.name}</h1>
|
||||
<div className={styles.headerLinks}>
|
||||
<a className={styles.headerOrgName} href={'/plugins'}>
|
||||
{plugin.orgName}
|
||||
</a>
|
||||
))}
|
||||
{downloads && (
|
||||
<span>
|
||||
<Icon name="cloud-download" />
|
||||
{` ${new Intl.NumberFormat().format(downloads)}`}{' '}
|
||||
</span>
|
||||
)}
|
||||
{version && <span>{version}</span>}
|
||||
{plugin.links.map((link: any) => (
|
||||
<a key={link.name} href={link.url}>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
{plugin.downloads > 0 && (
|
||||
<span>
|
||||
<Icon name="cloud-download" />
|
||||
{` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '}
|
||||
</span>
|
||||
)}
|
||||
{plugin.version && <span>{plugin.version}</span>}
|
||||
</div>
|
||||
<p>{plugin.description}</p>
|
||||
<InstallControls plugin={plugin} />
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
{remote && <InstallControls localPlugin={local} remotePlugin={remote} />}
|
||||
</div>
|
||||
</div>
|
||||
<TabsBar>
|
||||
{tabs.map((tab, key) => (
|
||||
<Tab
|
||||
key={key}
|
||||
label={tab.label}
|
||||
active={tab.active}
|
||||
onChangeTab={() => {
|
||||
setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key })));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TabsBar>
|
||||
<TabContent>
|
||||
{tabs.find((_) => _.label === 'Overview')?.active && (
|
||||
<div
|
||||
className={styles.readme}
|
||||
dangerouslySetInnerHTML={{ __html: readme ?? 'No plugin help or readme markdown file was found' }}
|
||||
/>
|
||||
)}
|
||||
{tabs.find((_) => _.label === 'Version history')?.active && <VersionList versions={remoteVersions ?? []} />}
|
||||
</TabContent>
|
||||
</PluginPage>
|
||||
</Page>
|
||||
);
|
||||
<TabsBar>
|
||||
{tabs.map((tab, key) => (
|
||||
<Tab
|
||||
key={key}
|
||||
label={tab.label}
|
||||
active={tab.active}
|
||||
onChangeTab={() => {
|
||||
setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key })));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TabsBar>
|
||||
<TabContent>
|
||||
{tabs.find((_) => _.label === 'Overview')?.active && (
|
||||
<div
|
||||
className={styles.readme}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: plugin?.readme ?? 'No plugin help or readme markdown file was found',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tabs.find((_) => _.label === 'Version history')?.active && (
|
||||
<VersionList versions={plugin?.versions ?? []} />
|
||||
)}
|
||||
</TabContent>
|
||||
</PluginPage>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
@ -137,9 +139,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
headerOrgName: css`
|
||||
font-size: ${theme.typography.h4.fontSize};
|
||||
`,
|
||||
message: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
readme: css`
|
||||
padding: ${theme.spacing(3, 4)};
|
||||
|
||||
|
@ -1,5 +1,41 @@
|
||||
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
|
||||
|
||||
export interface CatalogPlugin {
|
||||
description: string;
|
||||
downloads: number;
|
||||
hasUpdate: boolean;
|
||||
id: string;
|
||||
info: CatalogPluginInfo;
|
||||
isDev: boolean;
|
||||
isCore: boolean;
|
||||
isEnterprise: boolean;
|
||||
isInstalled: boolean;
|
||||
name: string;
|
||||
orgName: string;
|
||||
popularity: number;
|
||||
publishedAt: string;
|
||||
type: string;
|
||||
updatedAt: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface CatalogPluginDetails extends CatalogPlugin {
|
||||
readme: string;
|
||||
versions: Version[];
|
||||
links: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
grafanaDependency?: string;
|
||||
}
|
||||
|
||||
export interface CatalogPluginInfo {
|
||||
logos: {
|
||||
large: string;
|
||||
small: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
name: string;
|
||||
description: string;
|
||||
|
Loading…
Reference in New Issue
Block a user