Search add sorting picker (#23746)

* Search: Extend search_srv with getSortOptions

* Search: Enable sorting

* Search: Fullwidth search

* Search: Add SortPicker

* Search: Add useSearchLayout

* Search: Update sort query

* Search: Refactor items rendering

* Search: Add sort to manage dashboards

* Search: Add sort icon

* Search: Mock getSortOptions

* Search: Fix Select sizes

* Search: Move SortPicker to Select

* Grafana-UI: Add ActionRow.tsx

* Grafana-UI: Use ActionRow in dashboard search

* Grafana-UI: Update ActionRow styles

* Search: Update tests

* Search: enable clearing TagFilter

* Search: Move getSortOptions outside of component

* Search: Fix import

* Search: Limit container width

* Search: Replace SearchField's clear text with icon

* Search: Fix section items query #23792

* Search: Add icons for layout switch

* Search: Remove layout switch for folder page
This commit is contained in:
Alex Khomenko 2020-04-23 08:18:53 +03:00 committed by GitHub
parent 66d405acab
commit c0fe565499
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 405 additions and 182 deletions

View File

@ -30,6 +30,9 @@ const getRadioButtonGroupStyles = () => {
}
}
`,
icon: css`
margin-right: 6px;
`,
};
};
@ -81,7 +84,7 @@ export function RadioButtonGroup<T>({
name={groupName.current}
fullWidth={fullWidth}
>
{o.icon && <Icon name={o.icon} />}
{o.icon && <Icon name={o.icon} className={styles.icon} />}
{o.label}
</RadioButton>
);

View File

@ -10,7 +10,7 @@ import * as MonoIcon from './assets';
const alwaysMonoIcons = ['grafana', 'favorite'];
interface IconProps extends React.HTMLAttributes<HTMLDivElement> {
export interface IconProps extends React.HTMLAttributes<HTMLDivElement> {
name: IconName;
size?: IconSize;
type?: IconType;

View File

@ -37,13 +37,15 @@ export const Layout: React.FC<LayoutProps> = ({
const styles = getStyles(theme, orientation, spacing, justify, align);
return (
<div className={styles.layout} style={{ width }}>
{React.Children.map(children, (child, index) => {
return (
<div className={styles.childWrapper} key={index}>
{child}
</div>
);
})}
{React.Children.toArray(children)
.filter(Boolean)
.map((child, index) => {
return (
<div className={styles.childWrapper} key={index}>
{child}
</div>
);
})}
</div>
);
};

View File

@ -91,6 +91,7 @@ export function SelectBase<T>({
allowCustomValue = false,
autoFocus = false,
backspaceRemovesValue = true,
className,
closeMenuOnSelect = true,
components,
defaultOptions,
@ -110,8 +111,8 @@ export function SelectBase<T>({
loadingMessage = 'Loading options...',
maxMenuHeight = 300,
maxVisibleValues,
menuPosition,
menuPlacement = 'auto',
menuPosition,
noOptionsMessage = 'No options found',
onBlur,
onChange,
@ -127,7 +128,6 @@ export function SelectBase<T>({
renderControl,
showAllSelectedWhenOpen = true,
tabSelectsValue = true,
className,
value,
width,
}: SelectBaseProps<T>) {

View File

@ -24,6 +24,7 @@ export type IconName =
| 'plus-square'
| 'folder-plus'
| 'folder-open'
| 'folder'
| 'file-copy-alt'
| 'file-alt'
| 'exchange-alt'
@ -60,7 +61,7 @@ export type IconName =
| 'clock-nine'
| 'sync'
| 'sign-in-alt'
| 'cllud-download'
| 'cloud-download'
| 'cog'
| 'bars'
| 'save'
@ -111,7 +112,8 @@ export type IconName =
| 'heart'
| 'heart-break'
| 'ellipsis-v'
| 'favorite';
| 'favorite'
| 'sort-amount-down';
export const getAvailableIcons = (): IconName[] => [
'fa fa-spinner',
@ -135,6 +137,7 @@ export const getAvailableIcons = (): IconName[] => [
'plus-square',
'folder-plus',
'folder-open',
'folder',
'file-copy-alt',
'file-alt',
'exchange-alt',
@ -171,7 +174,7 @@ export const getAvailableIcons = (): IconName[] => [
'clock-nine',
'sync',
'sign-in-alt',
'cllud-download',
'cloud-download',
'cog',
'bars',
'save',
@ -223,4 +226,5 @@ export const getAvailableIcons = (): IconName[] => [
'heart-break',
'ellipsis-v',
'favorite',
'sort-amount-down',
];

View File

@ -0,0 +1,33 @@
import React, { FC } from 'react';
import { AsyncSelect, Icon } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { DEFAULT_SORT } from 'app/features/search/constants';
import { SearchSrv } from '../../services/search_srv';
const searchSrv = new SearchSrv();
export interface Props {
onChange: (sortValue: SelectableValue) => void;
value?: SelectableValue;
placeholder?: string;
}
const getSortOptions = () => {
return searchSrv.getSortOptions().then(({ sortOptions }) => {
return sortOptions.map((opt: any) => ({ label: opt.displayName, value: opt.name }));
});
};
export const SortPicker: FC<Props> = ({ onChange, value, placeholder }) => {
return (
<AsyncSelect
width={25}
onChange={onChange}
value={[value]}
loadOptions={getSortOptions}
defaultOptions
placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`}
prefix={<Icon name="sort-amount-down" />}
/>
);
};

View File

@ -1,10 +1,10 @@
// Libraries
import React from 'react';
import { css } from 'emotion';
// @ts-ignore
import { components } from '@torkelo/react-select';
import { AsyncSelect } from '@grafana/ui';
import { AsyncSelect, stylesFactory } from '@grafana/ui';
import { resetSelectStyles, Icon } from '@grafana/ui';
import { FormInputSize } from '@grafana/ui/src/components/Forms/types';
import { escapeStringForRegex } from '@grafana/data';
// Components
import { TagOption } from './TagOption';
@ -16,18 +16,23 @@ export interface TermCount {
}
export interface Props {
tags: string[];
tagOptions: () => Promise<TermCount[]>;
onChange: (tags: string[]) => void;
size?: FormInputSize;
placeholder?: string;
/** Do not show selected values inside Select. Useful when the values need to be shown in some other components */
hideValues?: boolean;
isClearable?: boolean;
onChange: (tags: string[]) => void;
placeholder?: string;
tagOptions: () => Promise<TermCount[]>;
tags: string[];
width?: number;
}
const filterOption = (option: any, searchQuery: string) => {
const regex = RegExp(escapeStringForRegex(searchQuery), 'i');
return regex.test(option.value);
};
export class TagFilter extends React.Component<Props, any> {
static defaultProps = {
size: 'auto',
placeholder: 'Tags',
};
@ -52,26 +57,26 @@ export class TagFilter extends React.Component<Props, any> {
};
render() {
const styles = getStyles();
const tags = this.props.tags.map(tag => ({ value: tag, label: tag, count: 0 }));
const { size, placeholder, hideValues } = this.props;
const { width, placeholder, hideValues, isClearable } = this.props;
const selectOptions = {
defaultOptions: true,
filterOption,
getOptionLabel: (i: any) => i.label,
getOptionValue: (i: any) => i.value,
isClearable,
isMulti: true,
loadOptions: this.onLoadOptions,
loadingMessage: 'Loading...',
noOptionsMessage: 'No tags found',
onChange: this.onChange,
placeholder,
size,
styles: resetSelectStyles(),
value: tags,
filterOption: (option: any, searchQuery: string) => {
const regex = RegExp(escapeStringForRegex(searchQuery), 'i');
return regex.test(option.value);
},
width,
components: {
Option: TagOption,
MultiValueLabel: (): any => {
@ -91,9 +96,29 @@ export class TagFilter extends React.Component<Props, any> {
};
return (
<div className="tag-filter">
<div className={styles.tagFilter}>
<AsyncSelect {...selectOptions} prefix={<Icon name="tag-alt" />} />
</div>
);
}
}
const getStyles = stylesFactory(() => {
return {
tagFilter: css`
min-width: 180px;
line-height: 22px;
flex-grow: 1;
.label-tag {
margin-left: 6px;
font-size: 11px;
cursor: pointer;
.fa.fa-remove {
margin-right: 3px;
}
}
`,
};
});

View File

@ -7,6 +7,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { backendSrv } from './backend_srv';
import { Section } from '../components/manage_dashboards/manage_dashboards';
import { DashboardSearchHit, DashboardSearchHitType } from 'app/types/search';
import { hasFilters } from '../../features/search/utils';
interface Sections {
[key: string]: Partial<Section>;
@ -97,22 +98,18 @@ export class SearchSrv {
const sections: any = {};
const promises = [];
const query = _.clone(options);
const hasFilters =
options.query ||
(options.tag && options.tag.length > 0) ||
options.starred ||
(options.folderIds && options.folderIds.length > 0);
const filters = hasFilters(options) || query.folderIds?.length > 0;
if (!options.skipRecent && !hasFilters) {
if (!options.skipRecent && !filters) {
promises.push(this.getRecentDashboards(sections));
}
if (!options.skipStarred && !hasFilters) {
if (!options.skipStarred && !filters) {
promises.push(this.getStarred(sections));
}
query.folderIds = query.folderIds || [];
if (!hasFilters) {
if (!filters) {
query.folderIds = [0];
}
@ -210,6 +207,10 @@ export class SearchSrv {
getDashboardTags() {
return backendSrv.get('/api/dashboards/tags');
}
getSortOptions() {
return backendSrv.get('/api/search/sorting');
}
}
coreModule.service('searchSrv', SearchSrv);

View File

@ -0,0 +1,85 @@
import React, { Dispatch, FC, SetStateAction } from 'react';
import { css } from 'emotion';
import { HorizontalGroup, RadioButtonGroup, Select, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { SortPicker } from 'app/core/components/Select/SortPicker';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { SearchSrv } from 'app/core/services/search_srv';
import { layoutOptions } from '../hooks/useSearchLayout';
import { DashboardQuery } from '../types';
const starredFilterOptions = [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
];
const searchSrv = new SearchSrv();
type onSelectChange = (value: SelectableValue) => void;
interface Props {
layout: string;
onLayoutChange: Dispatch<SetStateAction<string>>;
onSortChange: onSelectChange;
onStarredFilterChange?: onSelectChange;
onTagFilterChange: onSelectChange;
query: DashboardQuery;
showStarredFilter?: boolean;
hideSelectedTags?: boolean;
}
export const ActionRow: FC<Props> = ({
layout,
onLayoutChange,
onSortChange,
onStarredFilterChange,
onTagFilterChange,
query,
showStarredFilter,
hideSelectedTags,
}) => {
const theme = useTheme();
const styles = getStyles(theme);
return (
<div className={styles.actionRow}>
<HorizontalGroup spacing="md" width="100%">
{layout ? <RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={layout} /> : null}
<SortPicker onChange={onSortChange} value={query.sort} />
</HorizontalGroup>
<HorizontalGroup spacing="md" justify="space-between">
{showStarredFilter && (
<Select
width={20}
placeholder="Filter by starred"
key={starredFilterOptions?.find(f => f.value === query.starred)?.label}
options={starredFilterOptions}
onChange={onStarredFilterChange}
/>
)}
<TagFilter
placeholder="Filter by tag"
tags={query.tag}
tagOptions={searchSrv.getDashboardTags}
onChange={onTagFilterChange}
hideValues={hideSelectedTags}
isClearable={!hideSelectedTags}
/>
</HorizontalGroup>
</div>
);
};
ActionRow.displayName = 'ActionRow';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
actionRow: css`
display: flex;
justify-content: space-between;
align-items: center;
padding: ${theme.spacing.md} 0;
width: 100%;
`,
};
});

View File

@ -1,19 +1,13 @@
import React, { FC } from 'react';
import { css } from 'emotion';
import { Icon, useTheme, CustomScrollbar, stylesFactory, Button } from '@grafana/ui';
import { useTheme, CustomScrollbar, stylesFactory, Button } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { SearchSrv } from 'app/core/services/search_srv';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { contextSrv } from 'app/core/services/context_srv';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { useSearchLayout } from '../hooks/useSearchLayout';
import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults';
const searchSrv = new SearchSrv();
const { isEditor, hasEditPermissionInFolders } = contextSrv;
const canEdit = isEditor || hasEditPermissionInFolders;
import { ActionRow } from './ActionRow';
export interface Props {
onCloseSearch: () => void;
@ -22,8 +16,9 @@ export interface Props {
export const DashboardSearch: FC<Props> = ({ onCloseSearch, folder }) => {
const payload = folder ? { query: `folder:${folder}` } : {};
const { query, onQueryChange, onClearFilters, onTagFilterChange, onTagAdd } = useSearchQuery(payload);
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange } = useSearchQuery(payload);
const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch);
const { layout, setLayout } = useSearchLayout(query);
const theme = useTheme();
const styles = getStyles(theme);
@ -36,6 +31,13 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, folder }) => {
}
};
const onLayoutChange = (layout: string) => {
setLayout(layout);
if (query.sort) {
onSortChange(null);
}
};
return (
<div tabIndex={0} className="search-container" onKeyDown={onClose}>
<SearchField
@ -46,60 +48,30 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, folder }) => {
clearable
className={styles.searchField}
/>
<div className="search-dropdown">
<div className="search-dropdown__col_1">
<CustomScrollbar>
<SearchResults
results={results}
loading={loading}
onTagSelected={onTagAdd}
editable={false}
onToggleSection={onToggleSection}
/>
</CustomScrollbar>
</div>
<div className="search-dropdown__col_2">
<div className="search-filter-box">
<div className="search-filter-box__header">
<Icon name="filter" className={styles.filter} size="sm" />
Filter by:
{query.tag.length > 0 && (
<a className="pointer pull-right small" onClick={onClearFilters}>
<Icon name="times" size="sm" /> Clear
</a>
)}
</div>
<TagFilter tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} />
</div>
{canEdit && (
<div className="search-filter-box" onClick={onCloseSearch}>
<a href="dashboard/new" className="search-filter-box-link">
<Icon name="apps" size="xl" className={styles.icon} /> New dashboard
</a>
{isEditor && (
<a href="dashboards/folder/new" className="search-filter-box-link">
<Icon name="folder-plus" size="xl" className={styles.icon} /> New folder
</a>
)}
<a href="dashboard/import" className="search-filter-box-link">
<Icon name="import" size="xl" className={styles.icon} /> Import dashboard
</a>
<a
className="search-filter-box-link"
target="_blank"
href="https://grafana.com/dashboards?utm_source=grafana_search"
>
<Icon name="search" size="xl" className={styles.icon} /> Find dashboards on Grafana.com
</a>
</div>
)}
</div>
<Button icon="times" className={styles.closeBtn} onClick={onCloseSearch} variant="secondary">
Close
</Button>
<div className={styles.search}>
<ActionRow
{...{
layout,
onLayoutChange,
onSortChange,
onTagFilterChange,
query,
}}
/>
<CustomScrollbar>
<SearchResults
results={results}
loading={loading}
onTagSelected={onTagAdd}
editable={false}
onToggleSection={onToggleSection}
layout={layout}
/>
</CustomScrollbar>
</div>
<Button icon="times" className={styles.closeBtn} onClick={onCloseSearch} variant="secondary">
Close
</Button>
</div>
);
};
@ -111,19 +83,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
right: 8px;
position: absolute;
`,
icon: css`
margin-right: ${theme.spacing.sm};
color: ${theme.palette.blue95};
`,
filter: css`
margin-right: ${theme.spacing.xs};
`,
close: css`
margin-left: ${theme.spacing.xs};
margin-bottom: 1px;
`,
searchField: css`
padding-left: ${theme.spacing.md};
`,
search: css`
display: flex;
flex-direction: column;
padding: ${theme.spacing.xl};
height: 100%;
max-width: 1400px;
`,
};
});

View File

@ -12,6 +12,8 @@ import { SearchResultsFilter } from './SearchResultsFilter';
import { SearchResults } from './SearchResults';
import { DashboardActions } from './DashboardActions';
import { SearchField } from './SearchField';
import { useSearchLayout } from '../hooks/useSearchLayout';
import { SearchLayout } from '../types';
export interface Props {
folderId?: number;
@ -36,6 +38,7 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
onTagFilterChange,
onStarredFilterChange,
onTagAdd,
onSortChange,
} = useSearchQuery(queryParams);
const {
@ -53,6 +56,8 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
onMoveItems,
} = useManageDashboards(query, { hasEditPermissionInFolders: contextSrv.hasEditPermissionInFolders }, folderUid);
const { layout, setLayout } = useSearchLayout(query);
const onMoveTo = () => {
setIsMoveModalOpen(true);
};
@ -61,6 +66,13 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
setIsDeleteModalOpen(true);
};
const onLayoutChange = (layout: string) => {
setLayout(layout);
if (query.sort) {
onSortChange(null);
}
};
if (canSave && folderId && !hasFilters && results.length === 0) {
return (
<EmptyListCTA
@ -102,9 +114,24 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
</label>
</div>
)}
{query.sort && (
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={() => onSortChange(null)}>
Sort: {query.sort.label}
</a>
</label>
</div>
)}
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={onClearFilters}>
<a
className="pointer"
onClick={() => {
onClearFilters();
setLayout(SearchLayout.Folders);
}}
>
<Icon name="times" />
&nbsp;Clear
</a>
@ -124,9 +151,11 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
moveTo={onMoveTo}
onToggleAllChecked={onToggleAllChecked}
onStarredFilterChange={onStarredFilterChange}
onSortChange={onSortChange}
onTagFilterChange={onTagFilterChange}
selectedStarredFilter={query.starred}
selectedTagFilter={query.tag}
query={query}
layout={!folderUid && layout}
onLayoutChange={onLayoutChange}
/>
)}
<SearchResults
@ -136,6 +165,7 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
onTagSelected={onTagAdd}
onToggleSection={onToggleSection}
onToggleChecked={onToggleChecked}
layout={layout}
/>
</div>
<ConfirmDeleteModal

View File

@ -71,13 +71,7 @@ export const SearchField: FC<SearchFieldProps> = ({ query, onChange, size, clear
spellCheck={false}
className={styles.input}
prefix={<Icon name="search" />}
suffix={
clearable && (
<span className={styles.clearButton} onClick={() => onChange('')}>
Clear
</span>
)
}
suffix={clearable && <Icon name="times" className={styles.clearButton} onClick={() => onChange('')} />}
{...inputProps}
/>

View File

@ -4,7 +4,7 @@ import { GrafanaTheme } from '@grafana/data';
import { Icon, stylesFactory, useTheme, IconName, IconButton, Spinner } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types';
import { DashboardSection, OnToggleChecked } from '../types';
import { DashboardSection, OnToggleChecked, SearchLayout } from '../types';
import { SearchItem } from './SearchItem';
import { SearchCheckbox } from './SearchCheckbox';
@ -15,6 +15,7 @@ export interface Props {
onToggleChecked?: OnToggleChecked;
onToggleSection: (section: DashboardSection) => void;
results: DashboardSection[] | undefined;
layout?: string;
}
export const SearchResults: FC<Props> = ({
@ -24,10 +25,21 @@ export const SearchResults: FC<Props> = ({
onToggleChecked,
onToggleSection,
results,
layout,
}) => {
const theme = useTheme();
const styles = getSectionStyles(theme);
const renderItems = (section: DashboardSection) => {
if (!section.expanded && layout !== SearchLayout.List) {
return null;
}
return section.items.map(item => (
<SearchItem key={item.id} {...{ item, editable, onToggleChecked, onTagSelected }} />
));
};
if (loading) {
return <Spinner className={styles.spinner} />;
} else if (!results || !results.length) {
@ -37,17 +49,18 @@ export const SearchResults: FC<Props> = ({
return (
<div className="search-results-container">
<ul className={styles.wrapper}>
{results.map(section => (
<li aria-label="Search section" className={styles.section} key={section.title}>
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} />
<ul aria-label="Search items" className={styles.wrapper}>
{section.expanded &&
section.items.map(item => (
<SearchItem key={item.id} {...{ item, editable, onToggleChecked, onTagSelected }} />
))}
</ul>
</li>
))}
{results.map(section =>
layout !== SearchLayout.List ? (
<li aria-label="Search section" className={styles.section} key={section.title}>
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section, layout }} />
<ul aria-label="Search items" className={styles.wrapper}>
{renderItems(section)}
</ul>
</li>
) : (
renderItems(section)
)
)}
</ul>
</div>
);

View File

@ -18,8 +18,9 @@ const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
onStarredFilterChange: noop,
onTagFilterChange: noop,
onToggleAllChecked: noop,
selectedStarredFilter: false,
selectedTagFilter: ['tag'],
//@ts-ignore
query: { starred: false, sort: null, tag: ['tag'] },
onSortChange: noop,
};
Object.assign(props, propOverrides);
@ -36,8 +37,9 @@ const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
describe('SearchResultsFilter', () => {
it('should render "filter by starred" and "filter by tag" filters by default', () => {
const { wrapper } = setup();
expect(wrapper.find({ placeholder: 'Filter by starred' })).toHaveLength(1);
expect(wrapper.find({ placeholder: 'Filter by tag' })).toHaveLength(1);
const ActionRow = wrapper.find('ActionRow').shallow();
expect(ActionRow.find({ placeholder: 'Filter by starred' })).toHaveLength(1);
expect(ActionRow.find({ placeholder: 'Filter by tag' })).toHaveLength(1);
expect(findBtnByText(wrapper, 'Move')).toHaveLength(0);
expect(findBtnByText(wrapper, 'Delete')).toHaveLength(0);
});

View File

@ -1,9 +1,9 @@
import React, { FC } from 'react';
import React, { Dispatch, FC, SetStateAction } from 'react';
import { css } from 'emotion';
import { Button, Select, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
import { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { SearchSrv } from 'app/core/services/search_srv';
import { DashboardQuery } from '../types';
import { ActionRow } from './ActionRow';
type onSelectChange = (value: SelectableValue) => void;
@ -16,17 +16,12 @@ export interface Props {
onStarredFilterChange: onSelectChange;
onTagFilterChange: onSelectChange;
onToggleAllChecked: () => void;
selectedStarredFilter: boolean;
selectedTagFilter: string[];
query: DashboardQuery;
onSortChange: onSelectChange;
onLayoutChange: Dispatch<SetStateAction<string>>;
layout: string;
}
const starredFilterOptions = [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
];
const searchSrv = new SearchSrv();
export const SearchResultsFilter: FC<Props> = ({
allChecked,
canDelete,
@ -36,8 +31,10 @@ export const SearchResultsFilter: FC<Props> = ({
onToggleAllChecked,
onStarredFilterChange,
onTagFilterChange,
selectedStarredFilter = false,
selectedTagFilter,
query,
onSortChange,
layout,
onLayoutChange,
}) => {
const showActions = canDelete || canMove;
const theme = useTheme();
@ -56,39 +53,36 @@ export const SearchResultsFilter: FC<Props> = ({
</Button>
</HorizontalGroup>
) : (
<HorizontalGroup spacing="md">
<Select
placeholder="Filter by starred"
key={starredFilterOptions?.find(f => f.value === selectedStarredFilter)?.label}
options={starredFilterOptions}
onChange={onStarredFilterChange}
/>
<TagFilter
placeholder="Filter by tag"
tags={selectedTagFilter}
tagOptions={searchSrv.getDashboardTags}
onChange={onTagFilterChange}
hideValues
/>
</HorizontalGroup>
<ActionRow
{...{
layout,
onLayoutChange,
onSortChange,
onStarredFilterChange,
onTagFilterChange,
query,
}}
showStarredFilter
hideSelectedTags
/>
)}
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const { sm, md } = theme.spacing;
return {
wrapper: css`
height: 35px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: ${theme.spacing.sm};
margin-bottom: ${sm};
label {
> label {
height: 20px;
margin-left: 8px;
margin: 0 ${md} 0 ${sm};
}
`,
};

View File

@ -4,7 +4,11 @@ export const mockSearch = jest.fn(() => {
jest.mock('app/core/services/search_srv', () => {
return {
SearchSrv: jest.fn().mockImplementation(() => {
return { search: mockSearch, getDashboardTags: jest.fn(() => Promise.resolve(['Tag1', 'Tag2'])) };
return {
search: mockSearch,
getDashboardTags: jest.fn(() => Promise.resolve(['Tag1', 'Tag2'])),
getSortOptions: jest.fn(() => Promise.resolve({ sortOptions: [{ name: 'test', displayName: 'Test' }] })),
};
}),
};
});

View File

@ -1 +1,2 @@
export const NO_ID_SECTIONS = ['Recent', 'Starred'];
export const DEFAULT_SORT = { label: 'A-Z', value: 'alpha-asc' };

View File

@ -1,7 +1,8 @@
import { useEffect } from 'react';
import { useDebounce } from 'react-use';
import { SearchSrv } from 'app/core/services/search_srv';
import { backendSrv } from 'app/core/services/backend_srv';
import { FETCH_RESULTS, FETCH_ITEMS, TOGGLE_SECTION } from '../reducers/actionTypes';
import { FETCH_RESULTS, FETCH_ITEMS, TOGGLE_SECTION, SEARCH_START } from '../reducers/actionTypes';
import { DashboardSection, UseSearch } from '../types';
import { hasId, getParsedQuery } from '../utils';
@ -20,8 +21,8 @@ export const useSearch: UseSearch = (query, reducer, params) => {
const [state, dispatch] = reducer;
const search = () => {
dispatch({ type: SEARCH_START });
const parsedQuery = getParsedQuery(query, queryParsing);
searchSrv.search(parsedQuery).then(results => {
// Remove header for folder search
if (query.folderIds.length === 1 && results.length) {
@ -35,12 +36,17 @@ export const useSearch: UseSearch = (query, reducer, params) => {
});
};
// Set loading state before debounced search
useEffect(() => {
dispatch({ type: SEARCH_START });
}, [query.tag, query.sort, query.starred]);
useDebounce(search, 300, [query, folderUid, queryParsing]);
// TODO as possible improvement, show spinner after expanding section while items are fetching
const onToggleSection = (section: DashboardSection) => {
if (hasId(section.title) && !section.items.length) {
backendSrv.search({ ...query, folderIds: [section.id] }).then(items => {
backendSrv.search({ folderIds: [section.id] }).then(items => {
dispatch({ type: FETCH_ITEMS, payload: { section, items } });
dispatch({ type: TOGGLE_SECTION, payload: section });
});

View File

@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { SearchLayout } from '../types';
export const layoutOptions = [
{ label: 'Folders', value: SearchLayout.Folders, icon: 'folder' },
{ label: 'List', value: SearchLayout.List, icon: 'list-ul' },
];
export const useSearchLayout = (query: any) => {
const [layout, setLayout] = useState<string>(layoutOptions[0].value);
useEffect(() => {
if (query.sort) {
const list = layoutOptions.find(opt => opt.value === SearchLayout.List);
setLayout(list!.value);
}
}, [query]);
return { layout, setLayout };
};

View File

@ -8,9 +8,11 @@ import {
REMOVE_STARRED,
REMOVE_TAG,
SET_TAGS,
TOGGLE_SORT,
TOGGLE_STARRED,
} from '../reducers/actionTypes';
import { DashboardQuery } from '../types';
import { hasFilters } from '../utils';
export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => {
const initialState = { ...defaultQuery, ...queryParams };
@ -44,11 +46,13 @@ export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => {
dispatch({ type: TOGGLE_STARRED, payload: filter.value });
};
const hasFilters = query.query.length > 0 || query.tag.length > 0 || query.starred;
const onSortChange = (sort: SelectableValue) => {
dispatch({ type: TOGGLE_SORT, payload: sort });
};
return {
query,
hasFilters,
hasFilters: hasFilters(query),
onQueryChange,
onRemoveStarred,
onTagRemove,
@ -56,5 +60,6 @@ export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => {
onTagFilterChange,
onStarredFilterChange,
onTagAdd,
onSortChange,
};
};

View File

@ -3,6 +3,7 @@ export const TOGGLE_SECTION = 'TOGGLE_SECTION';
export const FETCH_ITEMS = 'FETCH_ITEMS';
export const MOVE_SELECTION_UP = 'MOVE_SELECTION_UP';
export const MOVE_SELECTION_DOWN = 'MOVE_SELECTION_DOWN';
export const SEARCH_START = 'SEARCH_START';
// Manage dashboards
export const TOGGLE_CAN_SAVE = 'TOGGLE_CAN_SAVE';
@ -20,3 +21,4 @@ export const REMOVE_TAG = 'REMOVE_TAG';
export const CLEAR_FILTERS = 'CLEAR_FILTERS';
export const SET_TAGS = 'SET_TAGS';
export const ADD_TAG = 'ADD_TAG';
export const TOGGLE_SORT = 'TOGGLE_SORT';

View File

@ -1,6 +1,13 @@
import { DashboardSection, SearchAction } from '../types';
import { getFlattenedSections, getLookupField, markSelected } from '../utils';
import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes';
import {
FETCH_ITEMS,
FETCH_RESULTS,
TOGGLE_SECTION,
MOVE_SELECTION_DOWN,
MOVE_SELECTION_UP,
SEARCH_START,
} from './actionTypes';
export interface DashboardsSearchState {
results: DashboardSection[];
@ -16,6 +23,11 @@ export const dashboardsSearchState: DashboardsSearchState = {
export const searchReducer = (state: DashboardsSearchState, action: SearchAction) => {
switch (action.type) {
case SEARCH_START:
if (!state.loading) {
return { ...state, loading: true };
}
return state;
case FETCH_RESULTS: {
const results = action.payload;
// Highlight the first item ('Starred' folder)

View File

@ -7,6 +7,7 @@ import {
REMOVE_TAG,
SET_TAGS,
TOGGLE_STARRED,
TOGGLE_SORT,
} from './actionTypes';
export const defaultQuery: DashboardQuery = {
@ -16,6 +17,7 @@ export const defaultQuery: DashboardQuery = {
skipRecent: false,
skipStarred: false,
folderIds: [],
sort: null,
};
export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
@ -35,7 +37,9 @@ export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
case REMOVE_STARRED:
return { ...state, starred: false };
case CLEAR_FILTERS:
return { ...state, query: '', tag: [], starred: false };
return { ...state, query: '', tag: [], starred: false, sort: null };
case TOGGLE_SORT:
return { ...state, sort: action.payload };
default:
return state;
}

View File

@ -1,5 +1,6 @@
import { Dispatch } from 'react';
import { Action } from 'redux';
import { SelectableValue } from '@grafana/data';
import { FolderInfo } from '../../types';
export enum DashboardSearchItemType {
@ -66,6 +67,7 @@ export interface DashboardQuery {
skipRecent: boolean;
skipStarred: boolean;
folderIds: number[];
sort: SelectableValue | null;
}
export type SearchReducer<S> = [S, Dispatch<SearchAction>];
@ -84,3 +86,8 @@ export type UseSearch = <S>(
export type OnToggleChecked = (item: DashboardSectionItem | DashboardSection) => void;
export type OnDeleteItems = (folders: string[], dashboards: string[]) => void;
export type OnMoveItems = (selectedDashboards: DashboardSectionItem[], folder: FolderInfo | null) => void;
export enum SearchLayout {
List = 'list',
Folders = 'folders',
}

View File

@ -1,6 +1,6 @@
import { parse, SearchParserResult } from 'search-query-parser';
import { DashboardQuery, DashboardSection, DashboardSectionItem, SearchAction, UidsToDelete } from './types';
import { NO_ID_SECTIONS } from './constants';
import { parse, SearchParserResult } from 'search-query-parser';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
/**
@ -166,8 +166,9 @@ export const getCheckedUids = (sections: DashboardSection[]): UidsToDelete => {
* @param queryParsing
*/
export const getParsedQuery = (query: DashboardQuery, queryParsing = false) => {
const parsedQuery = { ...query, sort: query.sort?.value };
if (!queryParsing) {
return query;
return parsedQuery;
}
let folderIds: number[] = [];
@ -178,5 +179,12 @@ export const getParsedQuery = (query: DashboardQuery, queryParsing = false) => {
folderIds = [folderId];
}
}
return { ...query, query: parseQuery(query.query).text as string, folderIds };
return { ...parsedQuery, query: parseQuery(query.query).text as string, folderIds };
};
export const hasFilters = (query: DashboardQuery) => {
if (!query) {
return false;
}
return Boolean(query.query || query.tag?.length > 0 || query.starred || query.sort);
};