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:
Levente Balogh 2022-06-10 12:13:31 +02:00 committed by GitHub
parent 502c6e4e6b
commit 9a85a2e441
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 3115 additions and 1 deletions

View File

@ -59,4 +59,5 @@ export interface FeatureToggles {
canvasPanelNesting?: boolean;
cloudMonitoringExperimentalUI?: boolean;
logRequestsInstrumentedAsUnknown?: boolean;
dataConnectionsConsole?: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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',
}

View 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,
};
};

View 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 [];
}

View File

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

View File

@ -0,0 +1 @@
export * from './CloudIntegrations';

View File

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

View File

@ -0,0 +1 @@
export * from './DataSources';

View File

@ -0,0 +1,5 @@
import React, { ReactElement } from 'react';
export function Plugins(): ReactElement | null {
return <div>The list of plugins is under development</div>;
}

View File

@ -0,0 +1 @@
export * from './Plugins';

View File

@ -0,0 +1,5 @@
import React, { ReactElement } from 'react';
export function RecordedQueries(): ReactElement | null {
return <div>The recorded queries tab is under development.</div>;
}

View File

@ -0,0 +1 @@
export * from './RecordedQueries';

View 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;
}

View 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}
/>
)}
</>
);
};

View File

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

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

View File

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