Plugins: Show screenshot in the tab in plugin details (#102521)

* New Screenshot tab added to plugin details

* update translation file, update tests

* create Carousel component for grafana-ui

* fix the build

* remove PluginScreenshotCarousel component
This commit is contained in:
Yulia Shanyrova
2025-03-31 16:51:17 +02:00
committed by GitHub
parent 608a2a7535
commit 5967b3321e
11 changed files with 473 additions and 2 deletions
@@ -0,0 +1,27 @@
import { Meta, ArgTypes } from '@storybook/blocks';
import { Carousel } from '@grafana/ui';
<Meta title="MDX|Carousel" component={Carousel} />
# Carousel
The Carousel component displays a grid of image thumbnails that can be clicked to view full-sized images in a modal with navigation controls. It provides an elegant way to present collections of images or screenshots with fullscreen preview capabilities.
The component handles image loading errors gracefully and provides keyboard navigation. It works with a simple array of image objects containing path and name information.
## Usage
```jsx
import { Carousel } from '@grafana/ui';
const images = [
{ path: '/path/to/image1.jpg', name: 'First Image' },
{ path: '/path/to/image2.jpg', name: 'Second Image' },
{ path: '/path/to/image3.jpg', name: 'Third Image' },
];
// Basic usage
<Carousel images={images} />;
```
<ArgTypes of={Carousel} />
@@ -0,0 +1,37 @@
import { Meta, StoryFn } from '@storybook/react';
import { Carousel, CarouselProps } from './Carousel';
import mdx from './Carousel.mdx';
// Sample images for the stories
const sampleImages = [
{
path: 'https://grafana.com/static/img/alerting/grafana-alerting-enterprise-scale-mimir-and-loki.png/apple-touch-icon.png',
name: 'Alert rule',
},
{ path: 'https://grafana.com/static/img/screenshots/grafana_dash.jpeg', name: 'Dashboard' },
{ path: 'https://grafana.com/static/img/screenshots/metrics.jpg', name: 'Metrics' },
{ path: 'https://grafana.com/static/img/screenshots/traces.jpg', name: 'Traces' },
];
const meta: Meta<typeof Carousel> = {
title: 'Data Display/Carousel',
component: Carousel,
parameters: {
docs: { page: mdx },
},
argTypes: {
images: {
description: 'Array of image objects with path and name properties',
},
},
};
const Template: StoryFn<CarouselProps> = (args) => <Carousel {...args} />;
export const Basic = Template.bind({});
Basic.args = {
images: sampleImages,
};
export default meta;
@@ -0,0 +1,154 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Carousel } from './Carousel';
const testImages = [
{
path: 'https://grafana.com/static/img/alerting/grafana-alerting-enterprise-scale-mimir-and-loki.png/apple-touch-icon.png',
name: 'Alert rule',
},
{ path: 'https://grafana.com/static/img/screenshots/grafana_dash.jpeg', name: 'Dashboard' },
{ path: 'https://grafana.com/static/img/screenshots/metrics.jpg', name: 'Metrics' },
{ path: 'https://grafana.com/static/img/screenshots/traces.jpg', name: 'Traces' },
];
describe('Carousel', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
});
it('renders the component with all images', () => {
render(<Carousel images={testImages} />);
testImages.forEach((image) => {
const imgElement = screen.getByAltText(image.name);
expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute('src', image.path);
});
});
it('shows full-screen preview when clicking on an image', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Alert rule'));
const fullScreenElement = screen.getByTestId('carousel-full-screen');
expect(fullScreenElement).toBeInTheDocument();
const previewImage = screen.getByTestId('carousel-full-image').querySelector('img');
expect(previewImage).toBeInTheDocument();
expect(previewImage).toHaveAttribute('src', testImages[0].path);
expect(previewImage).toHaveAttribute('alt', testImages[0].name);
});
it('closes the preview when clicking the close button', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Alert rule'));
expect(screen.getByLabelText('Close')).toBeInTheDocument();
await user.click(screen.getByLabelText('Close'));
await waitFor(() => {
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument();
});
});
it('navigates to next image when clicking the next button', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Alert rule'));
await user.click(screen.getByTestId('next-button'));
// Verify we're on the second image
const previewImage = screen.getByTestId('carousel-full-image').querySelector('img');
expect(previewImage).toHaveAttribute('src', testImages[1].path);
expect(previewImage).toHaveAttribute('alt', testImages[1].name);
});
it('navigates to previous image when clicking the previous button', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Dashboard'));
await user.click(screen.getByTestId('previous-button'));
const previewImage = screen.getByTestId('carousel-full-image').querySelector('img');
expect(previewImage).toHaveAttribute('src', testImages[0].path);
expect(previewImage).toHaveAttribute('alt', testImages[0].name);
});
it('wraps around to the first image when clicking next on the last image', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Traces'));
await user.click(screen.getByTestId('next-button'));
const previewImage = screen.getByTestId('carousel-full-image').querySelector('img');
expect(previewImage).toHaveAttribute('src', testImages[0].path);
expect(previewImage).toHaveAttribute('alt', testImages[0].name);
});
it('wraps around to the last image when clicking previous on the first image', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Alert rule'));
await user.click(screen.getByTestId('previous-button'));
const previewImage = screen.getByTestId('carousel-full-image').querySelector('img');
expect(previewImage).toHaveAttribute('src', testImages[3].path);
expect(previewImage).toHaveAttribute('alt', testImages[3].name);
});
it('navigates with keyboard arrow keys', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Alert rule'));
fireEvent.keyDown(screen.getByTestId('carousel-full-screen'), { key: 'ArrowRight' });
let previewImage = screen.getByTestId('carousel-full-image').querySelector('img');
expect(previewImage).toHaveAttribute('src', testImages[1].path);
fireEvent.keyDown(screen.getByTestId('carousel-full-screen'), { key: 'ArrowLeft' });
fireEvent.keyDown(screen.getByTestId('carousel-full-screen'), { key: 'ArrowLeft' });
previewImage = screen.getByTestId('carousel-full-image').querySelector('img');
expect(previewImage).toHaveAttribute('src', testImages[3].path);
});
it('closes the preview with the escape key', async () => {
render(<Carousel images={testImages} />);
await user.click(screen.getByText('Alert rule'));
expect(screen.getByTestId('carousel-full-screen')).toBeInTheDocument();
fireEvent.keyDown(screen.getByTestId('carousel-full-screen'), { key: 'Escape' });
expect(screen.queryByTestId('carousel-full-screen')).not.toBeInTheDocument();
});
it('shows warning when all images fail to load', async () => {
const originalConsoleError = console.error;
console.error = jest.fn();
render(<Carousel images={testImages} />);
const images = screen.getAllByRole('img');
images.forEach((img) => {
fireEvent.error(img);
});
await waitFor(() => {
expect(screen.getByTestId('alert-warning')).toBeInTheDocument();
});
console.error = originalConsoleError;
});
});
@@ -0,0 +1,214 @@
import { css, cx } from '@emotion/css';
import { useState, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { t } from '../../utils/i18n';
import { Alert } from '../Alert/Alert';
import { IconButton } from '../IconButton/IconButton';
// Define the image item interface
export interface CarouselImage {
path: string;
name: string;
}
export interface CarouselProps {
images: CarouselImage[];
}
export const Carousel: React.FC<CarouselProps> = ({ images }) => {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
const [validImages, setValidImages] = useState<CarouselImage[]>(images);
const styles = useStyles2(getStyles());
const handleImageError = (path: string) => {
setImageErrors((prev) => ({
...prev,
[path]: true,
}));
};
useEffect(() => {
const filteredImages = images.filter((image) => !imageErrors[image.path]);
setValidImages(filteredImages);
}, [imageErrors, images]);
const openPreview = (index: number) => {
setSelectedIndex(index);
};
const closePreview = () => {
setSelectedIndex(null);
};
const goToNext = () => {
if (selectedIndex !== null && validImages.length > 0) {
setSelectedIndex((selectedIndex + 1) % validImages.length);
}
};
const goToPrevious = () => {
if (selectedIndex !== null && validImages.length > 0) {
setSelectedIndex((selectedIndex - 1 + validImages.length) % validImages.length);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (selectedIndex === null) {
return;
}
switch (event.key) {
case 'ArrowRight':
goToNext();
break;
case 'ArrowLeft':
goToPrevious();
break;
case 'Escape':
closePreview();
break;
default:
break;
}
};
if (validImages.length === 0) {
return (
<Alert
title={t('carousel.error', 'Something went wrong loading images')}
severity="warning"
data-testid="alert-warning"
/>
);
}
return (
<div onKeyDown={handleKeyDown} tabIndex={0}>
<div className={cx(styles.imageGrid)}>
{validImages.map((image, index) => (
<div key={image.path} onClick={() => openPreview(index)} style={{ cursor: 'pointer' }}>
<img src={image.path} alt={image.name} onError={() => handleImageError(image.path)} />
<p>{image.name}</p>
</div>
))}
</div>
{selectedIndex !== null && (
<div className={cx(styles.fullScreenDiv)} onClick={closePreview} data-testid="carousel-full-screen">
<IconButton
name="times"
aria-label={t('carousel.close', 'Close')}
size="xl"
onClick={closePreview}
className={cx(styles.closeButton)}
/>
<IconButton
size="xl"
name="angle-left"
aria-label={t('carousel.previous', 'Previous')}
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
className={cx(styles.navigationButton, styles.previousButton)}
data-testid="previous-button"
/>
<div
style={{ position: 'relative', maxWidth: '90%', maxHeight: '90%' }}
onClick={(e) => e.stopPropagation()}
data-testid="carousel-full-image"
>
<img
src={validImages[selectedIndex].path}
alt={validImages[selectedIndex].name}
onError={() => handleImageError(validImages[selectedIndex].path)}
/>
</div>
<IconButton
size="xl"
name="angle-right"
aria-label={t('carousel.next', 'Next')}
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
className={cx(styles.navigationButton, styles.nextButton)}
data-testid="next-button"
/>
</div>
)}
</div>
);
};
const getStyles = () => (theme: GrafanaTheme2) => ({
imageGrid: css({
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(200px, 1fr))`,
gap: '16px',
marginBottom: '20px',
'& img': {
width: '100%',
height: '150px',
objectFit: 'cover',
border: theme.colors.border.strong,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z1,
},
'& p': {
margin: '4px 0',
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
},
}),
fullScreenDiv: css({
position: 'fixed',
zIndex: theme.zIndex.modalBackdrop,
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.components.overlay.background,
backdropFilter: 'blur(1px)',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
'& img': {
maxWidth: '100%',
maxHeight: '80vh',
objectFit: 'contain',
},
}),
closeButton: css({
position: 'absolute',
top: '20px',
right: '20px',
backgroundColor: 'transparent',
color: theme.colors.text.primary,
}),
navigationButton: css({
position: 'absolute',
backgroundColor: 'transparent',
color: theme.colors.text.primary,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}),
nextButton: css({
right: '20px',
}),
previousButton: css({
left: '20px',
}),
});
@@ -21,6 +21,7 @@ export { TabbedContainer, type TabConfig } from './TabbedContainer/TabbedContain
export { ClipboardButton } from './ClipboardButton/ClipboardButton';
export { Cascader, type CascaderOption } from './Cascader/Cascader';
export { Carousel } from './Carousel/Carousel';
export { ButtonCascader } from './ButtonCascader/ButtonCascader';
export { InlineToast } from './InlineToast/InlineToast';
@@ -90,6 +90,7 @@ export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow';
export { getContentItems } from '../components/VizTooltip/utils';
export { ColorIndicator, ColorPlacement, type VizTooltipItem } from '../components/VizTooltip/types';
export { mapMouseEventToMode } from '../components/VizLegend/utils';
export { Carousel } from '../components/Carousel/Carousel';
export { getFocusStyles, getMouseFocusStyles, getTooltipContainerStyles } from '../themes/mixins';
+1
View File
@@ -42,6 +42,7 @@ export async function getPluginDetails(id: string): Promise<CatalogPluginDetails
raiseAnIssueUrl: remote?.raiseAnIssueUrl,
signatureType: local?.signatureType || (remote?.signatureType !== '' ? remote?.signatureType : undefined),
signature: local?.signature,
screenshots: remote?.json?.info.screenshots || local?.info.screenshots,
};
}
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { AppPlugin, GrafanaTheme2, PluginContextProvider, UrlQueryMap, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { PageInfoItem } from '@grafana/runtime/internal';
import { CellProps, Column, InteractiveTable, Stack, useStyles2 } from '@grafana/ui';
import { CellProps, Column, InteractiveTable, Stack, useStyles2, Carousel } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Changelog } from '../components/Changelog';
@@ -12,7 +12,7 @@ import { PluginDetailsPanel } from '../components/PluginDetailsPanel';
import { VersionList } from '../components/VersionList';
import { shouldDisablePluginInstall } from '../helpers';
import { usePluginConfig } from '../hooks/usePluginConfig';
import { CatalogPlugin, Permission, PluginTabIds } from '../types';
import { CatalogPlugin, Permission, PluginTabIds, Screenshots } from '../types';
import { AppConfigCtrlWrapper } from './AppConfigWrapper';
import Connections from './ConnectionsTab';
@@ -48,6 +48,10 @@ export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetai
[]
);
const buildScreenshotPath = (plugin: CatalogPlugin, path: string) => {
return `${config.appSubUrl}/api/gnet/plugins/${plugin.id}/versions/${plugin.latestVersion}/images/${path}`;
};
if (pageId === PluginTabIds.OVERVIEW) {
return (
<div
@@ -76,6 +80,14 @@ export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetai
return <Changelog sanitizedHTML={plugin?.details?.changelog} />;
}
if (pageId === PluginTabIds.SCREENSHOTS && plugin?.details?.screenshots?.length) {
const carouselImages: Screenshots[] = plugin?.details?.screenshots.map((screenshot) => ({
path: buildScreenshotPath(plugin, screenshot.path),
name: screenshot.name,
}));
return <Carousel images={carouselImages} />;
}
if (pageId === PluginTabIds.CONFIG && pluginConfig?.angularConfigCtrl) {
return (
<div>
@@ -63,6 +63,16 @@ export const usePluginDetailsTabs = (
});
}
if (isPublished && plugin?.details?.screenshots?.length) {
navModelChildren.push({
text: PluginTabLabels.SCREENSHOTS,
id: PluginTabIds.SCREENSHOTS,
icon: 'camera',
url: `${pathname}?page=${PluginTabIds.SCREENSHOTS}`,
active: PluginTabIds.SCREENSHOTS === currentPageId,
});
}
if (isPublished && isNarrowScreen && config.featureToggles.pluginsDetailsRightPanel) {
navModelChildren.push({
text: PluginTabLabels.PLUGINDETAILS,
@@ -65,6 +65,10 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
isProvisioned?: boolean;
url?: string;
}
export interface Screenshots {
path: string;
name: string;
}
export interface CatalogPluginDetails {
readme?: string;
@@ -84,6 +88,7 @@ export interface CatalogPluginDetails {
raiseAnIssueUrl?: string;
signatureType?: PluginSignatureType;
signature?: PluginSignatureStatus;
screenshots?: Screenshots[] | null;
}
export interface CatalogPluginInfo {
@@ -112,6 +117,7 @@ export type RemotePlugin = {
name: string;
url: string;
}>;
screenshots?: Screenshots[] | null;
};
};
links: Array<{ rel: string; href: string }>;
@@ -268,6 +274,7 @@ export enum PluginTabLabels {
CHANGELOG = 'Changelog',
PLUGINDETAILS = 'Plugin details',
DATASOURCE_CONNECTIONS = 'Data source connections',
SCREENSHOTS = 'Screenshots',
}
export enum PluginTabIds {
@@ -280,6 +287,7 @@ export enum PluginTabIds {
CHANGELOG = 'changelog',
PLUGINDETAILS = 'right-panel',
DATASOURCE_CONNECTIONS = 'datasource-connections',
SCREENSHOTS = 'screenshots',
}
export enum RequestStatus {
+6
View File
@@ -1114,6 +1114,12 @@
"success": "Dashboard {{name}} moved to Recently deleted"
}
},
"carousel": {
"close": "Close",
"error": "Something went wrong loading images",
"next": "Next",
"previous": "Previous"
},
"clipboard-button": {
"inline-toast": {
"success": "Copied"