mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Connections: Update "Your connections/Data sources" page (#58589)
* navtree.go: update Data sources title and subtitle * DataSourceList: move add button to header * DataSourcesList: add buttons to items The action buttons are added inside `<Card.Tags>` so that they end up at the right end of the card, as it was designed. The "Build a Dashboard" button's functionality is not defined yet. * DataSourcesListHeader: add sort picker * fix css * tests: look for the updated "Add new data source" text * tests: use an async test method to verify component updates are wrapped in an act() * update e2e selector for add data source button * fix DataSourceList{,Page} tests * add comment for en dash character * simplify sorting * add link to Build a Dashboard button * fix test * test build a dashboard and explore buttons * test sorting data source elements * DataSourceAddButton: hide button when user has no permission * PageActionBar: remove unneeded '?' * DataSourcesList: hide explore button if user has no permission * DataSourcesListPage.test: make setup prop explicit * DataSourcesList: use theme.spacing * datasources: assure explore url includes appSubUrl * fix tests and add test case for missing permissions Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
parent
312dbc979e
commit
c72322874d
@ -31,7 +31,7 @@ export const Pages = {
|
|||||||
url: '/datasources/new',
|
url: '/datasources/new',
|
||||||
/** @deprecated Use dataSourcePluginsV2 */
|
/** @deprecated Use dataSourcePluginsV2 */
|
||||||
dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`,
|
dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`,
|
||||||
dataSourcePluginsV2: (pluginName: string) => `Add data source ${pluginName}`,
|
dataSourcePluginsV2: (pluginName: string) => `Add new data source ${pluginName}`,
|
||||||
},
|
},
|
||||||
ConfirmModal: {
|
ConfirmModal: {
|
||||||
delete: 'Confirm Modal Danger Button',
|
delete: 'Confirm Modal Danger Button',
|
||||||
|
@ -90,7 +90,8 @@ export function getPageStyles(theme: GrafanaTheme2) {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
||||||
> a,
|
> a,
|
||||||
> button {
|
> button,
|
||||||
|
> div:nth-child(2) {
|
||||||
margin-left: ${theme.spacing(2)};
|
margin-left: ${theme.spacing(2)};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -555,7 +555,7 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
|
|||||||
Children: []*navtree.NavLink{{
|
Children: []*navtree.NavLink{{
|
||||||
Id: "connections-your-connections-datasources",
|
Id: "connections-your-connections-datasources",
|
||||||
Text: "Data sources",
|
Text: "Data sources",
|
||||||
SubTitle: "Manage your existing datasource connections",
|
SubTitle: "View and manage your connected data source connections",
|
||||||
Url: baseUrl + "/your-connections/datasources",
|
Url: baseUrl + "/your-connections/datasources",
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
|
@ -1,18 +1,33 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { LinkButton, FilterInput } from '@grafana/ui';
|
import { LinkButton, FilterInput } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { SortPicker } from '../Select/SortPicker';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (value: string) => void;
|
setSearchQuery: (value: string) => void;
|
||||||
linkButton?: { href: string; title: string; disabled?: boolean };
|
linkButton?: { href: string; title: string; disabled?: boolean };
|
||||||
target?: string;
|
target?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
sortPicker?: {
|
||||||
|
onChange: (sortValue: SelectableValue) => void;
|
||||||
|
value?: string;
|
||||||
|
getSortOptions?: () => Promise<SelectableValue[]>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class PageActionBar extends PureComponent<Props> {
|
export default class PageActionBar extends PureComponent<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { searchQuery, linkButton, setSearchQuery, target, placeholder = 'Search by name or type' } = this.props;
|
const {
|
||||||
|
searchQuery,
|
||||||
|
linkButton,
|
||||||
|
setSearchQuery,
|
||||||
|
target,
|
||||||
|
placeholder = 'Search by name or type',
|
||||||
|
sortPicker,
|
||||||
|
} = this.props;
|
||||||
const linkProps: typeof LinkButton.defaultProps = { href: linkButton?.href, disabled: linkButton?.disabled };
|
const linkProps: typeof LinkButton.defaultProps = { href: linkButton?.href, disabled: linkButton?.disabled };
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
@ -24,6 +39,13 @@ export default class PageActionBar extends PureComponent<Props> {
|
|||||||
<div className="gf-form gf-form--grow">
|
<div className="gf-form gf-form--grow">
|
||||||
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={placeholder} />
|
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={placeholder} />
|
||||||
</div>
|
</div>
|
||||||
|
{sortPicker && (
|
||||||
|
<SortPicker
|
||||||
|
onChange={sortPicker.onChange}
|
||||||
|
value={sortPicker.value}
|
||||||
|
getSortOptions={sortPicker.getSortOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{linkButton && <LinkButton {...linkProps}>{linkButton.title}</LinkButton>}
|
{linkButton && <LinkButton {...linkProps}>{linkButton.title}</LinkButton>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
|
|||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
|
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { getMockDataSources } from 'app/features/datasources/__mocks__';
|
import { getMockDataSources } from 'app/features/datasources/__mocks__';
|
||||||
import * as api from 'app/features/datasources/api';
|
import * as api from 'app/features/datasources/api';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
@ -14,6 +15,7 @@ import Connections from './Connections';
|
|||||||
import { navIndex } from './__mocks__/store.navIndex.mock';
|
import { navIndex } from './__mocks__/store.navIndex.mock';
|
||||||
import { ROUTE_BASE_ID, ROUTES } from './constants';
|
import { ROUTE_BASE_ID, ROUTES } from './constants';
|
||||||
|
|
||||||
|
jest.mock('app/core/services/context_srv');
|
||||||
jest.mock('app/features/datasources/api');
|
jest.mock('app/features/datasources/api');
|
||||||
|
|
||||||
const renderPage = (
|
const renderPage = (
|
||||||
@ -36,6 +38,7 @@ describe('Connections', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(api.getDataSources as jest.Mock) = jest.fn().mockResolvedValue(mockDatasources);
|
(api.getDataSources as jest.Mock) = jest.fn().mockResolvedValue(mockDatasources);
|
||||||
|
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows the "Data sources" page by default', async () => {
|
test('shows the "Data sources" page by default', async () => {
|
||||||
@ -43,7 +46,8 @@ describe('Connections', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('Datasources')).toBeVisible();
|
expect(await screen.findByText('Datasources')).toBeVisible();
|
||||||
expect(await screen.findByText('Manage your existing datasource connections')).toBeVisible();
|
expect(await screen.findByText('Manage your existing datasource connections')).toBeVisible();
|
||||||
expect(await screen.findByRole('link', { name: /add data source/i })).toBeVisible();
|
expect(await screen.findByText('Sort by A–Z')).toBeVisible();
|
||||||
|
expect(await screen.findByRole('link', { name: /add new data source/i })).toBeVisible();
|
||||||
expect(await screen.findByText(mockDatasources[0].name)).toBeVisible();
|
expect(await screen.findByText(mockDatasources[0].name)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,7 +61,15 @@ describe('Connections', () => {
|
|||||||
expect(screen.queryByText('Manage your existing datasource connections')).not.toBeInTheDocument();
|
expect(screen.queryByText('Manage your existing datasource connections')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders the "Connect data" page using a plugin in case it is a standalone plugin page', async () => {
|
test('renders the core "Connect data" page in case there is no standalone plugin page override for it', async () => {
|
||||||
|
renderPage(ROUTES.ConnectData);
|
||||||
|
|
||||||
|
// We expect to see no results and "Data sources" as a header (we only have data sources in OSS Grafana at this point)
|
||||||
|
expect(await screen.findByText('Data sources')).toBeVisible();
|
||||||
|
expect(await screen.findByText('No results matching your query were found.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not render anything for the "Connect data" page in case it is displayed by a standalone plugin page', async () => {
|
||||||
// We are overriding the navIndex to have the "Connect data" page registered by a plugin
|
// We are overriding the navIndex to have the "Connect data" page registered by a plugin
|
||||||
const standalonePluginPage = {
|
const standalonePluginPage = {
|
||||||
id: 'standalone-plugin-page-/connections/connect-data',
|
id: 'standalone-plugin-page-/connections/connect-data',
|
||||||
@ -83,7 +95,10 @@ describe('Connections', () => {
|
|||||||
|
|
||||||
renderPage(ROUTES.ConnectData, store);
|
renderPage(ROUTES.ConnectData, store);
|
||||||
|
|
||||||
// We expect not to see the same text as if it was rendered by core.
|
// We expect not to see the text that would be rendered by the core "Connect data" page
|
||||||
|
// (Instead we expect to see the default route "Datasources")
|
||||||
|
expect(await screen.findByText('Datasources')).toBeVisible();
|
||||||
|
expect(await screen.findByText('Manage your existing datasource connections')).toBeVisible();
|
||||||
expect(screen.queryByText('No results matching your query were found.')).not.toBeInTheDocument();
|
expect(screen.queryByText('No results matching your query were found.')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
import { DataSourceAddButton } from 'app/features/datasources/components/DataSourceAddButton';
|
||||||
import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList';
|
import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList';
|
||||||
|
|
||||||
export function DataSourcesListPage() {
|
export function DataSourcesListPage() {
|
||||||
return (
|
return (
|
||||||
<Page navId={'connections-your-connections-datasources'}>
|
<Page navId={'connections-your-connections-datasources'} actions={DataSourceAddButton()}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
<DataSourcesList />
|
<DataSourcesList />
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { LinkButton } from '@grafana/ui';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
|
import { useDataSourcesRoutes } from '../state';
|
||||||
|
|
||||||
|
export function DataSourceAddButton() {
|
||||||
|
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
|
||||||
|
const dataSourcesRoutes = useDataSourcesRoutes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
canCreateDataSource && (
|
||||||
|
<LinkButton icon="plus" href={dataSourcesRoutes.New}>
|
||||||
|
Add new data source
|
||||||
|
</LinkButton>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
@ -2,12 +2,15 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
import { getMockDataSources } from '../__mocks__';
|
import { getMockDataSources } from '../__mocks__';
|
||||||
|
|
||||||
import { DataSourcesListView } from './DataSourcesList';
|
import { DataSourcesListView } from './DataSourcesList';
|
||||||
|
|
||||||
|
jest.mock('app/core/services/context_srv');
|
||||||
|
|
||||||
const setup = () => {
|
const setup = () => {
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
@ -24,17 +27,38 @@ const setup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('<DataSourcesList>', () => {
|
describe('<DataSourcesList>', () => {
|
||||||
it('should render list of datasources', () => {
|
beforeEach(() => {
|
||||||
|
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render action bar', async () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
expect(await screen.findByPlaceholderText('Search by name or type')).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('heading')).toHaveLength(3);
|
expect(await screen.findByRole('combobox', { name: 'Sort' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render all elements in the list item', () => {
|
it('should render list of datasources', async () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
|
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
|
||||||
expect(screen.getByRole('link', { name: 'dataSource-0' })).toBeInTheDocument();
|
expect(await screen.findAllByRole('heading')).toHaveLength(3);
|
||||||
|
expect(await screen.findAllByRole('link', { name: 'Build a Dashboard' })).toHaveLength(3);
|
||||||
|
expect(await screen.findAllByRole('link', { name: 'Explore' })).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render all elements in the list item', async () => {
|
||||||
|
setup();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('link', { name: 'dataSource-0' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render Explore button if user has no permissions', async () => {
|
||||||
|
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(false);
|
||||||
|
setup();
|
||||||
|
|
||||||
|
expect(await screen.findAllByRole('link', { name: 'Build a Dashboard' })).toHaveLength(3);
|
||||||
|
expect(screen.queryAllByRole('link', { name: 'Explore' })).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { DataSourceSettings } from '@grafana/data';
|
import { DataSourceSettings, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Card, Tag, useStyles2 } from '@grafana/ui';
|
import { LinkButton, Card, Tag, useStyles2 } from '@grafana/ui';
|
||||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
import { StoreState, AccessControlAction, useSelector } from 'app/types';
|
import { StoreState, AccessControlAction, useSelector } from 'app/types';
|
||||||
|
|
||||||
import { getDataSources, getDataSourcesCount, useDataSourcesRoutes, useLoadDataSources } from '../state';
|
import { getDataSources, getDataSourcesCount, useDataSourcesRoutes, useLoadDataSources } from '../state';
|
||||||
|
import { constructDataSourceExploreUrl } from '../utils';
|
||||||
|
|
||||||
import { DataSourcesListHeader } from './DataSourcesListHeader';
|
import { DataSourcesListHeader } from './DataSourcesListHeader';
|
||||||
|
|
||||||
@ -40,6 +41,7 @@ export type ViewProps = {
|
|||||||
export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) {
|
export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const dataSourcesRoutes = useDataSourcesRoutes();
|
const dataSourcesRoutes = useDataSourcesRoutes();
|
||||||
|
const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageLoader />;
|
return <PageLoader />;
|
||||||
@ -83,6 +85,22 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading,
|
|||||||
dataSource.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />,
|
dataSource.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />,
|
||||||
]}
|
]}
|
||||||
</Card.Meta>
|
</Card.Meta>
|
||||||
|
<Card.Tags>
|
||||||
|
<LinkButton icon="apps" fill="outline" variant="secondary" href="/dashboard/new">
|
||||||
|
Build a Dashboard
|
||||||
|
</LinkButton>
|
||||||
|
{canExploreDataSources && (
|
||||||
|
<LinkButton
|
||||||
|
icon="compass"
|
||||||
|
fill="outline"
|
||||||
|
variant="secondary"
|
||||||
|
className={styles.button}
|
||||||
|
href={constructDataSourceExploreUrl(dataSource)}
|
||||||
|
>
|
||||||
|
Explore
|
||||||
|
</LinkButton>
|
||||||
|
)}
|
||||||
|
</Card.Tags>
|
||||||
</Card>
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -92,7 +110,7 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = () => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
list: css({
|
list: css({
|
||||||
listStyle: 'none',
|
listStyle: 'none',
|
||||||
@ -102,5 +120,8 @@ const getStyles = () => {
|
|||||||
logo: css({
|
logo: css({
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
}),
|
}),
|
||||||
|
button: css({
|
||||||
|
marginLeft: theme.spacing(2),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,42 +1,40 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { AnyAction } from 'redux';
|
|
||||||
|
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { StoreState, useSelector, useDispatch } from 'app/types';
|
||||||
import { AccessControlAction, StoreState, useSelector, useDispatch } from 'app/types';
|
|
||||||
|
|
||||||
import { getDataSourcesSearchQuery, setDataSourcesSearchQuery, useDataSourcesRoutes } from '../state';
|
import { getDataSourcesSearchQuery, getDataSourcesSort, setDataSourcesSearchQuery, setIsSortAscending } from '../state';
|
||||||
|
|
||||||
|
const ascendingSortValue = 'alpha-asc';
|
||||||
|
const descendingSortValue = 'alpha-desc';
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
// We use this unicode 'en dash' character (U+2013), because it looks nicer
|
||||||
|
// than simple dash in this context. This is also used in the response of
|
||||||
|
// the `sorting` endpoint, which is used in the search dashboard page.
|
||||||
|
{ label: 'Sort by A–Z', value: ascendingSortValue },
|
||||||
|
{ label: 'Sort by Z–A', value: descendingSortValue },
|
||||||
|
];
|
||||||
|
|
||||||
export function DataSourcesListHeader() {
|
export function DataSourcesListHeader() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
|
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
|
||||||
const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources));
|
const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources));
|
||||||
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
|
|
||||||
|
|
||||||
return (
|
const setSort = useCallback(
|
||||||
<DataSourcesListHeaderView
|
(sort: SelectableValue) => dispatch(setIsSortAscending(sort.value === ascendingSortValue)),
|
||||||
searchQuery={searchQuery}
|
[dispatch]
|
||||||
setSearchQuery={setSearchQuery}
|
|
||||||
canCreateDataSource={canCreateDataSource}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
const isSortAscending = useSelector(({ dataSources }: StoreState) => getDataSourcesSort(dataSources));
|
||||||
|
|
||||||
export type ViewProps = {
|
const sortPicker = {
|
||||||
searchQuery: string;
|
onChange: setSort,
|
||||||
setSearchQuery: (q: string) => AnyAction;
|
value: isSortAscending ? ascendingSortValue : descendingSortValue,
|
||||||
canCreateDataSource: boolean;
|
getSortOptions: () => Promise.resolve(sortOptions),
|
||||||
};
|
|
||||||
|
|
||||||
export function DataSourcesListHeaderView({ searchQuery, setSearchQuery, canCreateDataSource }: ViewProps) {
|
|
||||||
const dataSourcesRoutes = useDataSourcesRoutes();
|
|
||||||
const linkButton = {
|
|
||||||
href: dataSourcesRoutes.New,
|
|
||||||
title: 'Add data source',
|
|
||||||
disabled: !canCreateDataSource,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} linkButton={linkButton} key="action-bar" />
|
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} key="action-bar" sortPicker={sortPicker} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,28 +2,30 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { DataSourceSettings, LayoutModes } from '@grafana/data';
|
import { LayoutModes } from '@grafana/data';
|
||||||
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import { DataSourcesState } from 'app/types';
|
|
||||||
|
|
||||||
import { navIndex, getMockDataSources } from '../__mocks__';
|
import { navIndex, getMockDataSources } from '../__mocks__';
|
||||||
|
import { getDataSources } from '../api';
|
||||||
import { initialState } from '../state';
|
import { initialState } from '../state';
|
||||||
|
|
||||||
import { DataSourcesListPage } from './DataSourcesListPage';
|
import { DataSourcesListPage } from './DataSourcesListPage';
|
||||||
|
|
||||||
jest.mock('app/core/services/backend_srv', () => ({
|
jest.mock('app/core/services/context_srv');
|
||||||
...jest.requireActual('app/core/services/backend_srv'),
|
jest.mock('../api', () => ({
|
||||||
getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]) }),
|
...jest.requireActual('../api'),
|
||||||
|
getDataSources: jest.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const setup = (stateOverride?: Partial<DataSourcesState>) => {
|
const getDataSourcesMock = getDataSources as jest.Mock;
|
||||||
|
|
||||||
|
const setup = (options: { isSortAscending: boolean }) => {
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
dataSources: {
|
dataSources: {
|
||||||
...initialState,
|
...initialState,
|
||||||
dataSources: [] as DataSourceSettings[],
|
|
||||||
layoutMode: LayoutModes.Grid,
|
layoutMode: LayoutModes.Grid,
|
||||||
hasFetched: false,
|
isSortAscending: options.isSortAscending,
|
||||||
...stateOverride,
|
|
||||||
},
|
},
|
||||||
navIndex,
|
navIndex,
|
||||||
});
|
});
|
||||||
@ -36,28 +38,70 @@ const setup = (stateOverride?: Partial<DataSourcesState>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('Render', () => {
|
describe('Render', () => {
|
||||||
it('should render component', () => {
|
beforeEach(() => {
|
||||||
setup();
|
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: 'Configuration' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: 'Documentation' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: 'Support' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: 'Community' })).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render action bar and datasources', () => {
|
it('should render component', async () => {
|
||||||
setup({
|
setup({ isSortAscending: true });
|
||||||
dataSources: getMockDataSources(5),
|
|
||||||
dataSourcesCount: 5,
|
expect(await screen.findByRole('heading', { name: 'Configuration' })).toBeInTheDocument();
|
||||||
hasFetched: true,
|
expect(await screen.findByRole('link', { name: 'Documentation' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('link', { name: 'Support' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('link', { name: 'Community' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('link', { name: 'Add new data source' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole('link', { name: 'Add data source' })).toBeInTheDocument();
|
it('should not render "Add new data source" button if user has no permissions', async () => {
|
||||||
expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
|
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(false);
|
||||||
expect(screen.getByRole('heading', { name: 'dataSource-1' })).toBeInTheDocument();
|
setup({ isSortAscending: true });
|
||||||
expect(screen.getByRole('heading', { name: 'dataSource-2' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('heading', { name: 'dataSource-3' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: 'Configuration' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: 'dataSource-4' })).toBeInTheDocument();
|
expect(await screen.findByRole('link', { name: 'Documentation' })).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('img')).toHaveLength(5);
|
expect(await screen.findByRole('link', { name: 'Support' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('link', { name: 'Community' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link', { name: 'Add new data source' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render action bar and datasources', async () => {
|
||||||
|
getDataSourcesMock.mockResolvedValue(getMockDataSources(5));
|
||||||
|
|
||||||
|
setup({ isSortAscending: true });
|
||||||
|
|
||||||
|
expect(await screen.findByPlaceholderText('Search by name or type')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('combobox', { name: 'Sort' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-1' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-2' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-3' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-4' })).toBeInTheDocument();
|
||||||
|
expect(await screen.findAllByRole('img')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should render elements in sort order', () => {
|
||||||
|
it('ascending', async () => {
|
||||||
|
getDataSourcesMock.mockResolvedValue(getMockDataSources(5));
|
||||||
|
setup({ isSortAscending: true });
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
|
||||||
|
const dataSourceItems = await screen.findAllByRole('heading');
|
||||||
|
|
||||||
|
expect(dataSourceItems).toHaveLength(6);
|
||||||
|
expect(dataSourceItems[0]).toHaveTextContent('Configuration');
|
||||||
|
expect(dataSourceItems[1]).toHaveTextContent('dataSource-0');
|
||||||
|
expect(dataSourceItems[2]).toHaveTextContent('dataSource-1');
|
||||||
|
});
|
||||||
|
it('descending', async () => {
|
||||||
|
getDataSourcesMock.mockResolvedValue(getMockDataSources(5));
|
||||||
|
setup({ isSortAscending: false });
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
|
||||||
|
const dataSourceItems = await screen.findAllByRole('heading');
|
||||||
|
|
||||||
|
expect(dataSourceItems).toHaveLength(6);
|
||||||
|
expect(dataSourceItems[0]).toHaveTextContent('Configuration');
|
||||||
|
expect(dataSourceItems[1]).toHaveTextContent('dataSource-4');
|
||||||
|
expect(dataSourceItems[2]).toHaveTextContent('dataSource-3');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,11 +2,12 @@ import React from 'react';
|
|||||||
|
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
|
import { DataSourceAddButton } from '../components/DataSourceAddButton';
|
||||||
import { DataSourcesList } from '../components/DataSourcesList';
|
import { DataSourcesList } from '../components/DataSourcesList';
|
||||||
|
|
||||||
export function DataSourcesListPage() {
|
export function DataSourcesListPage() {
|
||||||
return (
|
return (
|
||||||
<Page navId="datasources">
|
<Page navId="datasources" actions={DataSourceAddButton()}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
<DataSourcesList />
|
<DataSourcesList />
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
|
|
||||||
import { DataSourcePluginMeta, DataSourceSettings, NavModelItem, urlUtil } from '@grafana/data';
|
import { DataSourcePluginMeta, DataSourceSettings, NavModelItem } from '@grafana/data';
|
||||||
import { cleanUpAction } from 'app/core/actions/cleanUp';
|
import { cleanUpAction } from 'app/core/actions/cleanUp';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
@ -9,6 +9,7 @@ import { AccessControlAction, useDispatch, useSelector } from 'app/types';
|
|||||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||||
|
|
||||||
import { DataSourceRights } from '../types';
|
import { DataSourceRights } from '../types';
|
||||||
|
import { constructDataSourceExploreUrl } from '../utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
initDataSourceSettings,
|
initDataSourceSettings,
|
||||||
@ -108,10 +109,7 @@ export const useDataSource = (uid: string) => {
|
|||||||
|
|
||||||
export const useDataSourceExploreUrl = (uid: string) => {
|
export const useDataSourceExploreUrl = (uid: string) => {
|
||||||
const dataSource = useDataSource(uid);
|
const dataSource = useDataSource(uid);
|
||||||
const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' });
|
return constructDataSourceExploreUrl(dataSource);
|
||||||
const exploreUrl = urlUtil.renderUrl('/explore', { left: exploreState });
|
|
||||||
|
|
||||||
return exploreUrl;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDataSourceMeta = (pluginType: string): DataSourcePluginMeta => {
|
export const useDataSourceMeta = (pluginType: string): DataSourcePluginMeta => {
|
||||||
|
@ -19,6 +19,7 @@ export const initialState: DataSourcesState = {
|
|||||||
hasFetched: false,
|
hasFetched: false,
|
||||||
isLoadingDataSources: false,
|
isLoadingDataSources: false,
|
||||||
dataSourceMeta: {} as DataSourcePluginMeta,
|
dataSourceMeta: {} as DataSourcePluginMeta,
|
||||||
|
isSortAscending: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dataSourceLoaded = createAction<DataSourceSettings>('dataSources/dataSourceLoaded');
|
export const dataSourceLoaded = createAction<DataSourceSettings>('dataSources/dataSourceLoaded');
|
||||||
@ -33,6 +34,7 @@ export const setDataSourcesLayoutMode = createAction<LayoutMode>('dataSources/se
|
|||||||
export const setDataSourceTypeSearchQuery = createAction<string>('dataSources/setDataSourceTypeSearchQuery');
|
export const setDataSourceTypeSearchQuery = createAction<string>('dataSources/setDataSourceTypeSearchQuery');
|
||||||
export const setDataSourceName = createAction<string>('dataSources/setDataSourceName');
|
export const setDataSourceName = createAction<string>('dataSources/setDataSourceName');
|
||||||
export const setIsDefault = createAction<boolean>('dataSources/setIsDefault');
|
export const setIsDefault = createAction<boolean>('dataSources/setIsDefault');
|
||||||
|
export const setIsSortAscending = createAction<boolean>('dataSources/setIsSortAscending');
|
||||||
|
|
||||||
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
|
// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated.
|
||||||
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
|
// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice
|
||||||
@ -93,6 +95,13 @@ export const dataSourcesReducer = (state: DataSourcesState = initialState, actio
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (setIsSortAscending.match(action)) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isSortAscending: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,9 +4,13 @@ import { DataSourcesState } from 'app/types/datasources';
|
|||||||
export const getDataSources = (state: DataSourcesState) => {
|
export const getDataSources = (state: DataSourcesState) => {
|
||||||
const regex = new RegExp(state.searchQuery, 'i');
|
const regex = new RegExp(state.searchQuery, 'i');
|
||||||
|
|
||||||
return state.dataSources.filter((dataSource: DataSourceSettings) => {
|
const filteredDataSources = state.dataSources.filter((dataSource: DataSourceSettings) => {
|
||||||
return regex.test(dataSource.name) || regex.test(dataSource.database) || regex.test(dataSource.type);
|
return regex.test(dataSource.name) || regex.test(dataSource.database) || regex.test(dataSource.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return filteredDataSources.sort((a, b) =>
|
||||||
|
state.isSortAscending ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFilteredDataSourcePlugins = (state: DataSourcesState) => {
|
export const getFilteredDataSourcePlugins = (state: DataSourcesState) => {
|
||||||
@ -35,3 +39,4 @@ export const getDataSourceMeta = (state: DataSourcesState, type: string): DataSo
|
|||||||
export const getDataSourcesSearchQuery = (state: DataSourcesState) => state.searchQuery;
|
export const getDataSourcesSearchQuery = (state: DataSourcesState) => state.searchQuery;
|
||||||
export const getDataSourcesLayoutMode = (state: DataSourcesState) => state.layoutMode;
|
export const getDataSourcesLayoutMode = (state: DataSourcesState) => state.layoutMode;
|
||||||
export const getDataSourcesCount = (state: DataSourcesState) => state.dataSourcesCount;
|
export const getDataSourcesCount = (state: DataSourcesState) => state.dataSourcesCount;
|
||||||
|
export const getDataSourcesSort = (state: DataSourcesState) => state.isSortAscending;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { DataSourceJsonData, DataSourceSettings, urlUtil, locationUtil } from '@grafana/data';
|
||||||
|
|
||||||
interface ItemWithName {
|
interface ItemWithName {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
@ -45,3 +47,10 @@ function incrementLastDigit(digit: number) {
|
|||||||
function getNewName(name: string) {
|
function getNewName(name: string) {
|
||||||
return name.slice(0, name.length - 1);
|
return name.slice(0, name.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const constructDataSourceExploreUrl = (dataSource: DataSourceSettings<DataSourceJsonData, {}>) => {
|
||||||
|
const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' });
|
||||||
|
const exploreUrl = urlUtil.renderUrl(locationUtil.assureBaseUrl('/explore'), { left: exploreState });
|
||||||
|
|
||||||
|
return exploreUrl;
|
||||||
|
};
|
||||||
|
@ -14,6 +14,7 @@ export interface DataSourcesState {
|
|||||||
isLoadingDataSources: boolean;
|
isLoadingDataSources: boolean;
|
||||||
plugins: DataSourcePluginMeta[];
|
plugins: DataSourcePluginMeta[];
|
||||||
categories: DataSourcePluginCategory[];
|
categories: DataSourcePluginCategory[];
|
||||||
|
isSortAscending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestingStatus {
|
export interface TestingStatus {
|
||||||
|
Loading…
Reference in New Issue
Block a user