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:
mikkancso 2022-11-30 09:41:01 +01:00 committed by GitHub
parent 312dbc979e
commit c72322874d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 250 additions and 81 deletions

View File

@ -31,7 +31,7 @@ export const Pages = {
url: '/datasources/new',
/** @deprecated Use dataSourcePluginsV2 */
dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`,
dataSourcePluginsV2: (pluginName: string) => `Add data source ${pluginName}`,
dataSourcePluginsV2: (pluginName: string) => `Add new data source ${pluginName}`,
},
ConfirmModal: {
delete: 'Confirm Modal Danger Button',

View File

@ -90,7 +90,8 @@ export function getPageStyles(theme: GrafanaTheme2) {
align-items: flex-start;
> a,
> button {
> button,
> div:nth-child(2) {
margin-left: ${theme.spacing(2)};
}
}

View File

@ -554,8 +554,8 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
// Datasources
Children: []*navtree.NavLink{{
Id: "connections-your-connections-datasources",
Text: "Datasources",
SubTitle: "Manage your existing datasource connections",
Text: "Data sources",
SubTitle: "View and manage your connected data source connections",
Url: baseUrl + "/your-connections/datasources",
}},
})

View File

@ -1,18 +1,33 @@
import React, { PureComponent } from 'react';
import { SelectableValue } from '@grafana/data';
import { LinkButton, FilterInput } from '@grafana/ui';
import { SortPicker } from '../Select/SortPicker';
export interface Props {
searchQuery: string;
setSearchQuery: (value: string) => void;
linkButton?: { href: string; title: string; disabled?: boolean };
target?: string;
placeholder?: string;
sortPicker?: {
onChange: (sortValue: SelectableValue) => void;
value?: string;
getSortOptions?: () => Promise<SelectableValue[]>;
};
}
export default class PageActionBar extends PureComponent<Props> {
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 };
if (target) {
@ -24,6 +39,13 @@ export default class PageActionBar extends PureComponent<Props> {
<div className="gf-form gf-form--grow">
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={placeholder} />
</div>
{sortPicker && (
<SortPicker
onChange={sortPicker.onChange}
value={sortPicker.value}
getSortOptions={sortPicker.getSortOptions}
/>
)}
{linkButton && <LinkButton {...linkProps}>{linkButton.title}</LinkButton>}
</div>
);

View File

@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { getMockDataSources } from 'app/features/datasources/__mocks__';
import * as api from 'app/features/datasources/api';
import { configureStore } from 'app/store/configureStore';
@ -14,6 +15,7 @@ import Connections from './Connections';
import { navIndex } from './__mocks__/store.navIndex.mock';
import { ROUTE_BASE_ID, ROUTES } from './constants';
jest.mock('app/core/services/context_srv');
jest.mock('app/features/datasources/api');
const renderPage = (
@ -36,6 +38,7 @@ describe('Connections', () => {
beforeEach(() => {
(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 () => {
@ -43,7 +46,8 @@ describe('Connections', () => {
expect(await screen.findByText('Datasources')).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 AZ')).toBeVisible();
expect(await screen.findByRole('link', { name: /add new data source/i })).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();
});
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
const standalonePluginPage = {
id: 'standalone-plugin-page-/connections/connect-data',
@ -83,7 +95,10 @@ describe('Connections', () => {
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();
});
});

View File

@ -1,11 +1,12 @@
import * as React from 'react';
import { Page } from 'app/core/components/Page/Page';
import { DataSourceAddButton } from 'app/features/datasources/components/DataSourceAddButton';
import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList';
export function DataSourcesListPage() {
return (
<Page navId={'connections-your-connections-datasources'}>
<Page navId={'connections-your-connections-datasources'} actions={DataSourceAddButton()}>
<Page.Contents>
<DataSourcesList />
</Page.Contents>

View File

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

View File

@ -2,12 +2,15 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { getMockDataSources } from '../__mocks__';
import { DataSourcesListView } from './DataSourcesList';
jest.mock('app/core/services/context_srv');
const setup = () => {
const store = configureStore();
@ -24,17 +27,38 @@ const setup = () => {
};
describe('<DataSourcesList>', () => {
it('should render list of datasources', () => {
setup();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(screen.getAllByRole('heading')).toHaveLength(3);
beforeEach(() => {
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true);
});
it('should render all elements in the list item', () => {
it('should render action bar', async () => {
setup();
expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'dataSource-0' })).toBeInTheDocument();
expect(await screen.findByPlaceholderText('Search by name or type')).toBeInTheDocument();
expect(await screen.findByRole('combobox', { name: 'Sort' })).toBeInTheDocument();
});
it('should render list of datasources', async () => {
setup();
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
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);
});
});

View File

@ -1,14 +1,15 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataSourceSettings } from '@grafana/data';
import { Card, Tag, useStyles2 } from '@grafana/ui';
import { DataSourceSettings, GrafanaTheme2 } from '@grafana/data';
import { LinkButton, Card, Tag, useStyles2 } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { contextSrv } from 'app/core/core';
import { StoreState, AccessControlAction, useSelector } from 'app/types';
import { getDataSources, getDataSourcesCount, useDataSourcesRoutes, useLoadDataSources } from '../state';
import { constructDataSourceExploreUrl } from '../utils';
import { DataSourcesListHeader } from './DataSourcesListHeader';
@ -40,6 +41,7 @@ export type ViewProps = {
export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) {
const styles = useStyles2(getStyles);
const dataSourcesRoutes = useDataSourcesRoutes();
const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
if (isLoading) {
return <PageLoader />;
@ -83,6 +85,22 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading,
dataSource.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />,
]}
</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>
</li>
);
@ -92,7 +110,7 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading,
);
}
const getStyles = () => {
const getStyles = (theme: GrafanaTheme2) => {
return {
list: css({
listStyle: 'none',
@ -102,5 +120,8 @@ const getStyles = () => {
logo: css({
objectFit: 'contain',
}),
button: css({
marginLeft: theme.spacing(2),
}),
};
};

View File

@ -1,42 +1,40 @@
import React, { useCallback } from 'react';
import { AnyAction } from 'redux';
import { SelectableValue } from '@grafana/data';
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, StoreState, useSelector, useDispatch } from 'app/types';
import { 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 AZ', value: ascendingSortValue },
{ label: 'Sort by ZA', value: descendingSortValue },
];
export function DataSourcesListHeader() {
const dispatch = useDispatch();
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources));
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
return (
<DataSourcesListHeaderView
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
canCreateDataSource={canCreateDataSource}
/>
const setSort = useCallback(
(sort: SelectableValue) => dispatch(setIsSortAscending(sort.value === ascendingSortValue)),
[dispatch]
);
}
const isSortAscending = useSelector(({ dataSources }: StoreState) => getDataSourcesSort(dataSources));
export type ViewProps = {
searchQuery: string;
setSearchQuery: (q: string) => AnyAction;
canCreateDataSource: boolean;
};
export function DataSourcesListHeaderView({ searchQuery, setSearchQuery, canCreateDataSource }: ViewProps) {
const dataSourcesRoutes = useDataSourcesRoutes();
const linkButton = {
href: dataSourcesRoutes.New,
title: 'Add data source',
disabled: !canCreateDataSource,
const sortPicker = {
onChange: setSort,
value: isSortAscending ? ascendingSortValue : descendingSortValue,
getSortOptions: () => Promise.resolve(sortOptions),
};
return (
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} linkButton={linkButton} key="action-bar" />
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} key="action-bar" sortPicker={sortPicker} />
);
}

View File

@ -2,28 +2,30 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
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 { DataSourcesState } from 'app/types';
import { navIndex, getMockDataSources } from '../__mocks__';
import { getDataSources } from '../api';
import { initialState } from '../state';
import { DataSourcesListPage } from './DataSourcesListPage';
jest.mock('app/core/services/backend_srv', () => ({
...jest.requireActual('app/core/services/backend_srv'),
getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]) }),
jest.mock('app/core/services/context_srv');
jest.mock('../api', () => ({
...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({
dataSources: {
...initialState,
dataSources: [] as DataSourceSettings[],
layoutMode: LayoutModes.Grid,
hasFetched: false,
...stateOverride,
isSortAscending: options.isSortAscending,
},
navIndex,
});
@ -36,28 +38,70 @@ const setup = (stateOverride?: Partial<DataSourcesState>) => {
};
describe('Render', () => {
it('should render component', () => {
setup();
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();
beforeEach(() => {
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true);
});
it('should render action bar and datasources', () => {
setup({
dataSources: getMockDataSources(5),
dataSourcesCount: 5,
hasFetched: true,
});
it('should render component', async () => {
setup({ isSortAscending: true });
expect(screen.getByRole('link', { name: 'Add data source' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'dataSource-1' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'dataSource-2' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'dataSource-3' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'dataSource-4' })).toBeInTheDocument();
expect(screen.getAllByRole('img')).toHaveLength(5);
expect(await screen.findByRole('heading', { name: 'Configuration' })).toBeInTheDocument();
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();
});
it('should not render "Add new data source" button if user has no permissions', async () => {
(contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(false);
setup({ isSortAscending: true });
expect(await screen.findByRole('heading', { name: 'Configuration' })).toBeInTheDocument();
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(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');
});
});
});

View File

@ -2,11 +2,12 @@ import React from 'react';
import { Page } from 'app/core/components/Page/Page';
import { DataSourceAddButton } from '../components/DataSourceAddButton';
import { DataSourcesList } from '../components/DataSourcesList';
export function DataSourcesListPage() {
return (
<Page navId="datasources">
<Page navId="datasources" actions={DataSourceAddButton()}>
<Page.Contents>
<DataSourcesList />
</Page.Contents>

View File

@ -1,6 +1,6 @@
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 appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
@ -9,6 +9,7 @@ import { AccessControlAction, useDispatch, useSelector } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { DataSourceRights } from '../types';
import { constructDataSourceExploreUrl } from '../utils';
import {
initDataSourceSettings,
@ -108,10 +109,7 @@ export const useDataSource = (uid: string) => {
export const useDataSourceExploreUrl = (uid: string) => {
const dataSource = useDataSource(uid);
const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' });
const exploreUrl = urlUtil.renderUrl('/explore', { left: exploreState });
return exploreUrl;
return constructDataSourceExploreUrl(dataSource);
};
export const useDataSourceMeta = (pluginType: string): DataSourcePluginMeta => {

View File

@ -19,6 +19,7 @@ export const initialState: DataSourcesState = {
hasFetched: false,
isLoadingDataSources: false,
dataSourceMeta: {} as DataSourcePluginMeta,
isSortAscending: true,
};
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 setDataSourceName = createAction<string>('dataSources/setDataSourceName');
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.
// 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;
};

View File

@ -4,9 +4,13 @@ import { DataSourcesState } from 'app/types/datasources';
export const getDataSources = (state: DataSourcesState) => {
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 filteredDataSources.sort((a, b) =>
state.isSortAscending ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
);
};
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 getDataSourcesLayoutMode = (state: DataSourcesState) => state.layoutMode;
export const getDataSourcesCount = (state: DataSourcesState) => state.dataSourcesCount;
export const getDataSourcesSort = (state: DataSourcesState) => state.isSortAscending;

View File

@ -1,3 +1,5 @@
import { DataSourceJsonData, DataSourceSettings, urlUtil, locationUtil } from '@grafana/data';
interface ItemWithName {
name: string;
}
@ -45,3 +47,10 @@ function incrementLastDigit(digit: number) {
function getNewName(name: string) {
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;
};

View File

@ -14,6 +14,7 @@ export interface DataSourcesState {
isLoadingDataSources: boolean;
plugins: DataSourcePluginMeta[];
categories: DataSourcePluginCategory[];
isSortAscending: boolean;
}
export interface TestingStatus {