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:
Marcus Andersson 2021-09-29 15:44:57 +02:00 committed by GitHub
parent bc01c9cdbc
commit 2dedbcd3c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 392 additions and 114 deletions

View File

@ -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: [],

View File

@ -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)};
`,
};
};

View File

@ -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;
`,
});

View File

@ -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;
`,
};
};

View File

@ -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();
});

View File

@ -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>
);
}

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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();
});
});

View File

@ -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

View File

@ -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)),
};
};

View File

@ -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;

View File

@ -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) =>

View File

@ -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>