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 */}
+
+ {/* Display mode */}
+
+
+ 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' },
+ ]}
+ />
+
@@ -129,7 +151,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
text="Loading results"
/>
) : (
-
+
)}
@@ -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
diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts
index e425581e205..8eb340bd40b 100644
--- a/public/app/features/plugins/admin/state/hooks.ts
+++ b/public/app/features/plugins/admin/state/hooks.ts
@@ -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)),
+ };
+};
diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts
index 9cc73007319..a0307e1b549 100644
--- a/public/app/features/plugins/admin/state/reducer.ts
+++ b/public/app/features/plugins/admin/state/reducer.ts
@@ -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
();
@@ -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
@@ -34,7 +37,11 @@ export const { reducer } = createSlice({
isLoadingPluginDashboards: false,
panels: {},
} as ReducerState,
- reducers: {},
+ reducers: {
+ setDisplayMode(state, action: PayloadAction) {
+ 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;
diff --git a/public/app/features/plugins/admin/state/selectors.ts b/public/app/features/plugins/admin/state/selectors.ts
index fa0ba0281b1..8e2fc476829 100644
--- a/public/app/features/plugins/admin/state/selectors.ts
+++ b/public/app/features/plugins/admin/state/selectors.ts
@@ -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) =>
diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts
index 40903528b68..9cac2078867 100644
--- a/public/app/features/plugins/admin/types.ts
+++ b/public/app/features/plugins/admin/types.ts
@@ -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;
requests: Record;
+ settings: {
+ displayMode: PluginListDisplayMode;
+ };
};
// TODO