diff --git a/packages/grafana-ui/src/components/Table/TableCell.tsx b/packages/grafana-ui/src/components/Table/TableCell.tsx index 09b6f4bb6d5..9ba4ed6cc3a 100644 --- a/packages/grafana-ui/src/components/Table/TableCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableCell.tsx @@ -16,7 +16,7 @@ export const TableCell: FC = ({ 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; } diff --git a/public/app/features/search/page/SearchPage.tsx b/public/app/features/search/page/SearchPage.tsx index 19393c7d443..55fb1a3a11b 100644 --- a/public/app/features/search/page/SearchPage.tsx +++ b/public/app/features/search/page/SearchPage.tsx @@ -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 ; + } + + const df = results.value?.body; + if (!df || !df.length) { + return ( +
+
No results found for your query.
+
+ +
+ ); + } + + return ( + + {({ width, height }) => { + if (showPreviews) { + const view = new DataFrameView(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 ( + + {({ 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 ? ( +
  • + +
  • + ) : null; + }} +
    + ); + } + + return ( + <> + + + ); + }} +
    + ); + }; return ( @@ -84,113 +208,42 @@ export default function SearchPage() { placeholder="Search for dashboards and panels" /> - + setShowManage(!showManage)} />
    - {results.loading && } - {results.value?.body && ( -
    - { - if (v === SearchLayout.Folders) { - if (query.query) { - onQueryChange(''); // parent will clear the sort - } +
    + + {Boolean(searchSelection.items.size > 0) ? ( + + ) : ( + { + 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} - /> - - onLayoutChange(SearchLayout.List)} - /> - - - {({ width, height }) => { - if (showPreviews) { - const df = results.value?.body!; - const view = new DataFrameView(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 ( - - {({ 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 ? ( -
  • - -
  • - ) : null; - }} -
    - ); - } - - return ( - <> - - - ); - }} -
    -
    + } + onLayoutChange(v); + }} + onSortChange={onSortChange} + onTagFilterChange={onTagFilterChange} + getTagOptions={getTagOptions} + onDatasourceChange={onDatasourceChange} + query={query} + /> )} + + {showPreviews && ( + onLayoutChange(SearchLayout.List)} + /> + )} + + {renderResults()}
    ); @@ -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}; + `, }); diff --git a/public/app/features/search/page/components/ActionRow.tsx b/public/app/features/search/page/components/ActionRow.tsx index 2f0fc3a5295..d79af608095 100644 --- a/public/app/features/search/page/components/ActionRow.tsx +++ b/public/app/features/search/page/components/ActionRow.tsx @@ -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 = ({ ActionRow.displayName = 'ActionRow'; -const getStyles = (theme: GrafanaTheme2) => { +export const getStyles = (theme: GrafanaTheme2) => { return { actionRow: css` display: none; diff --git a/public/app/features/search/page/components/ManageActions.tsx b/public/app/features/search/page/components/ManageActions.tsx new file mode 100644 index 00000000000..50bff96f534 --- /dev/null +++ b/public/app/features/search/page/components/ManageActions.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { Button, Checkbox, HorizontalGroup, useStyles2 } from '@grafana/ui'; + +import { getStyles } from './ActionRow'; + +type Props = { + items: Map>; +}; + +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 ( +
    +
    + + + + + + {[...items.keys()].map((k) => { + const vals = items.get(k); + return ( +
    + {k} ({vals?.size}) +
    + ); + })} +
    +
    +
    + ); +} diff --git a/public/app/features/search/page/components/SearchResultsTable.tsx b/public/app/features/search/page/components/SearchResultsTable.tsx index e858451f45b..22cc6b758f5 100644 --- a/public/app/features/search/page/components/SearchResultsTable.tsx +++ b/public/app/features/search/page/components/SearchResultsTable.tsx @@ -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(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( () => ({ diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 8f9f7b14cc4..7abdc5aea81 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -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, 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!!!'); }} /> ), - 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 (
    { - console.log('SELECTED!!!', uid); + selectionToggle(kind, uid); }} />
    @@ -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) { diff --git a/public/app/features/search/page/selection.test.ts b/public/app/features/search/page/selection.test.ts new file mode 100644 index 00000000000..9045b7b4e91 --- /dev/null +++ b/public/app/features/search/page/selection.test.ts @@ -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 {}`); + }); +}); diff --git a/public/app/features/search/page/selection.ts b/public/app/features/search/page/selection.ts new file mode 100644 index 00000000000..3902b3c2d5e --- /dev/null +++ b/public/app/features/search/page/selection.ts @@ -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>; +} + +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>(); + + 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(uids)); + } + } + + return { + items, + isSelected: (kind: string, uid: string) => { + return Boolean(items.get(kind)?.has(uid)); + }, + }; +} diff --git a/public/app/features/search/service/minisearcher.ts b/public/app/features/search/service/minisearcher.ts index 9a7bf738009..194d9e90ee9 100644 --- a/public/app/features/search/service/minisearcher.ts +++ b/public/app/features/search/service/minisearcher.ts @@ -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) }, { diff --git a/public/app/features/search/service/searcher.test.ts b/public/app/features/search/service/searcher.test.ts index bbc762eb0b4..26742a1bc3e 100644 --- a/public/app/features/search/service/searcher.test.ts +++ b/public/app/features/search/service/searcher.test.ts @@ -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)",