Search: switch to a card view when the display is narrow (#51208)

This commit is contained in:
Ryan McKinley 2022-06-23 07:30:47 -07:00 committed by GitHub
parent 1f5f8aa5ab
commit eceb21e72d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 300 additions and 1 deletions

View File

@ -28,6 +28,7 @@ const getIconFromMeta = (meta = ''): IconName => {
return metaIconMap.has(meta) ? metaIconMap.get(meta)! : 'sort-amount-down';
};
/** @deprecated */
export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSelected }) => {
const styles = useStyles2(getStyles);
const tagSelected = useCallback(

View File

@ -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<React.KeyboardEvent>();
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<DashboardQueryResult>(searchData),
};
beforeAll(() => {
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult);
});
it('shows the list with the correct accessible label', () => {
render(
<SearchResultsCards
keyboardEvents={mockKeyboardEvents}
response={mockSearchResult}
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
clearSelection={mockClearSelection}
height={1000}
width={1000}
/>
);
expect(screen.getByRole('list', { name: 'Search results list' })).toBeInTheDocument();
});
it('displays the data correctly in the table', () => {
render(
<SearchResultsCards
keyboardEvents={mockKeyboardEvents}
response={mockSearchResult}
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
clearSelection={mockClearSelection}
height={1000}
width={1000}
/>
);
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<DashboardQueryResult>(emptySearchData),
};
beforeAll(() => {
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockEmptySearchResult);
});
it('shows a "No data" message', () => {
render(
<SearchResultsCards
keyboardEvents={mockKeyboardEvents}
response={mockEmptySearchResult}
onTagSelected={mockOnTagSelected}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
clearSelection={mockClearSelection}
height={1000}
width={1000}
/>
);
expect(screen.queryByRole('list', { name: 'Search results list' })).not.toBeInTheDocument();
expect(screen.getByText('No data')).toBeInTheDocument();
});
});
});

View File

@ -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<InfiniteLoader>(null);
const [listEl, setListEl] = useState<FixedSizeList | null>(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 (
<div style={style} key={item.uid} className={className} role="row">
<SearchItem
item={v}
onTagSelected={onTagSelected}
onToggleChecked={onToggleChecked as any}
editable={Boolean(selection != null)}
/>
</div>
);
},
[response.view, highlightIndex, styles, onTagSelected, selection, selectionToggle, onToggleChecked]
);
if (!response.totalRows) {
return (
<div className={styles.noData} style={{ width }}>
No data
</div>
);
}
return (
<div aria-label="Search results list" style={{ width }} role="list">
<InfiniteLoader
ref={infiniteLoaderRef}
isItemLoaded={response.isItemLoaded}
itemCount={response.totalRows}
loadMoreItems={response.loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<FixedSizeList
ref={(innerRef) => {
ref(innerRef);
setListEl(innerRef);
}}
onItemsRendered={onItemsRendered}
height={height}
itemCount={response.totalRows}
itemSize={72}
width="100%"
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
)}
</InfiniteLoader>
</div>
);
}
);
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};
`,
};
};

View File

@ -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 <SearchResultsGrid {...props} />;
}
if (width < 800) {
return <SearchResultsCards {...props} />;
}
return <SearchResultsTable {...props} />;
}}
</AutoSizer>

View File

@ -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;

View File

@ -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;