mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: switch to a card view when the display is narrow (#51208)
This commit is contained in:
parent
1f5f8aa5ab
commit
eceb21e72d
@ -28,6 +28,7 @@ const getIconFromMeta = (meta = ''): IconName => {
|
|||||||
return metaIconMap.has(meta) ? metaIconMap.get(meta)! : 'sort-amount-down';
|
return metaIconMap.has(meta) ? metaIconMap.get(meta)! : 'sort-amount-down';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSelected }) => {
|
export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSelected }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const tagSelected = useCallback(
|
const tagSelected = useCallback(
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -21,6 +21,7 @@ import { ActionRow, getValidQueryLayout } from './ActionRow';
|
|||||||
import { FolderSection } from './FolderSection';
|
import { FolderSection } from './FolderSection';
|
||||||
import { FolderView } from './FolderView';
|
import { FolderView } from './FolderView';
|
||||||
import { ManageActions } from './ManageActions';
|
import { ManageActions } from './ManageActions';
|
||||||
|
import { SearchResultsCards } from './SearchResultsCards';
|
||||||
import { SearchResultsGrid } from './SearchResultsGrid';
|
import { SearchResultsGrid } from './SearchResultsGrid';
|
||||||
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable';
|
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable';
|
||||||
|
|
||||||
@ -212,6 +213,10 @@ export const SearchView = ({
|
|||||||
return <SearchResultsGrid {...props} />;
|
return <SearchResultsGrid {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (width < 800) {
|
||||||
|
return <SearchResultsCards {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <SearchResultsTable {...props} />;
|
return <SearchResultsTable {...props} />;
|
||||||
}}
|
}}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
|
@ -13,7 +13,7 @@ import { SelectionChecker, SelectionToggle } from '../selection';
|
|||||||
|
|
||||||
import { TableColumn } from './SearchResultsTable';
|
import { TableColumn } from './SearchResultsTable';
|
||||||
|
|
||||||
const TYPE_COLUMN_WIDTH = 250;
|
const TYPE_COLUMN_WIDTH = 175;
|
||||||
const DATASOURCE_COLUMN_WIDTH = 200;
|
const DATASOURCE_COLUMN_WIDTH = 200;
|
||||||
const SORT_FIELD_WIDTH = 175;
|
const SORT_FIELD_WIDTH = 175;
|
||||||
|
|
||||||
|
@ -11,6 +11,9 @@ export enum DashboardSearchItemType {
|
|||||||
DashFolder = 'dash-folder',
|
DashFolder = 'dash-folder',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export interface DashboardSection {
|
export interface DashboardSection {
|
||||||
id: number;
|
id: number;
|
||||||
uid?: string;
|
uid?: string;
|
||||||
@ -28,6 +31,9 @@ export interface DashboardSection {
|
|||||||
itemsFetching?: boolean;
|
itemsFetching?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export interface DashboardSectionItem {
|
export interface DashboardSectionItem {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
folderId?: number;
|
folderId?: number;
|
||||||
|
Loading…
Reference in New Issue
Block a user