diff --git a/packages/grafana-ui/src/components/Carousel/Carousel.mdx b/packages/grafana-ui/src/components/Carousel/Carousel.mdx new file mode 100644 index 00000000000..ac36632457f --- /dev/null +++ b/packages/grafana-ui/src/components/Carousel/Carousel.mdx @@ -0,0 +1,27 @@ +import { Meta, ArgTypes } from '@storybook/blocks'; +import { Carousel } from '@grafana/ui'; + + + +# 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 +; +``` + + diff --git a/packages/grafana-ui/src/components/Carousel/Carousel.story.tsx b/packages/grafana-ui/src/components/Carousel/Carousel.story.tsx new file mode 100644 index 00000000000..ffed8b27098 --- /dev/null +++ b/packages/grafana-ui/src/components/Carousel/Carousel.story.tsx @@ -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 = { + 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 = (args) => ; + +export const Basic = Template.bind({}); +Basic.args = { + images: sampleImages, +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/Carousel/Carousel.test.tsx b/packages/grafana-ui/src/components/Carousel/Carousel.test.tsx new file mode 100644 index 00000000000..0966fd029e9 --- /dev/null +++ b/packages/grafana-ui/src/components/Carousel/Carousel.test.tsx @@ -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; + + beforeEach(() => { + user = userEvent.setup(); + }); + + it('renders the component with all images', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const images = screen.getAllByRole('img'); + images.forEach((img) => { + fireEvent.error(img); + }); + + await waitFor(() => { + expect(screen.getByTestId('alert-warning')).toBeInTheDocument(); + }); + + console.error = originalConsoleError; + }); +}); diff --git a/packages/grafana-ui/src/components/Carousel/Carousel.tsx b/packages/grafana-ui/src/components/Carousel/Carousel.tsx new file mode 100644 index 00000000000..bbf7173d5bd --- /dev/null +++ b/packages/grafana-ui/src/components/Carousel/Carousel.tsx @@ -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 = ({ images }) => { + const [selectedIndex, setSelectedIndex] = useState(null); + const [imageErrors, setImageErrors] = useState>({}); + const [validImages, setValidImages] = useState(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 ( + + ); + } + + return ( + + + {validImages.map((image, index) => ( + openPreview(index)} style={{ cursor: 'pointer' }}> + handleImageError(image.path)} /> + {image.name} + + ))} + + + {selectedIndex !== null && ( + + + + { + e.stopPropagation(); + goToPrevious(); + }} + className={cx(styles.navigationButton, styles.previousButton)} + data-testid="previous-button" + /> + + e.stopPropagation()} + data-testid="carousel-full-image" + > + handleImageError(validImages[selectedIndex].path)} + /> + + + { + e.stopPropagation(); + goToNext(); + }} + className={cx(styles.navigationButton, styles.nextButton)} + data-testid="next-button" + /> + + )} + + ); +}; + +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', + }), +}); diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index d4d12702bec..e999d66c0b0 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/internal/index.ts b/packages/grafana-ui/src/internal/index.ts index b49a258b021..07b943a07f3 100644 --- a/packages/grafana-ui/src/internal/index.ts +++ b/packages/grafana-ui/src/internal/index.ts @@ -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'; diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 4520989603f..120bdf3a66e 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -42,6 +42,7 @@ export async function getPluginDetails(id: string): Promise { + return `${config.appSubUrl}/api/gnet/plugins/${plugin.id}/versions/${plugin.latestVersion}/images/${path}`; + }; + if (pageId === PluginTabIds.OVERVIEW) { return ( ; } + 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 ; + } + if (pageId === PluginTabIds.CONFIG && pluginConfig?.angularConfigCtrl) { return ( diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index 60074bb053a..e273da1c73e 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -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, diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index a612c259c0a..06ed5de7787 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -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 { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b6c6e87026a..48bf288226d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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"
{image.name}