diff --git a/public/app/features/plugins/admin/components/PluginSubtitle.test.tsx b/public/app/features/plugins/admin/components/PluginSubtitle.test.tsx new file mode 100644 index 00000000000..cf2efa6fe54 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginSubtitle.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from '@testing-library/react'; + +import { PluginSignatureStatus } from '@grafana/data'; +import { contextSrv } from 'app/core/core'; + +import * as runtime from '../state/hooks'; +import { CatalogPlugin } from '../types'; + +import { PluginSubtitle, registerPluginSubtitleExtension } from './PluginSubtitle'; + +jest.mock('../state/hooks', () => ({ + useIsRemotePluginsAvailable: jest.fn().mockReturnValue(true), + useInstallStatus: jest.fn().mockReturnValue({ error: null }), +})); + +describe('PluginSubtitle', () => { + const basePlugin: CatalogPlugin = { + description: 'Test description', + downloads: 5, + id: 'test-plugin', + name: 'Test Plugin', + orgName: 'Test', + signature: PluginSignatureStatus.valid, + isInstalled: false, + hasUpdate: false, + isCore: false, + isDev: false, + details: { + links: [{ name: 'Website', url: 'http://test.com' }], + versions: [{ version: '1.0.0', grafanaDependency: '>=9.0.0', isCompatible: true, createdAt: '2020-01-01' }], + }, + publishedAt: '2020-09-01', + updatedAt: '2021-06-28', + isEnterprise: false, + isDisabled: false, + isDeprecated: false, + isPublished: true, + isManaged: false, + isPreinstalled: { found: false, withVersion: false }, + 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', + }, + keywords: ['test', 'plugin'], + }, + popularity: 0, + }; + + beforeEach(() => {}); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders nothing when no plugin provided', () => { + const { container } = render(<PluginSubtitle />); + expect(container.firstChild).toBeNull(); + }); + + it('renders plugin description', () => { + render(<PluginSubtitle plugin={basePlugin} />); + expect(screen.getByText('Test description')).toBeInTheDocument(); + }); + + it('renders links', () => { + render(<PluginSubtitle plugin={basePlugin} />); + expect(screen.getByText('Website')).toHaveAttribute('href', 'http://test.com'); + }); + + it('shows error alert when installation error exists', () => { + jest.spyOn(runtime, 'useInstallStatus').mockReturnValueOnce({ + error: { message: 'Install failed', error: 'Details' }, + isInstalling: false, + }); + render(<PluginSubtitle plugin={basePlugin} />); + expect(screen.getByText('Install failed')).toBeInTheDocument(); + }); + + describe('warning when no permissions', () => { + beforeEach(() => { + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + }); + + it('renders install control warning when conditions are met', () => { + const plugin = { ...basePlugin, isInstalled: false }; + render(<PluginSubtitle plugin={plugin} />); + expect(screen.queryByText(/permission to install/i)).toBeInTheDocument(); + }); + + it('renders uninstall control warning when conditions are met', () => { + const plugin = { ...basePlugin, isInstalled: true }; + render(<PluginSubtitle plugin={plugin} />); + expect(screen.queryByText(/permission to uninstall/i)).toBeInTheDocument(); + }); + }); + + describe('no warning when has permissions', () => { + beforeEach(() => { + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); + }); + + it('renders install control warning when conditions are met', () => { + const plugin = { ...basePlugin, isInstalled: false }; + render(<PluginSubtitle plugin={plugin} />); + expect(screen.queryByText(/permission to install/i)).not.toBeInTheDocument(); + }); + + it('renders uninstall control warning when conditions are met', () => { + const plugin = { ...basePlugin, isInstalled: true }; + render(<PluginSubtitle plugin={plugin} />); + expect(screen.queryByText(/permission to uninstall/i)).not.toBeInTheDocument(); + }); + }); + it('renders plugin subtitle extensions', () => { + const TestExtension = () => <div>Extension Content</div>; + registerPluginSubtitleExtension(TestExtension); + render(<PluginSubtitle plugin={basePlugin} />); + expect(screen.getByText('Extension Content')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginSubtitle.tsx b/public/app/features/plugins/admin/components/PluginSubtitle.tsx index 53e604bd110..926d27ac198 100644 --- a/public/app/features/plugins/admin/components/PluginSubtitle.tsx +++ b/public/app/features/plugins/admin/components/PluginSubtitle.tsx @@ -3,7 +3,7 @@ import { Fragment } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Alert, useStyles2 } from '@grafana/ui'; +import { Alert, Stack, useStyles2 } from '@grafana/ui'; import { InstallControlsWarning } from '../components/InstallControls'; import { getLatestCompatibleVersion, hasInstallControlWarning } from '../helpers'; @@ -14,6 +14,14 @@ interface Props { plugin?: CatalogPlugin; } +type PluginSubtitleExtension = (props: Props) => JSX.Element | null; + +const pluginSubtitleExtensions: PluginSubtitleExtension[] = []; + +export const registerPluginSubtitleExtension = (extension: PluginSubtitleExtension) => { + pluginSubtitleExtensions.push(extension); +}; + export const PluginSubtitle = ({ plugin }: Props) => { const isRemotePluginsAvailable = useIsRemotePluginsAvailable(); const styles = useStyles2(getStyles); @@ -35,26 +43,33 @@ export const PluginSubtitle = ({ plugin }: Props) => { {typeof errorInstalling === 'string' ? errorInstalling : errorInstalling.error} </Alert> )} - {plugin?.description && <div>{plugin?.description}</div>} - {!config.featureToggles.pluginsDetailsRightPanel && !!plugin?.details?.links?.length && ( - <span> - {plugin.details.links.map((link, index) => ( - <Fragment key={index}> - {index > 0 && ' | '} - <a href={link.url} className="external-link"> - {link.name} - </a> - </Fragment> - ))} - </span> - )} - {hasInstallControlWarning(plugin, isRemotePluginsAvailable, latestCompatibleVersion) && ( - <InstallControlsWarning - plugin={plugin} - pluginStatus={pluginStatus} - latestCompatibleVersion={latestCompatibleVersion} - /> - )} + <Stack direction="row" justifyContent="space-between"> + <div> + {plugin?.description && <div>{plugin?.description}</div>} + {!config.featureToggles.pluginsDetailsRightPanel && !!plugin?.details?.links?.length && ( + <span> + {plugin.details.links.map((link, index) => ( + <Fragment key={index}> + {index > 0 && ' | '} + <a href={link.url} className="external-link"> + {link.name} + </a> + </Fragment> + ))} + </span> + )} + {hasInstallControlWarning(plugin, isRemotePluginsAvailable, latestCompatibleVersion) && ( + <InstallControlsWarning + plugin={plugin} + pluginStatus={pluginStatus} + latestCompatibleVersion={latestCompatibleVersion} + /> + )} + </div> + {pluginSubtitleExtensions.map((extension) => { + return <Fragment key={extension.name}>{extension({ plugin })}</Fragment>; + })} + </Stack> </div> ); }; diff --git a/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts index c4bc6128a54..42848ce7863 100644 --- a/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts +++ b/public/app/features/plugins/sandbox/sandbox_plugin_loader_registry.ts @@ -25,7 +25,7 @@ export async function shouldLoadPluginInFrontendSandbox({ pluginId, }: SandboxEligibilityCheckParams): Promise<boolean> { // basic check if the plugin is eligible for the sandbox - if (!(await isPluginFrontendSandboxElegible({ isAngular, pluginId }))) { + if (!(await isPluginFrontendSandboxEligible({ isAngular, pluginId }))) { return false; } @@ -36,7 +36,7 @@ export async function shouldLoadPluginInFrontendSandbox({ * This is a basic check that checks if the plugin is eligible to run in the sandbox. * It does not check if the plugin is actually enabled for the sandbox. */ -async function isPluginFrontendSandboxElegible({ +export async function isPluginFrontendSandboxEligible({ isAngular, pluginId, }: SandboxEligibilityCheckParams): Promise<boolean> { @@ -61,9 +61,14 @@ async function isPluginFrontendSandboxElegible({ return false; } - // don't run grafana-signed plugins in sandbox - const pluginMeta = await getPluginSettings(pluginId); - if (pluginMeta.signatureType === PluginSignatureType.grafana) { + try { + // don't run grafana-signed plugins in sandbox + const pluginMeta = await getPluginSettings(pluginId, { showErrorAlert: false }); + if (pluginMeta.signatureType === PluginSignatureType.grafana) { + return false; + } + } catch (e) { + // this can fail if we are trying to fetch settings of a non-installed plugin return false; }