plugin details right panel tab (#97354)

* plugin details right panel tab

* fix betterer

* useMedia hook, use function for currentIdPage instead of state

* Rename PluginDetailsRightPanel to PluginDetailsPanel

* nit changes

* remove maxWidth for pluginDetailsPanel if screen is narrow

* fix width prop

* Add tests

* Rename PluginDetailsRight Panel file, rename info prop, fix the latestVersion

* delete console log

* move latestVersion from info arrya

* fix latestVersion test

---------

Co-authored-by: Esteban Beltran <esteban@academo.me>
This commit is contained in:
Yulia Shanyrova 2024-12-17 15:10:29 +01:00 committed by GitHub
parent b8a4784a50
commit 2f40a93bf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 343 additions and 23 deletions

View File

@ -3,9 +3,11 @@ import { useMemo } from 'react';
import { AppPlugin, GrafanaTheme2, PluginContextProvider, UrlQueryMap } from '@grafana/data';
import { config } from '@grafana/runtime';
import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage';
import { CellProps, Column, InteractiveTable, Stack, useStyles2 } from '@grafana/ui';
import { Changelog } from '../components/Changelog';
import { PluginDetailsPanel } from '../components/PluginDetailsPanel';
import { VersionList } from '../components/VersionList';
import { usePluginConfig } from '../hooks/usePluginConfig';
import { CatalogPlugin, Permission, PluginTabIds } from '../types';
@ -16,13 +18,15 @@ import { PluginUsage } from './PluginUsage';
type Props = {
plugin: CatalogPlugin;
info: PageInfoItem[];
queryParams: UrlQueryMap;
pageId: string;
showDetails: boolean;
};
type Cell<T extends keyof Permission = keyof Permission> = CellProps<Permission, Permission[T]>;
export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.Element {
export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetails }: Props): JSX.Element {
const styles = useStyles2(getStyles);
const { value: pluginConfig } = usePluginConfig(plugin);
@ -77,6 +81,14 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
);
}
if (pageId === PluginTabIds.PLUGINDETAILS && config.featureToggles.pluginsDetailsRightPanel && showDetails) {
return (
<div>
<PluginDetailsPanel pluginExtentionsInfo={info} plugin={plugin} width={'auto'} />
</div>
);
}
// Permissions will be returned in the iam field for installed plugins and in the details.iam field when fetching details from gcom
const permissions = plugin.iam?.permissions || plugin.details?.iam?.permissions;

View File

@ -0,0 +1,144 @@
import { render, screen } from 'test/test-utils';
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CatalogPlugin } from '../types';
import { PluginDetailsPage } from './PluginDetailsPage';
const plugin: CatalogPlugin = {
description: 'Test plugin description',
downloads: 1000,
hasUpdate: false,
id: 'test-plugin',
info: {
logos: {
small: 'small-logo-url',
large: 'large-logo-url',
},
keywords: ['test', 'plugin'],
},
isDev: false,
isCore: false,
isEnterprise: false,
isInstalled: true,
isDisabled: false,
isDeprecated: false,
isManaged: false,
isPreinstalled: { found: false, withVersion: false },
isPublished: true,
name: 'Test Plugin',
orgName: 'Test Org',
signature: PluginSignatureStatus.valid,
signatureType: PluginSignatureType.grafana,
signatureOrg: 'Test Signature Org',
popularity: 4,
publishedAt: '2023-01-01',
type: PluginType.app,
updatedAt: '2023-12-01',
installedVersion: '1.0.0',
details: {
readme: 'Test readme',
versions: [
{
version: '1.0.0',
createdAt: '2023-01-01',
isCompatible: true,
grafanaDependency: '>=9.0.0',
angularDetected: false,
},
],
links: [
{
name: 'Website',
url: 'https://test-plugin.com',
},
],
grafanaDependency: '>=9.0.0',
statusContext: 'stable',
},
angularDetected: false,
isFullyInstalled: true,
accessControl: {},
};
jest.mock('../state/hooks', () => ({
useGetSingle: jest.fn(),
useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }),
useFetchDetailsStatus: () => ({ isLoading: false }),
useIsRemotePluginsAvailable: () => false,
useInstallStatus: () => ({ error: null, isInstalling: false }),
useUninstallStatus: () => ({ error: null, isUninstalling: false }),
useInstall: () => jest.fn(),
useUninstall: () => jest.fn(),
useUnsetInstall: () => jest.fn(),
useFetchDetailsLazy: () => jest.fn(),
}));
const mockUseGetSingle = jest.requireMock('../state/hooks').useGetSingle;
describe('PluginDetailsPage', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation();
mockUseGetSingle.mockReturnValue(plugin);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should show loader when fetching plugin details', () => {
jest.requireMock('../state/hooks').useFetchStatus.mockReturnValueOnce({ isLoading: true });
render(<PluginDetailsPage pluginId="test-plugin" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should show not found component when plugin doesnt exist', () => {
mockUseGetSingle.mockReturnValue(undefined);
render(<PluginDetailsPage pluginId="not-exist" />);
expect(screen.getByText('Plugin not found')).toBeInTheDocument();
});
it('should show angular deprecation notice when angular is detected', () => {
mockUseGetSingle.mockReturnValue({ ...plugin, angularDetected: true });
render(<PluginDetailsPage pluginId="test-plugin" />);
expect(screen.getByText(/legacy platform based on AngularJS/i)).toBeInTheDocument();
});
it('should not show right panel when feature toggle is disabled', () => {
config.featureToggles.pluginsDetailsRightPanel = false;
render(<PluginDetailsPage pluginId="test-plugin" />);
expect(screen.queryByTestId('plugin-details-panel')).not.toBeInTheDocument();
});
it('should show right panel when feature toggle is enabled and screen is wide', () => {
config.featureToggles.pluginsDetailsRightPanel = true;
window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: query !== '(max-width: 600px)',
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
render(<PluginDetailsPage pluginId="test-plugin" />);
expect(screen.getByTestId('plugin-details-panel')).toBeInTheDocument();
});
it('should show "Plugin details" tab when screen is narrow', () => {
config.featureToggles.pluginsDetailsRightPanel = true;
window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: query === '(max-width: 600px)',
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
render(<PluginDetailsPage pluginId="test-plugin" />);
expect(screen.getByRole('tab', { name: 'Plugin details' })).toBeInTheDocument();
});
});

View File

@ -1,6 +1,7 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { useMedia } from 'react-use';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
@ -12,7 +13,7 @@ import { AngularDeprecationPluginNotice } from '../../angularDeprecation/Angular
import { Loader } from '../components/Loader';
import { PluginDetailsBody } from '../components/PluginDetailsBody';
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
import { PluginDetailsRightPanel } from '../components/PluginDetailsRightPanel';
import { PluginDetailsPanel } from '../components/PluginDetailsPanel';
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
@ -45,7 +46,12 @@ export function PluginDetailsPage({
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance
const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page') as PluginTabIds);
const isNarrowScreen = useMedia('(max-width: 600px)');
const { navModel, activePageId } = usePluginDetailsTabs(
plugin,
queryParams.get('page') as PluginTabIds,
isNarrowScreen
);
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
const { isLoading: isFetchLoading } = useFetchStatus();
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
@ -93,10 +99,18 @@ export function PluginDetailsPage({
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
<PluginDetailsDeprecatedWarning plugin={plugin} className={styles.alert} />
<PluginDetailsBody queryParams={Object.fromEntries(queryParams)} plugin={plugin} pageId={activePageId} />
<PluginDetailsBody
queryParams={Object.fromEntries(queryParams)}
plugin={plugin}
pageId={activePageId}
info={info}
showDetails={isNarrowScreen}
/>
</TabContent>
</Page.Contents>
{config.featureToggles.pluginsDetailsRightPanel && <PluginDetailsRightPanel info={info} plugin={plugin} />}
{!isNarrowScreen && config.featureToggles.pluginsDetailsRightPanel && (
<PluginDetailsPanel pluginExtentionsInfo={info} plugin={plugin} />
)}
</Stack>
</Page>
);

View File

@ -0,0 +1,121 @@
import { render, screen } from 'test/test-utils';
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
import { CatalogPlugin } from '../types';
import { PluginDetailsPanel } from './PluginDetailsPanel';
const mockPlugin: CatalogPlugin = {
description: 'Test plugin description',
downloads: 1000,
hasUpdate: false,
id: 'test-plugin',
info: {
logos: {
small: 'small-logo-url',
large: 'large-logo-url',
},
keywords: ['test', 'plugin'],
},
isDev: false,
isCore: false,
isEnterprise: false,
isInstalled: true,
isDisabled: false,
isDeprecated: false,
isManaged: false,
isPreinstalled: { found: false, withVersion: false },
isPublished: true,
name: 'Test Plugin',
orgName: 'Test Org',
signature: PluginSignatureStatus.valid,
signatureType: PluginSignatureType.grafana,
signatureOrg: 'Test Signature Org',
popularity: 4,
publishedAt: '2023-01-01',
type: PluginType.app,
updatedAt: '2023-12-01',
installedVersion: '1.0.0',
latestVersion: '1.1.0',
details: {
readme: 'Test readme',
versions: [
{
version: '1.0.0',
createdAt: '2023-01-01',
isCompatible: true,
grafanaDependency: '>=9.0.0',
angularDetected: false,
},
],
links: [
{
name: 'Website',
url: 'https://test-plugin.com',
},
],
grafanaDependency: '>=9.0.0',
statusContext: 'stable',
},
angularDetected: false,
isFullyInstalled: true,
accessControl: {},
};
const mockInfo = [
{ label: 'Version', value: '1.1.0' },
{ label: 'Author', value: 'Test Author' },
];
describe('PluginDetailsPanel', () => {
it('should render installed version when plugin is installed', () => {
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
const installedVersionLabel = screen.getByText('Installed version:');
// Get the version text that's next to the label
const installedVersion = installedVersionLabel.nextElementSibling;
expect(installedVersionLabel).toBeInTheDocument();
expect(installedVersion).toHaveTextContent('1.0.0');
});
it('should render latest version information', () => {
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
expect(screen.getByText('Latest version:')).toBeInTheDocument();
expect(screen.getByText('1.1.0')).toBeInTheDocument();
});
it('should render links section when plugin has links', () => {
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
const link = screen.getByText('Website');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://test-plugin.com');
});
it('should not render links section when plugin has no links', () => {
const pluginWithoutLinks = {
...mockPlugin,
details: { ...mockPlugin.details, links: [] },
};
render(<PluginDetailsPanel plugin={pluginWithoutLinks} pluginExtentionsInfo={mockInfo} />);
expect(screen.queryByText('Links')).not.toBeInTheDocument();
expect(screen.queryByText('Website')).not.toBeInTheDocument();
});
it('should render report abuse section for non-core plugins', () => {
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} />);
expect(screen.getByText('Report a concern')).toBeInTheDocument();
expect(screen.getByText('Contact Grafana Labs')).toBeInTheDocument();
});
it('should not render report abuse section for core plugins', () => {
const corePlugin = { ...mockPlugin, isCore: true };
render(<PluginDetailsPanel plugin={corePlugin} pluginExtentionsInfo={mockInfo} />);
expect(screen.queryByText('Report a concern')).not.toBeInTheDocument();
});
it('should respect custom width prop', () => {
render(<PluginDetailsPanel plugin={mockPlugin} pluginExtentionsInfo={mockInfo} width="300px" />);
const panel = screen.getByTestId('plugin-details-panel');
expect(panel).toHaveStyle({ maxWidth: '300px' });
});
});

View File

@ -10,15 +10,17 @@ import { getLatestCompatibleVersion } from '../helpers';
import { CatalogPlugin } from '../types';
type Props = {
info: PageInfoItem[];
pluginExtentionsInfo: PageInfoItem[];
plugin: CatalogPlugin;
width?: string;
};
export function PluginDetailsRightPanel(props: Props): React.ReactElement | null {
const { info, plugin } = props;
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
const { pluginExtentionsInfo, plugin, width = '250px' } = props;
const styles = useStyles2(getStyles);
return (
<Stack direction="column" gap={3} shrink={0} grow={0} maxWidth={'250px'}>
<Stack direction="column" gap={3} shrink={0} grow={0} maxWidth={width} data-testid="plugin-details-panel">
<Box padding={2} borderColor="medium" borderStyle="solid">
<Stack direction="column" gap={2}>
{plugin.isInstalled && plugin.installedVersion && (
@ -29,18 +31,17 @@ export function PluginDetailsRightPanel(props: Props): React.ReactElement | null
<div className={styles.pluginVersionDetails}>{plugin.installedVersion}</div>
</Stack>
)}
{info.map((infoItem, index) => {
if (infoItem.label === 'Version') {
return (
<Stack key={index} wrap direction="column" gap={0.5}>
<Stack wrap direction="column" gap={0.5}>
<Text color="secondary">
<Trans i18nKey="plugins.details.labels.latestVersion">Latest version: </Trans>
</Text>
<div className={styles.pluginVersionDetails}>
{getLatestCompatibleVersion(plugin.details?.versions)?.version}
{plugin.latestVersion || getLatestCompatibleVersion(plugin.details?.versions)?.version}
</div>
</Stack>
);
{pluginExtentionsInfo.map((infoItem, index) => {
if (infoItem.label === 'Version') {
return null;
}
return (
<Stack key={index} wrap direction="column" gap={0.5}>

View File

@ -16,13 +16,29 @@ type ReturnType = {
activePageId: PluginTabIds | string;
};
export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabIds): ReturnType => {
function getCurrentPageId(
pageId: PluginTabIds | undefined,
isNarrowScreen: boolean | undefined,
defaultTab: string
): PluginTabIds | string {
if (!isNarrowScreen && pageId === PluginTabIds.PLUGINDETAILS) {
return defaultTab;
}
return pageId || defaultTab;
}
export const usePluginDetailsTabs = (
plugin?: CatalogPlugin,
pageId?: PluginTabIds,
isNarrowScreen?: boolean
): ReturnType => {
const { loading, error, value: pluginConfig } = usePluginConfig(plugin);
const { pathname } = useLocation();
const defaultTab = useDefaultPage(plugin, pluginConfig);
const isPublished = Boolean(plugin?.isPublished);
const currentPageId = pageId || defaultTab;
const currentPageId = getCurrentPageId(pageId, isNarrowScreen, defaultTab);
const navModelChildren = useMemo(() => {
const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin);
const navModelChildren: NavModelItem[] = [];
@ -45,6 +61,16 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabI
});
}
if (isPublished && isNarrowScreen && config.featureToggles.pluginsDetailsRightPanel) {
navModelChildren.push({
text: PluginTabLabels.PLUGINDETAILS,
id: PluginTabIds.PLUGINDETAILS,
icon: 'info-circle',
url: `${pathname}?page=${PluginTabIds.PLUGINDETAILS}`,
active: PluginTabIds.PLUGINDETAILS === currentPageId,
});
}
// Not extending the tabs with the config pages if the plugin is not installed
if (!pluginConfig) {
return navModelChildren;
@ -112,7 +138,7 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabI
}
return navModelChildren;
}, [plugin, pluginConfig, pathname, isPublished, currentPageId]);
}, [plugin, pluginConfig, pathname, isPublished, currentPageId, isNarrowScreen]);
const navModel: NavModelItem = {
text: plugin?.name ?? '',

View File

@ -256,6 +256,7 @@ export enum PluginTabLabels {
USAGE = 'Usage',
IAM = 'IAM',
CHANGELOG = 'Changelog',
PLUGINDETAILS = 'Plugin details',
}
export enum PluginTabIds {
@ -266,6 +267,7 @@ export enum PluginTabIds {
USAGE = 'usage',
IAM = 'iam',
CHANGELOG = 'changelog',
PLUGINDETAILS = 'right-panel',
}
export enum RequestStatus {