Search: add actions row header to new search layout (#48735)

This commit is contained in:
Ryan McKinley
2022-05-05 01:25:27 +02:00
committed by GitHub
parent 35300a816a
commit 2d574f352c
6 changed files with 309 additions and 52 deletions
+130 -28
View File
@@ -1,30 +1,39 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React from 'react'; import React, { useState } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeGrid } from 'react-window';
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { DataFrameView, GrafanaTheme2, NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { Input, useStyles2, Spinner, Button } from '@grafana/ui'; import { Input, useStyles2, Spinner, InlineSwitch, InlineFieldRow, InlineField } from '@grafana/ui';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter'; import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { PreviewsSystemRequirements } from '../components/PreviewsSystemRequirements';
import { SearchCard } from '../components/SearchCard';
import { useSearchQuery } from '../hooks/useSearchQuery'; import { useSearchQuery } from '../hooks/useSearchQuery';
import { getGrafanaSearcher, QueryFilters } from '../service'; import { getGrafanaSearcher, QueryFilters, QueryResult } from '../service';
import { getTermCounts } from '../service/backend'; import { getTermCounts } from '../service/backend';
import { DashboardSearchItemType, DashboardSectionItem, SearchLayout } from '../types';
import { Table } from './table/Table'; import { ActionRow } from './components/ActionRow';
import { SearchResultsTable } from './components/SearchResultsTable';
const node: NavModelItem = { const node: NavModelItem = {
id: 'search', id: 'search',
text: 'Search', text: 'Search playground',
subTitle: 'The body below will eventually live inside existing UI layouts',
icon: 'dashboard', icon: 'dashboard',
url: 'search', url: 'search',
}; };
export default function SearchPage() { export default function SearchPage() {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { query, onQueryChange, onTagFilterChange, onDatasourceChange } = useSearchQuery({}); const { query, onQueryChange, onTagFilterChange, onDatasourceChange, onSortChange, onLayoutChange } = useSearchQuery(
{}
);
const [showManage, setShowManage] = useState(false); // grid vs list view
const results = useAsync(() => { const results = useAsync(() => {
const { query: searchQuery, tag: tags, datasource } = query; const { query: searchQuery, tag: tags, datasource } = query;
@@ -40,6 +49,7 @@ export default function SearchPage() {
return <div className={styles.unsupported}>Unsupported</div>; return <div className={styles.unsupported}>Unsupported</div>;
} }
// This gets the possible tags from within the query results
const getTagOptions = (): Promise<TermCount[]> => { const getTagOptions = (): Promise<TermCount[]> => {
const tags = results.value?.body.fields.find((f) => f.name === 'tags'); const tags = results.value?.body.fields.find((f) => f.name === 'tags');
@@ -57,33 +67,120 @@ export default function SearchPage() {
onTagFilterChange(tags); onTagFilterChange(tags);
}; };
const onTagSelected = (tag: string) => {
onTagFilterChange([...new Set(query.tag as string[]).add(tag)]);
};
const showPreviews = query.layout === SearchLayout.Grid && config.featureToggles.dashboardPreviews;
return ( return (
<Page navModel={{ node: node, main: node }}> <Page navModel={{ node: node, main: node }}>
<Page.Contents> <Page.Contents>
<Input value={query.query} onChange={onSearchQueryChange} autoFocus spellCheck={false} /> <Input
value={query.query}
onChange={onSearchQueryChange}
autoFocus
spellCheck={false}
placeholder="Search for dashboards and panels"
/>
<InlineFieldRow>
<InlineField label="Show the manage options">
<InlineSwitch value={showManage} onChange={() => setShowManage(!showManage)} />
</InlineField>
</InlineFieldRow>
<br /> <br />
{results.loading && <Spinner />} {results.loading && <Spinner />}
{results.value?.body && ( {results.value?.body && (
<div> <div>
<TagFilter isClearable tags={query.tag} tagOptions={getTagOptions} onChange={onTagChange} /> <ActionRow
<br /> onLayoutChange={(v) => {
{query.datasource && ( if (v === SearchLayout.Folders) {
<Button if (query.query) {
icon="times" onQueryChange(''); // parent will clear the sort
variant="secondary" }
onClick={() => onDatasourceChange(undefined)} }
className={styles.clearClick} onLayoutChange(v);
> }}
Datasource: {query.datasource} onSortChange={onSortChange}
</Button> onTagFilterChange={onTagFilterChange}
)} getTagOptions={getTagOptions}
<AutoSizer style={{ width: '100%', height: '2000px' }}> onDatasourceChange={onDatasourceChange}
{({ width }) => { 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 ( return (
<> <>
<Table <SearchResultsTable
data={results.value!.body} data={results.value!.body}
showCheckbox={showManage}
layout={query.layout}
width={width} width={width}
height={height}
tags={query.tag} tags={query.tag}
onTagFilterChange={onTagChange} onTagFilterChange={onTagChange}
onDatasourceChange={onDatasourceChange} onDatasourceChange={onDatasourceChange}
@@ -109,10 +206,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
font-size: 18px; font-size: 18px;
`, `,
clearClick: css` virtualizedGridItemWrapper: css`
&:hover { padding: 4px;
text-decoration: line-through; `,
wrapper: css`
display: flex;
flex-direction: column;
> ul {
list-style: none;
} }
margin-bottom: 20px;
`, `,
}); });
@@ -0,0 +1,107 @@
import { css } from '@emotion/css';
import React, { FC, FormEvent } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { HorizontalGroup, RadioButtonGroup, useStyles2, Checkbox, Button } from '@grafana/ui';
import { SortPicker } from 'app/core/components/Select/SortPicker';
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter';
import { DashboardQuery, SearchLayout } from '../../types';
export const layoutOptions = [
{ value: SearchLayout.Folders, icon: 'folder', ariaLabel: 'View by folders' },
{ value: SearchLayout.List, icon: 'list-ul', ariaLabel: 'View as list' },
];
if (config.featureToggles.dashboardPreviews) {
layoutOptions.push({ value: SearchLayout.Grid, icon: 'apps', ariaLabel: 'Grid view' });
}
interface Props {
onLayoutChange: (layout: SearchLayout) => void;
onSortChange: (value: SelectableValue) => void;
onStarredFilterChange?: (event: FormEvent<HTMLInputElement>) => void;
onTagFilterChange: (tags: string[]) => void;
getTagOptions: () => Promise<TermCount[]>;
onDatasourceChange: (ds?: string) => void;
query: DashboardQuery;
showStarredFilter?: boolean;
hideLayout?: boolean;
}
function getValidQueryLayout(q: DashboardQuery): SearchLayout {
// Folders is not valid when a query exists
if (q.layout === SearchLayout.Folders) {
if (q.query || q.sort) {
return SearchLayout.List;
}
}
return q.layout;
}
export const ActionRow: FC<Props> = ({
onLayoutChange,
onSortChange,
onStarredFilterChange = () => {},
onTagFilterChange,
getTagOptions,
onDatasourceChange,
query,
showStarredFilter,
hideLayout,
}) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.actionRow}>
<div className={styles.rowContainer}>
<HorizontalGroup spacing="md" width="auto">
{!hideLayout && (
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={getValidQueryLayout(query)} />
)}
<SortPicker onChange={onSortChange} value={query.sort?.value} />
</HorizontalGroup>
</div>
<HorizontalGroup spacing="md" width="auto">
{showStarredFilter && (
<div className={styles.checkboxWrapper}>
<Checkbox label="Filter by starred" onChange={onStarredFilterChange} value={query.starred} />
</div>
)}
{query.datasource && (
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
Datasource: {query.datasource}
</Button>
)}
<TagFilter isClearable tags={query.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
</HorizontalGroup>
</div>
);
};
ActionRow.displayName = 'ActionRow';
const getStyles = (theme: GrafanaTheme2) => {
return {
actionRow: css`
display: none;
@media only screen and (min-width: ${theme.v1.breakpoints.md}) {
display: flex;
justify-content: space-between;
align-items: center;
padding: ${theme.v1.spacing.lg} 0;
width: 100%;
}
`,
rowContainer: css`
margin-right: ${theme.v1.spacing.md};
`,
checkboxWrapper: css`
label {
line-height: 1.2;
}
`,
};
};
@@ -9,12 +9,16 @@ import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
import { getTableStyles } from '@grafana/ui/src/components/Table/styles'; import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
import { LocationInfo } from '../../service'; import { LocationInfo } from '../../service';
import { SearchLayout } from '../../types';
import { generateColumns } from './columns'; import { generateColumns } from './columns';
type Props = { type Props = {
data: DataFrame; data: DataFrame;
width: number; width: number;
height: number;
showCheckbox: boolean;
layout: SearchLayout;
tags: string[]; tags: string[];
onTagFilterChange: (tags: string[]) => void; onTagFilterChange: (tags: string[]) => void;
onDatasourceChange: (datasource?: string) => void; onDatasourceChange: (datasource?: string) => void;
@@ -25,6 +29,7 @@ export type TableColumn = Column & {
}; };
export interface FieldAccess { export interface FieldAccess {
uid: string; // the item UID
kind: string; // panel, dashboard, folder kind: string; // panel, dashboard, folder
name: string; name: string;
description: string; description: string;
@@ -39,7 +44,18 @@ export interface FieldAccess {
datasource: DataSourceRef[]; datasource: DataSourceRef[];
} }
export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange }: Props) => { const skipHREF = new Set(['column-checkbox', 'column-datasource']);
export const SearchResultsTable = ({
data,
width,
height,
tags,
showCheckbox,
layout,
onTagFilterChange,
onDatasourceChange,
}: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const tableStyles = useStyles2(getTableStyles); const tableStyles = useStyles2(getTableStyles);
@@ -56,9 +72,18 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
// React-table column definitions // React-table column definitions
const access = useMemo(() => new DataFrameView<FieldAccess>(data), [data]); const access = useMemo(() => new DataFrameView<FieldAccess>(data), [data]);
const memoizedColumns = useMemo(() => { const memoizedColumns = useMemo(() => {
const isDashboardList = data.meta?.type === DataFrameType.DirectoryListing; const isDashboardList = data.meta?.type === DataFrameType.DirectoryListing || layout === SearchLayout.Folders;
return generateColumns(access, isDashboardList, width, styles, tags, onTagFilterChange, onDatasourceChange); return generateColumns(
}, [data.meta?.type, access, width, styles, tags, onTagFilterChange, onDatasourceChange]); access,
isDashboardList,
width,
showCheckbox,
styles,
tags,
onTagFilterChange,
onDatasourceChange
);
}, [data.meta?.type, layout, access, width, styles, tags, showCheckbox, onTagFilterChange, onDatasourceChange]);
const options: TableOptions<{}> = useMemo( const options: TableOptions<{}> = useMemo(
() => ({ () => ({
@@ -80,15 +105,22 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
return ( return (
<div {...row.getRowProps({ style })} className={styles.rowContainer}> <div {...row.getRowProps({ style })} className={styles.rowContainer}>
{row.cells.map((cell: Cell, index: number) => { {row.cells.map((cell: Cell, index: number) => {
const body = (
<TableCell
key={index}
tableStyles={tableStyles}
cell={cell}
columnIndex={index}
columnCount={row.cells.length}
/>
);
if (skipHREF.has(cell.column.id)) {
return body;
}
return ( return (
<a href={url} key={index} className={styles.cellWrapper}> <a href={url} key={index} className={styles.cellWrapper}>
<TableCell {body}
key={index}
tableStyles={tableStyles}
cell={cell}
columnIndex={index}
columnCount={row.cells.length}
/>
</a> </a>
); );
})} })}
@@ -122,7 +154,7 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
<div {...getTableBodyProps()}> <div {...getTableBodyProps()}>
{rows.length > 0 ? ( {rows.length > 0 ? (
<FixedSizeList <FixedSizeList
height={500} height={height}
itemCount={rows.length} itemCount={rows.length}
itemSize={tableStyles.rowHeight} itemSize={tableStyles.rowHeight}
width={'100%'} width={'100%'}
@@ -8,42 +8,56 @@ import { DefaultCell } from '@grafana/ui/src/components/Table/DefaultCell';
import { LocationInfo } from '../../service'; import { LocationInfo } from '../../service';
import { FieldAccess, TableColumn } from './Table'; import { FieldAccess, TableColumn } from './SearchResultsTable';
export const generateColumns = ( export const generateColumns = (
data: DataFrameView<FieldAccess>, data: DataFrameView<FieldAccess>,
isDashboardList: boolean, isDashboardList: boolean,
availableWidth: number, availableWidth: number,
showCheckbox: boolean,
styles: { [key: string]: string }, styles: { [key: string]: string },
tags: string[], tags: string[],
onTagFilterChange: (tags: string[]) => void, onTagFilterChange: (tags: string[]) => void,
onDatasourceChange: (datasource?: string) => void onDatasourceChange: (datasource?: string) => void
): TableColumn[] => { ): TableColumn[] => {
const columns: TableColumn[] = []; const columns: TableColumn[] = [];
const urlField = data.fields.url!; const uidField = data.fields.uid!;
const access = data.fields; const access = data.fields;
availableWidth -= 8; // ??? availableWidth -= 8; // ???
let width = 50; let width = 50;
// TODO: Add optional checkbox support // TODO: Add optional checkbox support
if (false) { if (showCheckbox) {
// checkbox column
columns.push({ columns.push({
id: `column-checkbox`, id: `column-checkbox`,
Header: () => ( Header: () => (
<div className={styles.checkboxHeader}> <div className={styles.checkboxHeader}>
<Checkbox onChange={() => {}} /> <Checkbox
onChange={(e) => {
e.stopPropagation();
e.preventDefault();
console.log('SELECT ALL!!!', e);
}}
/>
</div> </div>
), ),
Cell: () => (
<div className={styles.checkbox}>
<Checkbox onChange={() => {}} />
</div>
),
accessor: 'check',
field: urlField,
width: 30, width: 30,
Cell: (p) => {
const uid = uidField.values.get(p.row.index);
return (
<div {...p.cellProps} className={p.cellStyle}>
<div className={styles.checkbox}>
<Checkbox
onChange={(e) => {
console.log('SELECTED!!!', uid);
}}
/>
</div>
</div>
);
},
field: uidField,
}); });
availableWidth -= width; availableWidth -= width;
} }
@@ -3,6 +3,7 @@ import { DataFrame, DataSourceRef } from '@grafana/data';
export interface QueryResult { export interface QueryResult {
kind: string; // panel, dashboard, folder kind: string; // panel, dashboard, folder
name: string; name: string;
uid: string;
description?: string; description?: string;
url: string; // link to value (unique) url: string; // link to value (unique)
tags?: string[]; tags?: string[];
+1
View File
@@ -97,6 +97,7 @@ export type OnMoveItems = (selectedDashboards: DashboardSectionItem[], folder: F
export enum SearchLayout { export enum SearchLayout {
List = 'list', List = 'list',
Folders = 'folders', Folders = 'folders',
Grid = 'grid', // preview
} }
export interface SearchQueryParams { export interface SearchQueryParams {