diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index a6d0faae018..8fa94cb8d9c 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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', diff --git a/packages/grafana-ui/src/themes/GlobalStyles/page.ts b/packages/grafana-ui/src/themes/GlobalStyles/page.ts index 033d0beac4a..1c301bc188d 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/page.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/page.ts @@ -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)}; } } diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 16f0a5e231d..602080c53d9 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -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", }}, }) diff --git a/public/app/core/components/PageActionBar/PageActionBar.tsx b/public/app/core/components/PageActionBar/PageActionBar.tsx index eac3fcc8f3c..f85f0bab465 100644 --- a/public/app/core/components/PageActionBar/PageActionBar.tsx +++ b/public/app/core/components/PageActionBar/PageActionBar.tsx @@ -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; + }; } export default class PageActionBar extends PureComponent { 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 {
+ {sortPicker && ( + + )} {linkButton && {linkButton.title}} ); diff --git a/public/app/features/connections/Connections.test.tsx b/public/app/features/connections/Connections.test.tsx index d6c8404848c..043e09321d1 100644 --- a/public/app/features/connections/Connections.test.tsx +++ b/public/app/features/connections/Connections.test.tsx @@ -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 A–Z')).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(); }); }); diff --git a/public/app/features/connections/pages/DataSourcesListPage.tsx b/public/app/features/connections/pages/DataSourcesListPage.tsx index 518cc4c39db..17612363ba4 100644 --- a/public/app/features/connections/pages/DataSourcesListPage.tsx +++ b/public/app/features/connections/pages/DataSourcesListPage.tsx @@ -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 ( - + diff --git a/public/app/features/datasources/components/DataSourceAddButton.tsx b/public/app/features/datasources/components/DataSourceAddButton.tsx new file mode 100644 index 00000000000..ca57d7c4165 --- /dev/null +++ b/public/app/features/datasources/components/DataSourceAddButton.tsx @@ -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 && ( + + Add new data source + + ) + ); +} diff --git a/public/app/features/datasources/components/DataSourcesList.test.tsx b/public/app/features/datasources/components/DataSourcesList.test.tsx index 45991e8bc1c..bb6cf117858 100644 --- a/public/app/features/datasources/components/DataSourcesList.test.tsx +++ b/public/app/features/datasources/components/DataSourcesList.test.tsx @@ -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('', () => { - 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); }); }); diff --git a/public/app/features/datasources/components/DataSourcesList.tsx b/public/app/features/datasources/components/DataSourcesList.tsx index 06343ce5d69..39cc16c1f4d 100644 --- a/public/app/features/datasources/components/DataSourcesList.tsx +++ b/public/app/features/datasources/components/DataSourcesList.tsx @@ -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 ; @@ -83,6 +85,22 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, dataSource.isDefault && , ]} + + + Build a Dashboard + + {canExploreDataSources && ( + + Explore + + )} + ); @@ -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), + }), }; }; diff --git a/public/app/features/datasources/components/DataSourcesListHeader.tsx b/public/app/features/datasources/components/DataSourcesListHeader.tsx index 8db867bb245..4961a22e172 100644 --- a/public/app/features/datasources/components/DataSourcesListHeader.tsx +++ b/public/app/features/datasources/components/DataSourcesListHeader.tsx @@ -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 A–Z', value: ascendingSortValue }, + { label: 'Sort by Z–A', 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 ( - + 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 ( - + ); } diff --git a/public/app/features/datasources/pages/DataSourcesListPage.test.tsx b/public/app/features/datasources/pages/DataSourcesListPage.test.tsx index 95eaaf78f39..ced9e960885 100644 --- a/public/app/features/datasources/pages/DataSourcesListPage.test.tsx +++ b/public/app/features/datasources/pages/DataSourcesListPage.test.tsx @@ -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) => { +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) => { }; 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'); + }); }); }); diff --git a/public/app/features/datasources/pages/DataSourcesListPage.tsx b/public/app/features/datasources/pages/DataSourcesListPage.tsx index 2315b036367..e8ddc5f4587 100644 --- a/public/app/features/datasources/pages/DataSourcesListPage.tsx +++ b/public/app/features/datasources/pages/DataSourcesListPage.tsx @@ -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 ( - + diff --git a/public/app/features/datasources/state/hooks.ts b/public/app/features/datasources/state/hooks.ts index cc6bc00c054..70bf7acfaa4 100644 --- a/public/app/features/datasources/state/hooks.ts +++ b/public/app/features/datasources/state/hooks.ts @@ -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 => { diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 1153c40962a..c0ee343d378 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -19,6 +19,7 @@ export const initialState: DataSourcesState = { hasFetched: false, isLoadingDataSources: false, dataSourceMeta: {} as DataSourcePluginMeta, + isSortAscending: true, }; export const dataSourceLoaded = createAction('dataSources/dataSourceLoaded'); @@ -33,6 +34,7 @@ export const setDataSourcesLayoutMode = createAction('dataSources/se export const setDataSourceTypeSearchQuery = createAction('dataSources/setDataSourceTypeSearchQuery'); export const setDataSourceName = createAction('dataSources/setDataSourceName'); export const setIsDefault = createAction('dataSources/setIsDefault'); +export const setIsSortAscending = createAction('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; }; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts index 36181f4e7c8..3585de24db0 100644 --- a/public/app/features/datasources/state/selectors.ts +++ b/public/app/features/datasources/state/selectors.ts @@ -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; diff --git a/public/app/features/datasources/utils.ts b/public/app/features/datasources/utils.ts index 48dcb500fe2..7da4a2d41fc 100644 --- a/public/app/features/datasources/utils.ts +++ b/public/app/features/datasources/utils.ts @@ -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) => { + const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' }); + const exploreUrl = urlUtil.renderUrl(locationUtil.assureBaseUrl('/explore'), { left: exploreState }); + + return exploreUrl; +}; diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index 3b05d3a07cb..207bd18995c 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -14,6 +14,7 @@ export interface DataSourcesState { isLoadingDataSources: boolean; plugins: DataSourcePluginMeta[]; categories: DataSourcePluginCategory[]; + isSortAscending: boolean; } export interface TestingStatus {