Search: support URL query params (#25541)

* Search: connect DashboardSearch

* Search: set url params

* Search: handle tag params

* Search: handle sort params

* Search: use getLocationQuery

* Search: fix type errors

* Docs: Save query params for manage dashboards

* Search: extract connect

* Search: add layout to URL params

* Search: update options

* Search: simplify options loading

* Search: Fix strict null errors

* Search: Change params order

* Search: Add tests

* Search: handle folder query
This commit is contained in:
Alex Khomenko 2020-06-16 11:52:10 +03:00 committed by GitHub
parent a5b38b799a
commit 531e658123
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 226 additions and 40 deletions

View File

@ -1,5 +1,6 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { AsyncSelect, Icon } from '@grafana/ui'; import { useAsync } from 'react-use';
import { Select, Icon } from '@grafana/ui';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { DEFAULT_SORT } from 'app/features/search/constants'; import { DEFAULT_SORT } from 'app/features/search/constants';
import { SearchSrv } from '../../services/search_srv'; import { SearchSrv } from '../../services/search_srv';
@ -19,15 +20,17 @@ const getSortOptions = () => {
}; };
export const SortPicker: FC<Props> = ({ onChange, value, placeholder }) => { export const SortPicker: FC<Props> = ({ onChange, value, placeholder }) => {
return ( // Using sync Select and manual options fetching here since we need to find the selected option by value
<AsyncSelect const { loading, value: options } = useAsync<SelectableValue[]>(getSortOptions, []);
return !loading ? (
<Select
width={25} width={25}
onChange={onChange} onChange={onChange}
value={[value]} value={options?.filter(opt => opt.value === value)}
loadOptions={getSortOptions} options={options}
defaultOptions
placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`} placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`}
prefix={<Icon name="sort-amount-down" />} prefix={<Icon name="sort-amount-down" />}
/> />
); ) : null;
}; };

View File

@ -1,20 +1,20 @@
import _ from 'lodash'; import _ from 'lodash';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
import { ILocationService, IRootScopeService, ITimeoutService } from 'angular';
import { locationUtil } from '@grafana/data';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore'; import { getExploreUrl } from 'app/core/utils/explore';
import { store } from 'app/store/store'; import { store } from 'app/store/store';
import { AppEventEmitter, CoreEvents } from 'app/types'; import { AppEventEmitter, CoreEvents } from 'app/types';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
import { ContextSrv } from './context_srv';
import { ILocationService, IRootScopeService, ITimeoutService } from 'angular';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { DashboardModel } from '../../features/dashboard/state'; import { DashboardModel } from 'app/features/dashboard/state';
import { ShareModal } from 'app/features/dashboard/components/ShareModal'; import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { SaveDashboardModalProxy } from '../../features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
import { locationUtil } from '@grafana/data'; import { defaultQueryParams } from 'app/features/search/reducers/searchQueryReducer';
import { ContextSrv } from './context_srv';
export class KeybindingSrv { export class KeybindingSrv {
helpModal: boolean; helpModal: boolean;
@ -88,7 +88,7 @@ export class KeybindingSrv {
} }
closeSearch() { closeSearch() {
const search = _.extend(this.$location.search(), { search: null }); const search = _.extend(this.$location.search(), { search: null, ...defaultQueryParams });
this.$location.search(search); this.$location.search(search);
} }

View File

@ -44,13 +44,13 @@ export const ActionRow: FC<Props> = ({
{!hideLayout ? ( {!hideLayout ? (
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} /> <RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
) : null} ) : null}
<SortPicker onChange={onSortChange} value={query.sort} /> <SortPicker onChange={onSortChange} value={query.sort?.value} />
</HorizontalGroup> </HorizontalGroup>
</div> </div>
<HorizontalGroup spacing="md" width="auto"> <HorizontalGroup spacing="md" width="auto">
{showStarredFilter && ( {showStarredFilter && (
<div className={styles.checkboxWrapper}> <div className={styles.checkboxWrapper}>
<Checkbox label="Filter by starred" onChange={onStarredFilterChange} /> <Checkbox label="Filter by starred" onChange={onStarredFilterChange} value={query.starred} />
</div> </div>
)} )}
<TagFilter isClearable tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} /> <TagFilter isClearable tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} />

View File

@ -8,7 +8,7 @@ import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParams, getUrl } from 'app/core/selectors/location'; import { getRouteParams, getUrl } from 'app/core/selectors/location';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { loadFolderPage } from '../loaders'; import { loadFolderPage } from '../loaders';
import { ManageDashboards } from './ManageDashboards'; import ManageDashboards from './ManageDashboards';
interface Props { interface Props {
navModel: NavModel; navModel: NavModel;

View File

@ -2,7 +2,7 @@ import React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { mockSearch } from './mocks'; import { mockSearch } from './mocks';
import { DashboardSearch } from './DashboardSearch'; import { DashboardSearch, Props } from './DashboardSearch';
import { searchResults } from '../testData'; import { searchResults } from '../testData';
import { SearchLayout } from '../types'; import { SearchLayout } from '../types';
@ -15,9 +15,10 @@ afterEach(() => {
jest.useRealTimers(); jest.useRealTimers();
}); });
const setup = async (): Promise<any> => { const setup = async (testProps?: Partial<Props>): Promise<any> => {
const props: any = { const props: any = {
onCloseSearch: () => {}, onCloseSearch: () => {},
...testProps,
}; };
let wrapper; let wrapper;
//@ts-ignore //@ts-ignore
@ -117,4 +118,18 @@ describe('DashboardSearch', () => {
sort: undefined, sort: undefined,
}); });
}); });
it('should call search api with provided search params', async () => {
const params = { query: 'test query', tag: ['tag1'], sort: { value: 'asc' } };
await setup({ params });
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
tag: ['tag1'],
sort: 'asc',
})
);
});
}); });

View File

@ -7,15 +7,19 @@ import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { SearchField } from './SearchField'; import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults'; import { SearchResults } from './SearchResults';
import { ActionRow } from './ActionRow'; import { ActionRow } from './ActionRow';
import { connectWithRouteParams, ConnectProps, DispatchProps } from '../connect';
export interface Props { export interface OwnProps {
onCloseSearch: () => void; onCloseSearch: () => void;
folder?: string;
} }
export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, folder }) => { export type Props = OwnProps & ConnectProps & DispatchProps;
const payload = folder ? { query: `folder:${folder} ` } : {};
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery(payload); export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, params, updateLocation }) => {
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery(
params,
updateLocation
);
const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch); const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch);
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
@ -54,6 +58,8 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, folder }) => {
); );
}); });
export default connectWithRouteParams(DashboardSearch);
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
return { return {
overlay: css` overlay: css`

View File

@ -14,6 +14,7 @@ import { useSearchQuery } from '../hooks/useSearchQuery';
import { SearchResultsFilter } from './SearchResultsFilter'; import { SearchResultsFilter } from './SearchResultsFilter';
import { SearchResults } from './SearchResults'; import { SearchResults } from './SearchResults';
import { DashboardActions } from './DashboardActions'; import { DashboardActions } from './DashboardActions';
import { connectWithRouteParams, ConnectProps, DispatchProps } from '../connect';
export interface Props { export interface Props {
folder?: FolderDTO; folder?: FolderDTO;
@ -21,7 +22,7 @@ export interface Props {
const { isEditor } = contextSrv; const { isEditor } = contextSrv;
export const ManageDashboards: FC<Props> = memo(({ folder }) => { export const ManageDashboards: FC<Props & ConnectProps & DispatchProps> = memo(({ folder, params, updateLocation }) => {
const folderId = folder?.id; const folderId = folder?.id;
const folderUid = folder?.uid; const folderUid = folder?.uid;
const theme = useTheme(); const theme = useTheme();
@ -34,6 +35,7 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
skipStarred: true, skipStarred: true,
folderIds: folderId ? [folderId] : [], folderIds: folderId ? [folderId] : [],
layout: defaultLayout, layout: defaultLayout,
...params,
}; };
const { const {
query, query,
@ -44,7 +46,7 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
onTagAdd, onTagAdd,
onSortChange, onSortChange,
onLayoutChange, onLayoutChange,
} = useSearchQuery(queryParams); } = useSearchQuery(queryParams, updateLocation);
const { const {
results, results,
@ -147,6 +149,8 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
); );
}); });
export default connectWithRouteParams(ManageDashboards);
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
return { return {
container: css` container: css`

View File

@ -1,10 +1,12 @@
import React, { FC, memo } from 'react'; import React, { FC, memo } from 'react';
import { MapDispatchToProps, MapStateToProps } from 'react-redux'; import { MapDispatchToProps, MapStateToProps } from 'react-redux';
import { UrlQueryMap } from '@grafana/data';
import { getLocationQuery } from 'app/core/selectors/location'; import { getLocationQuery } from 'app/core/selectors/location';
import { updateLocation } from 'app/core/reducers/location'; import { updateLocation } from 'app/core/reducers/location';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { DashboardSearch } from './DashboardSearch'; import DashboardSearch from './DashboardSearch';
import { defaultQueryParams } from '../reducers/searchQueryReducer';
interface OwnProps { interface OwnProps {
search?: string | null; search?: string | null;
@ -23,12 +25,13 @@ export const SearchWrapper: FC<Props> = memo(({ search, folder, updateLocation }
const isOpen = search === 'open'; const isOpen = search === 'open';
const closeSearch = () => { const closeSearch = () => {
if (search === 'open') { if (isOpen) {
updateLocation({ updateLocation({
query: { query: {
search: null, search: null,
folder: null, folder: null,
}, ...defaultQueryParams,
} as UrlQueryMap,
partial: true, partial: true,
}); });
} }

View File

@ -0,0 +1,41 @@
import React from 'react';
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { StoreState } from 'app/types';
import { getLocationQuery } from 'app/core/selectors/location';
import { updateLocation } from 'app/core/reducers/location';
import { parseRouteParams } from './utils';
import { DashboardQuery } from './types';
import { Props as DashboardSearchProps } from './components/DashboardSearch';
import { Props as ManageDashboardsProps } from './components/ManageDashboards';
export interface ConnectProps {
params: Partial<DashboardQuery>;
}
export interface DispatchProps {
updateLocation: typeof updateLocation;
}
type Props = DashboardSearchProps | ManageDashboardsProps;
const mapStateToProps: MapStateToProps<ConnectProps, Props, StoreState> = state => {
const { query, starred, sort, tag, layout, folder } = getLocationQuery(state.location);
return parseRouteParams(
{
query,
tag,
starred,
sort,
layout,
},
folder
);
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, Props> = {
updateLocation,
};
export const connectWithRouteParams = (Component: React.FC) =>
connectWithStore(Component, mapStateToProps, mapDispatchToProps);

View File

@ -1,6 +1,6 @@
import { FormEvent, useReducer } from 'react'; import { FormEvent, useReducer } from 'react';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { defaultQuery, queryReducer } from '../reducers/searchQueryReducer'; import { defaultQuery, defaultQueryParams, queryReducer } from '../reducers/searchQueryReducer';
import { import {
ADD_TAG, ADD_TAG,
CLEAR_FILTERS, CLEAR_FILTERS,
@ -10,39 +10,48 @@ import {
TOGGLE_SORT, TOGGLE_SORT,
TOGGLE_STARRED, TOGGLE_STARRED,
} from '../reducers/actionTypes'; } from '../reducers/actionTypes';
import { DashboardQuery, SearchLayout } from '../types'; import { DashboardQuery, RouteParams, SearchLayout } from '../types';
import { hasFilters } from '../utils'; import { hasFilters } from '../utils';
export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => { export const useSearchQuery = (queryParams: Partial<DashboardQuery>, updateLocation = (args: any) => {}) => {
const updateLocationQuery = (query: RouteParams) => updateLocation({ query, partial: true });
const initialState = { ...defaultQuery, ...queryParams }; const initialState = { ...defaultQuery, ...queryParams };
const [query, dispatch] = useReducer(queryReducer, initialState); const [query, dispatch] = useReducer(queryReducer, initialState);
const onQueryChange = (query: string) => { const onQueryChange = (query: string) => {
dispatch({ type: QUERY_CHANGE, payload: query }); dispatch({ type: QUERY_CHANGE, payload: query });
updateLocationQuery({ query });
}; };
const onTagFilterChange = (tags: string[]) => { const onTagFilterChange = (tags: string[]) => {
dispatch({ type: SET_TAGS, payload: tags }); dispatch({ type: SET_TAGS, payload: tags });
updateLocationQuery({ tag: tags });
}; };
const onTagAdd = (tag: string) => { const onTagAdd = (tag: string) => {
dispatch({ type: ADD_TAG, payload: tag }); dispatch({ type: ADD_TAG, payload: tag });
updateLocationQuery({ tag: [...query.tag, tag] });
}; };
const onClearFilters = () => { const onClearFilters = () => {
dispatch({ type: CLEAR_FILTERS }); dispatch({ type: CLEAR_FILTERS });
updateLocationQuery(defaultQueryParams);
}; };
const onStarredFilterChange = (e: FormEvent<HTMLInputElement>) => { const onStarredFilterChange = (e: FormEvent<HTMLInputElement>) => {
dispatch({ type: TOGGLE_STARRED, payload: (e.target as HTMLInputElement).checked }); const starred = (e.target as HTMLInputElement).checked;
dispatch({ type: TOGGLE_STARRED, payload: starred });
updateLocationQuery({ starred: starred || null });
}; };
const onSortChange = (sort: SelectableValue | null) => { const onSortChange = (sort: SelectableValue | null) => {
dispatch({ type: TOGGLE_SORT, payload: sort }); dispatch({ type: TOGGLE_SORT, payload: sort });
updateLocationQuery({ sort: sort?.value, layout: SearchLayout.List });
}; };
const onLayoutChange = (layout: SearchLayout) => { const onLayoutChange = (layout: SearchLayout) => {
dispatch({ type: LAYOUT_CHANGE, payload: layout }); dispatch({ type: LAYOUT_CHANGE, payload: layout });
updateLocationQuery({ layout });
}; };
return { return {

View File

@ -87,7 +87,7 @@ describe('Manage dashboards reducer', () => {
it('should not display dashboards in a non-expanded folder', () => { it('should not display dashboards in a non-expanded folder', () => {
const general = results.find(res => res.id === 0); const general = results.find(res => res.id === 0);
const toMove = { dashboards: general.items, folder: { id: 4074 } }; const toMove = { dashboards: general?.items, folder: { id: 4074 } };
const newState = reducer({ ...state, results }, { type: MOVE_ITEMS, payload: toMove }); const newState = reducer({ ...state, results }, { type: MOVE_ITEMS, payload: toMove });
expect(newState.results.find((res: DashboardSection) => res.id === 4074).items).toHaveLength(0); expect(newState.results.find((res: DashboardSection) => res.id === 4074).items).toHaveLength(0);
expect(newState.results.find((res: DashboardSection) => res.id === 0).items).toHaveLength(0); expect(newState.results.find((res: DashboardSection) => res.id === 0).items).toHaveLength(0);

View File

@ -1,4 +1,4 @@
import { DashboardQuery, SearchAction, SearchLayout } from '../types'; import { DashboardQuery, RouteParams, SearchAction, SearchLayout } from '../types';
import { import {
ADD_TAG, ADD_TAG,
CLEAR_FILTERS, CLEAR_FILTERS,
@ -22,6 +22,14 @@ export const defaultQuery: DashboardQuery = {
layout: SearchLayout.Folders, layout: SearchLayout.Folders,
}; };
export const defaultQueryParams: RouteParams = {
sort: null,
starred: null,
query: null,
tag: null,
layout: null,
};
export const queryReducer = (state: DashboardQuery, action: SearchAction) => { export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
switch (action.type) { switch (action.type) {
case QUERY_CHANGE: case QUERY_CHANGE:

View File

@ -95,3 +95,11 @@ export enum SearchLayout {
List = 'list', List = 'list',
Folders = 'folders', Folders = 'folders',
} }
export interface RouteParams {
query?: string | null;
sort?: string | null;
starred?: boolean | null;
tag?: string[] | null;
layout?: SearchLayout | null;
}

View File

@ -5,8 +5,10 @@ import {
getFlattenedSections, getFlattenedSections,
markSelected, markSelected,
mergeReducers, mergeReducers,
parseRouteParams,
} from './utils'; } from './utils';
import { sections, searchResults } from './testData'; import { sections, searchResults } from './testData';
import { RouteParams } from './types';
describe('Search utils', () => { describe('Search utils', () => {
describe('getFlattenedSections', () => { describe('getFlattenedSections', () => {
@ -146,4 +148,60 @@ describe('Search utils', () => {
expect(getCheckedDashboardsUids(searchResults as any[])).toEqual(['lBdLINUWk', '8DY63kQZk']); expect(getCheckedDashboardsUids(searchResults as any[])).toEqual(['lBdLINUWk', '8DY63kQZk']);
}); });
}); });
describe('parseRouteParams', () => {
it('should remove all undefined keys', () => {
const params: Partial<RouteParams> = { sort: undefined, tag: undefined, query: 'test' };
expect(parseRouteParams(params)).toEqual({
params: {
query: 'test',
},
});
});
it('should return tag as array, if present', () => {
//@ts-ignore
const params = { sort: undefined, tag: 'test', query: 'test' };
expect(parseRouteParams(params)).toEqual({
params: {
query: 'test',
tag: ['test'],
},
});
const params2: Partial<RouteParams> = { sort: undefined, tag: ['test'], query: 'test' };
expect(parseRouteParams(params2)).toEqual({
params: {
query: 'test',
tag: ['test'],
},
});
});
it('should return sort as a SelectableValue', () => {
const params: Partial<RouteParams> = { sort: 'test' };
expect(parseRouteParams(params)).toEqual({
params: {
sort: { value: 'test' },
},
});
});
it('should prepend folder:{folder} to the query if folder is present', () => {
expect(parseRouteParams({}, 'current')).toEqual({
params: {
query: 'folder:current ',
},
});
// Prepend to exiting query
const params: Partial<RouteParams> = { query: 'test' };
expect(parseRouteParams(params, 'current')).toEqual({
params: {
query: 'folder:current test',
},
});
});
});
}); });

View File

@ -1,5 +1,6 @@
import { parse, SearchParserResult } from 'search-query-parser'; import { parse, SearchParserResult } from 'search-query-parser';
import { IconName } from '@grafana/ui'; import { IconName } from '@grafana/ui';
import { UrlQueryMap, UrlQueryValue } from '@grafana/data';
import { DashboardQuery, DashboardSection, DashboardSectionItem, SearchAction, UidsToDelete } from './types'; import { DashboardQuery, DashboardSection, DashboardSectionItem, SearchAction, UidsToDelete } from './types';
import { NO_ID_SECTIONS, SECTION_STORAGE_KEY } from './constants'; import { NO_ID_SECTIONS, SECTION_STORAGE_KEY } from './constants';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv'; import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
@ -187,9 +188,13 @@ export const getParsedQuery = (query: DashboardQuery, queryParsing = false) => {
let folderIds: number[] = []; let folderIds: number[] = [];
if (parseQuery(query.query).folder === 'current') { if (parseQuery(query.query).folder === 'current') {
const { folderId } = getDashboardSrv().getCurrent().meta; try {
if (folderId) { const { folderId } = getDashboardSrv().getCurrent()?.meta;
folderIds = [folderId]; if (folderId) {
folderIds = [folderId];
}
} catch (e) {
console.error(e);
} }
} }
return { ...parsedQuery, query: parseQuery(query.query).text as string, folderIds }; return { ...parsedQuery, query: parseQuery(query.query).text as string, folderIds };
@ -228,3 +233,29 @@ export const getSectionStorageKey = (title: string) => {
} }
return `${SECTION_STORAGE_KEY}.${title.toLowerCase()}`; return `${SECTION_STORAGE_KEY}.${title.toLowerCase()}`;
}; };
/**
* Remove undefined keys from url params object and format non-primitive values
* @param params
* @param folder
*/
export const parseRouteParams = (params: UrlQueryMap, folder?: UrlQueryValue) => {
const cleanedParams = Object.entries(params).reduce((obj, [key, val]) => {
if (!val) {
return obj;
} else if (key === 'tag' && !Array.isArray(val)) {
return { ...obj, tag: [val] as string[] };
} else if (key === 'sort') {
return { ...obj, sort: { value: val } };
}
return { ...obj, [key]: val };
}, {} as Partial<DashboardQuery>);
if (folder) {
const folderStr = `folder:${folder}`;
return {
params: { ...cleanedParams, query: `${folderStr} ${(cleanedParams.query ?? '').replace(folderStr, '')}` },
};
}
return { params: cleanedParams };
};