Plugins: Make the Plugin Details page reusable (#58741)

* refactor(PluginDetails): use react-router hooks instead of props

* Wip

* refactor: remove unnecessary constant

* feat: use the original plugin details page under connections

* chore: use better wording in the not-found warning

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>

* chore: use the renderer utility everywhere in the test

* chore: don't show a title while loading a plugin

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
Levente Balogh 2022-11-29 13:45:03 +01:00 committed by GitHub
parent 5e53582bb1
commit 778fb07464
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 150 additions and 126 deletions

View File

@ -4298,6 +4298,9 @@ exports[`better eslint`] = {
"public/app/features/plugins/admin/components/PluginDetailsBody.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/plugins/admin/components/PluginDetailsPage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/plugins/admin/components/SearchField.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@ -4316,9 +4319,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/plugins/admin/pages/PluginDetails.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/plugins/admin/state/actions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],

View File

@ -1,6 +1,3 @@
// The ID of the app plugin that we render under that "Cloud Integrations" tab
export const CLOUD_ONBOARDING_APP_ID = 'grafana-easystart-app';
// The ID of the main nav-tree item (the main item in the NavIndex)
export const ROUTE_BASE_ID = 'connections';

View File

@ -1,24 +1,41 @@
import * as React from 'react';
import { useParams } from 'react-router-dom';
import { Page } from 'app/core/components/Page/Page';
import { StoreState, useSelector } from 'app/types';
import { Alert, Badge } from '@grafana/ui';
import { PluginDetailsPage } from 'app/features/plugins/admin/components/PluginDetailsPage';
import { StoreState, useSelector, AppNotificationSeverity } from 'app/types';
import { ROUTES } from '../constants';
export function DataSourceDetailsPage() {
const overrideNavId = 'standalone-plugin-page-/connections/connect-data';
const { id } = useParams<{ id: string }>();
const navIndex = useSelector((state: StoreState) => state.navIndex);
const isConnectDataPageOverriden = Boolean(navIndex[overrideNavId]);
const navId = isConnectDataPageOverriden ? overrideNavId : 'connections-connect-data'; // The nav id changes (gets a prefix) if it is overriden by a plugin
return (
<Page
<PluginDetailsPage
pluginId={id}
navId={navId}
pageNav={{
text: 'Datasource details',
subTitle: 'This is going to be the details page for a datasource',
notFoundComponent={<NotFoundDatasource />}
notFoundNavModel={{
text: 'Unknown datasource',
subTitle: 'No datasource with this ID could be found.',
active: true,
}}
>
<Page.Contents>Data Source Details (no exposed component from plugins yet)</Page.Contents>
</Page>
/>
);
}
function NotFoundDatasource() {
const { id } = useParams<{ id: string }>();
return (
<Alert severity={AppNotificationSeverity.Warning} title="">
Maybe you mistyped the URL or the plugin with the id <Badge text={id} color="orange" /> is unavailable.
<br />
To see a list of available datasources please <a href={ROUTES.ConnectData}>click here</a>.
</Alert>
);
}

View File

@ -0,0 +1,112 @@
import { css } from '@emotion/css';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { useStyles2, TabContent, Alert } from '@grafana/ui';
import { Layout } from '@grafana/ui/src/components/Layout/Layout';
import { Page } from 'app/core/components/Page/Page';
import { AppNotificationSeverity } from 'app/types';
import { Loader } from '../components/Loader';
import { PluginDetailsBody } from '../components/PluginDetailsBody';
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
import { PluginTabIds } from '../types';
export type Props = {
// The ID of the plugin
pluginId: string;
// The navigation ID used for displaying the sidebar navigation
navId?: string;
// Can be used to customise the title & subtitle for the not found page
notFoundNavModel?: NavModelItem;
// Can be used to customise the content shown when a plugin with the given ID cannot be found
notFoundComponent?: React.ReactElement;
};
export function PluginDetailsPage({
pluginId,
navId = 'plugins',
notFoundComponent = <NotFoundPlugin />,
notFoundNavModel = {
text: 'Unknown plugin',
subTitle: 'The requested ID does not belong to any plugin',
active: true,
},
}: Props) {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance
const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page') as PluginTabIds);
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
const { isLoading: isFetchLoading } = useFetchStatus();
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
const styles = useStyles2(getStyles);
if (isFetchLoading || isFetchDetailsLoading) {
return (
<Page
navId={navId}
pageNav={{
text: '',
active: true,
}}
>
<Loader />
</Page>
);
}
if (!plugin) {
return (
<Page navId={navId} pageNav={notFoundNavModel}>
{notFoundComponent}
</Page>
);
}
return (
<Page navId={navId} pageNav={navModel} actions={actions} subTitle={subtitle} info={info}>
<Page.Contents>
<TabContent className={styles.tabContent}>
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
<PluginDetailsBody queryParams={Object.fromEntries(queryParams)} plugin={plugin} pageId={activePageId} />
</TabContent>
</Page.Contents>
</Page>
);
}
export const getStyles = (theme: GrafanaTheme2) => {
return {
alert: css`
margin-bottom: ${theme.spacing(2)};
`,
subtitle: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
`,
// Needed due to block formatting context
tabContent: css`
overflow: auto;
height: 100%;
`,
};
};
function NotFoundPlugin() {
return (
<Layout justify="center" align="center">
<Alert severity={AppNotificationSeverity.Warning} title="Plugin not found">
That plugin cannot be found. Please check the url is correct or <br />
go to the <a href="/plugins">plugin catalog</a>.
</Alert>
</Layout>
);
}

View File

@ -2,7 +2,7 @@ import { getDefaultNormalizer, render, RenderResult, SelectorMatcherOptions, wai
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, Route } from 'react-router-dom';
import {
PluginErrorCode,
@ -13,7 +13,6 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { configureStore } from 'app/store/configureStore';
import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock, mockUserPermissions } from '../__mocks__';
@ -70,24 +69,14 @@ const renderPluginDetails = (
): RenderResult => {
const plugin = getCatalogPluginMock(pluginOverride);
const { id } = plugin;
const props = getRouteComponentProps({
match: { params: { pluginId: id }, isExact: true, url: '', path: '' },
queryParams: { page: pageId },
location: {
hash: '',
pathname: `/plugins/${id}`,
search: pageId ? `?page=${pageId}` : '',
state: undefined,
},
});
const store = configureStore({
plugins: pluginsStateOverride || getPluginsStateMock([plugin]),
});
return render(
<MemoryRouter>
<MemoryRouter initialEntries={[{ pathname: `/plugins/${id}`, search: pageId ? `?page=${pageId}` : '' }]}>
<Provider store={store}>
<PluginDetailsPage {...props} />
<Route path="/plugins/:pluginId" component={PluginDetailsPage} />
</Provider>
</MemoryRouter>
);
@ -137,24 +126,7 @@ describe('Plugin details page', () => {
local: { id },
});
const props = getRouteComponentProps({
match: { params: { pluginId: id }, isExact: true, url: '', path: '' },
queryParams: {},
location: {
hash: '',
pathname: `/plugins/${id}`,
search: '',
state: undefined,
},
});
const store = configureStore();
const { queryByText } = render(
<MemoryRouter>
<Provider store={store}>
<PluginDetailsPage {...props} />
</Provider>
</MemoryRouter>
);
const { queryByText } = renderPluginDetails({ id });
await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
});

View File

@ -1,84 +1,10 @@
import { css } from '@emotion/css';
import React from 'react';
import { useParams } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, TabContent, Alert } from '@grafana/ui';
import { Layout } from '@grafana/ui/src/components/Layout/Layout';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { AppNotificationSeverity } from 'app/types';
import { PluginDetailsPage } from '../components/PluginDetailsPage';
import { Loader } from '../components/Loader';
import { PluginDetailsBody } from '../components/PluginDetailsBody';
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
import { PluginTabIds } from '../types';
export default function PluginDetails(): JSX.Element {
const { pluginId } = useParams<{ pluginId: string }>();
type Props = GrafanaRouteComponentProps<{ pluginId?: string }>;
export default function PluginDetails({ match, queryParams }: Props): JSX.Element | null {
const {
params: { pluginId = '' },
url,
} = match;
const parentUrl = url.substring(0, url.lastIndexOf('/'));
const plugin = useGetSingle(pluginId); // fetches the localplugin settings
const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.page as PluginTabIds);
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
const { isLoading: isFetchLoading } = useFetchStatus();
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
const styles = useStyles2(getStyles);
if (isFetchLoading || isFetchDetailsLoading) {
return (
<Page navId="plugins">
<Loader />
</Page>
);
}
if (!plugin) {
return (
<Layout justify="center" align="center">
<Alert severity={AppNotificationSeverity.Warning} title="Plugin not found">
That plugin cannot be found. Please check the url is correct or <br />
go to the <a href={parentUrl}>plugin catalog</a>.
</Alert>
</Layout>
);
}
return (
<Page navId="plugins" pageNav={navModel} actions={actions} subTitle={subtitle} info={info}>
<Page.Contents>
<TabContent className={styles.tabContent}>
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
<PluginDetailsBody queryParams={queryParams} plugin={plugin} pageId={activePageId} />
</TabContent>
</Page.Contents>
</Page>
);
return <PluginDetailsPage pluginId={pluginId} />;
}
export const getStyles = (theme: GrafanaTheme2) => {
return {
alert: css`
margin-bottom: ${theme.spacing(2)};
`,
subtitle: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
`,
// Needed due to block formatting context
tabContent: css`
overflow: auto;
height: 100%;
`,
};
};