From eceb21e72d0e8294fc2ff40229b270522e8111fe Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 23 Jun 2022 07:30:47 -0700 Subject: [PATCH] Search: switch to a card view when the display is narrow (#51208) --- .../features/search/components/SearchItem.tsx | 1 + .../components/SearchResultsCards.test.tsx | 131 +++++++++++++++ .../page/components/SearchResultsCards.tsx | 156 ++++++++++++++++++ .../search/page/components/SearchView.tsx | 5 + .../search/page/components/columns.tsx | 2 +- public/app/features/search/types.ts | 6 + 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 public/app/features/search/page/components/SearchResultsCards.test.tsx create mode 100644 public/app/features/search/page/components/SearchResultsCards.tsx diff --git a/public/app/features/search/components/SearchItem.tsx b/public/app/features/search/components/SearchItem.tsx index d2eefa9654d..33a9086b24e 100644 --- a/public/app/features/search/components/SearchItem.tsx +++ b/public/app/features/search/components/SearchItem.tsx @@ -28,6 +28,7 @@ const getIconFromMeta = (meta = ''): IconName => { return metaIconMap.has(meta) ? metaIconMap.get(meta)! : 'sort-amount-down'; }; +/** @deprecated */ export const SearchItem: FC = ({ item, editable, onToggleChecked, onTagSelected }) => { const styles = useStyles2(getStyles); const tagSelected = useCallback( diff --git a/public/app/features/search/page/components/SearchResultsCards.test.tsx b/public/app/features/search/page/components/SearchResultsCards.test.tsx new file mode 100644 index 00000000000..d7bae65ae0d --- /dev/null +++ b/public/app/features/search/page/components/SearchResultsCards.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Subject } from 'rxjs'; + +import { ArrayVector, DataFrame, DataFrameView, FieldType } from '@grafana/data'; + +import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service'; +import { DashboardSearchItemType } from '../../types'; + +import { SearchResultsCards } from './SearchResultsCards'; + +describe('SearchResultsCards', () => { + const mockOnTagSelected = jest.fn(); + const mockClearSelection = jest.fn(); + const mockSelectionToggle = jest.fn(); + const mockSelection = jest.fn(); + const mockKeyboardEvents = new Subject(); + + describe('when there is data', () => { + const searchData: DataFrame = { + fields: [ + { name: 'kind', type: FieldType.string, config: {}, values: new ArrayVector([DashboardSearchItemType.DashDB]) }, + { name: 'uid', type: FieldType.string, config: {}, values: new ArrayVector(['my-dashboard-1']) }, + { name: 'name', type: FieldType.string, config: {}, values: new ArrayVector(['My dashboard 1']) }, + { name: 'panel_type', type: FieldType.string, config: {}, values: new ArrayVector(['']) }, + { name: 'url', type: FieldType.string, config: {}, values: new ArrayVector(['/my-dashboard-1']) }, + { name: 'tags', type: FieldType.other, config: {}, values: new ArrayVector([['foo', 'bar']]) }, + { name: 'ds_uid', type: FieldType.other, config: {}, values: new ArrayVector(['']) }, + { name: 'location', type: FieldType.string, config: {}, values: new ArrayVector(['folder0/my-dashboard-1']) }, + ], + meta: { + custom: { + locationInfo: { + folder0: { name: 'Folder 0', uid: 'f0' }, + }, + }, + }, + length: 1, + }; + + const mockSearchResult: QueryResponse = { + isItemLoaded: () => true, + loadMoreItems: () => Promise.resolve(), + totalRows: searchData.length, + view: new DataFrameView(searchData), + }; + + beforeAll(() => { + jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); + }); + + it('shows the list with the correct accessible label', () => { + render( + + ); + expect(screen.getByRole('list', { name: 'Search results list' })).toBeInTheDocument(); + }); + + it('displays the data correctly in the table', () => { + render( + + ); + + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(searchData.length); + expect(screen.getByText('My dashboard 1')).toBeInTheDocument(); + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.getByText('bar')).toBeInTheDocument(); + }); + }); + + describe('when there is no data', () => { + const emptySearchData: DataFrame = { + fields: [ + { name: 'kind', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'name', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'uid', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'url', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'tags', type: FieldType.other, config: {}, values: new ArrayVector([]) }, + { name: 'location', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + ], + length: 0, + }; + + const mockEmptySearchResult: QueryResponse = { + isItemLoaded: jest.fn(), + loadMoreItems: jest.fn(), + totalRows: emptySearchData.length, + view: new DataFrameView(emptySearchData), + }; + + beforeAll(() => { + jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockEmptySearchResult); + }); + + it('shows a "No data" message', () => { + render( + + ); + expect(screen.queryByRole('list', { name: 'Search results list' })).not.toBeInTheDocument(); + expect(screen.getByText('No data')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/search/page/components/SearchResultsCards.tsx b/public/app/features/search/page/components/SearchResultsCards.tsx new file mode 100644 index 00000000000..506f3c0d939 --- /dev/null +++ b/public/app/features/search/page/components/SearchResultsCards.tsx @@ -0,0 +1,156 @@ +/* eslint-disable react/jsx-no-undef */ +import { css } from '@emotion/css'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { FixedSizeList } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { SearchItem } from '../../components/SearchItem'; +import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection'; +import { SearchResultMeta } from '../../service'; +import { DashboardSearchItemType, DashboardSectionItem } from '../../types'; + +import { SearchResultsProps } from './SearchResultsTable'; + +export const SearchResultsCards = React.memo( + ({ + response, + width, + height, + selection, + selectionToggle, + clearSelection, + onTagSelected, + onDatasourceChange, + keyboardEvents, + }: SearchResultsProps) => { + const styles = useStyles2(getStyles); + const infiniteLoaderRef = useRef(null); + const [listEl, setListEl] = useState(null); + const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response); + + // Scroll to the top and clear loader cache when the query results change + useEffect(() => { + if (infiniteLoaderRef.current) { + infiniteLoaderRef.current.resetloadMoreItemsCache(); + } + if (listEl) { + listEl.scrollTo(0); + } + }, [response, listEl]); + + const onToggleChecked = useCallback( + (item: DashboardSectionItem) => { + if (selectionToggle) { + selectionToggle('dashboard', item.uid!); + } + }, + [selectionToggle] + ); + + const RenderRow = useCallback( + ({ index: rowIndex, style }) => { + const meta = response.view.dataFrame.meta?.custom as SearchResultMeta; + + let className = ''; + if (rowIndex === highlightIndex.y) { + className += ' ' + styles.selectedRow; + } + + const item = response.view.get(rowIndex); + let v: DashboardSectionItem = { + uid: item.uid, + title: item.name, + url: item.url, + uri: item.url, + type: item.kind === 'folder' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB, + id: 666, // do not use me! + isStarred: false, + tags: item.tags ?? [], + }; + + if (item.location) { + const first = item.location.split('/')[0]; + const finfo = meta.locationInfo[first]; + if (finfo) { + v.folderUid = item.location; + v.folderTitle = finfo.name; + } + } + + if (selection && selectionToggle) { + const type = v.type === DashboardSearchItemType.DashFolder ? 'folder' : 'dashboard'; + v = { + ...v, + checked: selection(type, v.uid!), + }; + } + return ( +
+ +
+ ); + }, + [response.view, highlightIndex, styles, onTagSelected, selection, selectionToggle, onToggleChecked] + ); + + if (!response.totalRows) { + return ( +
+ No data +
+ ); + } + + return ( +
+ + {({ onItemsRendered, ref }) => ( + { + ref(innerRef); + setListEl(innerRef); + }} + onItemsRendered={onItemsRendered} + height={height} + itemCount={response.totalRows} + itemSize={72} + width="100%" + style={{ overflow: 'hidden auto' }} + > + {RenderRow} + + )} + +
+ ); + } +); +SearchResultsCards.displayName = 'SearchResultsCards'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + noData: css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + `, + selectedRow: css` + border-left: 3px solid ${theme.colors.primary.border}; + `, + }; +}; diff --git a/public/app/features/search/page/components/SearchView.tsx b/public/app/features/search/page/components/SearchView.tsx index cb9553b9b10..ce7377a15b2 100644 --- a/public/app/features/search/page/components/SearchView.tsx +++ b/public/app/features/search/page/components/SearchView.tsx @@ -21,6 +21,7 @@ import { ActionRow, getValidQueryLayout } from './ActionRow'; import { FolderSection } from './FolderSection'; import { FolderView } from './FolderView'; import { ManageActions } from './ManageActions'; +import { SearchResultsCards } from './SearchResultsCards'; import { SearchResultsGrid } from './SearchResultsGrid'; import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable'; @@ -212,6 +213,10 @@ export const SearchView = ({ return ; } + if (width < 800) { + return ; + } + return ; }} diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 2e80a72d4ac..a1abbeb2e55 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -13,7 +13,7 @@ import { SelectionChecker, SelectionToggle } from '../selection'; import { TableColumn } from './SearchResultsTable'; -const TYPE_COLUMN_WIDTH = 250; +const TYPE_COLUMN_WIDTH = 175; const DATASOURCE_COLUMN_WIDTH = 200; const SORT_FIELD_WIDTH = 175; diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index 4c90b5e9c81..9f44a6c2cc1 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -11,6 +11,9 @@ export enum DashboardSearchItemType { DashFolder = 'dash-folder', } +/** + * @deprecated + */ export interface DashboardSection { id: number; uid?: string; @@ -28,6 +31,9 @@ export interface DashboardSection { itemsFetching?: boolean; } +/** + * @deprecated + */ export interface DashboardSectionItem { checked?: boolean; folderId?: number;