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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user