From 2f40a93bf83986cb0bd6acbbc147d8ccaf0512ea Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Tue, 17 Dec 2024 15:10:29 +0100 Subject: [PATCH] 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 --- .../admin/components/PluginDetailsBody.tsx | 14 +- .../components/PluginDetailsPage.test.tsx | 144 ++++++++++++++++++ .../admin/components/PluginDetailsPage.tsx | 22 ++- .../components/PluginDetailsPanel.test.tsx | 121 +++++++++++++++ ...sRightPanel.tsx => PluginDetailsPanel.tsx} | 31 ++-- .../admin/hooks/usePluginDetailsTabs.tsx | 32 +++- public/app/features/plugins/admin/types.ts | 2 + 7 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx create mode 100644 public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx rename public/app/features/plugins/admin/components/{PluginDetailsRightPanel.tsx => PluginDetailsPanel.tsx} (83%) diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index e0fc9a77e3c..7554359b537 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -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 = CellProps; -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 ( +
+ +
+ ); + } + // 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; diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx new file mode 100644 index 00000000000..c256b7f412a --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx @@ -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(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should show not found component when plugin doesnt exist', () => { + mockUseGetSingle.mockReturnValue(undefined); + render(); + expect(screen.getByText('Plugin not found')).toBeInTheDocument(); + }); + + it('should show angular deprecation notice when angular is detected', () => { + mockUseGetSingle.mockReturnValue({ ...plugin, angularDetected: true }); + render(); + 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(); + 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(); + 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(); + expect(screen.getByRole('tab', { name: 'Plugin details' })).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx index e11f83c2c89..f4fb708d965 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx @@ -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({ - + - {config.featureToggles.pluginsDetailsRightPanel && } + {!isNarrowScreen && config.featureToggles.pluginsDetailsRightPanel && ( + + )} ); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx new file mode 100644 index 00000000000..27df365178c --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.test.tsx @@ -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(); + 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(); + expect(screen.getByText('Latest version:')).toBeInTheDocument(); + expect(screen.getByText('1.1.0')).toBeInTheDocument(); + }); + + it('should render links section when plugin has links', () => { + render(); + 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(); + expect(screen.queryByText('Links')).not.toBeInTheDocument(); + expect(screen.queryByText('Website')).not.toBeInTheDocument(); + }); + + it('should render report abuse section for non-core plugins', () => { + render(); + 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(); + expect(screen.queryByText('Report a concern')).not.toBeInTheDocument(); + }); + + it('should respect custom width prop', () => { + render(); + const panel = screen.getByTestId('plugin-details-panel'); + expect(panel).toHaveStyle({ maxWidth: '300px' }); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginDetailsRightPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx similarity index 83% rename from public/app/features/plugins/admin/components/PluginDetailsRightPanel.tsx rename to public/app/features/plugins/admin/components/PluginDetailsPanel.tsx index 67354e9171c..938c1a8db17 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsRightPanel.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx @@ -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 ( - + {plugin.isInstalled && plugin.installedVersion && ( @@ -29,18 +31,17 @@ export function PluginDetailsRightPanel(props: Props): React.ReactElement | null
{plugin.installedVersion}
)} - {info.map((infoItem, index) => { + + + Latest version: + +
+ {plugin.latestVersion || getLatestCompatibleVersion(plugin.details?.versions)?.version} +
+
+ {pluginExtentionsInfo.map((infoItem, index) => { if (infoItem.label === 'Version') { - return ( - - - Latest version: - -
- {getLatestCompatibleVersion(plugin.details?.versions)?.version} -
-
- ); + return null; } return ( diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index 1778c03e058..eebb94e5748 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -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 ?? '', diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index d9fee5ffc46..ea61ba34cb0 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -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 {