mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Data Connections: Create a new top-level page (#50018)
* Feature Flags: introduce a flag for enabling the Data Connections page * Feature Flags: generate schemas * Navigation: add navigation weight for the Data Connections page * NavLink: add a comment pointing out where icon names can be looked up * NavTree: add a new page called Data Connections * fix(Api): prefix the navigation IDs with the parent ("data-connections") * feat(Frontend): add a basic page with four tabs * feat(Plugins): add a hook for importing an app plugin * feat(Plugins): add a component for loading app plugins anywhere * feat(Data Connections): load the cloud-onboarding app under the "Cloud onboarding" tab * feat(Data Connections): generate a proper nav model to highlight active tabs * test(Data Connections): add tests * refactor(Data Connections): update temporary text content This is only used as a placeholder until the tabs are under development. * refactor(Data Cnnnections): move /pages to /tabs * refactor(Data Connections): remove the `types.ts` file as it is not referenced by any module * feat(Data Connections): only register routes if feature is enabled
This commit is contained in:
parent
502c6e4e6b
commit
9a85a2e441
@ -59,4 +59,5 @@ export interface FeatureToggles {
|
||||
canvasPanelNesting?: boolean;
|
||||
cloudMonitoringExperimentalUI?: boolean;
|
||||
logRequestsInstrumentedAsUnknown?: boolean;
|
||||
dataConnectionsConsole?: boolean;
|
||||
}
|
||||
|
@ -42,6 +42,7 @@ const (
|
||||
WeightDashboard
|
||||
WeightExplore
|
||||
WeightAlerting
|
||||
WeightDataConnections
|
||||
WeightPlugin
|
||||
WeightConfig
|
||||
WeightAdmin
|
||||
@ -61,7 +62,7 @@ type NavLink struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Section string `json:"section,omitempty"`
|
||||
SubTitle string `json:"subTitle,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Icon string `json:"icon,omitempty"` // Available icons can be browsed in Storybook: https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview
|
||||
Img string `json:"img,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
|
@ -245,6 +245,10 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
navTree = append(navTree, hs.buildAlertNavLinks(c)...)
|
||||
}
|
||||
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) {
|
||||
navTree = append(navTree, hs.buildDataConnectionsNavLink(c))
|
||||
}
|
||||
|
||||
appLinks, err := hs.getAppLinks(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -630,6 +634,58 @@ func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink
|
||||
return children
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) buildDataConnectionsNavLink(c *models.ReqContext) *dtos.NavLink {
|
||||
var children []*dtos.NavLink
|
||||
var navLink *dtos.NavLink
|
||||
|
||||
baseId := "data-connections"
|
||||
baseUrl := hs.Cfg.AppSubURL + "/" + baseId
|
||||
|
||||
children = append(children, &dtos.NavLink{
|
||||
Id: baseId + "-datasources",
|
||||
Text: "Data sources",
|
||||
Icon: "database",
|
||||
Description: "Add and configure data sources",
|
||||
Url: baseUrl + "/datasources",
|
||||
})
|
||||
|
||||
children = append(children, &dtos.NavLink{
|
||||
Id: baseId + "-plugins",
|
||||
Text: "Plugins",
|
||||
Icon: "plug",
|
||||
Description: "Manage plugins",
|
||||
Url: baseUrl + "/plugins",
|
||||
})
|
||||
|
||||
children = append(children, &dtos.NavLink{
|
||||
Id: baseId + "-cloud-integrations",
|
||||
Text: "Cloud integrations",
|
||||
Icon: "bolt",
|
||||
Description: "Manage your cloud integrations",
|
||||
Url: baseUrl + "/cloud-integrations",
|
||||
})
|
||||
|
||||
children = append(children, &dtos.NavLink{
|
||||
Id: baseId + "-recorded-queries",
|
||||
Text: "Recorded queries",
|
||||
Icon: "record-audio",
|
||||
Description: "Manage your recorded queries",
|
||||
Url: baseUrl + "/recorded-queries",
|
||||
})
|
||||
|
||||
navLink = &dtos.NavLink{
|
||||
Text: "Data Connections",
|
||||
Icon: "link",
|
||||
Id: baseId,
|
||||
Url: baseUrl,
|
||||
Children: children,
|
||||
Section: dtos.NavSectionCore,
|
||||
SortWeight: dtos.WeightDataConnections,
|
||||
}
|
||||
|
||||
return navLink
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink {
|
||||
hasAccess := ac.HasAccess(hs.AccessControl, c)
|
||||
hasGlobalAccess := ac.HasGlobalAccess(hs.AccessControl, c)
|
||||
|
@ -240,6 +240,10 @@ var (
|
||||
{
|
||||
Name: "logRequestsInstrumentedAsUnknown",
|
||||
Description: "Logs the path for requests that are instrumented as unknown",
|
||||
},
|
||||
{
|
||||
Name: "dataConnectionsConsole",
|
||||
Description: "Enables a new top-level page called Data Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins.",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
}
|
||||
|
@ -178,4 +178,8 @@ const (
|
||||
// FlagLogRequestsInstrumentedAsUnknown
|
||||
// Logs the path for requests that are instrumented as unknown
|
||||
FlagLogRequestsInstrumentedAsUnknown = "logRequestsInstrumentedAsUnknown"
|
||||
|
||||
// FlagDataConnectionsConsole
|
||||
// Enables a new top-level page called Data Connections. This page is an experiment for better grouping of installing / configuring data sources and other plugins.
|
||||
FlagDataConnectionsConsole = "dataConnectionsConsole"
|
||||
)
|
||||
|
@ -0,0 +1,42 @@
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import DataConnectionsPage from './DataConnectionsPage';
|
||||
import navIndex from './__mocks__/store.navIndex.mock';
|
||||
import { ROUTE_BASE_ID } from './constants';
|
||||
|
||||
const renderPage = (path = `/${ROUTE_BASE_ID}`): RenderResult => {
|
||||
// @ts-ignore
|
||||
const store = configureStore({ navIndex });
|
||||
locationService.push(path);
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<DataConnectionsPage />
|
||||
</Router>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Data Connections Page', () => {
|
||||
test('shows all the four tabs', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByLabelText('Tab Data sources')).toBeVisible();
|
||||
expect(await screen.findByLabelText('Tab Plugins')).toBeVisible();
|
||||
expect(await screen.findByLabelText('Tab Cloud integrations')).toBeVisible();
|
||||
expect(await screen.findByLabelText('Tab Recorded queries')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows the "Data sources" tab by default', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('The list of data sources is under development.')).toBeVisible();
|
||||
});
|
||||
});
|
30
public/app/features/data-connections/DataConnectionsPage.tsx
Normal file
30
public/app/features/data-connections/DataConnectionsPage.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { ROUTES } from './constants';
|
||||
import { useNavModel } from './hooks/useNavModel';
|
||||
import { CloudIntegrations } from './tabs/CloudIntegrations';
|
||||
import { DataSources } from './tabs/DataSources';
|
||||
import { Plugins } from './tabs/Plugins';
|
||||
import { RecordedQueries } from './tabs/RecordedQueries';
|
||||
|
||||
export default function DataConnectionsPage(): React.ReactElement | null {
|
||||
const navModel = useNavModel();
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents>
|
||||
<Switch>
|
||||
<Route exact path={ROUTES.Plugins} component={Plugins} />
|
||||
<Route exact path={ROUTES.CloudIntegrations} component={CloudIntegrations} />
|
||||
<Route exact path={ROUTES.RecordedQueries} component={RecordedQueries} />
|
||||
|
||||
{/* Default page */}
|
||||
<Route component={DataSources} />
|
||||
</Switch>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load Diff
12
public/app/features/data-connections/constants.ts
Normal file
12
public/app/features/data-connections/constants.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// 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 = 'data-connections';
|
||||
|
||||
export enum ROUTES {
|
||||
DataSources = '/data-connections/data-sources',
|
||||
Plugins = '/data-connections/plugins',
|
||||
CloudIntegrations = '/data-connections/cloud-integrations',
|
||||
RecordedQueries = '/data-connections/recorded-queries',
|
||||
}
|
26
public/app/features/data-connections/hooks/useNavModel.ts
Normal file
26
public/app/features/data-connections/hooks/useNavModel.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { StoreState } from 'app/types/store';
|
||||
|
||||
import { ROUTE_BASE_ID } from '../constants';
|
||||
|
||||
// We need this utility logic to make sure that the tab with the current URL is marked as active.
|
||||
// (In case we were using `getNavModel()` from app/core/selectors/navModel, then we would need to set
|
||||
// the child nav-model-item's ID on the call-site.)
|
||||
export const useNavModel = () => {
|
||||
const { pathname } = useLocation();
|
||||
const navIndex = useSelector((state: StoreState) => state.navIndex);
|
||||
const node = navIndex[ROUTE_BASE_ID];
|
||||
const main = node;
|
||||
|
||||
main.children = main.children?.map((item) => ({
|
||||
...item,
|
||||
active: item.url === pathname,
|
||||
}));
|
||||
|
||||
return {
|
||||
node,
|
||||
main,
|
||||
};
|
||||
};
|
21
public/app/features/data-connections/routes.tsx
Normal file
21
public/app/features/data-connections/routes.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||
import { config } from 'app/core/config';
|
||||
import { RouteDescriptor } from 'app/core/navigation/types';
|
||||
|
||||
import { ROUTE_BASE_ID } from './constants';
|
||||
|
||||
export function getRoutes(): RouteDescriptor[] {
|
||||
if (config.featureToggles.dataConnectionsConsole) {
|
||||
return [
|
||||
{
|
||||
path: `/${ROUTE_BASE_ID}`,
|
||||
exact: false,
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "DataConnectionsPage"*/ 'app/features/data-connections/DataConnectionsPage')
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { AppPluginLoader } from 'app/features/plugins/components/AppPluginLoader';
|
||||
|
||||
import { CLOUD_ONBOARDING_APP_ID } from '../../constants';
|
||||
|
||||
export function CloudIntegrations(): ReactElement | null {
|
||||
const s = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={s.container}>
|
||||
<AppPluginLoader id={CLOUD_ONBOARDING_APP_ID} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
// We would like to force the app to stay inside the provided tab
|
||||
container: css`
|
||||
position: relative;
|
||||
`,
|
||||
});
|
@ -0,0 +1 @@
|
||||
export * from './CloudIntegrations';
|
@ -0,0 +1,5 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
export function DataSources(): ReactElement | null {
|
||||
return <div>The list of data sources is under development.</div>;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './DataSources';
|
@ -0,0 +1,5 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
export function Plugins(): ReactElement | null {
|
||||
return <div>The list of plugins is under development</div>;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './Plugins';
|
@ -0,0 +1,5 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
export function RecordedQueries(): ReactElement | null {
|
||||
return <div>The recorded queries tab is under development.</div>;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './RecordedQueries';
|
132
public/app/features/plugins/components/AppPluginLoader.test.tsx
Normal file
132
public/app/features/plugins/components/AppPluginLoader.test.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React, { Component } from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
|
||||
import { AppPlugin, PluginType, AppRootProps, NavModelItem } from '@grafana/data';
|
||||
import { locationService, setEchoSrv } from '@grafana/runtime';
|
||||
import { Echo } from 'app/core/services/echo/Echo';
|
||||
|
||||
import { getMockPlugin } from '../__mocks__/pluginMocks';
|
||||
import { useImportAppPlugin } from '../hooks/useImportAppPlugin';
|
||||
|
||||
import { AppPluginLoader } from './AppPluginLoader';
|
||||
|
||||
jest.mock('../hooks/useImportAppPlugin', () => ({
|
||||
useImportAppPlugin: jest.fn(),
|
||||
}));
|
||||
|
||||
const useImportAppPluginMock = useImportAppPlugin as jest.Mock<
|
||||
ReturnType<typeof useImportAppPlugin>,
|
||||
Parameters<typeof useImportAppPlugin>
|
||||
>;
|
||||
|
||||
const TEXTS = {
|
||||
PLUGIN_TITLE: 'Amazing App',
|
||||
PLUGIN_CONTENT: 'This is my amazing app plugin!',
|
||||
PLUGIN_TAB_TITLE_A: 'Tab (A)',
|
||||
PLUGIN_TAB_TITLE_B: 'Tab (B)',
|
||||
};
|
||||
|
||||
describe('AppPluginLoader', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
AppPluginComponent.timesMounted = 0;
|
||||
setEchoSrv(new Echo());
|
||||
});
|
||||
|
||||
test('renders the app plugin correctly', async () => {
|
||||
useImportAppPluginMock.mockReturnValue({ value: getAppPluginMock(), loading: false, error: undefined });
|
||||
|
||||
renderAppPlugin();
|
||||
|
||||
expect(await screen.findByText(TEXTS.PLUGIN_TITLE)).toBeVisible();
|
||||
expect(await screen.findByText(TEXTS.PLUGIN_CONTENT)).toBeVisible();
|
||||
expect(await screen.findByLabelText(`Tab ${TEXTS.PLUGIN_TAB_TITLE_A}`)).toBeVisible();
|
||||
expect(await screen.findByLabelText(`Tab ${TEXTS.PLUGIN_TAB_TITLE_B}`)).toBeVisible();
|
||||
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the app plugin only once', async () => {
|
||||
useImportAppPluginMock.mockReturnValue({ value: getAppPluginMock(), loading: false, error: undefined });
|
||||
|
||||
renderAppPlugin();
|
||||
|
||||
expect(await screen.findByText(TEXTS.PLUGIN_TITLE)).toBeVisible();
|
||||
expect(AppPluginComponent.timesMounted).toEqual(1);
|
||||
});
|
||||
|
||||
test('renders a loader while the plugin is loading', async () => {
|
||||
useImportAppPluginMock.mockReturnValue({ value: undefined, loading: true, error: undefined });
|
||||
|
||||
renderAppPlugin();
|
||||
|
||||
expect(await screen.findByText('Loading ...')).toBeVisible();
|
||||
expect(screen.queryByText(TEXTS.PLUGIN_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders an error message if there are any errors while importing the plugin', async () => {
|
||||
const errorMsg = 'Unable to find plugin';
|
||||
useImportAppPluginMock.mockReturnValue({ value: undefined, loading: false, error: new Error(errorMsg) });
|
||||
|
||||
renderAppPlugin();
|
||||
|
||||
expect(await screen.findByText(errorMsg)).toBeVisible();
|
||||
expect(screen.queryByText(TEXTS.PLUGIN_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function renderAppPlugin() {
|
||||
render(
|
||||
<Router history={locationService.getHistory()}>
|
||||
<AppPluginLoader id="foo" />;
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
class AppPluginComponent extends Component<AppRootProps> {
|
||||
static timesMounted = 0;
|
||||
|
||||
componentDidMount() {
|
||||
AppPluginComponent.timesMounted += 1;
|
||||
|
||||
const node: NavModelItem = {
|
||||
text: TEXTS.PLUGIN_TITLE,
|
||||
children: [
|
||||
{
|
||||
text: TEXTS.PLUGIN_TAB_TITLE_A,
|
||||
url: '/tab-a',
|
||||
id: 'a',
|
||||
},
|
||||
{
|
||||
text: TEXTS.PLUGIN_TAB_TITLE_B,
|
||||
url: '/tab-b',
|
||||
id: 'b',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.props.onNavChanged({
|
||||
main: node,
|
||||
node,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <p>{TEXTS.PLUGIN_CONTENT}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function getAppPluginMeta() {
|
||||
return getMockPlugin({
|
||||
type: PluginType.app,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
function getAppPluginMock() {
|
||||
const plugin = new AppPlugin();
|
||||
|
||||
plugin.root = AppPluginComponent;
|
||||
plugin.init(getAppPluginMeta());
|
||||
|
||||
return plugin;
|
||||
}
|
44
public/app/features/plugins/components/AppPluginLoader.tsx
Normal file
44
public/app/features/plugins/components/AppPluginLoader.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { NavModel } from '@grafana/data';
|
||||
import { getWarningNav } from 'app/angular/services/nav_model_srv';
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
|
||||
import { useImportAppPlugin } from '../hooks/useImportAppPlugin';
|
||||
|
||||
type AppPluginLoaderProps = {
|
||||
// The id of the app plugin to be loaded
|
||||
id: string;
|
||||
// The base URL path - defaults to the current path
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
// This component can be used to render an app-plugin based on its plugin ID.
|
||||
export const AppPluginLoader = ({ id, basePath }: AppPluginLoaderProps) => {
|
||||
const [nav, setNav] = useState<NavModel | null>(null);
|
||||
const { value: plugin, error, loading } = useImportAppPlugin(id);
|
||||
const queryParams = useParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
if (error) {
|
||||
return <Page.Header model={getWarningNav(error.message, error.stack)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <PageLoader />}
|
||||
{nav && <Page.Header model={nav} />}
|
||||
{!loading && plugin && plugin.root && (
|
||||
<plugin.root
|
||||
meta={plugin.meta}
|
||||
basename={basePath || pathname}
|
||||
onNavChanged={setNav}
|
||||
query={queryParams}
|
||||
path={pathname}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,150 @@
|
||||
import { render, act, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { AppPlugin, PluginType } from '@grafana/data';
|
||||
|
||||
import { getMockPlugin } from '../../__mocks__/pluginMocks';
|
||||
import { getPluginSettings } from '../../pluginSettings';
|
||||
import { importAppPlugin } from '../../plugin_loader';
|
||||
import { useImportAppPlugin } from '../useImportAppPlugin';
|
||||
|
||||
jest.mock('../../pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../plugin_loader', () => ({
|
||||
importAppPlugin: jest.fn(),
|
||||
}));
|
||||
|
||||
const importAppPluginMock = importAppPlugin as jest.Mock<
|
||||
ReturnType<typeof importAppPlugin>,
|
||||
Parameters<typeof importAppPlugin>
|
||||
>;
|
||||
|
||||
const getPluginSettingsMock = getPluginSettings as jest.Mock<
|
||||
ReturnType<typeof getPluginSettings>,
|
||||
Parameters<typeof getPluginSettings>
|
||||
>;
|
||||
|
||||
const PLUGIN_ID = 'sample-plugin';
|
||||
|
||||
describe('useImportAppPlugin()', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('returns the imported plugin in case it exists', async () => {
|
||||
let response: any;
|
||||
getPluginSettingsMock.mockResolvedValue(getAppPluginMeta());
|
||||
importAppPluginMock.mockResolvedValue(getAppPluginMock());
|
||||
|
||||
act(() => {
|
||||
response = runHook(PLUGIN_ID);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(response.value).not.toBeUndefined());
|
||||
await waitFor(() => expect(response.error).toBeUndefined());
|
||||
await waitFor(() => expect(response.loading).toBe(false));
|
||||
});
|
||||
|
||||
test('returns an error if the plugin does not exist', async () => {
|
||||
let response: any;
|
||||
|
||||
act(() => {
|
||||
response = runHook(PLUGIN_ID);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(response.value).toBeUndefined());
|
||||
await waitFor(() => expect(response.error).not.toBeUndefined());
|
||||
await waitFor(() => expect(response.error.message).toMatch(/unknown plugin/i));
|
||||
await waitFor(() => expect(response.loading).toBe(false));
|
||||
});
|
||||
|
||||
test('returns an error if the plugin is not an app', async () => {
|
||||
let response: any;
|
||||
getPluginSettingsMock.mockResolvedValue(getAppPluginMeta({ type: PluginType.panel }));
|
||||
importAppPluginMock.mockResolvedValue(getAppPluginMock());
|
||||
|
||||
act(() => {
|
||||
response = runHook(PLUGIN_ID);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(response.value).toBeUndefined());
|
||||
await waitFor(() => expect(response.error).not.toBeUndefined());
|
||||
await waitFor(() => expect(response.error.message).toMatch(/plugin must be an app/i));
|
||||
await waitFor(() => expect(response.loading).toBe(false));
|
||||
});
|
||||
|
||||
test('returns an error if the plugin is not enabled', async () => {
|
||||
let response: any;
|
||||
getPluginSettingsMock.mockResolvedValue(getAppPluginMeta({ enabled: false }));
|
||||
importAppPluginMock.mockResolvedValue(getAppPluginMock());
|
||||
|
||||
act(() => {
|
||||
response = runHook(PLUGIN_ID);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(response.value).toBeUndefined());
|
||||
await waitFor(() => expect(response.error).not.toBeUndefined());
|
||||
await waitFor(() => expect(response.error.message).toMatch(/is not enabled/i));
|
||||
await waitFor(() => expect(response.loading).toBe(false));
|
||||
});
|
||||
|
||||
test('returns errors that happen during fetching plugin settings', async () => {
|
||||
let response: any;
|
||||
const errorMsg = 'Error while fetching plugin data';
|
||||
getPluginSettingsMock.mockRejectedValue(new Error(errorMsg));
|
||||
importAppPluginMock.mockResolvedValue(getAppPluginMock());
|
||||
|
||||
act(() => {
|
||||
response = runHook(PLUGIN_ID);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(response.value).toBeUndefined());
|
||||
await waitFor(() => expect(response.error).not.toBeUndefined());
|
||||
await waitFor(() => expect(response.error.message).toBe(errorMsg));
|
||||
await waitFor(() => expect(response.loading).toBe(false));
|
||||
});
|
||||
|
||||
test('returns errors that happen during importing a plugin', async () => {
|
||||
let response: any;
|
||||
const errorMsg = 'Error while importing the plugin';
|
||||
getPluginSettingsMock.mockResolvedValue(getAppPluginMeta());
|
||||
importAppPluginMock.mockRejectedValue(new Error(errorMsg));
|
||||
|
||||
act(() => {
|
||||
response = runHook(PLUGIN_ID);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(response.value).toBeUndefined());
|
||||
await waitFor(() => expect(response.error).not.toBeUndefined());
|
||||
await waitFor(() => expect(response.error.message).toBe(errorMsg));
|
||||
await waitFor(() => expect(response.loading).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
function runHook(id: string): any {
|
||||
const returnVal = {};
|
||||
function TestComponent() {
|
||||
Object.assign(returnVal, useImportAppPlugin(id));
|
||||
return null;
|
||||
}
|
||||
render(<TestComponent />);
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
function getAppPluginMeta(overrides?: Record<string, any>) {
|
||||
return getMockPlugin({
|
||||
id: PLUGIN_ID,
|
||||
type: PluginType.app,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function getAppPluginMock() {
|
||||
const plugin = new AppPlugin();
|
||||
|
||||
plugin.init(getAppPluginMeta());
|
||||
|
||||
return plugin;
|
||||
}
|
26
public/app/features/plugins/hooks/useImportAppPlugin.ts
Normal file
26
public/app/features/plugins/hooks/useImportAppPlugin.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { PluginType } from '@grafana/data';
|
||||
|
||||
import { getPluginSettings } from '../pluginSettings';
|
||||
import { importAppPlugin } from '../plugin_loader';
|
||||
|
||||
export const useImportAppPlugin = (id: string) => {
|
||||
return useAsync(async () => {
|
||||
const pluginMeta = await getPluginSettings(id);
|
||||
|
||||
if (!pluginMeta) {
|
||||
throw new Error(`Unknown plugin: "${id}"`);
|
||||
}
|
||||
|
||||
if (pluginMeta.type !== PluginType.app) {
|
||||
throw new Error(`Plugin must be an app (currently "${pluginMeta.type}")`);
|
||||
}
|
||||
|
||||
if (!pluginMeta.enabled) {
|
||||
throw new Error(`Application "${id}" is not enabled`);
|
||||
}
|
||||
|
||||
return await importAppPlugin(pluginMeta);
|
||||
});
|
||||
};
|
@ -8,6 +8,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import UserAdminPage from 'app/features/admin/UserAdminPage';
|
||||
import LdapPage from 'app/features/admin/ldap/LdapPage';
|
||||
import { getAlertingRoutes } from 'app/features/alerting/routes';
|
||||
import { getRoutes as getDataConnectionsRoutes } from 'app/features/data-connections/routes';
|
||||
import { getLiveRoutes } from 'app/features/live/pages/routes';
|
||||
import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes';
|
||||
import { getProfileRoutes } from 'app/features/profile/routes';
|
||||
@ -431,6 +432,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
...getProfileRoutes(),
|
||||
...extraRoutes,
|
||||
...getPublicDashboardRoutes(),
|
||||
...getDataConnectionsRoutes(),
|
||||
{
|
||||
path: '/*',
|
||||
component: ErrorPage,
|
||||
|
Loading…
Reference in New Issue
Block a user