Search: extract a reusable view from the search playground (#49132)

Co-authored-by: Alexandra Vargas <alexa1866@gmail.com>
This commit is contained in:
Ryan McKinley 2022-05-18 06:46:43 -07:00 committed by GitHub
parent 7251115457
commit f0496955e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 331 additions and 182 deletions

View File

@ -11,7 +11,7 @@ import * as SearchSrv from 'app/core/services/search_srv';
import { searchResults } from '../testData';
import { SearchLayout } from '../types';
import { DashboardSearch, Props } from './DashboardSearch';
import { DashboardSearchOLD as DashboardSearch, Props } from './DashboardSearch';
jest.mock('app/core/services/search_srv');
// Typecast the mock search so the mock import is correctly recognised by TS

View File

@ -1,11 +1,14 @@
import { css } from '@emotion/css';
import React, { FC, memo } from 'react';
import React, { FC, memo, useState } from 'react';
import { useDebounce } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { CustomScrollbar, IconButton, stylesFactory, useTheme2 } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { CustomScrollbar, IconButton, stylesFactory, useStyles2, useTheme2 } from '@grafana/ui';
import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { SearchView } from '../page/components/SearchView';
import { ActionRow } from './ActionRow';
import { PreviewsSystemRequirements } from './PreviewsSystemRequirements';
@ -16,7 +19,55 @@ export interface Props {
onCloseSearch: () => void;
}
export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
export default function DashboardSearch({ onCloseSearch }: Props) {
if (false && config.featureToggles.panelTitleSearch) {
// TODO: "folder:current" ????
return <DashbaordSearchNEW onCloseSearch={onCloseSearch} />;
}
return <DashboardSearchOLD onCloseSearch={onCloseSearch} />;
}
function DashbaordSearchNEW({ onCloseSearch }: Props) {
const styles = useStyles2(getStyles);
const { query, onQueryChange } = useSearchQuery({});
const [inputValue, setInputValue] = useState(query.query ?? '');
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
setInputValue(e.currentTarget.value);
};
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
return (
<div tabIndex={0} className={styles.overlay}>
<div className={styles.container}>
<div className={styles.searchField}>
<div>
<input
type="text"
placeholder="Search dashboards by name"
value={inputValue}
onChange={onSearchQueryChange}
tabIndex={0}
spellCheck={false}
className={styles.input}
autoFocus
/>
</div>
<div className={styles.closeBtn}>
<IconButton name="times" surface="panel" onClick={onCloseSearch} size="xxl" tooltip="Close search" />
</div>
</div>
<div className={styles.search}>
<SearchView showManage={false} queryText={query.query} />
</div>
</div>
</div>
);
}
export const DashboardSearchOLD: FC<Props> = memo(({ onCloseSearch }) => {
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery({});
const { results, loading, onToggleSection, onKeyDown, showPreviews, setShowPreviews } = useDashboardSearch(
query,
@ -67,9 +118,7 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch }) => {
);
});
DashboardSearch.displayName = 'DashboardSearch';
export default DashboardSearch;
DashboardSearchOLD.displayName = 'DashboardSearchOLD';
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
@ -113,5 +162,19 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
height: 100%;
padding-bottom: ${theme.spacing(3)};
`,
input: css`
box-sizing: border-box;
outline: none;
background-color: transparent;
background: transparent;
border-bottom: 2px solid ${theme.v1.colors.border1};
font-size: 20px;
line-height: 38px;
width: 100%;
&::placeholder {
color: ${theme.v1.colors.textWeak};
}
`,
};
});

View File

@ -1,26 +1,17 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import React, { useState } from 'react';
import { useAsync, useDebounce } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Input, useStyles2, Spinner, InlineSwitch, InlineFieldRow, InlineField, Button, Select } from '@grafana/ui';
import { Input, useStyles2, Spinner, InlineSwitch, InlineFieldRow, InlineField, Select } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { backendSrv } from 'app/core/services/backend_srv';
import { FolderDTO } from 'app/types';
import { PreviewsSystemRequirements } from '../components/PreviewsSystemRequirements';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { getGrafanaSearcher, SearchQuery } from '../service';
import { SearchLayout } from '../types';
import { getGrafanaSearcher } from '../service';
import { ActionRow, getValidQueryLayout } from './components/ActionRow';
import { FolderSection } from './components/FolderSection';
import { FolderView } from './components/FolderView';
import { ManageActions } from './components/ManageActions';
import { SearchResultsGrid } from './components/SearchResultsGrid';
import { SearchResultsTable, SearchResultsProps } from './components/SearchResultsTable';
import { newSearchSelection, updateSearchSelection } from './selection';
import { SearchView } from './components/SearchView';
const node: NavModelItem = {
id: 'search',
@ -32,11 +23,9 @@ const node: NavModelItem = {
export default function SearchPage() {
const styles = useStyles2(getStyles);
const { query, onQueryChange, onTagFilterChange, onTagAdd, onDatasourceChange, onSortChange, onLayoutChange } =
useSearchQuery({});
const [showManage, setShowManage] = useState(false); // grid vs list view
const [folder, setFolder] = useState<string>(); // grid vs list view
const [folderDTO, setFolderDTO] = useState<FolderDTO>(); // grid vs list view
const folders = useAsync(async () => {
const rsp = await getGrafanaSearcher().search({
query: '*',
@ -44,139 +33,25 @@ export default function SearchPage() {
});
return rsp.view.map((v) => ({ value: v.uid, label: v.name }));
}, []);
const [searchSelection, setSearchSelection] = useState(newSearchSelection());
const layout = getValidQueryLayout(query);
const isFolders = layout === SearchLayout.Folders;
const results = useAsync(() => {
let qstr = query.query as string;
if (!qstr?.length) {
qstr = '*';
const setFolder = async (uid?: string) => {
if (uid?.length) {
const dto = await backendSrv.getFolderByUid(uid);
setFolderDTO(dto);
} else {
setFolderDTO(undefined);
}
const q: SearchQuery = {
query: qstr,
tags: query.tag as string[],
ds_uid: query.datasource as string,
location: folder, // This will scope all results to the prefix
};
return getGrafanaSearcher().search(q);
}, [query, layout, folder]);
};
const [inputValue, setInputValue] = useState('');
// since we don't use "query" from use search... it is not actually loaded from the URL!
const { query, onQueryChange } = useSearchQuery({});
const [inputValue, setInputValue] = useState(query.query ?? '');
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
setInputValue(e.currentTarget.value);
};
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
const toggleSelection = useCallback(
(kind: string, uid: string) => {
const current = searchSelection.isSelected(kind, uid);
if (kind === 'folder') {
// ??? also select all children?
}
setSearchSelection(updateSearchSelection(searchSelection, !current, kind, [uid]));
},
[searchSelection]
);
if (!config.featureToggles.panelTitleSearch) {
return <div className={styles.unsupported}>Unsupported</div>;
}
// This gets the possible tags from within the query results
const getTagOptions = (): Promise<TermCount[]> => {
const q: SearchQuery = {
query: query.query?.length ? query.query : '*',
tags: query.tag,
ds_uid: query.datasource,
};
return getGrafanaSearcher().tags(q);
};
// function to update items when dashboards or folders are moved or deleted
const onChangeItemsList = async () => {
// clean up search selection
setSearchSelection(newSearchSelection());
// trigger again the search to the backend
onQueryChange(inputValue);
};
const renderResults = () => {
const value = results.value;
if ((!value || !value.totalRows) && !isFolders) {
if (results.loading && !value) {
return <Spinner />;
}
return (
<div className={styles.noResults}>
<div>No results found for your query.</div>
<br />
<Button
variant="secondary"
onClick={() => {
if (query.query) {
onQueryChange('');
}
if (query.tag?.length) {
onTagFilterChange([]);
}
if (query.datasource) {
onDatasourceChange(undefined);
}
}}
>
Remove search constraints
</Button>
</div>
);
}
const selection = showManage ? searchSelection.isSelected : undefined;
if (layout === SearchLayout.Folders) {
if (folder) {
return (
<FolderSection
section={{ uid: folder, kind: 'folder', title: folder }}
selection={selection}
selectionToggle={toggleSelection}
onTagSelected={onTagAdd}
renderStandaloneBody={true}
/>
);
}
return <FolderView selection={selection} selectionToggle={toggleSelection} onTagSelected={onTagAdd} />;
}
return (
<div style={{ height: '100%', width: '100%' }}>
<AutoSizer>
{({ width, height }) => {
const props: SearchResultsProps = {
response: value!,
selection,
selectionToggle: toggleSelection,
width: width,
height: height,
onTagSelected: onTagAdd,
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
};
if (layout === SearchLayout.Grid) {
return <SearchResultsGrid {...props} />;
}
return <SearchResultsTable {...props} />;
}}
</AutoSizer>
</div>
);
};
return (
<Page navModel={{ node: node, main: node }}>
<Page.Contents
@ -193,7 +68,7 @@ export default function SearchPage() {
spellCheck={false}
placeholder="Search for dashboards and panels"
className={styles.searchInput}
suffix={results.loading ? <Spinner /> : null}
suffix={false ? <Spinner /> : null}
/>
<InlineFieldRow>
<InlineField label="Show manage options">
@ -209,34 +84,7 @@ export default function SearchPage() {
</InlineField>
</InlineFieldRow>
{Boolean(searchSelection.items.size > 0) ? (
<ManageActions items={searchSelection.items} onChange={onChangeItemsList} />
) : (
<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}
/>
)}
{layout === SearchLayout.Grid && (
<PreviewsSystemRequirements
bottomSpacing={3}
showPreviews={true}
onRemove={() => onLayoutChange(SearchLayout.List)}
/>
)}
{renderResults()}
<SearchView showManage={showManage} folderDTO={folderDTO} queryText={query.query} />
</Page.Contents>
</Page>
);

View File

@ -58,13 +58,22 @@ export const ActionRow: FC<Props> = ({
hideLayout,
}) => {
const styles = useStyles2(getStyles);
const layout = getValidQueryLayout(query);
// Disabled folder layout option when query is present
const disabledOptions = query.sort || query.query ? [SearchLayout.Folders] : [];
return (
<div className={styles.actionRow}>
<div className={styles.rowContainer}>
<HorizontalGroup spacing="md" width="auto">
{!hideLayout && (
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={getValidQueryLayout(query)} />
<RadioButtonGroup
options={layoutOptions}
disabledOptions={disabledOptions}
onChange={onLayoutChange}
value={layout}
/>
)}
<SortPicker onChange={onSortChange} value={query.sort?.value} />
</HorizontalGroup>

View File

@ -4,7 +4,7 @@ import { useAsync, useLocalStorage } from 'react-use';
import { GrafanaTheme } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { Checkbox, CollapsableSection, Icon, stylesFactory, useTheme } from '@grafana/ui';
import { Card, Checkbox, CollapsableSection, Icon, Spinner, stylesFactory, useTheme } from '@grafana/ui';
import impressionSrv from 'app/core/services/impression_srv';
import { getSectionStorageKey } from 'app/features/search/utils';
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
@ -128,7 +128,15 @@ export const FolderSection: FC<SectionHeaderProps> = ({
const renderResults = () => {
if (!results.value?.length) {
return <div>No items found</div>;
if (results.loading) {
return <Spinner />;
}
return (
<Card>
<Card.Heading>No results found</Card.Heading>
</Card>
);
}
return results.value.map((v) => {

View File

@ -83,6 +83,8 @@ const getStyles = (theme: GrafanaTheme2) => {
> ul {
list-style: none;
}
border: solid 1px ${theme.v1.colors.border2};
`,
section: css`
display: flex;

View File

@ -0,0 +1,219 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2, Spinner, Button } from '@grafana/ui';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { FolderDTO } from 'app/types';
import { PreviewsSystemRequirements } from '../../components/PreviewsSystemRequirements';
import { useSearchQuery } from '../../hooks/useSearchQuery';
import { getGrafanaSearcher, SearchQuery } from '../../service';
import { SearchLayout } from '../../types';
import { newSearchSelection, updateSearchSelection } from '../selection';
import { ActionRow, getValidQueryLayout } from './ActionRow';
import { FolderSection } from './FolderSection';
import { FolderView } from './FolderView';
import { ManageActions } from './ManageActions';
import { SearchResultsGrid } from './SearchResultsGrid';
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable';
type SearchViewProps = {
queryText: string; // odd that it is not from query.query
showManage: boolean;
folderDTO?: FolderDTO;
};
export const SearchView = ({ showManage, folderDTO, queryText }: SearchViewProps) => {
const styles = useStyles2(getStyles);
const { query, onQueryChange, onTagFilterChange, onTagAdd, onDatasourceChange, onSortChange, onLayoutChange } =
useSearchQuery({});
query.query = queryText; // Use the query value passed in from parent rather than from URL
const [searchSelection, setSearchSelection] = useState(newSearchSelection());
const layout = getValidQueryLayout(query);
const isFolders = layout === SearchLayout.Folders;
const results = useAsync(() => {
let qstr = queryText;
if (!qstr?.length) {
qstr = '*';
}
const q: SearchQuery = {
query: qstr,
tags: query.tag as string[],
ds_uid: query.datasource as string,
location: folderDTO?.uid, // This will scope all results to the prefix
};
return getGrafanaSearcher().search(q);
}, [query, layout, queryText, folderDTO]);
const toggleSelection = useCallback(
(kind: string, uid: string) => {
const current = searchSelection.isSelected(kind, uid);
if (kind === 'folder') {
// ??? also select all children?
}
setSearchSelection(updateSearchSelection(searchSelection, !current, kind, [uid]));
},
[searchSelection]
);
if (!config.featureToggles.panelTitleSearch) {
return <div className={styles.unsupported}>Unsupported</div>;
}
// This gets the possible tags from within the query results
const getTagOptions = (): Promise<TermCount[]> => {
const q: SearchQuery = {
query: query.query?.length ? query.query : '*',
tags: query.tag,
ds_uid: query.datasource,
};
return getGrafanaSearcher().tags(q);
};
// function to update items when dashboards or folders are moved or deleted
const onChangeItemsList = async () => {
// clean up search selection
setSearchSelection(newSearchSelection());
// trigger again the search to the backend
onQueryChange(query.query);
};
const renderResults = () => {
const value = results.value;
if ((!value || !value.totalRows) && !isFolders) {
if (results.loading && !value) {
return <Spinner />;
}
return (
<div className={styles.noResults}>
<div>No results found for your query.</div>
<br />
<Button
variant="secondary"
onClick={() => {
if (query.query) {
onQueryChange('');
}
if (query.tag?.length) {
onTagFilterChange([]);
}
if (query.datasource) {
onDatasourceChange(undefined);
}
}}
>
Remove search constraints
</Button>
</div>
);
}
const selection = showManage ? searchSelection.isSelected : undefined;
if (layout === SearchLayout.Folders) {
if (folderDTO) {
return (
<FolderSection
section={{ uid: folderDTO.uid, kind: 'folder', title: folderDTO.title }}
selection={selection}
selectionToggle={toggleSelection}
onTagSelected={onTagAdd}
renderStandaloneBody={true}
/>
);
}
return <FolderView selection={selection} selectionToggle={toggleSelection} onTagSelected={onTagAdd} />;
}
return (
<div style={{ height: '100%', width: '100%' }}>
<AutoSizer>
{({ width, height }) => {
const props: SearchResultsProps = {
response: value!,
selection,
selectionToggle: toggleSelection,
width: width,
height: height,
onTagSelected: onTagAdd,
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
};
if (layout === SearchLayout.Grid) {
return <SearchResultsGrid {...props} />;
}
return <SearchResultsTable {...props} />;
}}
</AutoSizer>
</div>
);
};
if (!config.featureToggles.panelTitleSearch) {
return <div className={styles.unsupported}>Unsupported</div>;
}
return (
<>
{Boolean(searchSelection.items.size > 0) ? (
<ManageActions items={searchSelection.items} onChange={onChangeItemsList} />
) : (
<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}
/>
)}
{layout === SearchLayout.Grid && (
<PreviewsSystemRequirements
bottomSpacing={3}
showPreviews={true}
onRemove={() => onLayoutChange(SearchLayout.List)}
/>
)}
{renderResults()}
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
searchInput: css`
margin-bottom: 6px;
min-height: ${theme.spacing(4)};
`,
unsupported: css`
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 18px;
`,
noResults: css`
padding: ${theme.v1.spacing.md};
background: ${theme.v1.colors.bg2};
font-style: italic;
margin-top: ${theme.v1.spacing.md};
`,
});