diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index 72f372e3168..44c6d88db21 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -30,6 +30,9 @@ const getRadioButtonGroupStyles = () => { } } `, + icon: css` + margin-right: 6px; + `, }; }; @@ -81,7 +84,7 @@ export function RadioButtonGroup({ name={groupName.current} fullWidth={fullWidth} > - {o.icon && } + {o.icon && } {o.label} ); diff --git a/packages/grafana-ui/src/components/Icon/Icon.tsx b/packages/grafana-ui/src/components/Icon/Icon.tsx index f3e468f1a77..9c21022bb30 100644 --- a/packages/grafana-ui/src/components/Icon/Icon.tsx +++ b/packages/grafana-ui/src/components/Icon/Icon.tsx @@ -10,7 +10,7 @@ import * as MonoIcon from './assets'; const alwaysMonoIcons = ['grafana', 'favorite']; -interface IconProps extends React.HTMLAttributes { +export interface IconProps extends React.HTMLAttributes { name: IconName; size?: IconSize; type?: IconType; diff --git a/packages/grafana-ui/src/components/Layout/Layout.tsx b/packages/grafana-ui/src/components/Layout/Layout.tsx index 094a6601fd0..b4dfac7105d 100644 --- a/packages/grafana-ui/src/components/Layout/Layout.tsx +++ b/packages/grafana-ui/src/components/Layout/Layout.tsx @@ -37,13 +37,15 @@ export const Layout: React.FC = ({ const styles = getStyles(theme, orientation, spacing, justify, align); return (
- {React.Children.map(children, (child, index) => { - return ( -
- {child} -
- ); - })} + {React.Children.toArray(children) + .filter(Boolean) + .map((child, index) => { + return ( +
+ {child} +
+ ); + })}
); }; diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index 3bd44464b0f..20974c59be5 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -91,6 +91,7 @@ export function SelectBase({ allowCustomValue = false, autoFocus = false, backspaceRemovesValue = true, + className, closeMenuOnSelect = true, components, defaultOptions, @@ -110,8 +111,8 @@ export function SelectBase({ loadingMessage = 'Loading options...', maxMenuHeight = 300, maxVisibleValues, - menuPosition, menuPlacement = 'auto', + menuPosition, noOptionsMessage = 'No options found', onBlur, onChange, @@ -127,7 +128,6 @@ export function SelectBase({ renderControl, showAllSelectedWhenOpen = true, tabSelectsValue = true, - className, value, width, }: SelectBaseProps) { diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index bc1120b17c8..85cee848821 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -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', ]; diff --git a/public/app/core/components/Select/SortPicker.tsx b/public/app/core/components/Select/SortPicker.tsx new file mode 100644 index 00000000000..93649fbb440 --- /dev/null +++ b/public/app/core/components/Select/SortPicker.tsx @@ -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 = ({ onChange, value, placeholder }) => { + return ( + } + /> + ); +}; diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 472b53a0002..b908b9ba724 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -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; - 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; + 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 { static defaultProps = { - size: 'auto', placeholder: 'Tags', }; @@ -52,26 +57,26 @@ export class TagFilter extends React.Component { }; 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 { }; return ( -
+
} />
); } } + +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; + } + } + `, + }; +}); diff --git a/public/app/core/services/search_srv.ts b/public/app/core/services/search_srv.ts index c6a63fecc7a..848d8f47836 100644 --- a/public/app/core/services/search_srv.ts +++ b/public/app/core/services/search_srv.ts @@ -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
; @@ -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); diff --git a/public/app/features/search/components/ActionRow.tsx b/public/app/features/search/components/ActionRow.tsx new file mode 100644 index 00000000000..89ead3e0831 --- /dev/null +++ b/public/app/features/search/components/ActionRow.tsx @@ -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>; + onSortChange: onSelectChange; + onStarredFilterChange?: onSelectChange; + onTagFilterChange: onSelectChange; + query: DashboardQuery; + showStarredFilter?: boolean; + hideSelectedTags?: boolean; +} + +export const ActionRow: FC = ({ + layout, + onLayoutChange, + onSortChange, + onStarredFilterChange, + onTagFilterChange, + query, + showStarredFilter, + hideSelectedTags, +}) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( +
+ + {layout ? : null} + + + + {showStarredFilter && ( + f.value === selectedStarredFilter)?.label} - options={starredFilterOptions} - onChange={onStarredFilterChange} - /> - - - + )}
); }; 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}; } `, }; diff --git a/public/app/features/search/components/mocks.ts b/public/app/features/search/components/mocks.ts index 2035138fbc3..c98fb4d7218 100644 --- a/public/app/features/search/components/mocks.ts +++ b/public/app/features/search/components/mocks.ts @@ -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' }] })), + }; }), }; }); diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts index 6ad4e794046..87a99520dab 100644 --- a/public/app/features/search/constants.ts +++ b/public/app/features/search/constants.ts @@ -1 +1,2 @@ export const NO_ID_SECTIONS = ['Recent', 'Starred']; +export const DEFAULT_SORT = { label: 'A-Z', value: 'alpha-asc' }; diff --git a/public/app/features/search/hooks/useSearch.ts b/public/app/features/search/hooks/useSearch.ts index 9f229af5074..3f78dbb396f 100644 --- a/public/app/features/search/hooks/useSearch.ts +++ b/public/app/features/search/hooks/useSearch.ts @@ -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 }); }); diff --git a/public/app/features/search/hooks/useSearchLayout.ts b/public/app/features/search/hooks/useSearchLayout.ts new file mode 100644 index 00000000000..6b01145d483 --- /dev/null +++ b/public/app/features/search/hooks/useSearchLayout.ts @@ -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(layoutOptions[0].value); + + useEffect(() => { + if (query.sort) { + const list = layoutOptions.find(opt => opt.value === SearchLayout.List); + setLayout(list!.value); + } + }, [query]); + + return { layout, setLayout }; +}; diff --git a/public/app/features/search/hooks/useSearchQuery.ts b/public/app/features/search/hooks/useSearchQuery.ts index 0213781fde6..1a798bbbb6e 100644 --- a/public/app/features/search/hooks/useSearchQuery.ts +++ b/public/app/features/search/hooks/useSearchQuery.ts @@ -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) => { const initialState = { ...defaultQuery, ...queryParams }; @@ -44,11 +46,13 @@ export const useSearchQuery = (queryParams: Partial) => { 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) => { onTagFilterChange, onStarredFilterChange, onTagAdd, + onSortChange, }; }; diff --git a/public/app/features/search/reducers/actionTypes.ts b/public/app/features/search/reducers/actionTypes.ts index 69c3db6e5bf..59d2bb30b66 100644 --- a/public/app/features/search/reducers/actionTypes.ts +++ b/public/app/features/search/reducers/actionTypes.ts @@ -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'; diff --git a/public/app/features/search/reducers/dashboardSearch.ts b/public/app/features/search/reducers/dashboardSearch.ts index fdfddf58a82..bb7b4eba4ea 100644 --- a/public/app/features/search/reducers/dashboardSearch.ts +++ b/public/app/features/search/reducers/dashboardSearch.ts @@ -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) diff --git a/public/app/features/search/reducers/searchQueryReducer.ts b/public/app/features/search/reducers/searchQueryReducer.ts index 69f8088b707..18a570467e6 100644 --- a/public/app/features/search/reducers/searchQueryReducer.ts +++ b/public/app/features/search/reducers/searchQueryReducer.ts @@ -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; } diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index bcbaa7dfe86..f0fbfe6459f 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -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, Dispatch]; @@ -84,3 +86,8 @@ export type UseSearch = ( 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', +} diff --git a/public/app/features/search/utils.ts b/public/app/features/search/utils.ts index b9a2ce622d8..1538c84cacc 100644 --- a/public/app/features/search/utils.ts +++ b/public/app/features/search/utils.ts @@ -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); };