mirror of
https://github.com/grafana/grafana.git
synced 2025-02-14 01:23:32 -06:00
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:
parent
66d405acab
commit
c0fe565499
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>) {
|
||||
|
@ -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',
|
||||
];
|
||||
|
33
public/app/core/components/Select/SortPicker.tsx
Normal file
33
public/app/core/components/Select/SortPicker.tsx
Normal 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" />}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -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);
|
||||
|
85
public/app/features/search/components/ActionRow.tsx
Normal file
85
public/app/features/search/components/ActionRow.tsx
Normal 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%;
|
||||
`,
|
||||
};
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -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" />
|
||||
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
|
||||
|
@ -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}
|
||||
/>
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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};
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
@ -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' }] })),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
@ -1 +1,2 @@
|
||||
export const NO_ID_SECTIONS = ['Recent', 'Starred'];
|
||||
export const DEFAULT_SORT = { label: 'A-Z', value: 'alpha-asc' };
|
||||
|
@ -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 });
|
||||
});
|
||||
|
20
public/app/features/search/hooks/useSearchLayout.ts
Normal file
20
public/app/features/search/hooks/useSearchLayout.ts
Normal 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 };
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user