From d419402a4344674a68c42205c41f010c5dd0240d Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Fri, 21 Apr 2023 17:07:11 +0200 Subject: [PATCH] DataSourcePicker: Add recently used from local storage to ds picker (#66985) * Add recently used from local storage to ds picker --- .../components/picker/DataSourceDropdown.tsx | 6 +- .../components/picker/DataSourceList.tsx | 24 ++++++- .../components/picker/utils.test.ts | 67 ++++++++++++++++++- .../datasources/components/picker/utils.ts | 57 ++++++++++++---- public/app/features/datasources/hooks.ts | 57 ++++++++++++++++ 5 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 public/app/features/datasources/hooks.ts diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx index 416951f0038..01436e506e8 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx @@ -10,11 +10,13 @@ import { DataSourceJsonData } from '@grafana/schema'; import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui'; import config from 'app/core/config'; +import { useDatasource } from '../../hooks'; + import { DataSourceList } from './DataSourceList'; import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo'; import { DataSourceModal } from './DataSourceModal'; import { PickerContentProps, DataSourceDropdownProps } from './types'; -import { dataSourceLabel, useGetDatasource } from './utils'; +import { dataSourceLabel } from './utils'; export function DataSourceDropdown(props: DataSourceDropdownProps) { const { current, onChange, ...restProps } = props; @@ -24,7 +26,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { const [selectorElement, setSelectorElement] = useState(); const [filterTerm, setFilterTerm] = useState(); - const currentDataSourceInstanceSettings = useGetDatasource(current); + const currentDataSourceInstanceSettings = useDatasource(current); const popper = usePopper(markerElement, selectorElement, { placement: 'bottom-start', diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx index a199a793c7b..afefb30f4b8 100644 --- a/public/app/features/datasources/components/picker/DataSourceList.tsx +++ b/public/app/features/datasources/components/picker/DataSourceList.tsx @@ -1,9 +1,12 @@ import React from 'react'; import { DataSourceInstanceSettings, DataSourceRef } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; + +import { useDatasources, useRecentlyUsedDataSources } from '../../hooks'; import { DataSourceCard } from './DataSourceCard'; -import { isDataSourceMatch, useGetDatasources } from './utils'; +import { getDataSourceCompareFn, isDataSourceMatch } from './utils'; /** * Component props description for the {@link DataSourceList} @@ -35,7 +38,7 @@ export interface DataSourceListProps { export function DataSourceList(props: DataSourceListProps) { const { className, current, onChange } = props; // QUESTION: Should we use data from the Redux store as admin DS view does? - const dataSources = useGetDatasources({ + const dataSources = useDatasources({ alerting: props.alerting, annotations: props.annotations, dashboard: props.dashboard, @@ -48,18 +51,33 @@ export function DataSourceList(props: DataSourceListProps) { variables: props.variables, }); + const [recentlyUsedDataSources, pushRecentlyUsedDataSource] = useRecentlyUsedDataSources(); + return (
{dataSources .filter((ds) => (props.filter ? props.filter(ds) : true)) + .sort(getDataSourceCompareFn(current, recentlyUsedDataSources, getDataSourceVariableIDs())) .map((ds) => ( onChange(ds)} + onClick={() => { + pushRecentlyUsedDataSource(ds); + onChange(ds); + }} selected={!!isDataSourceMatch(ds, current)} /> ))}
); } + +function getDataSourceVariableIDs() { + const templateSrv = getTemplateSrv(); + /** Unforunately there is no easy way to identify data sources that are variables. The uid of the data source will be the name of the variable in a templating syntax $([name]) **/ + return templateSrv + .getVariables() + .filter((v) => v.type === 'datasource') + .map((v) => `\${${v.id}}`); +} diff --git a/public/app/features/datasources/components/picker/utils.test.ts b/public/app/features/datasources/components/picker/utils.test.ts index 8462309c539..163ca0f371f 100644 --- a/public/app/features/datasources/components/picker/utils.test.ts +++ b/public/app/features/datasources/components/picker/utils.test.ts @@ -1,6 +1,6 @@ import { DataSourceInstanceSettings, DataSourceRef } from '@grafana/data'; -import { isDataSourceMatch } from './utils'; +import { isDataSourceMatch, getDataSourceCompareFn } from './utils'; describe('isDataSourceMatch', () => { const dataSourceInstanceSettings = { uid: 'a' } as DataSourceInstanceSettings; @@ -28,3 +28,68 @@ describe('isDataSourceMatch', () => { expect(isDataSourceMatch(dataSourceInstanceSettings, { uid: 'b' } as DataSourceRef)).toBeFalsy(); }); }); + +describe('getDataSouceCompareFn', () => { + const dataSources = [ + { uid: 'c', name: 'c', meta: { builtIn: false } }, + { uid: 'a', name: 'a', meta: { builtIn: true } }, + { uid: 'b', name: 'b', meta: { builtIn: false } }, + ] as DataSourceInstanceSettings[]; + + it('sorts built in datasources last and other data sources alphabetically', () => { + dataSources.sort(getDataSourceCompareFn(undefined, [], [])); + expect(dataSources).toEqual([ + { uid: 'b', name: 'b', meta: { builtIn: false } }, + { uid: 'c', name: 'c', meta: { builtIn: false } }, + { uid: 'a', name: 'a', meta: { builtIn: true } }, + ] as DataSourceInstanceSettings[]); + }); + + it('sorts the current datasource before others', () => { + dataSources.sort(getDataSourceCompareFn('c', [], [])); + expect(dataSources).toEqual([ + { uid: 'c', name: 'c', meta: { builtIn: false } }, + { uid: 'b', name: 'b', meta: { builtIn: false } }, + { uid: 'a', name: 'a', meta: { builtIn: true } }, + ] as DataSourceInstanceSettings[]); + }); + + it('sorts recently used datasources first', () => { + dataSources.sort(getDataSourceCompareFn(undefined, ['c', 'a'], [])); + expect(dataSources).toEqual([ + { uid: 'a', name: 'a', meta: { builtIn: true } }, + { uid: 'c', name: 'c', meta: { builtIn: false } }, + { uid: 'b', name: 'b', meta: { builtIn: false } }, + ] as DataSourceInstanceSettings[]); + }); + + it('sorts variables before other datasources', () => { + dataSources.sort(getDataSourceCompareFn(undefined, [], ['c', 'b'])); + expect(dataSources).toEqual([ + { uid: 'b', name: 'b', meta: { builtIn: false } }, + { uid: 'c', name: 'c', meta: { builtIn: false } }, + { uid: 'a', name: 'a', meta: { builtIn: true } }, + ] as DataSourceInstanceSettings[]); + }); + + it('sorts datasources current -> recently used -> variables -> others -> built in', () => { + const dataSources = [ + { uid: 'a', name: 'a', meta: { builtIn: true } }, + { uid: 'b', name: 'b', meta: { builtIn: false } }, + { uid: 'c', name: 'c', meta: { builtIn: false } }, + { uid: 'e', name: 'e', meta: { builtIn: false } }, + { uid: 'd', name: 'd', meta: { builtIn: false } }, + { uid: 'f', name: 'f', meta: { builtIn: false } }, + ] as DataSourceInstanceSettings[]; + + dataSources.sort(getDataSourceCompareFn('c', ['b', 'e'], ['d'])); + expect(dataSources).toEqual([ + { uid: 'c', name: 'c', meta: { builtIn: false } }, + { uid: 'e', name: 'e', meta: { builtIn: false } }, + { uid: 'b', name: 'b', meta: { builtIn: false } }, + { uid: 'd', name: 'd', meta: { builtIn: false } }, + { uid: 'f', name: 'f', meta: { builtIn: false } }, + { uid: 'a', name: 'a', meta: { builtIn: true } }, + ] as DataSourceInstanceSettings[]); + }); +}); diff --git a/public/app/features/datasources/components/picker/utils.ts b/public/app/features/datasources/components/picker/utils.ts index 61b7abeb5c7..fdca19ecf15 100644 --- a/public/app/features/datasources/components/picker/utils.ts +++ b/public/app/features/datasources/components/picker/utils.ts @@ -1,5 +1,4 @@ import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef } from '@grafana/data'; -import { GetDataSourceListFilters, getDataSourceSrv } from '@grafana/runtime'; export function isDataSourceMatch( ds: DataSourceInstanceSettings | undefined, @@ -39,22 +38,52 @@ export function dataSourceLabel( return 'Unknown'; } -export function useGetDatasources(filters: GetDataSourceListFilters) { - const dataSourceSrv = getDataSourceSrv(); +export function getDataSourceCompareFn( + current: DataSourceRef | DataSourceInstanceSettings | string | null | undefined, + recentlyUsedDataSources: string[], + dataSourceVariablesIDs: string[] +) { + const cmpDataSources = (a: DataSourceInstanceSettings, b: DataSourceInstanceSettings) => { + // Sort the current ds before everything else. + if (current && isDataSourceMatch(a, current)) { + return -1; + } else if (current && isDataSourceMatch(b, current)) { + return 1; + } - return dataSourceSrv.getList(filters); -} + // Sort recently used data sources by latest used, but after current. + const aIndex = recentlyUsedDataSources.indexOf(a.uid); + const bIndex = recentlyUsedDataSources.indexOf(b.uid); + if (aIndex > -1 && aIndex > bIndex) { + return -1; + } + if (bIndex > -1 && bIndex > aIndex) { + return 1; + } -export function useGetDatasource(dataSource: string | DataSourceRef | DataSourceInstanceSettings | null | undefined) { - const dataSourceSrv = getDataSourceSrv(); + // Sort variables before the rest. Variables sorted alphabetically by name. + const aIsVariable = dataSourceVariablesIDs.includes(a.uid); + const bIsVariable = dataSourceVariablesIDs.includes(b.uid); + if (aIsVariable && !bIsVariable) { + return -1; + } else if (bIsVariable && !aIsVariable) { + return 1; + } else if (bIsVariable && aIsVariable) { + return a.name < b.name ? -1 : 1; + } - if (!dataSource) { - return undefined; - } + // Sort built in DataSources to the bottom and alphabetically by name. + if (a.meta.builtIn && !b.meta.builtIn) { + return 1; + } else if (b.meta.builtIn && !a.meta.builtIn) { + return -1; + } else if (a.meta.builtIn && b.meta.builtIn) { + return a.name < b.name ? -1 : 1; + } - if (typeof dataSource === 'string') { - return dataSourceSrv.getInstanceSettings(dataSource); - } + // Sort the rest alphabetically by name. + return a.name < b.name ? -1 : 1; + }; - return dataSourceSrv.getInstanceSettings(dataSource); + return cmpDataSources; } diff --git a/public/app/features/datasources/hooks.ts b/public/app/features/datasources/hooks.ts new file mode 100644 index 00000000000..b0985ed5863 --- /dev/null +++ b/public/app/features/datasources/hooks.ts @@ -0,0 +1,57 @@ +import { useCallback } from 'react'; +import { useLocalStorage } from 'react-use'; + +import { DataSourceInstanceSettings, DataSourceRef } from '@grafana/data'; +import { GetDataSourceListFilters, getDataSourceSrv } from '@grafana/runtime'; + +export const LOCAL_STORAGE_KEY = 'grafana.features.datasources.components.picker.DataSourceDropDown.history'; + +/** + * Stores the uid of the last 5 data sources selected by the user. The last UID is the one most recently used. + */ +export function useRecentlyUsedDataSources(): [string[], (ds: DataSourceInstanceSettings) => void] { + const [value = [], setStorage] = useLocalStorage(LOCAL_STORAGE_KEY, []); + + const pushRecentlyUsedDataSource = useCallback( + (ds: DataSourceInstanceSettings) => { + if (ds.meta.builtIn) { + // Prevent storing the built in datasources (-- Grafana --, -- Mixed --, -- Dashboard --) + return; + } + if (value.includes(ds.uid)) { + // Prevent storing multiple copies of the same data source, put it at the front of the array instead. + value.splice( + value.findIndex((dsUid) => ds.uid === dsUid), + 1 + ); + setStorage([...value, ds.uid]); + } else { + setStorage([...value, ds.uid].slice(1, 6)); + } + }, + [value, setStorage] + ); + + return [value, pushRecentlyUsedDataSource]; +} + +export function useDatasources(filters: GetDataSourceListFilters) { + const dataSourceSrv = getDataSourceSrv(); + const dataSources = dataSourceSrv.getList(filters); + + return dataSources; +} + +export function useDatasource(dataSource: string | DataSourceRef | DataSourceInstanceSettings | null | undefined) { + const dataSourceSrv = getDataSourceSrv(); + + if (!dataSource) { + return undefined; + } + + if (typeof dataSource === 'string') { + return dataSourceSrv.getInstanceSettings(dataSource); + } + + return dataSourceSrv.getInstanceSettings(dataSource); +}