mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: add actions row header to new search layout (#48735)
This commit is contained in:
parent
35300a816a
commit
2d574f352c
@ -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;
|
||||
`,
|
||||
});
|
||||
|
107
public/app/features/search/page/components/ActionRow.tsx
Normal file
107
public/app/features/search/page/components/ActionRow.tsx
Normal 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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -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%'}
|
@ -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;
|
||||
}
|
@ -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[];
|
||||
|
@ -97,6 +97,7 @@ export type OnMoveItems = (selectedDashboards: DashboardSectionItem[], folder: F
|
||||
export enum SearchLayout {
|
||||
List = 'list',
|
||||
Folders = 'folders',
|
||||
Grid = 'grid', // preview
|
||||
}
|
||||
|
||||
export interface SearchQueryParams {
|
||||
|
Loading…
Reference in New Issue
Block a user