Search: a few minor improvements (#48989)

This commit is contained in:
Ryan McKinley 2022-05-16 15:38:27 -07:00 committed by GitHub
parent 2691872c7a
commit 68757cfa73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 138 additions and 25 deletions

View File

@ -28,7 +28,8 @@ export class ImpressionSrv {
store.set(impressionsKey, JSON.stringify(impressions)); store.set(impressionsKey, JSON.stringify(impressions));
} }
getDashboardOpened() { /** Returns an array of internal (numeric) dashboard IDs */
getDashboardOpened(): number[] {
let impressions = store.get(this.impressionKey()) || '[]'; let impressions = store.get(this.impressionKey()) || '[]';
impressions = JSON.parse(impressions); impressions = JSON.parse(impressions);

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import SVG from 'react-inlinesvg';
import { usePopper } from 'react-popper'; import { usePopper } from 'react-popper';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
@ -130,7 +131,11 @@ export function SearchCard({ editable, item, onTagSelected, onToggleChecked }: P
<img loading="lazy" className={styles.image} src={imageSrc} onError={() => setHasImage(false)} /> <img loading="lazy" className={styles.image} src={imageSrc} onError={() => setHasImage(false)} />
) : ( ) : (
<div className={styles.imagePlaceholder}> <div className={styles.imagePlaceholder}>
<Icon name="apps" size="xl" /> {item.icon ? (
<SVG src={item.icon} width={36} height={36} title={item.title} />
) : (
<Icon name="apps" size="xl" />
)}
</div> </div>
)} )}
</div> </div>

View File

@ -1,6 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useState } from 'react'; import React, { useState } from 'react';
import SVG from 'react-inlinesvg';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Spinner, TagList, useTheme2 } from '@grafana/ui'; import { Icon, Spinner, TagList, useTheme2 } from '@grafana/ui';
@ -38,7 +39,11 @@ export function SearchCardExpanded({ className, imageHeight, imageWidth, item, l
/> />
) : ( ) : (
<div className={styles.imagePlaceholder}> <div className={styles.imagePlaceholder}>
<Icon name="apps" size="xl" /> {item.icon ? (
<SVG src={item.icon} width={36} height={36} title={item.title} />
) : (
<Icon name="apps" size="xl" />
)}
</div> </div>
)} )}
</div> </div>

View File

@ -79,7 +79,7 @@ export default function SearchPage() {
// This gets the possible tags from within the query results // This gets the possible tags from within the query results
const getTagOptions = (): Promise<TermCount[]> => { const getTagOptions = (): Promise<TermCount[]> => {
const q: SearchQuery = { const q: SearchQuery = {
query: query.query ?? '*', query: query.query?.length ? query.query : '*',
tags: query.tag, tags: query.tag,
ds_uid: query.datasource, ds_uid: query.datasource,
}; };

View File

@ -3,12 +3,14 @@ import React, { FC } from 'react';
import { useAsync, useLocalStorage } from 'react-use'; import { useAsync, useLocalStorage } from 'react-use';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { Checkbox, CollapsableSection, Icon, stylesFactory, useTheme } from '@grafana/ui'; import { Checkbox, CollapsableSection, Icon, stylesFactory, useTheme } from '@grafana/ui';
import impressionSrv from 'app/core/services/impression_srv';
import { getSectionStorageKey } from 'app/features/search/utils'; import { getSectionStorageKey } from 'app/features/search/utils';
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId'; import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
import { SearchItem } from '../..'; import { SearchItem } from '../..';
import { getGrafanaSearcher } from '../../service'; import { getGrafanaSearcher, SearchQuery } from '../../service';
import { DashboardSearchItemType, DashboardSectionItem } from '../../types'; import { DashboardSearchItemType, DashboardSectionItem } from '../../types';
import { SelectionChecker, SelectionToggle } from '../selection'; import { SelectionChecker, SelectionToggle } from '../selection';
@ -38,15 +40,32 @@ export const FolderSection: FC<SectionHeaderProps> = ({ section, selectionToggle
if (!sectionExpanded) { if (!sectionExpanded) {
return Promise.resolve([] as DashboardSectionItem[]); return Promise.resolve([] as DashboardSectionItem[]);
} }
let query = { let folderUid: string | undefined = section.uid;
let folderTitle: string | undefined = section.title;
let query: SearchQuery = {
query: '*', query: '*',
kind: ['dashboard'], kind: ['dashboard'],
location: section.uid, location: section.uid,
}; };
if (section.title === 'Starred') { if (section.title === 'Starred') {
// TODO const stars = await getBackendSrv().get('api/user/stars');
if (stars.length > 0) {
query = {
uid: stars, // array of UIDs
};
}
folderUid = undefined;
folderTitle = undefined;
} else if (section.title === 'Recent') { } else if (section.title === 'Recent') {
// TODO const ids = impressionSrv.getDashboardOpened();
const uids = await getBackendSrv().get(`/api/dashboards/ids/${ids.slice(0, 30).join(',')}`);
if (uids?.length) {
query = {
uid: uids,
};
}
folderUid = undefined;
folderTitle = undefined;
} }
const raw = await getGrafanaSearcher().search(query); const raw = await getGrafanaSearcher().search(query);
const v = raw.view.map( const v = raw.view.map(
@ -60,16 +79,36 @@ export const FolderSection: FC<SectionHeaderProps> = ({ section, selectionToggle
id: 666, // do not use me! id: 666, // do not use me!
isStarred: false, isStarred: false,
tags: item.tags ?? [], tags: item.tags ?? [],
checked: selection ? selection(item.kind, item.uid) : false, folderUid,
folderTitle,
} as DashboardSectionItem) } as DashboardSectionItem)
); );
console.log('HERE!');
return v; return v;
}, [sectionExpanded, section]); }, [sectionExpanded, section]);
const onSectionExpand = () => { const onSectionExpand = () => {
setSectionExpanded(!sectionExpanded); setSectionExpanded(!sectionExpanded);
console.log('TODO!! section', section.title, section); };
const onToggleFolder = (evt: React.FormEvent) => {
evt.preventDefault();
evt.stopPropagation();
if (selectionToggle && selection) {
const checked = !selection(section.kind, section.uid);
selectionToggle(section.kind, section.uid);
const sub = results.value ?? [];
for (const item of sub) {
if (selection('dashboard', item.uid!) !== checked) {
selectionToggle('dashboard', item.uid!);
}
}
}
};
const onToggleChecked = (item: DashboardSectionItem) => {
if (selectionToggle) {
selectionToggle('dashboard', item.uid!);
}
}; };
const id = useUniqueId(); const id = useUniqueId();
@ -80,6 +119,31 @@ export const FolderSection: FC<SectionHeaderProps> = ({ section, selectionToggle
icon = sectionExpanded ? 'folder-open' : 'folder'; icon = sectionExpanded ? 'folder-open' : 'folder';
} }
const renderResults = () => {
if (!results.value?.length) {
return <div>No items found</div>;
}
return results.value.map((v) => {
if (selection && selectionToggle) {
const type = v.type === DashboardSearchItemType.DashFolder ? 'folder' : 'dashboard';
v = {
...v,
checked: selection(type, v.uid!),
};
}
return (
<SearchItem
key={v.uid}
item={v}
onTagSelected={onTagSelected}
onToggleChecked={onToggleChecked as any}
editable={Boolean(selection != null)}
/>
);
});
};
return ( return (
<CollapsableSection <CollapsableSection
isOpen={sectionExpanded ?? false} isOpen={sectionExpanded ?? false}
@ -91,7 +155,7 @@ export const FolderSection: FC<SectionHeaderProps> = ({ section, selectionToggle
label={ label={
<> <>
{selectionToggle && selection && ( {selectionToggle && selection && (
<div onClick={(v) => console.log(v)} className={styles.checkbox}> <div className={styles.checkbox} onClick={onToggleFolder}>
<Checkbox value={selection(section.kind, section.uid)} aria-label="Select folder" /> <Checkbox value={selection(section.kind, section.uid)} aria-label="Select folder" />
</div> </div>
)} )}
@ -111,13 +175,7 @@ export const FolderSection: FC<SectionHeaderProps> = ({ section, selectionToggle
</> </>
} }
> >
{results.value && ( {results.value && <ul className={styles.sectionItems}>{renderResults()}</ul>}
<ul>
{results.value.map((v) => (
<SearchItem key={v.uid} item={v} onTagSelected={onTagSelected} />
))}
</ul>
)}
</CollapsableSection> </CollapsableSection>
); );
}; };
@ -150,6 +208,9 @@ const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = fa
'pointer', 'pointer',
{ selected } { selected }
), ),
sectionItems: css`
margin: 0 24px 0 32px;
`,
checkbox: css` checkbox: css`
padding: 0 ${sm} 0 0; padding: 0 ${sm} 0 0;
`, `,

View File

@ -4,6 +4,7 @@ import { FixedSizeGrid } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader'; import InfiniteLoader from 'react-window-infinite-loader';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { SearchCard } from '../../components/SearchCard'; import { SearchCard } from '../../components/SearchCard';
@ -42,10 +43,20 @@ export const SearchResultsGrid = ({
const cellWidth = width / numColumns; const cellWidth = width / numColumns;
const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8; const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8;
const numRows = Math.ceil(itemCount / numColumns); const numRows = Math.ceil(itemCount / numColumns);
return ( return (
<InfiniteLoader isItemLoaded={response.isItemLoaded} itemCount={itemCount} loadMoreItems={response.loadMoreItems}> <InfiniteLoader isItemLoaded={response.isItemLoaded} itemCount={itemCount} loadMoreItems={response.loadMoreItems}>
{({ onItemsRendered, ref }) => ( {({ onItemsRendered, ref }) => (
<FixedSizeGrid <FixedSizeGrid
ref={ref}
onItemsRendered={(v) => {
onItemsRendered({
visibleStartIndex: v.visibleRowStartIndex * numColumns,
visibleStopIndex: v.visibleRowStopIndex * numColumns,
overscanStartIndex: v.overscanRowStartIndex * numColumns,
overscanStopIndex: v.overscanColumnStopIndex * numColumns,
});
}}
columnCount={numColumns} columnCount={numColumns}
columnWidth={cellWidth} columnWidth={cellWidth}
rowCount={numRows} rowCount={numRows}
@ -60,9 +71,9 @@ export const SearchResultsGrid = ({
if (index >= view.length) { if (index >= view.length) {
return null; return null;
} }
const item = view.get(index); const item = view.get(index);
const kind = item.kind ?? 'dashboard'; const kind = item.kind ?? 'dashboard';
const facade: DashboardSectionItem = { const facade: DashboardSectionItem = {
uid: item.uid, uid: item.uid,
title: item.name, title: item.name,
@ -75,6 +86,20 @@ export const SearchResultsGrid = ({
checked: selection ? selection(kind, item.uid) : false, checked: selection ? selection(kind, item.uid) : false,
}; };
if (kind === 'panel') {
const type = item.panel_type;
facade.icon = 'public/img/icons/unicons/graph-bar.svg';
if (type) {
const info = config.panels[type];
if (info?.name) {
const v = info.info?.logos.small;
if (v && v.endsWith('.svg')) {
facade.icon = v;
}
}
}
}
// The wrapper div is needed as the inner SearchItem has margin-bottom spacing // The wrapper div is needed as the inner SearchItem has margin-bottom spacing
// And without this wrapper there is no room for that margin // And without this wrapper there is no room for that margin
return item ? ( return item ? (

View File

@ -5,7 +5,6 @@ import SVG from 'react-inlinesvg';
import { Field } from '@grafana/data'; import { Field } from '@grafana/data';
import { config, getDataSourceSrv } from '@grafana/runtime'; import { config, getDataSourceSrv } from '@grafana/runtime';
import { Checkbox, Icon, IconName, TagList } from '@grafana/ui'; import { Checkbox, Icon, IconName, TagList } from '@grafana/ui';
import { DefaultCell } from '@grafana/ui/src/components/Table/DefaultCell';
import { QueryResponse, SearchResultMeta } from '../../service'; import { QueryResponse, SearchResultMeta } from '../../service';
import { SelectionChecker, SelectionToggle } from '../selection'; import { SelectionChecker, SelectionToggle } from '../selection';
@ -221,11 +220,11 @@ function makeTypeColumn(
styles: Record<string, string> styles: Record<string, string>
): TableColumn { ): TableColumn {
return { return {
Cell: DefaultCell,
id: `column-type`, id: `column-type`,
field: kindField ?? typeField, field: kindField ?? typeField,
Header: 'Type', Header: 'Type',
accessor: (row: any, i: number) => { Cell: (p) => {
const i = p.row.index;
const kind = kindField?.values.get(i) ?? 'dashboard'; const kind = kindField?.values.get(i) ?? 'dashboard';
let icon = 'public/img/icons/unicons/apps.svg'; let icon = 'public/img/icons/unicons/apps.svg';
let txt = 'Dashboard'; let txt = 'Dashboard';
@ -253,13 +252,15 @@ function makeTypeColumn(
icon = v; icon = v;
} }
txt = info.name; txt = info.name;
} else {
icon = `public/img/icons/unicons/question.svg`; // plugin not found
} }
} }
break; break;
} }
} }
return ( return (
<div className={styles.typeText}> <div {...p.cellProps} className={styles.typeText}>
<SVG src={icon} width={14} height={14} title={txt} className={styles.typeIcon} /> <SVG src={icon} width={14} height={14} title={txt} className={styles.typeIcon} />
{txt} {txt}
</div> </div>

View File

@ -70,7 +70,21 @@ export async function doSearchQuery(query: SearchQuery): Promise<QueryResponse>
field.display = getDisplayProcessor({ field, theme: config.theme2 }); field.display = getDisplayProcessor({ field, theme: config.theme2 });
} }
const meta = first.meta?.custom as SearchResultMeta; // Make sure the object exists
if (!first.meta?.custom) {
first.meta = {
...first.meta,
custom: {
count: first.length,
max_score: 1,
},
};
}
const meta = first.meta.custom as SearchResultMeta;
if (!meta.locationInfo) {
meta.locationInfo = {};
}
const view = new DataFrameView<DashboardQueryResult>(first); const view = new DataFrameView<DashboardQueryResult>(first);
return { return {
totalRows: meta.count ?? first.length, totalRows: meta.count ?? first.length,

View File

@ -40,6 +40,7 @@ export interface DashboardSectionItem {
tags: string[]; tags: string[];
title: string; title: string;
type: DashboardSearchItemType; type: DashboardSearchItemType;
icon?: string; // used for grid view
uid?: string; uid?: string;
uri: string; uri: string;
url: string; url: string;