diff --git a/public/app/features/plugins/admin/__mocks__/mockHelpers.ts b/public/app/features/plugins/admin/__mocks__/mockHelpers.ts index 6c1a9949bea..23d586ba269 100644 --- a/public/app/features/plugins/admin/__mocks__/mockHelpers.ts +++ b/public/app/features/plugins/admin/__mocks__/mockHelpers.ts @@ -1,6 +1,14 @@ import { setBackendSrv } from '@grafana/runtime'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; -import { CatalogPlugin, LocalPlugin, RemotePlugin, Version, ReducerState, RequestStatus } from '../types'; +import { + CatalogPlugin, + LocalPlugin, + RemotePlugin, + Version, + ReducerState, + RequestStatus, + PluginListDisplayMode, +} from '../types'; import remotePluginMock from './remotePlugin.mock'; import localPluginMock from './localPlugin.mock'; import catalogPluginMock from './catalogPlugin.mock'; @@ -29,6 +37,9 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState status: RequestStatus.Fulfilled, }, }, + settings: { + displayMode: PluginListDisplayMode.Grid, + }, // Backward compatibility plugins: [], errors: [], diff --git a/public/app/features/plugins/admin/components/PluginList.tsx b/public/app/features/plugins/admin/components/PluginList.tsx index 0beda053505..38a67e5e8a6 100644 --- a/public/app/features/plugins/admin/components/PluginList.tsx +++ b/public/app/features/plugins/admin/components/PluginList.tsx @@ -2,29 +2,36 @@ import React from 'react'; import { css } from '@emotion/css'; import { useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; -import { CatalogPlugin } from '../types'; -import { PluginListCard } from './PluginListCard'; import { useLocation } from 'react-router-dom'; +import { CatalogPlugin, PluginListDisplayMode } from '../types'; +import { PluginListItem } from './PluginListItem'; interface Props { plugins: CatalogPlugin[]; + displayMode: PluginListDisplayMode; } -export const PluginList = ({ plugins }: Props) => { - const styles = useStyles2(getStyles); +export const PluginList = ({ plugins, displayMode }: Props) => { + const styles = useStyles2((theme) => getStyles(theme, displayMode)); const location = useLocation(); return ( -
+
{plugins.map((plugin) => ( - + ))}
); }; -const getStyles = (theme: GrafanaTheme2) => css` - display: grid; - grid-template-columns: repeat(auto-fill, minmax(288px, 1fr)); - grid-gap: ${theme.spacing(3)}; -`; +const getStyles = (theme: GrafanaTheme2, display: PluginListDisplayMode) => { + const isList = display === PluginListDisplayMode.List; + + return { + container: css` + display: grid; + grid-template-columns: ${isList ? '1fr' : 'repeat(auto-fill, minmax(288px, 1fr))'}; + grid-gap: ${theme.spacing(3)}; + `, + }; +}; diff --git a/public/app/features/plugins/admin/components/PluginListCard.tsx b/public/app/features/plugins/admin/components/PluginListCard.tsx deleted file mode 100644 index f32801a1cf4..00000000000 --- a/public/app/features/plugins/admin/components/PluginListCard.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { css } from '@emotion/css'; -import { Icon, useStyles2, CardContainer, HorizontalGroup, VerticalGroup, Tooltip } from '@grafana/ui'; -import { GrafanaTheme2 } from '@grafana/data'; -import { CatalogPlugin, PluginIconName, PluginTabIds } from '../types'; -import { PluginLogo } from './PluginLogo'; -import { PluginListBadges } from './PluginListBadges'; - -const LOGO_SIZE = '48px'; - -type PluginListCardProps = { - plugin: CatalogPlugin; - pathName: string; -}; - -export function PluginListCard({ plugin, pathName }: PluginListCardProps) { - const styles = useStyles2(getStyles); - - return ( - - -
- -

{plugin.name}

- {plugin.type && ( -
- -
- )} -
-

By {plugin.orgName}

- - - {plugin.hasUpdate && !plugin.isCore ? ( - -

Update available!

-
- ) : null} -
-
-
- ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - cardContainer: css` - margin-bottom: 0; - padding: ${theme.spacing()}; - `, - headerWrap: css` - align-items: center; - display: grid; - grid-template-columns: ${LOGO_SIZE} 1fr ${theme.spacing(3)}; - grid-gap: ${theme.spacing(2)}; - width: 100%; - `, - name: css` - color: ${theme.colors.text.primary}; - flex-grow: 1; - font-size: ${theme.typography.h4.fontSize}; - margin-bottom: 0; - `, - image: css` - object-fit: contain; - max-width: 100%; - `, - icon: css` - align-self: flex-start; - color: ${theme.colors.text.secondary}; - `, - orgName: css` - color: ${theme.colors.text.secondary}; - margin-bottom: 0; - `, - hasUpdate: css` - color: ${theme.colors.text.secondary}; - font-size: ${theme.typography.bodySmall.fontSize}; - margin-bottom: 0; - `, -}); diff --git a/public/app/features/plugins/admin/components/PluginListItem.tsx b/public/app/features/plugins/admin/components/PluginListItem.tsx new file mode 100644 index 00000000000..03eab8892ca --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginListItem.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useDisplayMode } from '../state/hooks'; +import { CatalogPlugin, PluginListDisplayMode } from '../types'; +import { PluginListItemRow } from './PluginListItemRow'; +import { PluginListItemCard } from './PluginListItemCard'; + +export const LOGO_SIZE = '48px'; + +type Props = { + plugin: CatalogPlugin; + pathName: string; +}; + +export function PluginListItem({ plugin, pathName }: Props) { + const { displayMode } = useDisplayMode(); + const isList = displayMode === PluginListDisplayMode.List; + + if (isList) { + return ; + } + + return ; +} + +// Styles shared between the different type of list items +export const getStyles = (theme: GrafanaTheme2, displayMode: PluginListDisplayMode) => { + const isRow = displayMode === PluginListDisplayMode.List; + const isCard = displayMode === PluginListDisplayMode.Grid; + + return { + cardContainer: css` + margin-bottom: 0; + padding: ${theme.spacing()}; + `, + headerWrap: css` + display: grid; + grid-template-columns: ${LOGO_SIZE} 1fr ${theme.spacing(3)}; + grid-gap: ${theme.spacing(2)}; + width: 100%; + ${isCard && + css` + align-items: center; + `} + `, + name: css` + color: ${theme.colors.text.primary}; + flex-grow: 1; + font-size: ${theme.typography.h4.fontSize}; + margin-bottom: 0; + `, + image: css` + object-fit: contain; + max-width: 100%; + `, + icon: css` + align-self: flex-start; + color: ${theme.colors.text.secondary}; + `, + orgName: css` + color: ${theme.colors.text.secondary}; + ${isRow && + css` + margin: ${theme.spacing(0, 0, 0.5, 0)}; + `} + ${isCard && + css` + margin-bottom: 0; + `}; + `, + hasUpdate: css` + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.bodySmall.fontSize}; + margin-bottom: 0; + `, + }; +}; diff --git a/public/app/features/plugins/admin/components/PluginListCard.test.tsx b/public/app/features/plugins/admin/components/PluginListItemCard.test.tsx similarity index 84% rename from public/app/features/plugins/admin/components/PluginListCard.test.tsx rename to public/app/features/plugins/admin/components/PluginListItemCard.test.tsx index 8f0188c8dd7..7ca53c9afff 100644 --- a/public/app/features/plugins/admin/components/PluginListCard.test.tsx +++ b/public/app/features/plugins/admin/components/PluginListItemCard.test.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data'; -import { PluginListCard } from './PluginListCard'; +import { PluginListItemCard } from './PluginListItemCard'; import { CatalogPlugin } from '../types'; -describe('PluginCard', () => { +describe('PluginListItemCard', () => { const plugin: CatalogPlugin = { description: 'The test plugin', downloads: 5, @@ -31,7 +31,7 @@ describe('PluginCard', () => { }; it('renders a card with link, image, name, orgName and badges', () => { - render(); + render(); expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview'); @@ -47,28 +47,28 @@ describe('PluginCard', () => { it('renders a datasource plugin with correct icon', () => { const datasourcePlugin = { ...plugin, type: PluginType.datasource }; - render(); + render(); expect(screen.getByTestId(/datasource plugin icon/i)).toBeVisible(); }); it('renders a panel plugin with correct icon', () => { const panelPlugin = { ...plugin, type: PluginType.panel }; - render(); + render(); expect(screen.getByTestId(/panel plugin icon/i)).toBeVisible(); }); it('renders an app plugin with correct icon', () => { const appPlugin = { ...plugin, type: PluginType.app }; - render(); + render(); expect(screen.getByTestId(/app plugin icon/i)).toBeVisible(); }); it('renders a disabled plugin with a badge to indicate its error', () => { const pluginWithError = { ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature }; - render(); + render(); expect(screen.getByText(/disabled/i)).toBeVisible(); }); diff --git a/public/app/features/plugins/admin/components/PluginListItemCard.tsx b/public/app/features/plugins/admin/components/PluginListItemCard.tsx new file mode 100644 index 00000000000..94fed6e6a39 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginListItemCard.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Icon, useStyles2, HorizontalGroup, Tooltip, CardContainer, VerticalGroup } from '@grafana/ui'; +import { CatalogPlugin, PluginIconName, PluginListDisplayMode, PluginTabIds } from '../types'; +import { PluginLogo } from './PluginLogo'; +import { PluginListBadges } from './PluginListBadges'; +import { getStyles, LOGO_SIZE } from './PluginListItem'; + +type Props = { + plugin: CatalogPlugin; + pathName: string; +}; + +export function PluginListItemCard({ plugin, pathName }: Props) { + const styles = useStyles2((theme) => getStyles(theme, PluginListDisplayMode.Grid)); + + return ( + + +
+ +

{plugin.name}

+ {plugin.type && ( +
+ +
+ )} +
+

By {plugin.orgName}

+ + + {plugin.hasUpdate && !plugin.isCore ? ( + +

Update available!

+
+ ) : null} +
+
+
+ ); +} diff --git a/public/app/features/plugins/admin/components/PluginListItemRow.test.tsx b/public/app/features/plugins/admin/components/PluginListItemRow.test.tsx new file mode 100644 index 00000000000..5ce61f1c236 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginListItemRow.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data'; +import { PluginListItemRow } from './PluginListItemRow'; +import { CatalogPlugin } from '../types'; + +describe('PluginListItemRow', () => { + const plugin: CatalogPlugin = { + description: 'The test plugin', + downloads: 5, + id: 'test-plugin', + info: { + logos: { + small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small', + large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large', + }, + }, + name: 'Testing Plugin', + orgName: 'Test', + popularity: 0, + signature: PluginSignatureStatus.valid, + publishedAt: '2020-09-01', + updatedAt: '2021-06-28', + version: '1.0.0', + hasUpdate: false, + isInstalled: false, + isCore: false, + isDev: false, + isEnterprise: false, + isDisabled: false, + }; + + it('renders a row with link, image, name, orgName and badges', () => { + render(); + + expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview'); + + const logo = screen.getByRole('img'); + expect(logo).toHaveAttribute('src', plugin.info.logos.small); + expect(logo).toHaveAttribute('alt', `${plugin.name} logo`); + + expect(screen.getByRole('heading', { name: /testing plugin/i })).toBeVisible(); + expect(screen.getByText(`By ${plugin.orgName}`)).toBeVisible(); + expect(screen.getByText(/signed/i)).toBeVisible(); + expect(screen.queryByLabelText(/icon/i)).not.toBeInTheDocument(); + }); + + it('renders a datasource plugin with correct icon', () => { + const datasourcePlugin = { ...plugin, type: PluginType.datasource }; + render(); + + expect(screen.getByLabelText(/datasource plugin icon/i)).toBeVisible(); + }); + + it('renders a panel plugin with correct icon', () => { + const panelPlugin = { ...plugin, type: PluginType.panel }; + render(); + + expect(screen.getByLabelText(/panel plugin icon/i)).toBeVisible(); + }); + + it('renders an app plugin with correct icon', () => { + const appPlugin = { ...plugin, type: PluginType.app }; + render(); + + expect(screen.getByLabelText(/app plugin icon/i)).toBeVisible(); + }); + + it('renders a disabled plugin with a badge to indicate its error', () => { + const pluginWithError = { ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature }; + render(); + + expect(screen.getByText(/disabled/i)).toBeVisible(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginListItemRow.tsx b/public/app/features/plugins/admin/components/PluginListItemRow.tsx new file mode 100644 index 00000000000..f6fda00a918 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginListItemRow.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Icon, useStyles2, HorizontalGroup, Tooltip, CardContainer, VerticalGroup } from '@grafana/ui'; +import { CatalogPlugin, PluginIconName, PluginListDisplayMode, PluginTabIds } from '../types'; +import { PluginLogo } from './PluginLogo'; +import { PluginListBadges } from './PluginListBadges'; +import { getStyles, LOGO_SIZE } from './PluginListItem'; + +type Props = { + plugin: CatalogPlugin; + pathName: string; +}; + +export function PluginListItemRow({ plugin, pathName }: Props) { + const styles = useStyles2((theme) => getStyles(theme, PluginListDisplayMode.List)); + + return ( + + +
+ +
+

{plugin.name}

+

By {plugin.orgName}

+ + + {plugin.hasUpdate && !plugin.isCore && ( + +

Update available!

+
+ )} +
+
+ {plugin.type && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index 2a4083d3c80..f583fab3058 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Router } from 'react-router-dom'; import { render, RenderResult, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { locationService } from '@grafana/runtime'; import { PluginType } from '@grafana/data'; @@ -14,8 +15,13 @@ import BrowsePage from './Browse'; // Mock the config to enable the plugin catalog jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); + const mockedRuntime = { ...original }; - return { ...original, pluginAdminEnabled: true }; + mockedRuntime.config.bootData.user.isGrafanaAdmin = true; + mockedRuntime.config.buildInfo.version = 'v8.1.0'; + mockedRuntime.config.pluginAdminEnabled = true; + + return mockedRuntime; }); const renderBrowse = ( @@ -319,4 +325,39 @@ describe('Browse list of plugins', () => { await waitFor(() => expect(getByRole('radio', { name: 'Installed' })).toBeDisabled()); }); }); + + it('should be possible to switch between display modes', async () => { + const { findByTestId, getByRole, getByTitle, queryByText } = renderBrowse('/plugins?filterBy=all', [ + getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1' }), + getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2' }), + getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3' }), + ]); + + await findByTestId('plugin-list'); + + const listOptionTitle = 'Display plugins in list'; + const gridOptionTitle = 'Display plugins in a grid layout'; + const listOption = getByRole('radio', { name: listOptionTitle }); + const listOptionLabel = getByTitle(listOptionTitle); + const gridOption = getByRole('radio', { name: gridOptionTitle }); + const gridOptionLabel = getByTitle(gridOptionTitle); + + // All options should be visible + expect(listOptionLabel).toBeVisible(); + expect(gridOptionLabel).toBeVisible(); + + // The default display mode should be "grid" + expect(gridOption).toBeChecked(); + expect(listOption).not.toBeChecked(); + + // Switch to "list" view + userEvent.click(listOption); + expect(gridOption).not.toBeChecked(); + expect(listOption).toBeChecked(); + + // All plugins are still visible + expect(queryByText('Plugin 1')).toBeInTheDocument(); + expect(queryByText('Plugin 2')).toBeInTheDocument(); + expect(queryByText('Plugin 3')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index 53774c2e6ab..2d8a08eafee 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -8,22 +8,22 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { PluginList } from '../components/PluginList'; import { SearchField } from '../components/SearchField'; import { useHistory } from '../hooks/useHistory'; -import { PluginAdminRoutes } from '../types'; +import { PluginAdminRoutes, PluginListDisplayMode } from '../types'; import { Page as PluginPage } from '../components/Page'; import { HorizontalGroup } from '../components/HorizontalGroup'; import { Page } from 'app/core/components/Page/Page'; import { useSelector } from 'react-redux'; import { StoreState } from 'app/types/store'; import { getNavModel } from 'app/core/selectors/navModel'; -import { useGetAll, useGetAllWithFilters, useIsRemotePluginsAvailable } from '../state/hooks'; +import { useGetAllWithFilters, useIsRemotePluginsAvailable, useDisplayMode } from '../state/hooks'; import { Sorters } from '../helpers'; export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null { - useGetAll(); const location = useLocation(); const locationSearch = locationSearchToObject(location.search); const navModelId = getNavModelId(route.routeName); const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, navModelId)); + const { displayMode, setDisplayMode } = useDisplayMode(); const styles = useStyles2(getStyles); const history = useHistory(); const remotePluginsAvailable = useIsRemotePluginsAvailable(); @@ -71,6 +71,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem + {/* Filter by type */}
+ + {/* Filter by installed / all */} {remotePluginsAvailable ? (
@@ -102,6 +105,8 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
)} + + {/* Sorting */}