Search: manage selection in state (#48793)

This commit is contained in:
Ryan McKinley
2022-05-06 10:16:12 +02:00
committed by GitHub
parent bcb0bfce3a
commit 0e1dffc655
10 changed files with 323 additions and 124 deletions
@@ -16,7 +16,7 @@ export const TableCell: FC<Props> = ({ cell, tableStyles, onCellFilterAdded, col
const cellProps = cell.getCellProps();
const field = (cell.column as any as GrafanaTableColumn).field;
if (!field.display) {
if (!field?.display) {
return null;
}
+163 -105
View File
@@ -6,7 +6,7 @@ import { FixedSizeGrid } from 'react-window';
import { DataFrameView, GrafanaTheme2, NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Input, useStyles2, Spinner, InlineSwitch, InlineFieldRow, InlineField } from '@grafana/ui';
import { Input, useStyles2, Spinner, InlineSwitch, InlineFieldRow, InlineField, Button } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
@@ -17,8 +17,10 @@ import { getGrafanaSearcher, QueryFilters, QueryResult } from '../service';
import { getTermCounts } from '../service/backend';
import { DashboardSearchItemType, DashboardSectionItem, SearchLayout } from '../types';
import { ActionRow } from './components/ActionRow';
import { ActionRow, getValidQueryLayout } from './components/ActionRow';
import { ManageActions } from './components/ManageActions';
import { SearchResultsTable } from './components/SearchResultsTable';
import { newSearchSelection, updateSearchSelection } from './selection';
const node: NavModelItem = {
id: 'search',
@@ -35,6 +37,8 @@ export default function SearchPage() {
);
const [showManage, setShowManage] = useState(false); // grid vs list view
const [searchSelection, setSearchSelection] = useState(newSearchSelection());
const results = useAsync(() => {
const { query: searchQuery, tag: tags, datasource } = query;
@@ -71,7 +75,127 @@ export default function SearchPage() {
onTagFilterChange([...new Set(query.tag as string[]).add(tag)]);
};
const showPreviews = query.layout === SearchLayout.Grid && config.featureToggles.dashboardPreviews;
const toggleSelection = (kind: string, uid: string) => {
const current = searchSelection.isSelected(kind, uid);
if (kind === 'folder') {
// ??? also select all children?
}
setSearchSelection(updateSearchSelection(searchSelection, !current, kind, [uid]));
};
const layout = getValidQueryLayout(query);
const showPreviews = layout === SearchLayout.Grid && config.featureToggles.dashboardPreviews;
const renderResults = () => {
if (results.loading) {
return <Spinner />;
}
const df = results.value?.body;
if (!df || !df.length) {
return (
<div className={styles.noResults}>
<div>No results found for your query.</div>
<br />
<Button
variant="secondary"
onClick={() => {
if (query.query) {
onQueryChange('');
}
if (query.tag?.length) {
onTagFilterChange([]);
}
if (query.datasource) {
onDatasourceChange(undefined);
}
}}
>
Remove search constraints
</Button>
</div>
);
}
return (
<AutoSizer style={{ width: '100%', height: '700px' }}>
{({ width, height }) => {
if (showPreviews) {
const view = new DataFrameView<QueryResult>(df);
// Hacked to reuse existing SearchCard (and old DashboardSectionItem)
const itemProps = {
editable: showManage,
onToggleChecked: (item: any) => {
const d = item as DashboardSectionItem;
const t = d.type === DashboardSearchItemType.DashFolder ? 'folder' : 'dashboard';
toggleSelection(t, d.uid!);
},
onTagSelected,
};
const numColumns = Math.ceil(width / 320);
const cellWidth = width / numColumns;
const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8;
const numRows = Math.ceil(df.length / numColumns);
return (
<FixedSizeGrid
columnCount={numColumns}
columnWidth={cellWidth}
rowCount={numRows}
rowHeight={cellHeight}
className={styles.wrapper}
innerElementType="ul"
height={height}
width={width - 2}
>
{({ columnIndex, rowIndex, style }) => {
const index = rowIndex * numColumns + columnIndex;
const item = view.get(index);
const kind = item.kind ?? 'dashboard';
const facade: DashboardSectionItem = {
uid: item.uid,
title: item.name,
url: item.url,
uri: item.url,
type: kind === 'folder' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB,
id: 666, // do not use me!
isStarred: false,
tags: item.tags ?? [],
checked: searchSelection.isSelected(kind, item.uid),
};
// 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}>
<SearchCard key={item.uid} {...itemProps} item={facade} />
</li>
) : null;
}}
</FixedSizeGrid>
);
}
return (
<>
<SearchResultsTable
data={df}
selection={showManage ? searchSelection.isSelected : undefined}
selectionToggle={toggleSelection}
layout={layout}
width={width - 5}
height={height}
tags={query.tag}
onTagFilterChange={onTagChange}
onDatasourceChange={onDatasourceChange}
/>
</>
);
}}
</AutoSizer>
);
};
return (
<Page navModel={{ node: node, main: node }}>
@@ -84,113 +208,42 @@ export default function SearchPage() {
placeholder="Search for dashboards and panels"
/>
<InlineFieldRow>
<InlineField label="Show the manage options">
<InlineField label="Show manage options">
<InlineSwitch value={showManage} onChange={() => setShowManage(!showManage)} />
</InlineField>
</InlineFieldRow>
<br />
{results.loading && <Spinner />}
{results.value?.body && (
<div>
<ActionRow
onLayoutChange={(v) => {
if (v === SearchLayout.Folders) {
if (query.query) {
onQueryChange(''); // parent will clear the sort
}
<hr />
{Boolean(searchSelection.items.size > 0) ? (
<ManageActions items={searchSelection.items} />
) : (
<ActionRow
onLayoutChange={(v) => {
if (v === SearchLayout.Folders) {
if (query.query) {
onQueryChange(''); // parent will clear the sort
}
onLayoutChange(v);
}}
onSortChange={onSortChange}
onTagFilterChange={onTagFilterChange}
getTagOptions={getTagOptions}
onDatasourceChange={onDatasourceChange}
query={query}
/>
<PreviewsSystemRequirements
bottomSpacing={3}
showPreviews={showPreviews}
onRemove={() => onLayoutChange(SearchLayout.List)}
/>
<AutoSizer style={{ width: '100%', height: '700px' }}>
{({ width, height }) => {
if (showPreviews) {
const df = results.value?.body!;
const view = new DataFrameView<QueryResult>(df);
// HACK for grid view
const itemProps = {
editable: showManage,
onToggleChecked: (v: any) => {
console.log('CHECKED?', v);
},
onTagSelected,
};
const numColumns = Math.ceil(width / 320);
const cellWidth = width / numColumns;
const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8;
const numRows = Math.ceil(df.length / numColumns);
return (
<FixedSizeGrid
columnCount={numColumns}
columnWidth={cellWidth}
rowCount={numRows}
rowHeight={cellHeight}
className={styles.wrapper}
innerElementType="ul"
height={height}
width={width}
>
{({ columnIndex, rowIndex, style }) => {
const index = rowIndex * numColumns + columnIndex;
const item = view.get(index);
const facade: 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 ?? [],
};
// 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}>
<SearchCard key={item.uid} {...itemProps} item={facade} />
</li>
) : null;
}}
</FixedSizeGrid>
);
}
return (
<>
<SearchResultsTable
data={results.value!.body}
showCheckbox={showManage}
layout={query.layout}
width={width}
height={height}
tags={query.tag}
onTagFilterChange={onTagChange}
onDatasourceChange={onDatasourceChange}
/>
</>
);
}}
</AutoSizer>
</div>
}
onLayoutChange(v);
}}
onSortChange={onSortChange}
onTagFilterChange={onTagFilterChange}
getTagOptions={getTagOptions}
onDatasourceChange={onDatasourceChange}
query={query}
/>
)}
{showPreviews && (
<PreviewsSystemRequirements
bottomSpacing={3}
showPreviews={showPreviews}
onRemove={() => onLayoutChange(SearchLayout.List)}
/>
)}
{renderResults()}
</Page.Contents>
</Page>
);
@@ -205,7 +258,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
height: 100%;
font-size: 18px;
`,
virtualizedGridItemWrapper: css`
padding: 4px;
`,
@@ -217,4 +269,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
list-style: none;
}
`,
noResults: css`
padding: ${theme.v1.spacing.md};
background: ${theme.v1.colors.bg2};
font-style: italic;
margin-top: ${theme.v1.spacing.md};
`,
});
@@ -30,7 +30,7 @@ interface Props {
hideLayout?: boolean;
}
function getValidQueryLayout(q: DashboardQuery): SearchLayout {
export function getValidQueryLayout(q: DashboardQuery): SearchLayout {
// Folders is not valid when a query exists
if (q.layout === SearchLayout.Folders) {
if (q.query || q.sort) {
@@ -82,7 +82,7 @@ export const ActionRow: FC<Props> = ({
ActionRow.displayName = 'ActionRow';
const getStyles = (theme: GrafanaTheme2) => {
export const getStyles = (theme: GrafanaTheme2) => {
return {
actionRow: css`
display: none;
@@ -0,0 +1,53 @@
import React from 'react';
import { Button, Checkbox, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { getStyles } from './ActionRow';
type Props = {
items: Map<string, Set<string>>;
};
export function ManageActions({ items }: Props) {
const styles = useStyles2(getStyles);
const canMove = true;
const canDelete = true;
const onMove = () => {
alert('TODO, move....');
};
const onDelete = () => {
alert('TODO, delete....');
};
const onToggleAll = () => {
alert('TODO, toggle all....');
};
return (
<div className={styles.actionRow}>
<div className={styles.rowContainer}>
<HorizontalGroup spacing="md" width="auto">
<Checkbox value={false} onClick={onToggleAll} />
<Button disabled={!canMove} onClick={onMove} icon="exchange-alt" variant="secondary">
Move
</Button>
<Button disabled={!canDelete} onClick={onDelete} icon="trash-alt" variant="destructive">
Delete
</Button>
{[...items.keys()].map((k) => {
const vals = items.get(k);
return (
<div key={k}>
{k} ({vals?.size})
</div>
);
})}
</HorizontalGroup>
</div>
</div>
);
}
@@ -3,13 +3,14 @@ import React, { useMemo } from 'react';
import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table';
import { FixedSizeList } from 'react-window';
import { DataFrame, DataFrameType, DataFrameView, DataSourceRef, Field, GrafanaTheme2 } from '@grafana/data';
import { DataFrame, DataFrameView, DataSourceRef, 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 { LocationInfo } from '../../service';
import { SearchLayout } from '../../types';
import { SelectionChecker, SelectionToggle } from '../selection';
import { generateColumns } from './columns';
@@ -17,7 +18,8 @@ type Props = {
data: DataFrame;
width: number;
height: number;
showCheckbox: boolean;
selection?: SelectionChecker;
selectionToggle?: SelectionToggle;
layout: SearchLayout;
tags: string[];
onTagFilterChange: (tags: string[]) => void;
@@ -51,7 +53,8 @@ export const SearchResultsTable = ({
width,
height,
tags,
showCheckbox,
selection,
selectionToggle,
layout,
onTagFilterChange,
onDatasourceChange,
@@ -72,18 +75,19 @@ export const SearchResultsTable = ({
// React-table column definitions
const access = useMemo(() => new DataFrameView<FieldAccess>(data), [data]);
const memoizedColumns = useMemo(() => {
const isDashboardList = data.meta?.type === DataFrameType.DirectoryListing || layout === SearchLayout.Folders;
const isDashboardList = layout === SearchLayout.Folders;
return generateColumns(
access,
isDashboardList,
width,
showCheckbox,
selection,
selectionToggle,
styles,
tags,
onTagFilterChange,
onDatasourceChange
);
}, [data.meta?.type, layout, access, width, styles, tags, showCheckbox, onTagFilterChange, onDatasourceChange]);
}, [layout, access, width, styles, tags, selection, selectionToggle, onTagFilterChange, onDatasourceChange]);
const options: TableOptions<{}> = useMemo(
() => ({
@@ -7,6 +7,7 @@ import { Checkbox, Icon, IconName, TagList } from '@grafana/ui';
import { DefaultCell } from '@grafana/ui/src/components/Table/DefaultCell';
import { LocationInfo } from '../../service';
import { SelectionChecker, SelectionToggle } from '../selection';
import { FieldAccess, TableColumn } from './SearchResultsTable';
@@ -14,7 +15,8 @@ export const generateColumns = (
data: DataFrameView<FieldAccess>,
isDashboardList: boolean,
availableWidth: number,
showCheckbox: boolean,
selection: SelectionChecker | undefined,
selectionToggle: SelectionToggle | undefined,
styles: { [key: string]: string },
tags: string[],
onTagFilterChange: (tags: string[]) => void,
@@ -22,13 +24,14 @@ export const generateColumns = (
): TableColumn[] => {
const columns: TableColumn[] = [];
const uidField = data.fields.uid!;
const kindField = data.fields.kind!;
const access = data.fields;
availableWidth -= 8; // ???
let width = 50;
// TODO: Add optional checkbox support
if (showCheckbox) {
if (selection && selectionToggle) {
width = 30;
columns.push({
id: `column-checkbox`,
Header: () => (
@@ -37,20 +40,25 @@ export const generateColumns = (
onChange={(e) => {
e.stopPropagation();
e.preventDefault();
console.log('SELECT ALL!!!', e);
alert('SELECT ALL!!!');
}}
/>
</div>
),
width: 30,
width,
Cell: (p) => {
const uid = uidField.values.get(p.row.index);
const kind = kindField ? kindField.values.get(p.row.index) : 'dashboard'; // HACK for now
const selected = selection(kind, uid);
const hasUID = uid != null; // Panels don't have UID! Likely should not be shown on pages with manage options
return (
<div {...p.cellProps} className={p.cellStyle}>
<div className={styles.checkbox}>
<Checkbox
disabled={!hasUID}
value={selected && hasUID}
onChange={(e) => {
console.log('SELECTED!!!', uid);
selectionToggle(kind, uid);
}}
/>
</div>
@@ -260,10 +268,10 @@ function makeTypeColumn(
return {
Cell: DefaultCell,
id: `column-type`,
field: kindField,
field: kindField ?? typeField,
Header: 'Type',
accessor: (row: any, i: number) => {
const kind = kindField.values.get(i);
const kind = kindField?.values.get(i) ?? 'dashboard';
let icon = 'public/img/icons/unicons/apps.svg';
let txt = 'Dashboard';
if (kind) {
@@ -0,0 +1,15 @@
import { newSearchSelection, updateSearchSelection } from './selection';
describe('Search selection helper', () => {
it('simple dashboard selection', () => {
let sel = newSearchSelection();
expect(sel.isSelected('dash', 'aaa')).toBe(false);
sel = updateSearchSelection(sel, true, 'dash', ['aaa']);
expect(sel.isSelected('dash', 'aaa')).toBe(true);
sel = updateSearchSelection(sel, false, 'dash', ['aaa']);
expect(sel.isSelected('dash', 'aaa')).toBe(false);
expect(sel.items).toMatchInlineSnapshot(`Map {}`);
});
});
@@ -0,0 +1,58 @@
export type SelectionChecker = (kind: string, uid: string) => boolean;
export type SelectionToggle = (kind: string, uid: string) => void;
export interface SearchSelection {
// Check if an item is selected
isSelected: SelectionChecker;
// Selected items by kind
items: Map<string, Set<string>>;
}
export function newSearchSelection(): SearchSelection {
// the check is called often, on potentially large (all) results so using Map/Set is better than simple array
const items = new Map<string, Set<string>>();
const isSelected = (kind: string, uid: string) => {
return Boolean(items.get(kind)?.has(uid));
};
return {
items,
isSelected,
};
}
export function updateSearchSelection(
old: SearchSelection,
selected: boolean,
kind: string,
uids: string[]
): SearchSelection {
const items = old.items; // mutate! :/
if (uids.length) {
const k = items.get(kind);
if (k) {
for (const uid of uids) {
if (selected) {
k.add(uid);
} else {
k.delete(uid);
}
}
if (k.size < 1) {
items.delete(kind);
}
} else if (selected) {
items.set(kind, new Set<string>(uids));
}
}
return {
items,
isSelected: (kind: string, uid: string) => {
return Boolean(items.get(kind)?.has(uid));
},
};
}
@@ -173,6 +173,7 @@ export class MiniSearcher implements GrafanaSearcher {
const found = this.index!.search(query);
// frame fields
const uid: string[] = [];
const url: string[] = [];
const kind: string[] = [];
const type: string[] = [];
@@ -195,6 +196,7 @@ export class MiniSearcher implements GrafanaSearcher {
continue;
}
uid.push(input.uid?.get(index)!);
url.push(input.url?.get(index) ?? '?');
location.push(input.location?.get(index) as any);
datasource.push(input.datasource?.get(index) as any);
@@ -206,6 +208,7 @@ export class MiniSearcher implements GrafanaSearcher {
score.push(res.score);
}
const fields: Field[] = [
{ name: 'uid', config: {}, type: FieldType.string, values: new ArrayVector(uid) },
{ name: 'kind', config: {}, type: FieldType.string, values: new ArrayVector(kind) },
{ name: 'name', config: {}, type: FieldType.string, values: new ArrayVector(name) },
{
@@ -27,7 +27,7 @@ describe('simple search', () => {
const searcher = new MiniSearcher(supplier);
let results = await searcher.search('name');
expect(results.body.fields[1].values.toArray()).toMatchInlineSnapshot(`
expect(results.body.fields[2].values.toArray()).toMatchInlineSnapshot(`
Array [
"A name (dash)",
"B name (dash)",
@@ -37,7 +37,7 @@ describe('simple search', () => {
`);
results = await searcher.search('B');
expect(results.body.fields[1].values.toArray()).toMatchInlineSnapshot(`
expect(results.body.fields[2].values.toArray()).toMatchInlineSnapshot(`
Array [
"B name (dash)",
"B name (panels)",