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 { useDashboardSearch } from '../hooks/useDashboardSearch';
|
||||
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
|
||||
import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||
import { SearchView } from '../page/components/SearchView';
|
||||
|
||||
@ -42,8 +43,11 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
|
||||
e.preventDefault();
|
||||
setInputValue(e.currentTarget.value);
|
||||
};
|
||||
|
||||
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
|
||||
|
||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||
|
||||
return (
|
||||
<div tabIndex={0} className={styles.overlay}>
|
||||
<div className={styles.container}>
|
||||
@ -54,6 +58,7 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
|
||||
placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
||||
value={inputValue}
|
||||
onChange={onSearchQueryChange}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
spellCheck={false}
|
||||
className={styles.input}
|
||||
@ -74,6 +79,7 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
|
||||
queryText={query.query}
|
||||
includePanels={includePanels!}
|
||||
setIncludePanels={setIncludePanels}
|
||||
keyboardEvents={keyboardEvents}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,6 +9,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { FolderDTO, AccessControlAction } from 'app/types';
|
||||
|
||||
import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants';
|
||||
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
|
||||
import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||
import { SearchView } from '../page/components/SearchView';
|
||||
|
||||
@ -22,6 +23,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
// since we don't use "query" from use search... it is not actually loaded from the URL!
|
||||
const { query, onQueryChange } = useSearchQuery({});
|
||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||
|
||||
// TODO: we need to refactor DashboardActions to use folder.uid instead
|
||||
const folderId = folder?.id;
|
||||
@ -50,6 +52,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={onSearchQueryChange}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={includePanels ? 'Search for dashboards and panels' : 'Search for dashboards'}
|
||||
@ -77,6 +80,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
||||
hidePseudoFolders={true}
|
||||
includePanels={includePanels!}
|
||||
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 { SearchCard } from '../../components/SearchCard';
|
||||
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
|
||||
import { DashboardSearchItemType, DashboardSectionItem } from '../../types';
|
||||
|
||||
import { SearchResultsProps } from './SearchResultsTable';
|
||||
@ -19,7 +20,7 @@ export const SearchResultsGrid = ({
|
||||
selection,
|
||||
selectionToggle,
|
||||
onTagSelected,
|
||||
onDatasourceChange,
|
||||
keyboardEvents,
|
||||
}: SearchResultsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@ -37,12 +38,12 @@ export const SearchResultsGrid = ({
|
||||
};
|
||||
|
||||
const itemCount = response.totalRows ?? response.view.length;
|
||||
|
||||
const view = response.view;
|
||||
const numColumns = Math.ceil(width / 320);
|
||||
const cellWidth = width / numColumns;
|
||||
const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8;
|
||||
const numRows = Math.ceil(itemCount / numColumns);
|
||||
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, numColumns, response);
|
||||
|
||||
return (
|
||||
<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
|
||||
// And without this wrapper there is no room for that margin
|
||||
return item ? (
|
||||
<li style={style} className={styles.virtualizedGridItemWrapper}>
|
||||
<li style={style} className={className}>
|
||||
<SearchCard key={item.uid} {...itemProps} item={facade} />
|
||||
</li>
|
||||
) : null;
|
||||
@ -126,4 +132,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
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 */
|
||||
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 { FixedSizeList } from 'react-window';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { Field, GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
|
||||
import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
|
||||
|
||||
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
|
||||
import { QueryResponse } from '../../service';
|
||||
import { SelectionChecker, SelectionToggle } from '../selection';
|
||||
|
||||
@ -24,6 +26,7 @@ export type SearchResultsProps = {
|
||||
clearSelection: () => void;
|
||||
onTagSelected: (tag: string) => void;
|
||||
onDatasourceChange?: (datasource?: string) => void;
|
||||
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||
};
|
||||
|
||||
export type TableColumn = Column & {
|
||||
@ -42,17 +45,19 @@ export const SearchResultsTable = React.memo(
|
||||
clearSelection,
|
||||
onTagSelected,
|
||||
onDatasourceChange,
|
||||
keyboardEvents,
|
||||
}: SearchResultsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const tableStyles = useStyles2(getTableStyles);
|
||||
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
const listRef = useRef<FixedSizeList>(null);
|
||||
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response);
|
||||
|
||||
const memoizedData = useMemo(() => {
|
||||
if (!response?.view?.dataFrame.fields.length) {
|
||||
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
|
||||
// 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
|
||||
@ -93,14 +98,19 @@ export const SearchResultsTable = React.memo(
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout);
|
||||
|
||||
const RenderRow = React.useCallback(
|
||||
const RenderRow = useCallback(
|
||||
({ index: rowIndex, style }) => {
|
||||
const row = rows[rowIndex];
|
||||
prepareRow(row);
|
||||
|
||||
const url = response.view.fields.url?.values.get(rowIndex);
|
||||
let className = styles.rowContainer;
|
||||
if (rowIndex === highlightIndex.y) {
|
||||
className += ' ' + styles.selectedRow;
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...row.getRowProps({ style })} className={styles.rowContainer}>
|
||||
<div {...row.getRowProps({ style })} className={className}>
|
||||
{row.cells.map((cell: Cell, index: number) => {
|
||||
return (
|
||||
<TableCell
|
||||
@ -116,7 +126,7 @@ export const SearchResultsTable = React.memo(
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[rows, prepareRow, response.view.fields.url?.values, styles.rowContainer, tableStyles]
|
||||
[rows, prepareRow, response.view.fields.url?.values, highlightIndex, styles, tableStyles]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
@ -212,6 +222,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
height: ${HEADER_HEIGHT}px;
|
||||
align-items: center;
|
||||
`,
|
||||
selectedRow: css`
|
||||
background-color: ${rowHoverBg};
|
||||
box-shadow: inset 3px 0px ${theme.colors.primary.border};
|
||||
`,
|
||||
rowContainer: css`
|
||||
label: row;
|
||||
&:hover {
|
||||
|
@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useAsync, useDebounce } from 'react-use';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Spinner, Button } from '@grafana/ui';
|
||||
@ -31,6 +32,7 @@ type SearchViewProps = {
|
||||
onQueryTextChange: (newQueryText: string) => void;
|
||||
includePanels: boolean;
|
||||
setIncludePanels: (v: boolean) => void;
|
||||
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||
};
|
||||
|
||||
export const SearchView = ({
|
||||
@ -41,6 +43,7 @@ export const SearchView = ({
|
||||
hidePseudoFolders,
|
||||
includePanels,
|
||||
setIncludePanels,
|
||||
keyboardEvents,
|
||||
}: SearchViewProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
@ -201,6 +204,7 @@ export const SearchView = ({
|
||||
width: width,
|
||||
height: height,
|
||||
onTagSelected: onTagAdd,
|
||||
keyboardEvents,
|
||||
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user