mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
a5b38b799a
commit
531e658123
@ -1,5 +1,6 @@
|
||||
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;
|
||||
};
|
||||
|
@ -1,20 +1,20 @@
|
||||
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`
|
||||
|
@ -1,10 +1,12 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
41
public/app/features/search/connect.ts
Normal file
41
public/app/features/search/connect.ts
Normal 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);
|
@ -1,6 +1,6 @@
|
||||
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);
|
||||
|
@ -1,4 +1,4 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
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 };
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user