Catalog: Allow extensions to render in the plugin catalog subtitle (#95239)

* Plugins Catalog: Allow plugin subtitle additional extensions to register and render

* Add tests

* Fix prettier issues

* Fix typo

* Empty commit
This commit is contained in:
Esteban Beltran 2024-10-24 15:44:50 +02:00 committed by GitHub
parent 2a65de8aaa
commit 77b8b505ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 166 additions and 26 deletions

View File

@ -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();
});
});

View File

@ -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>
);
};

View File

@ -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;
}