Commit 531e6581 by Alex Khomenko Committed by GitHub

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
parent a5b38b79
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 { DEFAULT_SORT } from 'app/features/search/constants';
import { SearchSrv } from '../../services/search_srv';
......@@ -19,15 +20,17 @@ const getSortOptions = () => {
};
export const SortPicker: FC<Props> = ({ onChange, value, placeholder }) => {
return (
<AsyncSelect
// Using sync Select and manual options fetching here since we need to find the selected option by value
const { loading, value: options } = useAsync<SelectableValue[]>(getSortOptions, []);
return !loading ? (
<Select
width={25}
onChange={onChange}
value={[value]}
loadOptions={getSortOptions}
defaultOptions
value={options?.filter(opt => opt.value === value)}
options={options}
placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`}
prefix={<Icon name="sort-amount-down" />}
/>
);
) : null;
};
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 appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import { store } from 'app/store/store';
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 { DashboardModel } from '../../features/dashboard/state';
import { DashboardModel } from 'app/features/dashboard/state';
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { SaveDashboardModalProxy } from '../../features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
import { locationUtil } from '@grafana/data';
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
import { defaultQueryParams } from 'app/features/search/reducers/searchQueryReducer';
import { ContextSrv } from './context_srv';
export class KeybindingSrv {
helpModal: boolean;
......@@ -88,7 +88,7 @@ export class KeybindingSrv {
}
closeSearch() {
const search = _.extend(this.$location.search(), { search: null });
const search = _.extend(this.$location.search(), { search: null, ...defaultQueryParams });
this.$location.search(search);
}
......
......@@ -44,13 +44,13 @@ export const ActionRow: FC<Props> = ({
{!hideLayout ? (
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
) : null}
<SortPicker onChange={onSortChange} value={query.sort} />
<SortPicker onChange={onSortChange} value={query.sort?.value} />
</HorizontalGroup>
</div>
<HorizontalGroup spacing="md" width="auto">
{showStarredFilter && (
<div className={styles.checkboxWrapper}>
<Checkbox label="Filter by starred" onChange={onStarredFilterChange} />
<Checkbox label="Filter by starred" onChange={onStarredFilterChange} value={query.starred} />
</div>
)}
<TagFilter isClearable tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} />
......
......@@ -8,7 +8,7 @@ import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParams, getUrl } from 'app/core/selectors/location';
import Page from 'app/core/components/Page/Page';
import { loadFolderPage } from '../loaders';
import { ManageDashboards } from './ManageDashboards';
import ManageDashboards from './ManageDashboards';
interface Props {
navModel: NavModel;
......
......@@ -2,7 +2,7 @@ import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { mockSearch } from './mocks';
import { DashboardSearch } from './DashboardSearch';
import { DashboardSearch, Props } from './DashboardSearch';
import { searchResults } from '../testData';
import { SearchLayout } from '../types';
......@@ -15,9 +15,10 @@ afterEach(() => {
jest.useRealTimers();
});
const setup = async (): Promise<any> => {
const setup = async (testProps?: Partial<Props>): Promise<any> => {
const props: any = {
onCloseSearch: () => {},
...testProps,
};
let wrapper;
//@ts-ignore
......@@ -117,4 +118,18 @@ describe('DashboardSearch', () => {
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',
})
);
});
});
......@@ -7,15 +7,19 @@ import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults';
import { ActionRow } from './ActionRow';
import { connectWithRouteParams, ConnectProps, DispatchProps } from '../connect';
export interface Props {
export interface OwnProps {
onCloseSearch: () => void;
folder?: string;
}
export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, folder }) => {
const payload = folder ? { query: `folder:${folder} ` } : {};
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery(payload);
export type Props = OwnProps & ConnectProps & DispatchProps;
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 theme = useTheme();
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) => {
return {
overlay: css`
......
......@@ -14,6 +14,7 @@ import { useSearchQuery } from '../hooks/useSearchQuery';
import { SearchResultsFilter } from './SearchResultsFilter';
import { SearchResults } from './SearchResults';
import { DashboardActions } from './DashboardActions';
import { connectWithRouteParams, ConnectProps, DispatchProps } from '../connect';
export interface Props {
folder?: FolderDTO;
......@@ -21,7 +22,7 @@ export interface Props {
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 folderUid = folder?.uid;
const theme = useTheme();
......@@ -34,6 +35,7 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
skipStarred: true,
folderIds: folderId ? [folderId] : [],
layout: defaultLayout,
...params,
};
const {
query,
......@@ -44,7 +46,7 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
onTagAdd,
onSortChange,
onLayoutChange,
} = useSearchQuery(queryParams);
} = useSearchQuery(queryParams, updateLocation);
const {
results,
......@@ -147,6 +149,8 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
);
});
export default connectWithRouteParams(ManageDashboards);
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
......
import React, { FC, memo } from 'react';
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
import { UrlQueryMap } from '@grafana/data';
import { getLocationQuery } from 'app/core/selectors/location';
import { updateLocation } from 'app/core/reducers/location';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { StoreState } from 'app/types';
import { DashboardSearch } from './DashboardSearch';
import DashboardSearch from './DashboardSearch';
import { defaultQueryParams } from '../reducers/searchQueryReducer';
interface OwnProps {
search?: string | null;
......@@ -23,12 +25,13 @@ export const SearchWrapper: FC<Props> = memo(({ search, folder, updateLocation }
const isOpen = search === 'open';
const closeSearch = () => {
if (search === 'open') {
if (isOpen) {
updateLocation({
query: {
search: null,
folder: null,
},
...defaultQueryParams,
} as UrlQueryMap,
partial: true,
});
}
......
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);
import { FormEvent, useReducer } from 'react';
import { SelectableValue } from '@grafana/data';
import { defaultQuery, queryReducer } from '../reducers/searchQueryReducer';
import { defaultQuery, defaultQueryParams, queryReducer } from '../reducers/searchQueryReducer';
import {
ADD_TAG,
CLEAR_FILTERS,
......@@ -10,39 +10,48 @@ import {
TOGGLE_SORT,
TOGGLE_STARRED,
} from '../reducers/actionTypes';
import { DashboardQuery, SearchLayout } from '../types';
import { DashboardQuery, RouteParams, SearchLayout } from '../types';
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 [query, dispatch] = useReducer(queryReducer, initialState);
const onQueryChange = (query: string) => {
dispatch({ type: QUERY_CHANGE, payload: query });
updateLocationQuery({ query });
};
const onTagFilterChange = (tags: string[]) => {
dispatch({ type: SET_TAGS, payload: tags });
updateLocationQuery({ tag: tags });
};
const onTagAdd = (tag: string) => {
dispatch({ type: ADD_TAG, payload: tag });
updateLocationQuery({ tag: [...query.tag, tag] });
};
const onClearFilters = () => {
dispatch({ type: CLEAR_FILTERS });
updateLocationQuery(defaultQueryParams);
};
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) => {
dispatch({ type: TOGGLE_SORT, payload: sort });
updateLocationQuery({ sort: sort?.value, layout: SearchLayout.List });
};
const onLayoutChange = (layout: SearchLayout) => {
dispatch({ type: LAYOUT_CHANGE, payload: layout });
updateLocationQuery({ layout });
};
return {
......
......@@ -87,7 +87,7 @@ describe('Manage dashboards reducer', () => {
it('should not display dashboards in a non-expanded folder', () => {
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 });
expect(newState.results.find((res: DashboardSection) => res.id === 4074).items).toHaveLength(0);
expect(newState.results.find((res: DashboardSection) => res.id === 0).items).toHaveLength(0);
......
import { DashboardQuery, SearchAction, SearchLayout } from '../types';
import { DashboardQuery, RouteParams, SearchAction, SearchLayout } from '../types';
import {
ADD_TAG,
CLEAR_FILTERS,
......@@ -22,6 +22,14 @@ export const defaultQuery: DashboardQuery = {
layout: SearchLayout.Folders,
};
export const defaultQueryParams: RouteParams = {
sort: null,
starred: null,
query: null,
tag: null,
layout: null,
};
export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
switch (action.type) {
case QUERY_CHANGE:
......
......@@ -95,3 +95,11 @@ export enum SearchLayout {
List = 'list',
Folders = 'folders',
}
export interface RouteParams {
query?: string | null;
sort?: string | null;
starred?: boolean | null;
tag?: string[] | null;
layout?: SearchLayout | null;
}
......@@ -5,8 +5,10 @@ import {
getFlattenedSections,
markSelected,
mergeReducers,
parseRouteParams,
} from './utils';
import { sections, searchResults } from './testData';
import { RouteParams } from './types';
describe('Search utils', () => {
describe('getFlattenedSections', () => {
......@@ -146,4 +148,60 @@ describe('Search utils', () => {
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',
},
});
});
});
});
import { parse, SearchParserResult } from 'search-query-parser';
import { IconName } from '@grafana/ui';
import { UrlQueryMap, UrlQueryValue } from '@grafana/data';
import { DashboardQuery, DashboardSection, DashboardSectionItem, SearchAction, UidsToDelete } from './types';
import { NO_ID_SECTIONS, SECTION_STORAGE_KEY } from './constants';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
......@@ -187,9 +188,13 @@ export const getParsedQuery = (query: DashboardQuery, queryParsing = false) => {
let folderIds: number[] = [];
if (parseQuery(query.query).folder === 'current') {
const { folderId } = getDashboardSrv().getCurrent().meta;
if (folderId) {
folderIds = [folderId];
try {
const { folderId } = getDashboardSrv().getCurrent()?.meta;
if (folderId) {
folderIds = [folderId];
}
} catch (e) {
console.error(e);
}
}
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()}`;
};
/**
* 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 };
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment