mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
SearchV2: support keyboard navigation (#49650)
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
parent
1ed7280363
commit
402b5ce4c6
@ -8,6 +8,7 @@ import { CustomScrollbar, IconButton, stylesFactory, useStyles2, useTheme2 } fro
|
|||||||
|
|
||||||
import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants';
|
import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants';
|
||||||
import { useDashboardSearch } from '../hooks/useDashboardSearch';
|
import { useDashboardSearch } from '../hooks/useDashboardSearch';
|
||||||
|
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
|
||||||
import { useSearchQuery } from '../hooks/useSearchQuery';
|
import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||||
import { SearchView } from '../page/components/SearchView';
|
import { SearchView } from '../page/components/SearchView';
|
||||||
|
|
||||||
@ -42,8 +43,11 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setInputValue(e.currentTarget.value);
|
setInputValue(e.currentTarget.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
|
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
|
||||||
|
|
||||||
|
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div tabIndex={0} className={styles.overlay}>
|
<div tabIndex={0} className={styles.overlay}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@ -54,6 +58,7 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
|
|||||||
placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={onSearchQueryChange}
|
onChange={onSearchQueryChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
@ -74,6 +79,7 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
|
|||||||
queryText={query.query}
|
queryText={query.query}
|
||||||
includePanels={includePanels!}
|
includePanels={includePanels!}
|
||||||
setIncludePanels={setIncludePanels}
|
setIncludePanels={setIncludePanels}
|
||||||
|
keyboardEvents={keyboardEvents}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,6 +9,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
|||||||
import { FolderDTO, AccessControlAction } from 'app/types';
|
import { FolderDTO, AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants';
|
import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants';
|
||||||
|
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
|
||||||
import { useSearchQuery } from '../hooks/useSearchQuery';
|
import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||||
import { SearchView } from '../page/components/SearchView';
|
import { SearchView } from '../page/components/SearchView';
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
// since we don't use "query" from use search... it is not actually loaded from the URL!
|
// since we don't use "query" from use search... it is not actually loaded from the URL!
|
||||||
const { query, onQueryChange } = useSearchQuery({});
|
const { query, onQueryChange } = useSearchQuery({});
|
||||||
|
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||||
|
|
||||||
// TODO: we need to refactor DashboardActions to use folder.uid instead
|
// TODO: we need to refactor DashboardActions to use folder.uid instead
|
||||||
const folderId = folder?.id;
|
const folderId = folder?.id;
|
||||||
@ -50,6 +52,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={onSearchQueryChange}
|
onChange={onSearchQueryChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
autoFocus
|
autoFocus
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder={includePanels ? 'Search for dashboards and panels' : 'Search for dashboards'}
|
placeholder={includePanels ? 'Search for dashboards and panels' : 'Search for dashboards'}
|
||||||
@ -77,6 +80,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
|||||||
hidePseudoFolders={true}
|
hidePseudoFolders={true}
|
||||||
includePanels={includePanels!}
|
includePanels={includePanels!}
|
||||||
setIncludePanels={setIncludePanels}
|
setIncludePanels={setIncludePanels}
|
||||||
|
keyboardEvents={keyboardEvents}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
100
public/app/features/search/hooks/useSearchKeyboardSelection.ts
Normal file
100
public/app/features/search/hooks/useSearchKeyboardSelection.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Observable, Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { Field, locationUtil } from '@grafana/data';
|
||||||
|
import { locationService } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { QueryResponse } from '../service';
|
||||||
|
|
||||||
|
export function useKeyNavigationListener() {
|
||||||
|
const eventsRef = useRef(new Subject<React.KeyboardEvent>());
|
||||||
|
return {
|
||||||
|
keyboardEvents: eventsRef.current,
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
switch (e.code) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'Enter':
|
||||||
|
eventsRef.current.next(e);
|
||||||
|
default:
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemSelection {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchKeyboardNavigation(
|
||||||
|
keyboardEvents: Observable<React.KeyboardEvent>,
|
||||||
|
numColumns: number,
|
||||||
|
response: QueryResponse
|
||||||
|
): ItemSelection {
|
||||||
|
const highlightIndexRef = useRef<ItemSelection>({ x: 0, y: -1 });
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState<ItemSelection>({ x: 0, y: -1 });
|
||||||
|
const urlsRef = useRef<Field>();
|
||||||
|
|
||||||
|
// Clear selection when the search results change
|
||||||
|
useEffect(() => {
|
||||||
|
urlsRef.current = response.view.fields.url;
|
||||||
|
highlightIndexRef.current.x = 0;
|
||||||
|
highlightIndexRef.current.y = -1;
|
||||||
|
setHighlightIndex({ ...highlightIndexRef.current });
|
||||||
|
}, [response]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = keyboardEvents.subscribe({
|
||||||
|
next: (keyEvent) => {
|
||||||
|
switch (keyEvent?.code) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
highlightIndexRef.current.y++;
|
||||||
|
setHighlightIndex({ ...highlightIndexRef.current });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp':
|
||||||
|
highlightIndexRef.current.y = Math.max(0, highlightIndexRef.current.y - 1);
|
||||||
|
setHighlightIndex({ ...highlightIndexRef.current });
|
||||||
|
break;
|
||||||
|
case 'ArrowRight': {
|
||||||
|
if (numColumns > 0) {
|
||||||
|
highlightIndexRef.current.x = Math.min(numColumns, highlightIndexRef.current.x + 1);
|
||||||
|
setHighlightIndex({ ...highlightIndexRef.current });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
if (numColumns > 0) {
|
||||||
|
highlightIndexRef.current.x = Math.max(0, highlightIndexRef.current.x - 1);
|
||||||
|
setHighlightIndex({ ...highlightIndexRef.current });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Enter':
|
||||||
|
if (!urlsRef.current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const idx = highlightIndexRef.current.x * numColumns + highlightIndexRef.current.y;
|
||||||
|
if (idx < 0) {
|
||||||
|
highlightIndexRef.current.x = 0;
|
||||||
|
highlightIndexRef.current.y = 0;
|
||||||
|
setHighlightIndex({ ...highlightIndexRef.current });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const url = urlsRef.current.values?.get(idx) as string;
|
||||||
|
if (url) {
|
||||||
|
locationService.push(locationUtil.stripBaseFromUrl(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [keyboardEvents, numColumns]);
|
||||||
|
|
||||||
|
return highlightIndex;
|
||||||
|
}
|
@ -8,6 +8,7 @@ import { config } from '@grafana/runtime';
|
|||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { SearchCard } from '../../components/SearchCard';
|
import { SearchCard } from '../../components/SearchCard';
|
||||||
|
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
|
||||||
import { DashboardSearchItemType, DashboardSectionItem } from '../../types';
|
import { DashboardSearchItemType, DashboardSectionItem } from '../../types';
|
||||||
|
|
||||||
import { SearchResultsProps } from './SearchResultsTable';
|
import { SearchResultsProps } from './SearchResultsTable';
|
||||||
@ -19,7 +20,7 @@ export const SearchResultsGrid = ({
|
|||||||
selection,
|
selection,
|
||||||
selectionToggle,
|
selectionToggle,
|
||||||
onTagSelected,
|
onTagSelected,
|
||||||
onDatasourceChange,
|
keyboardEvents,
|
||||||
}: SearchResultsProps) => {
|
}: SearchResultsProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
@ -37,12 +38,12 @@ export const SearchResultsGrid = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const itemCount = response.totalRows ?? response.view.length;
|
const itemCount = response.totalRows ?? response.view.length;
|
||||||
|
|
||||||
const view = response.view;
|
const view = response.view;
|
||||||
const numColumns = Math.ceil(width / 320);
|
const numColumns = Math.ceil(width / 320);
|
||||||
const cellWidth = width / numColumns;
|
const cellWidth = width / numColumns;
|
||||||
const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8;
|
const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8;
|
||||||
const numRows = Math.ceil(itemCount / numColumns);
|
const numRows = Math.ceil(itemCount / numColumns);
|
||||||
|
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, numColumns, response);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteLoader isItemLoaded={response.isItemLoaded} itemCount={itemCount} loadMoreItems={response.loadMoreItems}>
|
<InfiniteLoader isItemLoaded={response.isItemLoaded} itemCount={itemCount} loadMoreItems={response.loadMoreItems}>
|
||||||
@ -100,10 +101,15 @@ export const SearchResultsGrid = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let className = styles.virtualizedGridItemWrapper;
|
||||||
|
if (rowIndex === highlightIndex.y && columnIndex === highlightIndex.x) {
|
||||||
|
className += ' ' + styles.selectedItem;
|
||||||
|
}
|
||||||
|
|
||||||
// The wrapper div is needed as the inner SearchItem has margin-bottom spacing
|
// The wrapper div is needed as the inner SearchItem has margin-bottom spacing
|
||||||
// And without this wrapper there is no room for that margin
|
// And without this wrapper there is no room for that margin
|
||||||
return item ? (
|
return item ? (
|
||||||
<li style={style} className={styles.virtualizedGridItemWrapper}>
|
<li style={style} className={className}>
|
||||||
<SearchCard key={item.uid} {...itemProps} item={facade} />
|
<SearchCard key={item.uid} {...itemProps} item={facade} />
|
||||||
</li>
|
</li>
|
||||||
) : null;
|
) : null;
|
||||||
@ -126,4 +132,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
selectedItem: css`
|
||||||
|
box-shadow: inset 1px 1px 3px 3px ${theme.colors.primary.border};
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
/* eslint-disable react/jsx-no-undef */
|
/* eslint-disable react/jsx-no-undef */
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
import React, { useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table';
|
import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table';
|
||||||
import { FixedSizeList } from 'react-window';
|
import { FixedSizeList } from 'react-window';
|
||||||
import InfiniteLoader from 'react-window-infinite-loader';
|
import InfiniteLoader from 'react-window-infinite-loader';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { Field, GrafanaTheme2 } from '@grafana/data';
|
import { Field, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
|
import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
|
||||||
import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
|
import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
|
||||||
|
|
||||||
|
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
|
||||||
import { QueryResponse } from '../../service';
|
import { QueryResponse } from '../../service';
|
||||||
import { SelectionChecker, SelectionToggle } from '../selection';
|
import { SelectionChecker, SelectionToggle } from '../selection';
|
||||||
|
|
||||||
@ -24,6 +26,7 @@ export type SearchResultsProps = {
|
|||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
onTagSelected: (tag: string) => void;
|
onTagSelected: (tag: string) => void;
|
||||||
onDatasourceChange?: (datasource?: string) => void;
|
onDatasourceChange?: (datasource?: string) => void;
|
||||||
|
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TableColumn = Column & {
|
export type TableColumn = Column & {
|
||||||
@ -42,17 +45,19 @@ export const SearchResultsTable = React.memo(
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
onTagSelected,
|
onTagSelected,
|
||||||
onDatasourceChange,
|
onDatasourceChange,
|
||||||
|
keyboardEvents,
|
||||||
}: SearchResultsProps) => {
|
}: SearchResultsProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const tableStyles = useStyles2(getTableStyles);
|
const tableStyles = useStyles2(getTableStyles);
|
||||||
|
|
||||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||||
const listRef = useRef<FixedSizeList>(null);
|
const listRef = useRef<FixedSizeList>(null);
|
||||||
|
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response);
|
||||||
|
|
||||||
const memoizedData = useMemo(() => {
|
const memoizedData = useMemo(() => {
|
||||||
if (!response?.view?.dataFrame.fields.length) {
|
if (!response?.view?.dataFrame.fields.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// as we only use this to fake the length of our data set for react-table we need to make sure we always return an array
|
// as we only use this to fake the length of our data set for react-table we need to make sure we always return an array
|
||||||
// filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in
|
// filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in
|
||||||
// https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585
|
// https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585
|
||||||
@ -93,14 +98,19 @@ export const SearchResultsTable = React.memo(
|
|||||||
|
|
||||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout);
|
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout);
|
||||||
|
|
||||||
const RenderRow = React.useCallback(
|
const RenderRow = useCallback(
|
||||||
({ index: rowIndex, style }) => {
|
({ index: rowIndex, style }) => {
|
||||||
const row = rows[rowIndex];
|
const row = rows[rowIndex];
|
||||||
prepareRow(row);
|
prepareRow(row);
|
||||||
|
|
||||||
const url = response.view.fields.url?.values.get(rowIndex);
|
const url = response.view.fields.url?.values.get(rowIndex);
|
||||||
|
let className = styles.rowContainer;
|
||||||
|
if (rowIndex === highlightIndex.y) {
|
||||||
|
className += ' ' + styles.selectedRow;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...row.getRowProps({ style })} className={styles.rowContainer}>
|
<div {...row.getRowProps({ style })} className={className}>
|
||||||
{row.cells.map((cell: Cell, index: number) => {
|
{row.cells.map((cell: Cell, index: number) => {
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
@ -116,7 +126,7 @@ export const SearchResultsTable = React.memo(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[rows, prepareRow, response.view.fields.url?.values, styles.rowContainer, tableStyles]
|
[rows, prepareRow, response.view.fields.url?.values, highlightIndex, styles, tableStyles]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
@ -212,6 +222,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
height: ${HEADER_HEIGHT}px;
|
height: ${HEADER_HEIGHT}px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
`,
|
`,
|
||||||
|
selectedRow: css`
|
||||||
|
background-color: ${rowHoverBg};
|
||||||
|
box-shadow: inset 3px 0px ${theme.colors.primary.border};
|
||||||
|
`,
|
||||||
rowContainer: css`
|
rowContainer: css`
|
||||||
label: row;
|
label: row;
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useAsync, useDebounce } from 'react-use';
|
import { useAsync, useDebounce } from 'react-use';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2, Spinner, Button } from '@grafana/ui';
|
import { useStyles2, Spinner, Button } from '@grafana/ui';
|
||||||
@ -31,6 +32,7 @@ type SearchViewProps = {
|
|||||||
onQueryTextChange: (newQueryText: string) => void;
|
onQueryTextChange: (newQueryText: string) => void;
|
||||||
includePanels: boolean;
|
includePanels: boolean;
|
||||||
setIncludePanels: (v: boolean) => void;
|
setIncludePanels: (v: boolean) => void;
|
||||||
|
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SearchView = ({
|
export const SearchView = ({
|
||||||
@ -41,6 +43,7 @@ export const SearchView = ({
|
|||||||
hidePseudoFolders,
|
hidePseudoFolders,
|
||||||
includePanels,
|
includePanels,
|
||||||
setIncludePanels,
|
setIncludePanels,
|
||||||
|
keyboardEvents,
|
||||||
}: SearchViewProps) => {
|
}: SearchViewProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
@ -201,6 +204,7 @@ export const SearchView = ({
|
|||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
onTagSelected: onTagAdd,
|
onTagSelected: onTagAdd,
|
||||||
|
keyboardEvents,
|
||||||
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
|
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user