Skip to content

Commit

Permalink
SearchV2: support keyboard navigation (grafana#49650)
Browse files Browse the repository at this point in the history
Co-authored-by: Torkel Ödegaard <[email protected]>
  • Loading branch information
ryantxu and torkelo authored Jun 13, 2022
1 parent 1ed7280 commit 402b5ce
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 8 deletions.
6 changes: 6 additions & 0 deletions public/app/features/search/components/DashboardSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}>
Expand All @@ -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}
Expand All @@ -74,6 +79,7 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
queryText={query.query}
includePanels={includePanels!}
setIncludePanels={setIncludePanels}
keyboardEvents={keyboardEvents}
/>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions public/app/features/search/components/ManageDashboardsNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -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'}
Expand Down Expand Up @@ -77,6 +80,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
hidePseudoFolders={true}
includePanels={includePanels!}
setIncludePanels={setIncludePanels}
keyboardEvents={keyboardEvents}
/>
</>
);
Expand Down
100 changes: 100 additions & 0 deletions public/app/features/search/hooks/useSearchKeyboardSelection.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 12 additions & 3 deletions public/app/features/search/page/components/SearchResultsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,7 +20,7 @@ export const SearchResultsGrid = ({
selection,
selectionToggle,
onTagSelected,
onDatasourceChange,
keyboardEvents,
}: SearchResultsProps) => {
const styles = useStyles2(getStyles);

Expand All @@ -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}>
Expand Down Expand Up @@ -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;
Expand All @@ -126,4 +132,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
list-style: none;
}
`,
selectedItem: css`
box-shadow: inset 1px 1px 3px 3px ${theme.colors.primary.border};
`,
});
24 changes: 19 additions & 5 deletions public/app/features/search/page/components/SearchResultsTable.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 & {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions public/app/features/search/page/components/SearchView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -31,6 +32,7 @@ type SearchViewProps = {
onQueryTextChange: (newQueryText: string) => void;
includePanels: boolean;
setIncludePanels: (v: boolean) => void;
keyboardEvents: Observable<React.KeyboardEvent>;
};

export const SearchView = ({
Expand All @@ -41,6 +43,7 @@ export const SearchView = ({
hidePseudoFolders,
includePanels,
setIncludePanels,
keyboardEvents,
}: SearchViewProps) => {
const styles = useStyles2(getStyles);

Expand Down Expand Up @@ -201,6 +204,7 @@ export const SearchView = ({
width: width,
height: height,
onTagSelected: onTagAdd,
keyboardEvents,
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
};

Expand Down

0 comments on commit 402b5ce

Please sign in to comment.