mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugin Catalog: Use routing for PluginDetails Tabs (#39555)
* feat(catalog): introduce id and href to PluginDetailsTabs * feat(catalog): add hrefs and ids to PluginDetails tabs. Pass queryParams to PluginDetailsBody * feat(catalog): pass queryParams to PluginsDetailsBody and add page param to PluginListCard * fix(catalog): prevent flicker of content by waiting for fetch details to finish loading * feat(catalog): add tab icons to PluginDetails page * feat(catalog): make breadcrumbs in PluginDetailsHeader aware of page queryparam * fix(catalog): fix deeplinking to PluginDetails by comparing tabs length * test(catalog): update tests with correct props and wrap in router
This commit is contained in:
parent
a79ee09cf5
commit
4dc556445c
@ -1,25 +1,26 @@
|
||||
import React from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { AppPlugin, GrafanaTheme2 } from '@grafana/data';
|
||||
import { AppPlugin, GrafanaTheme2, UrlQueryMap } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { CatalogPlugin, PluginTabLabels } from '../types';
|
||||
import { CatalogPlugin, PluginTabIds } from '../types';
|
||||
import { VersionList } from '../components/VersionList';
|
||||
import { usePluginConfig } from '../hooks/usePluginConfig';
|
||||
import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper';
|
||||
import { PluginDashboards } from '../../PluginDashboards';
|
||||
|
||||
type Props = {
|
||||
tab: { label: string };
|
||||
plugin: CatalogPlugin;
|
||||
queryParams: UrlQueryMap;
|
||||
};
|
||||
|
||||
export function PluginDetailsBody({ tab, plugin }: Props): JSX.Element | null {
|
||||
export function PluginDetailsBody({ plugin, queryParams }: Props): JSX.Element {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { value: pluginConfig } = usePluginConfig(plugin);
|
||||
const pageId = queryParams.page;
|
||||
|
||||
if (tab?.label === PluginTabLabels.OVERVIEW) {
|
||||
if (pageId === PluginTabIds.OVERVIEW) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.readme, styles.container)}
|
||||
@ -30,7 +31,7 @@ export function PluginDetailsBody({ tab, plugin }: Props): JSX.Element | null {
|
||||
);
|
||||
}
|
||||
|
||||
if (tab?.label === PluginTabLabels.VERSIONS) {
|
||||
if (pageId === PluginTabIds.VERSIONS) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<VersionList versions={plugin.details?.versions} />
|
||||
@ -38,7 +39,7 @@ export function PluginDetailsBody({ tab, plugin }: Props): JSX.Element | null {
|
||||
);
|
||||
}
|
||||
|
||||
if (tab?.label === PluginTabLabels.CONFIG && pluginConfig?.angularConfigCtrl) {
|
||||
if (pageId === PluginTabIds.CONFIG && pluginConfig?.angularConfigCtrl) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<AppConfigCtrlWrapper app={pluginConfig as AppPlugin} />
|
||||
@ -48,18 +49,17 @@ export function PluginDetailsBody({ tab, plugin }: Props): JSX.Element | null {
|
||||
|
||||
if (pluginConfig?.configPages) {
|
||||
for (const configPage of pluginConfig.configPages) {
|
||||
if (tab?.label === configPage.title) {
|
||||
if (pageId === configPage.id) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* TODO: we should pass the query params down */}
|
||||
<configPage.body plugin={pluginConfig} query={{}} />
|
||||
<configPage.body plugin={pluginConfig} query={queryParams} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tab?.label === PluginTabLabels.DASHBOARDS && pluginConfig) {
|
||||
if (pageId === PluginTabIds.DASHBOARDS && pluginConfig) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PluginDashboards plugin={pluginConfig?.meta} />
|
||||
@ -67,7 +67,11 @@ export function PluginDetailsBody({ tab, plugin }: Props): JSX.Element | null {
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<p>Page not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Icon } from '@grafana/ui';
|
||||
import { CatalogPlugin, IconName } from '../types';
|
||||
import { CatalogPlugin, PluginIconName } from '../types';
|
||||
|
||||
type Props = {
|
||||
plugin: CatalogPlugin;
|
||||
@ -37,7 +37,7 @@ export function PluginDetailsHeaderDependencies({ plugin, className }: Props): R
|
||||
{pluginDependencies.map((p) => {
|
||||
return (
|
||||
<span key={p.name}>
|
||||
<Icon name={IconName[p.type]} className={styles.icon} />
|
||||
<Icon name={PluginIconName[p.type]} className={styles.icon} />
|
||||
{p.name} {p.version}
|
||||
</span>
|
||||
);
|
||||
|
@ -33,7 +33,7 @@ describe('PluginCard', () => {
|
||||
it('renders a card with link, image, name, orgName and badges', () => {
|
||||
render(<PluginListCard plugin={plugin} pathName="/plugins" />);
|
||||
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin');
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview');
|
||||
|
||||
const logo = screen.getByRole('img');
|
||||
expect(logo).toHaveAttribute('src', plugin.info.logos.small);
|
||||
|
@ -2,7 +2,7 @@ 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, IconName } from '../types';
|
||||
import { CatalogPlugin, PluginIconName, PluginTabIds } from '../types';
|
||||
import { PluginLogo } from './PluginLogo';
|
||||
import { PluginListBadges } from './PluginListBadges';
|
||||
|
||||
@ -17,7 +17,7 @@ export function PluginListCard({ plugin, pathName }: PluginListCardProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<CardContainer href={`${pathName}/${plugin.id}`} className={styles.cardContainer}>
|
||||
<CardContainer href={`${pathName}/${plugin.id}?page=${PluginTabIds.OVERVIEW}`} className={styles.cardContainer}>
|
||||
<VerticalGroup spacing="md">
|
||||
<div className={styles.headerWrap}>
|
||||
<PluginLogo
|
||||
@ -29,7 +29,7 @@ export function PluginListCard({ plugin, pathName }: PluginListCardProps) {
|
||||
<h2 className={styles.name}>{plugin.name}</h2>
|
||||
{plugin.type && (
|
||||
<div className={styles.icon} data-testid={`${plugin.type} plugin icon`}>
|
||||
<Icon name={IconName[plugin.type]} />
|
||||
<Icon name={PluginIconName[plugin.type]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { PluginIncludeType, PluginType } from '@grafana/data';
|
||||
import { CatalogPlugin, PluginDetailsTab } from '../types';
|
||||
import { CatalogPlugin, PluginDetailsTab, PluginTabIds } from '../types';
|
||||
import { isOrgAdmin } from '../helpers';
|
||||
import { usePluginConfig } from '../hooks/usePluginConfig';
|
||||
|
||||
@ -12,6 +13,7 @@ type ReturnType = {
|
||||
|
||||
export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: PluginDetailsTab[] = []): ReturnType => {
|
||||
const { loading, error, value: pluginConfig } = usePluginConfig(plugin);
|
||||
const { pathname } = useLocation();
|
||||
const tabs = useMemo(() => {
|
||||
const canConfigurePlugins = isOrgAdmin();
|
||||
const tabs: PluginDetailsTab[] = [...defaultTabs];
|
||||
@ -26,6 +28,9 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
|
||||
if (pluginConfig.angularConfigCtrl) {
|
||||
tabs.push({
|
||||
label: 'Config',
|
||||
icon: 'cog',
|
||||
id: PluginTabIds.CONFIG,
|
||||
href: `${pathname}?page=${PluginTabIds.CONFIG}`,
|
||||
});
|
||||
}
|
||||
|
||||
@ -33,6 +38,9 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
|
||||
for (const page of pluginConfig.configPages) {
|
||||
tabs.push({
|
||||
label: page.title,
|
||||
icon: page.icon,
|
||||
id: page.id,
|
||||
href: `${pathname}?page=${page.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -40,13 +48,16 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
|
||||
if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) {
|
||||
tabs.push({
|
||||
label: 'Dashboards',
|
||||
icon: 'apps',
|
||||
id: PluginTabIds.DASHBOARDS,
|
||||
href: `${pathname}?page=${PluginTabIds.DASHBOARDS}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}, [pluginConfig, defaultTabs]);
|
||||
}, [pluginConfig, defaultTabs, pathname]);
|
||||
|
||||
return {
|
||||
error,
|
||||
|
@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, RenderResult, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import PluginDetailsPage from './PluginDetails';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
import { CatalogPlugin } from '../types';
|
||||
import { CatalogPlugin, PluginTabIds } from '../types';
|
||||
import * as api from '../api';
|
||||
import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock } from '../__mocks__';
|
||||
import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data';
|
||||
@ -24,10 +25,19 @@ jest.mock('@grafana/runtime', () => {
|
||||
return mockedRuntime;
|
||||
});
|
||||
|
||||
const renderPluginDetails = (pluginOverride: Partial<CatalogPlugin>): RenderResult => {
|
||||
const renderPluginDetails = (pluginOverride: Partial<CatalogPlugin>, pageId = PluginTabIds.OVERVIEW): RenderResult => {
|
||||
const plugin = getCatalogPluginMock(pluginOverride);
|
||||
const { id } = plugin;
|
||||
const props = getRouteComponentProps({ match: { params: { pluginId: id }, isExact: true, url: '', path: '' } });
|
||||
const props = getRouteComponentProps({
|
||||
match: { params: { pluginId: id }, isExact: true, url: '', path: '' },
|
||||
queryParams: { page: pageId },
|
||||
location: {
|
||||
hash: '',
|
||||
pathname: `/plugins/${id}`,
|
||||
search: `?page=${pageId}`,
|
||||
state: undefined,
|
||||
},
|
||||
});
|
||||
const store = configureStore({
|
||||
plugins: getPluginsStateMock([plugin]),
|
||||
});
|
||||
@ -35,7 +45,8 @@ const renderPluginDetails = (pluginOverride: Partial<CatalogPlugin>): RenderResu
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<PluginDetailsPage {...props} />
|
||||
</Provider>
|
||||
</Provider>,
|
||||
{ wrapper: MemoryRouter }
|
||||
);
|
||||
};
|
||||
|
||||
@ -66,12 +77,22 @@ describe('Plugin details page', () => {
|
||||
local: { id },
|
||||
});
|
||||
|
||||
const props = getRouteComponentProps({ match: { params: { pluginId: id }, isExact: true, url: '', path: '' } });
|
||||
const props = getRouteComponentProps({
|
||||
match: { params: { pluginId: id }, isExact: true, url: '', path: '' },
|
||||
queryParams: { page: PluginTabIds.OVERVIEW },
|
||||
location: {
|
||||
hash: '',
|
||||
pathname: `/plugins/${id}`,
|
||||
search: `?page=${PluginTabIds.OVERVIEW}`,
|
||||
state: undefined,
|
||||
},
|
||||
});
|
||||
const store = configureStore();
|
||||
const { queryByText } = render(
|
||||
<Provider store={store}>
|
||||
<PluginDetailsPage {...props} />
|
||||
</Provider>
|
||||
</Provider>,
|
||||
{ wrapper: MemoryRouter }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
|
||||
@ -129,24 +150,25 @@ describe('Plugin details page', () => {
|
||||
});
|
||||
|
||||
it('should display version history in case it is available', async () => {
|
||||
const { queryByText, getByText, getByRole } = renderPluginDetails({
|
||||
id,
|
||||
details: {
|
||||
links: [],
|
||||
versions: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
createdAt: '2016-04-06T20:23:41.000Z',
|
||||
},
|
||||
],
|
||||
const { queryByText, getByRole } = renderPluginDetails(
|
||||
{
|
||||
id,
|
||||
details: {
|
||||
links: [],
|
||||
versions: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
createdAt: '2016-04-06T20:23:41.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
PluginTabIds.VERSIONS
|
||||
);
|
||||
|
||||
// Check if version information is available
|
||||
await waitFor(() => expect(queryByText(/version history/i)).toBeInTheDocument());
|
||||
|
||||
// Go to the versions tab
|
||||
userEvent.click(getByText(/version history/i));
|
||||
expect(
|
||||
getByRole('columnheader', {
|
||||
name: /version/i,
|
||||
|
@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { usePrevious } from 'react-use';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, TabsBar, TabContent, Tab, Alert } from '@grafana/ui';
|
||||
import { useStyles2, TabsBar, TabContent, Tab, Alert, IconName } from '@grafana/ui';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Layout } from '@grafana/ui/src/components/Layout/Layout';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
@ -10,43 +12,53 @@ import { PluginDetailsHeader } from '../components/PluginDetailsHeader';
|
||||
import { PluginDetailsBody } from '../components/PluginDetailsBody';
|
||||
import { Page as PluginPage } from '../components/Page';
|
||||
import { Loader } from '../components/Loader';
|
||||
import { PluginTabLabels, PluginDetailsTab } from '../types';
|
||||
import { useGetSingle, useFetchStatus } from '../state/hooks';
|
||||
import { PluginTabLabels, PluginTabIds, PluginDetailsTab } from '../types';
|
||||
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
|
||||
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
|
||||
import { AppNotificationSeverity } from 'app/types';
|
||||
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
|
||||
|
||||
type Props = GrafanaRouteComponentProps<{ pluginId?: string }>;
|
||||
|
||||
type State = {
|
||||
tabs: PluginDetailsTab[];
|
||||
activeTabIndex: number;
|
||||
};
|
||||
|
||||
const DefaultState = {
|
||||
tabs: [{ label: PluginTabLabels.OVERVIEW }, { label: PluginTabLabels.VERSIONS }],
|
||||
activeTabIndex: 0,
|
||||
};
|
||||
|
||||
export default function PluginDetails({ match }: Props): JSX.Element | null {
|
||||
const { pluginId = '' } = match.params;
|
||||
const [state, setState] = useState<State>(DefaultState);
|
||||
export default function PluginDetails({ match, queryParams }: Props): JSX.Element | null {
|
||||
const {
|
||||
params: { pluginId = '' },
|
||||
url,
|
||||
} = match;
|
||||
const pageId = (queryParams.page as PluginTabIds) || PluginTabIds.OVERVIEW;
|
||||
const parentUrl = url.substring(0, url.lastIndexOf('/'));
|
||||
const defaultTabs: PluginDetailsTab[] = [
|
||||
{
|
||||
label: PluginTabLabels.OVERVIEW,
|
||||
icon: 'file-alt',
|
||||
id: PluginTabIds.OVERVIEW,
|
||||
href: `${url}?page=${PluginTabIds.OVERVIEW}`,
|
||||
},
|
||||
{
|
||||
label: PluginTabLabels.VERSIONS,
|
||||
icon: 'history',
|
||||
id: PluginTabIds.VERSIONS,
|
||||
href: `${url}?page=${PluginTabIds.VERSIONS}`,
|
||||
},
|
||||
];
|
||||
const plugin = useGetSingle(pluginId); // fetches the localplugin settings
|
||||
const { tabs } = usePluginDetailsTabs(plugin, DefaultState.tabs);
|
||||
const { activeTabIndex } = state;
|
||||
const { isLoading } = useFetchStatus();
|
||||
const { tabs } = usePluginDetailsTabs(plugin, defaultTabs);
|
||||
const { isLoading: isFetchLoading } = useFetchStatus();
|
||||
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
|
||||
const styles = useStyles2(getStyles);
|
||||
const setActiveTab = useCallback((activeTabIndex: number) => setState({ ...state, activeTabIndex }), [state]);
|
||||
const parentUrl = match.url.substring(0, match.url.lastIndexOf('/'));
|
||||
const prevTabs = usePrevious(tabs);
|
||||
|
||||
// If an app plugin is uninstalled we need to reset the active tab when the config / dashboards tabs are removed.
|
||||
useEffect(() => {
|
||||
if (activeTabIndex > tabs.length - 1) {
|
||||
setActiveTab(0);
|
||||
}
|
||||
}, [setActiveTab, activeTabIndex, tabs]);
|
||||
const hasUninstalledWithConfigPages = prevTabs && prevTabs.length > tabs.length;
|
||||
const isViewingAConfigPage = pageId !== PluginTabIds.OVERVIEW && pageId !== PluginTabIds.VERSIONS;
|
||||
|
||||
if (isLoading) {
|
||||
if (hasUninstalledWithConfigPages && isViewingAConfigPage) {
|
||||
locationService.replace(`${url}?page=${PluginTabIds.OVERVIEW}`);
|
||||
}
|
||||
}, [pageId, url, tabs, prevTabs]);
|
||||
|
||||
if (isFetchLoading || isFetchDetailsLoading) {
|
||||
return (
|
||||
<Page>
|
||||
<Loader />
|
||||
@ -68,25 +80,28 @@ export default function PluginDetails({ match }: Props): JSX.Element | null {
|
||||
return (
|
||||
<Page>
|
||||
<PluginPage>
|
||||
<PluginDetailsHeader currentUrl={match.url} parentUrl={parentUrl} plugin={plugin} />
|
||||
<PluginDetailsHeader currentUrl={`${url}?page=${pageId}`} parentUrl={parentUrl} plugin={plugin} />
|
||||
|
||||
{/* Tab navigation */}
|
||||
<TabsBar>
|
||||
{tabs.map((tab: PluginDetailsTab, idx: number) => (
|
||||
<Tab
|
||||
key={tab.label}
|
||||
label={tab.label}
|
||||
active={idx === activeTabIndex}
|
||||
onChangeTab={() => setActiveTab(idx)}
|
||||
/>
|
||||
))}
|
||||
{tabs.map((tab: PluginDetailsTab) => {
|
||||
return (
|
||||
<Tab
|
||||
key={tab.label}
|
||||
label={tab.label}
|
||||
href={tab.href}
|
||||
icon={tab.icon as IconName}
|
||||
active={tab.id === pageId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabsBar>
|
||||
|
||||
{/* Active tab */}
|
||||
<TabContent className={styles.tabContent}>
|
||||
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
|
||||
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
|
||||
<PluginDetailsBody tab={tabs[activeTabIndex]} plugin={plugin} />
|
||||
<PluginDetailsBody queryParams={queryParams} plugin={plugin} />
|
||||
</TabContent>
|
||||
</PluginPage>
|
||||
</Page>
|
||||
|
@ -70,6 +70,13 @@ export const useFetchStatus = () => {
|
||||
return { isLoading, error };
|
||||
};
|
||||
|
||||
export const useFetchDetailsStatus = () => {
|
||||
const isLoading = useSelector(selectIsRequestPending(fetchDetails.typePrefix));
|
||||
const error = useSelector(selectRequestError(fetchDetails.typePrefix));
|
||||
|
||||
return { isLoading, error };
|
||||
};
|
||||
|
||||
export const useInstallStatus = () => {
|
||||
const isInstalling = useSelector(selectIsRequestPending(install.typePrefix));
|
||||
const error = useSelector(selectRequestError(install.typePrefix));
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
PluginDependencies,
|
||||
PluginErrorCode,
|
||||
} from '@grafana/data';
|
||||
import { IconName } from '@grafana/ui';
|
||||
import { StoreState, PluginsState } from 'app/types';
|
||||
|
||||
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
|
||||
@ -19,7 +20,7 @@ export enum PluginAdminRoutes {
|
||||
DetailsAdmin = 'plugins-details-admin',
|
||||
}
|
||||
|
||||
export enum IconName {
|
||||
export enum PluginIconName {
|
||||
app = 'apps',
|
||||
datasource = 'database',
|
||||
panel = 'credit-card',
|
||||
@ -203,6 +204,13 @@ export enum PluginTabLabels {
|
||||
DASHBOARDS = 'Dashboards',
|
||||
}
|
||||
|
||||
export enum PluginTabIds {
|
||||
OVERVIEW = 'overview',
|
||||
VERSIONS = 'version-history',
|
||||
CONFIG = 'config',
|
||||
DASHBOARDS = 'dashboards',
|
||||
}
|
||||
|
||||
export enum RequestStatus {
|
||||
Pending = 'Pending',
|
||||
Fulfilled = 'Fulfilled',
|
||||
@ -219,6 +227,9 @@ export type RequestInfo = {
|
||||
|
||||
export type PluginDetailsTab = {
|
||||
label: PluginTabLabels | string;
|
||||
icon?: IconName | string;
|
||||
id: PluginTabIds | string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
// TODO<remove `PluginsState &` when the "plugin_admin_enabled" feature flag is removed>
|
||||
|
Loading…
Reference in New Issue
Block a user