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

This commit is contained in:
Ryan McKinley 2022-05-04 16:25:27 -07:00 committed by GitHub
parent 35300a816a
commit 2d574f352c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 309 additions and 52 deletions

View File

@ -1,30 +1,39 @@
import { css } from '@emotion/css';
import React from 'react';
import React, { useState } from 'react';
import { useAsync } from 'react-use';
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 { 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 { 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 { getGrafanaSearcher, QueryFilters } from '../service';
import { getGrafanaSearcher, QueryFilters, QueryResult } from '../service';
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 = {
id: 'search',
text: 'Search',
text: 'Search playground',
subTitle: 'The body below will eventually live inside existing UI layouts',
icon: 'dashboard',
url: 'search',
};
export default function SearchPage() {
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 { query: searchQuery, tag: tags, datasource } = query;
@ -40,6 +49,7 @@ export default function SearchPage() {
return <div className={styles.unsupported}>Unsupported</div>;
}
// This gets the possible tags from within the query results
const getTagOptions = (): Promise<TermCount[]> => {
const tags = results.value?.body.fields.find((f) => f.name === 'tags');
@ -57,33 +67,120 @@ export default function SearchPage() {
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 (
<Page navModel={{ node: node, main: node }}>
<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 />
{results.loading && <Spinner />}
{results.value?.body && (
<div>
<TagFilter isClearable tags={query.tag} tagOptions={getTagOptions} onChange={onTagChange} />
<br />
{query.datasource && (
<Button
icon="times"
variant="secondary"
onClick={() => onDatasourceChange(undefined)}
className={styles.clearClick}
>
Datasource: {query.datasource}
</Button>
)}
<AutoSizer style={{ width: '100%', height: '2000px' }}>
{({ width }) => {
<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 (
<>
<Table
<SearchResultsTable
data={results.value!.body}
showCheckbox={showManage}
layout={query.layout}
width={width}
height={height}
tags={query.tag}
onTagFilterChange={onTagChange}
onDatasourceChange={onDatasourceChange}
@ -109,10 +206,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
font-size: 18px;
`,
clearClick: css`
&:hover {
text-decoration: line-through;
virtualizedGridItemWrapper: css`
padding: 4px;
`,
wrapper: css`
display: flex;
flex-direction: column;
> ul {
list-style: none;
}
margin-bottom: 20px;
`,
});

View File

@ -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;
}
`,
};
};

View File

@ -9,12 +9,16 @@ 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 { generateColumns } from './columns';
type Props = {
data: DataFrame;
width: number;
height: number;
showCheckbox: boolean;
layout: SearchLayout;
tags: string[];
onTagFilterChange: (tags: string[]) => void;
onDatasourceChange: (datasource?: string) => void;
@ -25,6 +29,7 @@ export type TableColumn = Column & {
};
export interface FieldAccess {
uid: string; // the item UID
kind: string; // panel, dashboard, folder
name: string;
description: string;
@ -39,7 +44,18 @@ export interface FieldAccess {
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 tableStyles = useStyles2(getTableStyles);
@ -56,9 +72,18 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
// React-table column definitions
const access = useMemo(() => new DataFrameView<FieldAccess>(data), [data]);
const memoizedColumns = useMemo(() => {
const isDashboardList = data.meta?.type === DataFrameType.DirectoryListing;
return generateColumns(access, isDashboardList, width, styles, tags, onTagFilterChange, onDatasourceChange);
}, [data.meta?.type, access, width, styles, tags, onTagFilterChange, onDatasourceChange]);
const isDashboardList = data.meta?.type === DataFrameType.DirectoryListing || layout === SearchLayout.Folders;
return generateColumns(
access,
isDashboardList,
width,
showCheckbox,
styles,
tags,
onTagFilterChange,
onDatasourceChange
);
}, [data.meta?.type, layout, access, width, styles, tags, showCheckbox, onTagFilterChange, onDatasourceChange]);
const options: TableOptions<{}> = useMemo(
() => ({
@ -80,15 +105,22 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
return (
<div {...row.getRowProps({ style })} className={styles.rowContainer}>
{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 (
<a href={url} key={index} className={styles.cellWrapper}>
<TableCell
key={index}
tableStyles={tableStyles}
cell={cell}
columnIndex={index}
columnCount={row.cells.length}
/>
{body}
</a>
);
})}
@ -122,7 +154,7 @@ export const Table = ({ data, width, tags, onTagFilterChange, onDatasourceChange
<div {...getTableBodyProps()}>
{rows.length > 0 ? (
<FixedSizeList
height={500}
height={height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}

View File

@ -8,42 +8,56 @@ import { DefaultCell } from '@grafana/ui/src/components/Table/DefaultCell';
import { LocationInfo } from '../../service';
import { FieldAccess, TableColumn } from './Table';
import { FieldAccess, TableColumn } from './SearchResultsTable';
export const generateColumns = (
data: DataFrameView<FieldAccess>,
isDashboardList: boolean,
availableWidth: number,
showCheckbox: boolean,
styles: { [key: string]: string },
tags: string[],
onTagFilterChange: (tags: string[]) => void,
onDatasourceChange: (datasource?: string) => void
): TableColumn[] => {
const columns: TableColumn[] = [];
const urlField = data.fields.url!;
const uidField = data.fields.uid!;
const access = data.fields;
availableWidth -= 8; // ???
let width = 50;
// TODO: Add optional checkbox support
if (false) {
// checkbox column
if (showCheckbox) {
columns.push({
id: `column-checkbox`,
Header: () => (
<div className={styles.checkboxHeader}>
<Checkbox onChange={() => {}} />
<Checkbox
onChange={(e) => {
e.stopPropagation();
e.preventDefault();
console.log('SELECT ALL!!!', e);
}}
/>
</div>
),
Cell: () => (
<div className={styles.checkbox}>
<Checkbox onChange={() => {}} />
</div>
),
accessor: 'check',
field: urlField,
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;
}

View File

@ -3,6 +3,7 @@ import { DataFrame, DataSourceRef } from '@grafana/data';
export interface QueryResult {
kind: string; // panel, dashboard, folder
name: string;
uid: string;
description?: string;
url: string; // link to value (unique)
tags?: string[];

View File

@ -97,6 +97,7 @@ export type OnMoveItems = (selectedDashboards: DashboardSectionItem[], folder: F
export enum SearchLayout {
List = 'list',
Folders = 'folders',
Grid = 'grid', // preview
}
export interface SearchQueryParams {