mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PluginsCatalog: adding support for viewing the plugins list as both table and list (#39466)
* working version but with duplications. * refactor(Plugins/Admin): use "fr" instead of "repeat" for grid columns Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com> * fix(Plugins/Admin): use PluginIconName instead of IconName * refactor(Plugins/Admin): store the display-mode on the state * refactor(Plugins/Admin): use && for conditional rendering * refactor(Plugins/Admin): rename variable * refactor(Plugins/Admin): share code between card and row components * test(PluginListItemRow): add tests * test(Plugins/Admin): add a simple test for the display-mode switching * fix(Plugins/Admin): compose styles with css`` * refactor(Plugins/Admin): rename "table" to "grid" for display modes * test(Plugins/Browse): follow up on renaming "table" to "grid" Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
parent
bc01c9cdbc
commit
2dedbcd3c3
@ -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: [],
|
||||
|
@ -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 (
|
||||
<div className={styles} data-testid="plugin-list">
|
||||
<div className={styles.container} data-testid="plugin-list">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginListCard key={plugin.id} plugin={plugin} pathName={location.pathname} />
|
||||
<PluginListItem key={plugin.id} plugin={plugin} pathName={location.pathname} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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 (
|
||||
<CardContainer href={`${pathName}/${plugin.id}?page=${PluginTabIds.OVERVIEW}`} className={styles.cardContainer}>
|
||||
<VerticalGroup spacing="md">
|
||||
<div className={styles.headerWrap}>
|
||||
<PluginLogo
|
||||
src={plugin.info.logos.small}
|
||||
alt={`${plugin.name} logo`}
|
||||
className={styles.image}
|
||||
height={LOGO_SIZE}
|
||||
/>
|
||||
<h2 className={styles.name}>{plugin.name}</h2>
|
||||
{plugin.type && (
|
||||
<div className={styles.icon} data-testid={`${plugin.type} plugin icon`}>
|
||||
<Icon name={PluginIconName[plugin.type]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.orgName}>By {plugin.orgName}</p>
|
||||
<HorizontalGroup align="center">
|
||||
<PluginListBadges plugin={plugin} />
|
||||
{plugin.hasUpdate && !plugin.isCore ? (
|
||||
<Tooltip content={plugin.version}>
|
||||
<p className={styles.hasUpdate}>Update available!</p>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</CardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`,
|
||||
});
|
@ -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 <PluginListItemRow plugin={plugin} pathName={pathName} />;
|
||||
}
|
||||
|
||||
return <PluginListItemCard plugin={plugin} pathName={pathName} />;
|
||||
}
|
||||
|
||||
// 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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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(<PluginListCard plugin={plugin} pathName="/plugins" />);
|
||||
render(<PluginListItemCard plugin={plugin} pathName="/plugins" />);
|
||||
|
||||
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(<PluginListCard plugin={datasourcePlugin} pathName="" />);
|
||||
render(<PluginListItemCard plugin={datasourcePlugin} pathName="" />);
|
||||
|
||||
expect(screen.getByTestId(/datasource plugin icon/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a panel plugin with correct icon', () => {
|
||||
const panelPlugin = { ...plugin, type: PluginType.panel };
|
||||
render(<PluginListCard plugin={panelPlugin} pathName="" />);
|
||||
render(<PluginListItemCard plugin={panelPlugin} pathName="" />);
|
||||
|
||||
expect(screen.getByTestId(/panel plugin icon/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an app plugin with correct icon', () => {
|
||||
const appPlugin = { ...plugin, type: PluginType.app };
|
||||
render(<PluginListCard plugin={appPlugin} pathName="" />);
|
||||
render(<PluginListItemCard plugin={appPlugin} pathName="" />);
|
||||
|
||||
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(<PluginListCard plugin={pluginWithError} pathName="" />);
|
||||
render(<PluginListItemCard plugin={pluginWithError} pathName="" />);
|
||||
|
||||
expect(screen.getByText(/disabled/i)).toBeVisible();
|
||||
});
|
@ -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 (
|
||||
<CardContainer href={`${pathName}/${plugin.id}?page=${PluginTabIds.OVERVIEW}`} className={styles.cardContainer}>
|
||||
<VerticalGroup spacing="md">
|
||||
<div className={styles.headerWrap}>
|
||||
<PluginLogo
|
||||
src={plugin.info.logos.small}
|
||||
alt={`${plugin.name} logo`}
|
||||
className={styles.image}
|
||||
height={LOGO_SIZE}
|
||||
/>
|
||||
<h2 className={styles.name}>{plugin.name}</h2>
|
||||
{plugin.type && (
|
||||
<div className={styles.icon} data-testid={`${plugin.type} plugin icon`}>
|
||||
<Icon name={PluginIconName[plugin.type]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.orgName}>By {plugin.orgName}</p>
|
||||
<HorizontalGroup align="center">
|
||||
<PluginListBadges plugin={plugin} />
|
||||
{plugin.hasUpdate && !plugin.isCore ? (
|
||||
<Tooltip content={plugin.version}>
|
||||
<p className={styles.hasUpdate}>Update available!</p>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</CardContainer>
|
||||
);
|
||||
}
|
@ -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(<PluginListItemRow plugin={plugin} pathName="/plugins" />);
|
||||
|
||||
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(<PluginListItemRow plugin={datasourcePlugin} pathName="" />);
|
||||
|
||||
expect(screen.getByLabelText(/datasource plugin icon/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a panel plugin with correct icon', () => {
|
||||
const panelPlugin = { ...plugin, type: PluginType.panel };
|
||||
render(<PluginListItemRow plugin={panelPlugin} pathName="" />);
|
||||
|
||||
expect(screen.getByLabelText(/panel plugin icon/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an app plugin with correct icon', () => {
|
||||
const appPlugin = { ...plugin, type: PluginType.app };
|
||||
render(<PluginListItemRow plugin={appPlugin} pathName="" />);
|
||||
|
||||
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(<PluginListItemRow plugin={pluginWithError} pathName="" />);
|
||||
|
||||
expect(screen.getByText(/disabled/i)).toBeVisible();
|
||||
});
|
||||
});
|
@ -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 (
|
||||
<CardContainer href={`${pathName}/${plugin.id}?page=${PluginTabIds.OVERVIEW}`} className={styles.cardContainer}>
|
||||
<VerticalGroup spacing="md">
|
||||
<div className={styles.headerWrap}>
|
||||
<PluginLogo
|
||||
src={plugin.info.logos.small}
|
||||
alt={`${plugin.name} logo`}
|
||||
className={styles.image}
|
||||
height={LOGO_SIZE}
|
||||
/>
|
||||
<div>
|
||||
<h3 className={styles.name}>{plugin.name}</h3>
|
||||
<p className={styles.orgName}>By {plugin.orgName}</p>
|
||||
<HorizontalGroup height="auto">
|
||||
<PluginListBadges plugin={plugin} />
|
||||
{plugin.hasUpdate && !plugin.isCore && (
|
||||
<Tooltip content={plugin.version}>
|
||||
<p className={styles.hasUpdate}>Update available!</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
{plugin.type && (
|
||||
<div className={styles.icon}>
|
||||
<Icon name={PluginIconName[plugin.type]} aria-label={`${plugin.type} plugin icon`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</CardContainer>
|
||||
);
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
<HorizontalGroup wrap>
|
||||
<SearchField value={query} onSearch={onSearch} />
|
||||
<HorizontalGroup wrap className={styles.actionBar}>
|
||||
{/* Filter by type */}
|
||||
<div>
|
||||
<RadioButtonGroup
|
||||
value={filterByType}
|
||||
@ -83,6 +84,8 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter by installed / all */}
|
||||
{remotePluginsAvailable ? (
|
||||
<div>
|
||||
<RadioButtonGroup value={filterBy} onChange={onFilterByChange} options={filterByOptions} />
|
||||
@ -102,6 +105,8 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Sorting */}
|
||||
<div>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
@ -118,6 +123,23 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display mode */}
|
||||
<div>
|
||||
<RadioButtonGroup<PluginListDisplayMode>
|
||||
className={styles.displayAs}
|
||||
value={displayMode}
|
||||
onChange={setDisplayMode}
|
||||
options={[
|
||||
{
|
||||
value: PluginListDisplayMode.Grid,
|
||||
icon: 'table',
|
||||
description: 'Display plugins in a grid layout',
|
||||
},
|
||||
{ value: PluginListDisplayMode.List, icon: 'list-ul', description: 'Display plugins in list' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<div className={styles.listWrap}>
|
||||
@ -129,7 +151,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
text="Loading results"
|
||||
/>
|
||||
) : (
|
||||
<PluginList plugins={plugins} />
|
||||
<PluginList plugins={plugins} displayMode={displayMode} />
|
||||
)}
|
||||
</div>
|
||||
</PluginPage>
|
||||
@ -147,6 +169,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
listWrap: css`
|
||||
margin-top: ${theme.spacing(2)};
|
||||
`,
|
||||
displayAs: css`
|
||||
svg {
|
||||
margin-right: 0;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Because the component is used under multiple paths (/plugins and /admin/plugins) we need to get
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { setDisplayMode } from './reducer';
|
||||
import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall } from './actions';
|
||||
import { CatalogPlugin, PluginCatalogStoreState } from '../types';
|
||||
import { CatalogPlugin, PluginCatalogStoreState, PluginListDisplayMode } from '../types';
|
||||
import {
|
||||
find,
|
||||
selectAll,
|
||||
@ -9,6 +10,7 @@ import {
|
||||
selectIsRequestPending,
|
||||
selectRequestError,
|
||||
selectIsRequestNotFetched,
|
||||
selectDisplayMode,
|
||||
} from './selectors';
|
||||
import { sortPlugins, Sorters } from '../helpers';
|
||||
|
||||
@ -116,3 +118,13 @@ export const useFetchDetails = (id: string) => {
|
||||
shouldFetch && dispatch(fetchDetails(id));
|
||||
}, [plugin]); // eslint-disable-line
|
||||
};
|
||||
|
||||
export const useDisplayMode = () => {
|
||||
const dispatch = useDispatch();
|
||||
const displayMode = useSelector(selectDisplayMode);
|
||||
|
||||
return {
|
||||
displayMode,
|
||||
setDisplayMode: (v: PluginListDisplayMode) => dispatch(setDisplayMode(v)),
|
||||
};
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { createSlice, createEntityAdapter, AnyAction } from '@reduxjs/toolkit';
|
||||
import { createSlice, createEntityAdapter, AnyAction, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { fetchAll, fetchDetails, install, uninstall, loadPluginDashboards } from './actions';
|
||||
import { CatalogPlugin, ReducerState, RequestStatus } from '../types';
|
||||
import { CatalogPlugin, PluginListDisplayMode, ReducerState, RequestStatus } from '../types';
|
||||
import { STATE_PREFIX } from '../constants';
|
||||
|
||||
export const pluginsAdapter = createEntityAdapter<CatalogPlugin>();
|
||||
@ -18,11 +18,14 @@ const getOriginalActionType = (type: string) => {
|
||||
return type.substring(0, separator);
|
||||
};
|
||||
|
||||
export const { reducer } = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'plugins',
|
||||
initialState: {
|
||||
items: pluginsAdapter.getInitialState(),
|
||||
requests: {},
|
||||
settings: {
|
||||
displayMode: PluginListDisplayMode.Grid,
|
||||
},
|
||||
// Backwards compatibility
|
||||
// (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana)
|
||||
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
|
||||
@ -34,7 +37,11 @@ export const { reducer } = createSlice({
|
||||
isLoadingPluginDashboards: false,
|
||||
panels: {},
|
||||
} as ReducerState,
|
||||
reducers: {},
|
||||
reducers: {
|
||||
setDisplayMode(state, action: PayloadAction<PluginListDisplayMode>) {
|
||||
state.settings.displayMode = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) =>
|
||||
builder
|
||||
// Fetch All
|
||||
@ -87,3 +94,6 @@ export const { reducer } = createSlice({
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const { setDisplayMode } = slice.actions;
|
||||
export const { reducer } = slice;
|
||||
|
@ -6,6 +6,8 @@ export const selectRoot = (state: PluginCatalogStoreState) => state.plugins;
|
||||
|
||||
export const selectItems = createSelector(selectRoot, ({ items }) => items);
|
||||
|
||||
export const selectDisplayMode = createSelector(selectRoot, ({ settings }) => settings.displayMode);
|
||||
|
||||
export const { selectAll, selectById } = pluginsAdapter.getSelectors(selectItems);
|
||||
|
||||
const selectInstalled = (filterBy: string) =>
|
||||
|
@ -11,6 +11,11 @@ import { StoreState, PluginsState } from 'app/types';
|
||||
|
||||
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
|
||||
|
||||
export enum PluginListDisplayMode {
|
||||
Grid = 'grid',
|
||||
List = 'list',
|
||||
}
|
||||
|
||||
export enum PluginAdminRoutes {
|
||||
Home = 'plugins-home',
|
||||
Browse = 'plugins-browse',
|
||||
@ -240,6 +245,9 @@ export type PluginDetailsTab = {
|
||||
export type ReducerState = PluginsState & {
|
||||
items: EntityState<CatalogPlugin>;
|
||||
requests: Record<string, RequestInfo>;
|
||||
settings: {
|
||||
displayMode: PluginListDisplayMode;
|
||||
};
|
||||
};
|
||||
|
||||
// TODO<remove when the "plugin_admin_enabled" feature flag is removed>
|
||||
|
Loading…
Reference in New Issue
Block a user