Data Connections: Add data sources (#52436)

* feat: show data-sources under the data-connections page

* refactor: add a constant for data-sources routes

* refactor: add a context that holds the currently active data-sources routes

* refactor: use the data-sources routes constant wherever possible

* refactor: use the data-sources routes context wherever possible

* feat(data-connections): add edit and new pages

* feat(data-connections): set the the custom routes via the context provider

* fix(data-connections): set the active tab properly

We needed to update the routes to match with the ones on the backend ("data-sources" vs "datasources"),
and we also needed to check if it is the default tab, in which case we would like to highlight the Datasources tab.

* tests: fix tests for Data Connections page

* fix: address rebase issues

* tests: find button based on role and text

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

* fix: add missing closing ) paren in tests

* refactor: use implicit return types for components

* tests: change role from "button" to "link"

* refactor: stop using unnecessary wrapper components

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
Levente Balogh 2022-07-25 14:29:21 +02:00 committed by GitHub
parent 53b8e528fc
commit a1c565dec9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 128 additions and 44 deletions

View File

@ -4,12 +4,16 @@ import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { getMockDataSources } from 'app/features/datasources/__mocks__';
import * as api from 'app/features/datasources/api';
import { configureStore } from 'app/store/configureStore';
import DataConnectionsPage from './DataConnectionsPage';
import { navIndex } from './__mocks__/store.navIndex.mock';
import { ROUTE_BASE_ID, ROUTES } from './constants';
jest.mock('app/features/datasources/api');
const renderPage = (path = `/${ROUTE_BASE_ID}`): RenderResult => {
// @ts-ignore
const store = configureStore({ navIndex });
@ -25,6 +29,12 @@ const renderPage = (path = `/${ROUTE_BASE_ID}`): RenderResult => {
};
describe('Data Connections Page', () => {
const mockDatasources = getMockDataSources(3);
beforeEach(() => {
(api.getDataSources as jest.Mock) = jest.fn().mockResolvedValue(mockDatasources);
});
test('shows all the four tabs', async () => {
renderPage();
@ -37,7 +47,8 @@ describe('Data Connections Page', () => {
test('shows the "Data sources" tab by default', async () => {
renderPage();
expect(await screen.findByText('The list of data sources is under development.')).toBeVisible();
expect(await screen.findByRole('link', { name: /add data source/i })).toBeVisible();
expect(await screen.findByText(mockDatasources[0].name)).toBeVisible();
});
test('renders the correct tab even if accessing it with a "sub-url"', async () => {

View File

@ -2,29 +2,44 @@ import * as React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Page } from 'app/core/components/Page/Page';
import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList';
import { NewDataSource } from 'app/features/datasources/components/NewDataSource';
import { DataSourcesRoutesContext } from 'app/features/datasources/state';
import { ROUTES } from './constants';
import { useNavModel } from './hooks/useNavModel';
import { CloudIntegrations } from './tabs/CloudIntegrations';
import { DataSources } from './tabs/DataSources';
import { DataSourcesEdit } from './tabs/DataSourcesEdit';
import { Plugins } from './tabs/Plugins';
import { RecordedQueries } from './tabs/RecordedQueries';
export default function DataConnectionsPage(): React.ReactElement | null {
export default function DataConnectionsPage() {
const navModel = useNavModel();
return (
<Page navModel={navModel}>
<Page.Contents>
<Switch>
<Route path={ROUTES.Plugins} component={Plugins} />
<Route path={ROUTES.CloudIntegrations} component={CloudIntegrations} />
<Route path={ROUTES.RecordedQueries} component={RecordedQueries} />
<DataSourcesRoutesContext.Provider
value={{
New: ROUTES.DataSourcesNew,
List: ROUTES.DataSources,
Edit: ROUTES.DataSourcesEdit,
Dashboards: ROUTES.DataSourcesDashboards,
}}
>
<Page navModel={navModel}>
<Page.Contents>
<Switch>
<Route path={ROUTES.DataSourcesNew} component={NewDataSource} />
<Route path={ROUTES.DataSourcesEdit} component={DataSourcesEdit} />
<Route path={ROUTES.DataSources} component={DataSourcesList} />
<Route path={ROUTES.Plugins} component={Plugins} />
<Route path={ROUTES.CloudIntegrations} component={CloudIntegrations} />
<Route path={ROUTES.RecordedQueries} component={RecordedQueries} />
{/* Default page */}
<Route component={DataSources} />
</Switch>
</Page.Contents>
</Page>
{/* Default page */}
<Route component={DataSourcesList} />
</Switch>
</Page.Contents>
</Page>
</DataSourcesRoutesContext.Provider>
);
}

View File

@ -5,7 +5,10 @@ export const CLOUD_ONBOARDING_APP_ID = 'grafana-easystart-app';
export const ROUTE_BASE_ID = 'data-connections';
export enum ROUTES {
DataSources = '/data-connections/data-sources',
DataSources = '/data-connections/datasources',
DataSourcesNew = '/data-connections/datasources/new',
DataSourcesEdit = '/data-connections/datasources/edit/:uid',
DataSourcesDashboards = '/data-connections/datasources/edit/:uid/dashboards',
Plugins = '/data-connections/plugins',
CloudIntegrations = '/data-connections/cloud-integrations',
RecordedQueries = '/data-connections/recorded-queries',

View File

@ -1,6 +1,7 @@
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { NavModelItem } from '@grafana/data';
import { StoreState } from 'app/types/store';
import { ROUTE_BASE_ID } from '../constants';
@ -9,14 +10,17 @@ import { ROUTE_BASE_ID } from '../constants';
// (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 { pathname: currentPath } = useLocation();
const navIndex = useSelector((state: StoreState) => state.navIndex);
const node = navIndex[ROUTE_BASE_ID];
const main = node;
const isDefaultRoute = (item: NavModelItem) =>
currentPath === `/${ROUTE_BASE_ID}` && item.id === 'data-connections-datasources';
const isItemActive = (item: NavModelItem) => currentPath.startsWith(item.url || '');
main.children = main.children?.map((item) => ({
...item,
active: pathname.startsWith(item.url || ''),
active: isItemActive(item) || isDefaultRoute(item),
}));
return {

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { ReactElement } from 'react';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
@ -7,7 +7,7 @@ import { AppPluginLoader } from 'app/features/plugins/components/AppPluginLoader
import { CLOUD_ONBOARDING_APP_ID, ROUTES } from '../../constants';
export function CloudIntegrations(): ReactElement | null {
export function CloudIntegrations() {
const s = useStyles2(getStyles);
return (

View File

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

View File

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

View File

@ -0,0 +1,14 @@
import React from 'react';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { EditDataSource } from 'app/features/datasources/components/EditDataSource';
export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
export function DataSourcesEdit(props: Props) {
const uid = props.match.params.uid;
const params = new URLSearchParams(props.location.search);
const pageId = params.get('page');
return <EditDataSource uid={uid} pageId={pageId} />;
}

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { contextSrv } from 'app/core/core';
import { StoreState, AccessControlAction } from 'app/types';
import { getDataSources, getDataSourcesCount, useLoadDataSources } from '../state';
import { getDataSources, getDataSourcesCount, useDataSourcesRoutes, useLoadDataSources } from '../state';
import { DataSourcesListHeader } from './DataSourcesListHeader';
@ -40,6 +40,7 @@ export type ViewProps = {
export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) {
const styles = useStyles(getStyles);
const dataSourcesRoutes = useDataSourcesRoutes();
if (isLoading) {
return <PageLoader />;
@ -51,7 +52,7 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading,
buttonDisabled={!hasCreateRights}
title="No data sources defined"
buttonIcon="database"
buttonLink="datasources/new"
buttonLink={dataSourcesRoutes.New}
buttonTitle="Add data source"
proTip="You can also define data sources through configuration files."
proTipLink="http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list"
@ -71,7 +72,7 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading,
{dataSources.map((dataSource) => {
return (
<li key={dataSource.uid}>
<Card href={`datasources/edit/${dataSource.uid}`}>
<Card href={dataSourcesRoutes.Edit.replace(/:uid/gi, dataSource.uid)}>
<Card.Heading>{dataSource.name}</Card.Heading>
<Card.Figure>
<img src={dataSource.typeLogoUrl} alt="" height="40px" width="40px" className={styles.logo} />

View File

@ -6,7 +6,7 @@ import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, StoreState } from 'app/types';
import { getDataSourcesSearchQuery, setDataSourcesSearchQuery } from '../state';
import { getDataSourcesSearchQuery, setDataSourcesSearchQuery, useDataSourcesRoutes } from '../state';
export function DataSourcesListHeader() {
const dispatch = useDispatch();
@ -30,8 +30,9 @@ export type ViewProps = {
};
export function DataSourcesListHeaderView({ searchQuery, setSearchQuery, canCreateDataSource }: ViewProps) {
const dataSourcesRoutes = useDataSourcesRoutes();
const linkButton = {
href: 'datasources/new',
href: dataSourcesRoutes.New,
title: 'Add data source',
disabled: !canCreateDataSource,
};

View File

@ -15,6 +15,7 @@ import {
useLoadDataSourcePlugins,
getFilteredDataSourcePlugins,
setDataSourceTypeSearchQuery,
useDataSourcesRoutes,
} from '../state';
export function NewDataSource() {
@ -57,6 +58,8 @@ export function NewDataSourceView({
onAddDataSource,
onSetSearchQuery,
}: ViewProps) {
const dataSourcesRoutes = useDataSourcesRoutes();
if (isLoading) {
return <PageLoader />;
}
@ -67,7 +70,7 @@ export function NewDataSourceView({
<div className="page-action-bar">
<FilterInput value={searchQuery} onChange={onSetSearchQuery} placeholder="Filter by name or type" />
<div className="page-action-bar__spacer" />
<LinkButton href="datasources" fill="outline" variant="secondary" icon="arrow-left">
<LinkButton href={dataSourcesRoutes.List} fill="outline" variant="secondary" icon="arrow-left">
Cancel
</LinkButton>
</div>

View File

@ -0,0 +1,12 @@
import { DataSourcesRoutes } from './types';
/**
* Default routes for data sources pages.
* (Links to the pages can be overriden for this feature by using `DataSourcesRoutesContext`)
*/
export const DATASOURCES_ROUTES: DataSourcesRoutes = {
List: '/datasources',
Edit: '/datasources/edit/:uid',
Dashboards: '/datasources/edit/:uid/dashboards',
New: '/datasources/new',
} as const;

View File

@ -4,6 +4,7 @@ import { NavModel } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page';
import { NewDataSource } from '../components/NewDataSource';
import { DATASOURCES_ROUTES } from '../constants';
const navModel = getNavModel();
@ -22,7 +23,7 @@ export function getNavModel(): NavModel {
icon: 'database',
id: 'datasource-new',
text: 'Add data source',
href: 'datasources/new',
href: DATASOURCES_ROUTES.New,
subTitle: 'Choose a data source type',
};

View File

@ -16,6 +16,7 @@ import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
import { DataSourcePluginCategory, ThunkDispatch, ThunkResult } from 'app/types';
import * as api from '../api';
import { DATASOURCES_ROUTES } from '../constants';
import { nameExits, findNewName } from '../utils';
import { buildCategories } from './buildCategories';
@ -174,7 +175,7 @@ export function loadDataSourceMeta(dataSource: DataSourceSettings): ThunkResult<
};
}
export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult<void> {
export function addDataSource(plugin: DataSourcePluginMeta, editLink = DATASOURCES_ROUTES.Edit): ThunkResult<void> {
return async (dispatch, getStore) => {
await dispatch(loadDataSources());
@ -196,7 +197,7 @@ export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult<void> {
await getDatasourceSrv().reload();
await contextSrv.fetchUserPermissions();
locationService.push(`/datasources/edit/${result.datasource.uid}`);
locationService.push(editLink.replace(/:uid/gi, result.datasource.uid));
};
}

View File

@ -0,0 +1,8 @@
import { createContext } from 'react';
import { DATASOURCES_ROUTES } from '../constants';
import { DataSourcesRoutes } from '../types';
// The purpose of this context is to be able to override the data-sources routes (used for links for example) used under
// the app/features/datasources modules, so we can reuse them more easily in different parts of the application (e.g. under Data Connections)
export const DataSourcesRoutesContext = createContext<DataSourcesRoutes>(DATASOURCES_ROUTES);

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { DataSourcePluginMeta, DataSourceSettings, urlUtil } from '@grafana/data';
@ -21,6 +21,7 @@ import {
updateDataSource,
deleteLoadedDataSource,
} from './actions';
import { DataSourcesRoutesContext } from './contexts';
import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from './navModel';
import { getDataSource, getDataSourceMeta } from './selectors';
@ -72,9 +73,10 @@ export const useLoadDataSourcePlugins = () => {
export const useAddDatasource = () => {
const dispatch = useDispatch();
const dataSourcesRoutes = useDataSourcesRoutes();
return (plugin: DataSourcePluginMeta) => {
dispatch(addDataSource(plugin));
dispatch(addDataSource(plugin, dataSourcesRoutes.Edit));
};
};
@ -159,3 +161,7 @@ export const useDataSourceRights = (uid: string): DataSourceRights => {
hasDeleteRights,
};
};
export const useDataSourcesRoutes = () => {
return useContext(DataSourcesRoutesContext);
};

View File

@ -1,5 +1,6 @@
export * from './actions';
export * from './buildCategories';
export * from './contexts';
export * from './hooks';
export * from './navModel';
export * from './reducers';

View File

@ -7,3 +7,10 @@ export type DataSourceRights = {
hasWriteRights: boolean;
hasDeleteRights: boolean;
};
export type DataSourcesRoutes = {
New: string;
Edit: string;
List: string;
Dashboards: string;
};

View File

@ -9,6 +9,7 @@ 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 { DATASOURCES_ROUTES } from 'app/features/datasources/constants';
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';
@ -90,19 +91,19 @@ export function getAppRoutes(): RouteDescriptor[] {
),
},
{
path: '/datasources',
path: DATASOURCES_ROUTES.List,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/pages/DataSourcesListPage')
),
},
{
path: '/datasources/edit/:uid/',
path: DATASOURCES_ROUTES.Edit,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "EditDataSourcePage"*/ '../features/datasources/pages/EditDataSourcePage')
),
},
{
path: '/datasources/edit/:uid/dashboards',
path: DATASOURCES_ROUTES.Dashboards,
component: SafeDynamicImport(
() =>
import(
@ -111,7 +112,7 @@ export function getAppRoutes(): RouteDescriptor[] {
),
},
{
path: '/datasources/new',
path: DATASOURCES_ROUTES.New,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/pages/NewDataSourcePage')
),