diff --git a/.betterer.results b/.betterer.results
index 55d3e4cc1b9..f7fff7a0b2d 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -5188,8 +5188,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/search/hooks/useSearchQuery.ts:5381": [
- [0, 0, 0, "Do not use any type assertions.", "0"],
- [0, 0, 0, "Do not use any type assertions.", "1"]
+ [0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/search/page/components/MoveToFolderModal.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@@ -5217,6 +5216,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Do not use any type assertions.", "5"]
],
+ "public/app/features/search/reducers/searchQueryReducer.ts:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"]
+ ],
"public/app/features/search/service/bluge.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
diff --git a/public/app/core/components/AppChrome/TopSearchBar.tsx b/public/app/core/components/AppChrome/TopSearchBar.tsx
index b056dbf3ad0..e6e2910e3ef 100644
--- a/public/app/core/components/AppChrome/TopSearchBar.tsx
+++ b/public/app/core/components/AppChrome/TopSearchBar.tsx
@@ -5,8 +5,10 @@ import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, NavSection } from '@grafana/data';
+import { locationService } from '@grafana/runtime';
import { Dropdown, FilterInput, Icon, Tooltip, useStyles2, toIconName } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
+import { useSearchQuery } from 'app/features/search/hooks/useSearchQuery';
import { StoreState } from 'app/types';
import { enrichConfigItems, enrichWithInteractionTracking } from '../NavBar/utils';
@@ -18,12 +20,24 @@ import { TOP_BAR_LEVEL_HEIGHT } from './types';
export function TopSearchBar() {
const styles = useStyles2(getStyles);
const location = useLocation();
+ const { query, onQueryChange } = useSearchQuery({});
const navBarTree = useSelector((state: StoreState) => state.navBarTree);
const navTree = cloneDeep(navBarTree);
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
const toggleSwitcherModal = () => {
setShowSwitcherModal(!showSwitcherModal);
};
+
+ const onOpenSearch = () => {
+ locationService.partial({ search: 'open' });
+ };
+ const onSearchChange = (value: string) => {
+ onQueryChange(value);
+ if (value) {
+ onOpenSearch();
+ }
+ };
+
const configItems = enrichConfigItems(
navTree.filter((item) => item.section === NavSection.Config),
location,
@@ -42,7 +56,13 @@ export function TopSearchBar() {
- {}} className={styles.searchInput} />
+
diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts
index c6ae27cc0c1..7cc067b2a86 100644
--- a/public/app/core/reducers/root.ts
+++ b/public/app/core/reducers/root.ts
@@ -15,6 +15,7 @@ import organizationReducers from 'app/features/org/state/reducers';
import panelsReducers from 'app/features/panel/state/reducers';
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';
import userReducers from 'app/features/profile/state/reducers';
+import searchQueryReducer from 'app/features/search/reducers/searchQueryReducer';
import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers';
import teamsReducers from 'app/features/teams/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
@@ -42,6 +43,7 @@ const rootReducers = {
...panelEditorReducers,
...panelsReducers,
...templatingReducers,
+ ...searchQueryReducer,
plugins: pluginsReducer,
[alertingApi.reducerPath]: alertingApi.reducer,
};
diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx
index 1c51bb3c2f5..aab64dc1b6b 100644
--- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx
+++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx
@@ -16,6 +16,7 @@ import { DashboardSettings } from './DashboardSettings';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
locationService: {
+ getSearchObject: jest.fn().mockResolvedValue({}),
partial: jest.fn(),
},
}));
diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx
index 5f1818c3335..b3b89c9fe2d 100644
--- a/public/app/features/search/components/DashboardSearch.tsx
+++ b/public/app/features/search/components/DashboardSearch.tsx
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
-import React, { useState } from 'react';
-import { useDebounce, useLocalStorage } from 'react-use';
+import React from 'react';
+import { useLocalStorage } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
@@ -11,27 +11,21 @@ import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { SearchView } from '../page/components/SearchView';
-export interface Props {
- onCloseSearch: () => void;
-}
+export interface Props {}
-export function DashboardSearch({ onCloseSearch }: Props) {
+export function DashboardSearch({}: Props) {
const styles = useStyles2(getStyles);
- const { query, onQueryChange } = useSearchQuery({});
+ const { query, onQueryChange, onCloseSearch } = useSearchQuery({});
let [includePanels, setIncludePanels] = useLocalStorage(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
if (!config.featureToggles.panelTitleSearch) {
includePanels = false;
}
- const [inputValue, setInputValue] = useState(query.query ?? '');
const onSearchQueryChange = (e: React.ChangeEvent) => {
- e.preventDefault();
- setInputValue(e.currentTarget.value);
+ onQueryChange(e.currentTarget.value);
};
- useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
-
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
return (
@@ -42,7 +36,7 @@ export function DashboardSearch({ onCloseSearch }: Props) {
{
- setInputValue(newQueryText);
- }}
showManage={false}
- queryText={query.query}
includePanels={includePanels!}
setIncludePanels={setIncludePanels}
keyboardEvents={keyboardEvents}
diff --git a/public/app/features/search/components/DashboardSearchModal.tsx b/public/app/features/search/components/DashboardSearchModal.tsx
new file mode 100644
index 00000000000..593863272bb
--- /dev/null
+++ b/public/app/features/search/components/DashboardSearchModal.tsx
@@ -0,0 +1,225 @@
+import { css } from '@emotion/css';
+import { useDialog } from '@react-aria/dialog';
+import { FocusScope } from '@react-aria/focus';
+import { OverlayContainer, useOverlay } from '@react-aria/overlays';
+import React, { useRef, useState } from 'react';
+import CSSTransition from 'react-transition-group/CSSTransition';
+import { useLocalStorage } from 'react-use';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { config } from '@grafana/runtime';
+import { IconButton, useStyles2 } from '@grafana/ui';
+
+import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants';
+import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
+import { useSearchQuery } from '../hooks/useSearchQuery';
+import { SearchView } from '../page/components/SearchView';
+
+const ANIMATION_DURATION = 200;
+
+export interface Props {
+ isOpen: boolean;
+}
+
+export function DashboardSearchModal({ isOpen }: Props) {
+ const styles = useStyles2(getStyles);
+ const animStyles = useStyles2((theme) => getAnimStyles(theme, ANIMATION_DURATION));
+ const { query, onQueryChange, onCloseSearch } = useSearchQuery({});
+ const ref = useRef(null);
+ const [animationComplete, setAnimationComplete] = useState(false);
+
+ const { overlayProps, underlayProps } = useOverlay({ isOpen, onClose: onCloseSearch }, ref);
+
+ const { dialogProps } = useDialog({}, ref);
+
+ let [includePanels, setIncludePanels] = useLocalStorage(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
+ if (!config.featureToggles.panelTitleSearch) {
+ includePanels = false;
+ }
+
+ const onSearchQueryChange = (e: React.ChangeEvent) => {
+ onQueryChange(e.currentTarget.value);
+ };
+
+ const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
+
+ return (
+
+
+
+
+ setAnimationComplete(true)}
+ appear
+ in
+ timeout={ANIMATION_DURATION}
+ classNames={animStyles.overlay}
+ >
+
+
+
+ {animationComplete && (
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => {
+ const commonTransition = {
+ transitionDuration: `${animationDuration}ms`,
+ transitionTimingFunction: theme.transitions.easing.easeInOut,
+ };
+
+ const underlayTransition = {
+ [theme.breakpoints.up('md')]: {
+ ...commonTransition,
+ transitionProperty: 'opacity',
+ },
+ };
+
+ const underlayClosed = {
+ [theme.breakpoints.up('md')]: {
+ opacity: 0,
+ },
+ };
+
+ const underlayOpen = {
+ [theme.breakpoints.up('md')]: {
+ opacity: 1,
+ },
+ };
+
+ const overlayTransition = {
+ [theme.breakpoints.up('md')]: {
+ ...commonTransition,
+ transitionProperty: 'height, width',
+ overflow: 'hidden',
+ },
+ };
+
+ const overlayClosed = {
+ height: '100%',
+ width: '100%',
+ [theme.breakpoints.up('md')]: {
+ height: '32px',
+ width: '50%',
+ },
+ };
+
+ const overlayOpen = {
+ height: '100%',
+ width: '100%',
+ [theme.breakpoints.up('md')]: {
+ height: '90%',
+ width: '75%',
+ },
+ };
+
+ return {
+ overlay: {
+ appear: css(overlayClosed),
+ appearActive: css(overlayTransition, overlayOpen),
+ appearDone: css(overlayOpen),
+ },
+ underlay: {
+ appear: css(underlayClosed),
+ appearActive: css(underlayTransition, underlayOpen),
+ appearDone: css(underlayOpen),
+ },
+ };
+};
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ underlay: css`
+ background-color: ${theme.components.overlay.background};
+ backdrop-filter: blur(1px);
+ bottom: 0;
+ left: 0;
+ padding: 0;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: ${theme.zIndex.modalBackdrop};
+ `,
+ overlay: css`
+ background: ${theme.colors.background.primary};
+ border: 1px solid ${theme.components.panel.borderColor};
+ display: flex;
+ flex-direction: column;
+ margin: 0 auto;
+ padding: ${theme.spacing(1)};
+ position: fixed;
+ height: 100%;
+ z-index: ${theme.zIndex.modal};
+
+ ${theme.breakpoints.up('md')} {
+ border-radius: ${theme.shape.borderRadius(2)};
+ box-shadow: ${theme.shadows.z3};
+ left: 0;
+ margin: ${theme.spacing(0.5, 'auto', 0)};
+ padding: ${theme.spacing(1)};
+ right: 0;
+ }
+ `,
+ closeBtn: css`
+ right: -5px;
+ top: 0px;
+ z-index: 1;
+ position: absolute;
+ `,
+ searchField: css`
+ position: relative;
+ `,
+ search: css`
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+ padding: ${theme.spacing(2, 0, 3, 0)};
+ `,
+ input: css`
+ box-sizing: border-box;
+ outline: none;
+ background-color: transparent;
+ background: transparent;
+ border-bottom: 1px solid ${theme.colors.border.medium};
+ font-size: 16px;
+ line-height: 30px;
+ width: 100%;
+
+ &::placeholder {
+ color: ${theme.colors.text.disabled};
+ }
+ `,
+ };
+};
diff --git a/public/app/features/search/components/ManageDashboardsNew.tsx b/public/app/features/search/components/ManageDashboardsNew.tsx
index 710a6ea2769..3c3d507345e 100644
--- a/public/app/features/search/components/ManageDashboardsNew.tsx
+++ b/public/app/features/search/components/ManageDashboardsNew.tsx
@@ -1,6 +1,6 @@
import { css, cx } from '@emotion/css';
-import React, { useState } from 'react';
-import { useDebounce, useLocalStorage } from 'react-use';
+import React from 'react';
+import { useLocalStorage } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
@@ -38,19 +38,16 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
const { isEditor } = contextSrv;
- const [inputValue, setInputValue] = useState(query.query ?? '');
const onSearchQueryChange = (e: React.ChangeEvent) => {
- e.preventDefault();
- setInputValue(e.currentTarget.value);
+ onQueryChange(e.currentTarget.value);
};
- useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
return (
<>
{
{
- setInputValue(newQueryText);
- }}
hidePseudoFolders={true}
includePanels={includePanels!}
setIncludePanels={setIncludePanels}
diff --git a/public/app/features/search/components/SearchWrapper.tsx b/public/app/features/search/components/SearchWrapper.tsx
index 7d7881edd75..cb282f8b4d4 100644
--- a/public/app/features/search/components/SearchWrapper.tsx
+++ b/public/app/features/search/components/SearchWrapper.tsx
@@ -1,22 +1,24 @@
import React, { FC, memo } from 'react';
+import { config } from '@grafana/runtime';
import { useUrlParams } from 'app/core/navigation/hooks';
-import { defaultQueryParams } from '../reducers/searchQueryReducer';
-
import { DashboardSearch } from './DashboardSearch';
+import { DashboardSearchModal } from './DashboardSearchModal';
export const SearchWrapper: FC = memo(() => {
- const [params, updateUrlParams] = useUrlParams();
+ const [params] = useUrlParams();
const isOpen = params.get('search') === 'open';
+ const isTopnav = config.featureToggles.topnav;
- const closeSearch = () => {
- if (isOpen) {
- updateUrlParams({ search: null, folder: null, ...defaultQueryParams });
- }
- };
-
- return isOpen ? : null;
+ return isOpen ? (
+ isTopnav ? (
+
+ ) : (
+ // TODO: remove this component when we turn on the topnav feature toggle
+
+ )
+ ) : null;
});
SearchWrapper.displayName = 'SearchWrapper';
diff --git a/public/app/features/search/hooks/useSearchQuery.ts b/public/app/features/search/hooks/useSearchQuery.ts
index f7434df559e..1689db1ac0e 100644
--- a/public/app/features/search/hooks/useSearchQuery.ts
+++ b/public/app/features/search/hooks/useSearchQuery.ts
@@ -1,87 +1,104 @@
import { debounce } from 'lodash';
-import { FormEvent, useCallback, useReducer } from 'react';
+import { FormEvent } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import { SelectableValue } from '@grafana/data';
import { locationService } from '@grafana/runtime';
+import { StoreState } from 'app/types';
-import { SEARCH_SELECTED_LAYOUT } from '../constants';
import {
- ADD_TAG,
- CLEAR_FILTERS,
- LAYOUT_CHANGE,
- QUERY_CHANGE,
- SET_TAGS,
- TOGGLE_SORT,
- TOGGLE_STARRED,
- DATASOURCE_CHANGE,
-} from '../reducers/actionTypes';
-import { defaultQuery, defaultQueryParams, queryReducer } from '../reducers/searchQueryReducer';
+ defaultQueryParams,
+ queryChange,
+ setTags,
+ addTag,
+ datasourceChange,
+ toggleStarred,
+ removeStarred,
+ clearFilters,
+ toggleSort,
+ layoutChange,
+} from '../reducers/searchQueryReducer';
import { DashboardQuery, SearchLayout } from '../types';
-import { hasFilters, parseRouteParams } from '../utils';
+import { hasFilters } from '../utils';
const updateLocation = debounce((query) => locationService.partial(query, true), 300);
export const useSearchQuery = (defaults: Partial) => {
- const queryParams = parseRouteParams(locationService.getSearchObject());
- const initialState = { ...defaultQuery, ...defaults, ...queryParams };
- const selectedLayout = localStorage.getItem(SEARCH_SELECTED_LAYOUT) as SearchLayout;
- if (!queryParams.layout?.length && selectedLayout?.length) {
- initialState.layout = selectedLayout;
- }
- const [query, dispatch] = useReducer(queryReducer, initialState);
+ const query = useSelector((state: StoreState) => state.searchQuery);
+ const dispatch = useDispatch();
- const onQueryChange = useCallback((query: string) => {
- dispatch({ type: QUERY_CHANGE, payload: query });
+ const onQueryChange = (query: string) => {
+ dispatch(queryChange(query));
updateLocation({ query });
- }, []);
+ };
- const onTagFilterChange = useCallback((tags: string[]) => {
- dispatch({ type: SET_TAGS, payload: tags });
+ const onCloseSearch = () => {
+ locationService.partial(
+ {
+ search: null,
+ folder: null,
+ ...defaultQueryParams,
+ },
+ true
+ );
+ };
+
+ const onSelectSearchItem = () => {
+ dispatch(queryChange(''));
+ locationService.partial(
+ {
+ search: null,
+ folder: null,
+ ...defaultQueryParams,
+ },
+ true
+ );
+ };
+
+ const onTagFilterChange = (tags: string[]) => {
+ dispatch(setTags(tags));
updateLocation({ tag: tags });
- }, []);
+ };
- const onDatasourceChange = useCallback((datasource?: string) => {
- dispatch({ type: DATASOURCE_CHANGE, payload: datasource });
+ const onDatasourceChange = (datasource?: string) => {
+ dispatch(datasourceChange(datasource));
updateLocation({ datasource });
- }, []);
+ };
- const onTagAdd = useCallback(
- (tag: string) => {
- dispatch({ type: ADD_TAG, payload: tag });
- updateLocation({ tag: [...query.tag, tag] });
- },
- [query.tag]
- );
+ const onTagAdd = (tag: string) => {
+ dispatch(addTag(tag));
+ updateLocation({ tag: [...query.tag, tag] });
+ };
- const onClearFilters = useCallback(() => {
- dispatch({ type: CLEAR_FILTERS });
+ const onClearFilters = () => {
+ dispatch(clearFilters());
updateLocation(defaultQueryParams);
- }, []);
+ };
- const onStarredFilterChange = useCallback((e: FormEvent) => {
+ const onStarredFilterChange = (e: FormEvent) => {
const starred = (e.target as HTMLInputElement).checked;
- dispatch({ type: TOGGLE_STARRED, payload: starred });
+ dispatch(toggleStarred(starred));
updateLocation({ starred: starred || null });
- }, []);
+ };
- const onClearStarred = useCallback(() => {
- dispatch({ type: TOGGLE_STARRED, payload: false });
+ const onClearStarred = () => {
+ dispatch(removeStarred());
updateLocation({ starred: null });
- }, []);
+ };
- const onSortChange = useCallback((sort: SelectableValue | null) => {
- dispatch({ type: TOGGLE_SORT, payload: sort });
+ const onSortChange = (sort: SelectableValue | null) => {
+ dispatch(toggleSort(sort));
updateLocation({ sort: sort?.value, layout: SearchLayout.List });
- }, []);
+ };
- const onLayoutChange = useCallback((layout: SearchLayout) => {
- dispatch({ type: LAYOUT_CHANGE, payload: layout });
+ const onLayoutChange = (layout: SearchLayout) => {
+ dispatch(layoutChange(layout));
if (layout === SearchLayout.Folders) {
updateLocation({ layout, sort: null });
return;
}
updateLocation({ layout });
- }, []);
+ };
return {
query,
@@ -95,5 +112,7 @@ export const useSearchQuery = (defaults: Partial) => {
onSortChange,
onLayoutChange,
onDatasourceChange,
+ onCloseSearch,
+ onSelectSearchItem,
};
};
diff --git a/public/app/features/search/page/components/SearchView.test.tsx b/public/app/features/search/page/components/SearchView.test.tsx
index 9b11acbc1fe..704dd66ba33 100644
--- a/public/app/features/search/page/components/SearchView.test.tsx
+++ b/public/app/features/search/page/components/SearchView.test.tsx
@@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
import { ArrayVector, DataFrame, DataFrameView, FieldType } from '@grafana/data';
import { config } from '@grafana/runtime';
+import { StoreState } from 'app/types';
import { defaultQuery } from '../../reducers/searchQueryReducer';
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service';
@@ -28,15 +29,23 @@ jest.mock('@grafana/runtime', () => {
};
});
-jest.mock('../../reducers/searchQueryReducer', () => {
- const originalModule = jest.requireActual('../../reducers/searchQueryReducer');
- return {
- ...originalModule,
- defaultQuery: {
- ...originalModule.defaultQuery,
- },
+const setup = (propOverrides?: Partial, storeOverrides?: Partial) => {
+ const props: SearchViewProps = {
+ showManage: false,
+ includePanels: false,
+ setIncludePanels: jest.fn(),
+ keyboardEvents: {} as Observable,
+ ...propOverrides,
};
-});
+
+ const mockStore = configureMockStore();
+ const store = mockStore({ searchQuery: defaultQuery, ...storeOverrides });
+ render(
+
+
+
+ );
+};
describe('SearchView', () => {
const folderData: DataFrame = {
@@ -60,15 +69,6 @@ describe('SearchView', () => {
view: new DataFrameView(folderData),
};
- const baseProps: SearchViewProps = {
- showManage: false,
- queryText: '',
- onQueryTextChange: jest.fn(),
- includePanels: false,
- setIncludePanels: jest.fn(),
- keyboardEvents: {} as Observable,
- };
-
beforeAll(() => {
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult);
});
@@ -79,26 +79,18 @@ describe('SearchView', () => {
});
it('does not show checkboxes or manage actions if showManage is false', async () => {
- render();
+ setup();
await waitFor(() => expect(screen.queryAllByRole('checkbox')).toHaveLength(0));
expect(screen.queryByTestId('manage-actions')).not.toBeInTheDocument();
});
it('shows checkboxes if showManage is true', async () => {
- render();
+ setup({ showManage: true });
await waitFor(() => expect(screen.queryAllByRole('checkbox')).toHaveLength(2));
});
it('shows the manage actions if show manage is true and the user clicked a checkbox', async () => {
- //Mock store
- const mockStore = configureMockStore();
- const store = mockStore({ dashboard: { panels: [] } });
-
- render(
-
-
-
- );
+ setup({ showManage: true });
await waitFor(() => userEvent.click(screen.getAllByRole('checkbox')[0]));
expect(screen.queryByTestId('manage-actions')).toBeInTheDocument();
@@ -110,7 +102,12 @@ describe('SearchView', () => {
totalRows: 0,
view: new DataFrameView({ fields: [], length: 0 }),
});
- render();
+ setup(undefined, {
+ searchQuery: {
+ ...defaultQuery,
+ query: 'asdfasdfasdf',
+ },
+ });
await waitFor(() => expect(screen.queryByText('No results found for your query.')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Clear search and filters' })).toBeInTheDocument();
});
@@ -119,14 +116,14 @@ describe('SearchView', () => {
it('should be enabled when layout is list', async () => {
config.featureToggles.panelTitleSearch = true;
defaultQuery.layout = SearchLayout.List;
- render();
+ setup();
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument());
expect(screen.getByTestId('include-panels')).toBeEnabled();
});
it('should be disabled when layout is folder', async () => {
config.featureToggles.panelTitleSearch = true;
- render();
+ setup();
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument());
expect(screen.getByTestId('include-panels')).toBeDisabled();
diff --git a/public/app/features/search/page/components/SearchView.tsx b/public/app/features/search/page/components/SearchView.tsx
index f8395ff7421..208c43d2d7c 100644
--- a/public/app/features/search/page/components/SearchView.tsx
+++ b/public/app/features/search/page/components/SearchView.tsx
@@ -1,4 +1,5 @@
import { css } from '@emotion/css';
+import debounce from 'debounce-promise';
import React, { useCallback, useMemo, useState } from 'react';
import { useAsync, useDebounce } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
@@ -31,11 +32,9 @@ import { SearchResultsGrid } from './SearchResultsGrid';
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable';
export type SearchViewProps = {
- queryText: string; // odd that it is not from query.query
showManage: boolean;
folderDTO?: FolderDTO;
hidePseudoFolders?: boolean; // Recent + starred
- onQueryTextChange: (newQueryText: string) => void;
includePanels: boolean;
setIncludePanels: (v: boolean) => void;
keyboardEvents: Observable;
@@ -44,8 +43,6 @@ export type SearchViewProps = {
export const SearchView = ({
showManage,
folderDTO,
- queryText,
- onQueryTextChange,
hidePseudoFolders,
includePanels,
setIncludePanels,
@@ -55,6 +52,7 @@ export const SearchView = ({
const {
query,
+ onQueryChange,
onTagFilterChange,
onStarredFilterChange,
onTagAdd,
@@ -62,8 +60,8 @@ export const SearchView = ({
onSortChange,
onLayoutChange,
onClearStarred,
+ onSelectSearchItem,
} = useSearchQuery({});
- query.query = queryText; // Use the query value passed in from parent rather than from URL
const [searchSelection, setSearchSelection] = useState(newSearchSelection());
const layout = getValidQueryLayout(query);
@@ -74,7 +72,7 @@ export const SearchView = ({
const searchQuery = useMemo(() => {
const q: SearchQuery = {
- query: queryText,
+ query: query.query,
tags: query.tag as string[],
ds_uid: query.datasource as string,
location: folderDTO?.uid, // This will scope all results to the prefix
@@ -104,7 +102,7 @@ export const SearchView = ({
q.sort = 'name_sort';
}
return q;
- }, [query, queryText, folderDTO, includePanels]);
+ }, [query, folderDTO, includePanels]);
// Search usage reporting
useDebounce(
@@ -131,34 +129,41 @@ export const SearchView = ({
tagCount: query.tag?.length,
includePanels,
});
+ onSelectSearchItem();
};
- const results = useAsync(() => {
- const trackingInfo = {
- layout: query.layout,
- starred: query.starred,
- sortValue: query.sort?.value,
- query: query.query,
- tagCount: query.tag?.length,
- includePanels,
- };
+ const doSearch = useMemo(
+ () =>
+ debounce((query, searchQuery, includePanels, eventTrackingNamespace) => {
+ const trackingInfo = {
+ layout: query.layout,
+ starred: query.starred,
+ sortValue: query.sort?.value,
+ query: query.query,
+ tagCount: query.tag?.length,
+ includePanels,
+ };
- reportSearchQueryInteraction(eventTrackingNamespace, trackingInfo);
+ reportSearchQueryInteraction(eventTrackingNamespace, trackingInfo);
- if (searchQuery.starred) {
- return getGrafanaSearcher()
- .starred(searchQuery)
- .catch((error) =>
- reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
- );
- }
+ if (searchQuery.starred) {
+ return getGrafanaSearcher()
+ .starred(searchQuery)
+ .catch((error) =>
+ reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
+ );
+ }
- return getGrafanaSearcher()
- .search(searchQuery)
- .catch((error) =>
- reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
- );
- }, [searchQuery]);
+ return getGrafanaSearcher()
+ .search(searchQuery)
+ .catch((error) =>
+ reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
+ );
+ }, 300),
+ []
+ );
+
+ const results = useAsync(() => doSearch(query, searchQuery, includePanels, eventTrackingNamespace), [searchQuery]);
const clearSelection = useCallback(() => {
searchSelection.items.clear();
@@ -184,7 +189,7 @@ export const SearchView = ({
clearSelection();
setListKey(Date.now());
// trigger again the search to the backend
- onQueryTextChange(query.query);
+ onQueryChange(query.query);
};
const getStarredItems = useCallback(
@@ -210,7 +215,7 @@ export const SearchView = ({
variant="secondary"
onClick={() => {
if (query.query) {
- onQueryTextChange('');
+ onQueryChange('');
}
if (query.tag?.length) {
onTagFilterChange([]);
@@ -287,7 +292,7 @@ export const SearchView = ({
);
};
- if (folderDTO && !results.loading && !results.value?.totalRows && !queryText.length) {
+ if (folderDTO && !results.loading && !results.value?.totalRows && !query.query.length) {
return (
{
if (v === SearchLayout.Folders) {
if (query.query) {
- onQueryTextChange(''); // parent will clear the sort
+ onQueryChange(''); // parent will clear the sort
}
if (query.starred) {
onClearStarred();
diff --git a/public/app/features/search/reducers/actionTypes.ts b/public/app/features/search/reducers/actionTypes.ts
deleted file mode 100644
index 048960c8158..00000000000
--- a/public/app/features/search/reducers/actionTypes.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// Search Query
-export const TOGGLE_STARRED = 'TOGGLE_STARRED';
-export const REMOVE_STARRED = 'REMOVE_STARRED';
-export const QUERY_CHANGE = 'QUERY_CHANGE';
-export const DATASOURCE_CHANGE = 'DATASOURCE_CHANGE';
-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';
-export const LAYOUT_CHANGE = 'LAYOUT_CHANGE';
diff --git a/public/app/features/search/reducers/searchQueryReducer.ts b/public/app/features/search/reducers/searchQueryReducer.ts
index 84b42beed07..08131213b0c 100644
--- a/public/app/features/search/reducers/searchQueryReducer.ts
+++ b/public/app/features/search/reducers/searchQueryReducer.ts
@@ -1,17 +1,11 @@
-import { DashboardQuery, SearchQueryParams, SearchAction, SearchLayout } from '../types';
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-import {
- ADD_TAG,
- CLEAR_FILTERS,
- LAYOUT_CHANGE,
- QUERY_CHANGE,
- REMOVE_STARRED,
- REMOVE_TAG,
- SET_TAGS,
- DATASOURCE_CHANGE,
- TOGGLE_SORT,
- TOGGLE_STARRED,
-} from './actionTypes';
+import { SelectableValue } from '@grafana/data';
+import { locationService } from '@grafana/runtime';
+
+import { SEARCH_SELECTED_LAYOUT } from '../constants';
+import { DashboardQuery, SearchQueryParams, SearchLayout } from '../types';
+import { parseRouteParams } from '../utils';
export const defaultQuery: DashboardQuery = {
query: '',
@@ -30,41 +24,84 @@ export const defaultQueryParams: SearchQueryParams = {
layout: null,
};
-export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
- switch (action.type) {
- case QUERY_CHANGE:
- return { ...state, query: action.payload };
- case REMOVE_TAG:
- return { ...state, tag: state.tag.filter((t) => t !== action.payload) };
- case SET_TAGS:
- return { ...state, tag: action.payload };
- case ADD_TAG: {
+const queryParams = parseRouteParams(locationService.getSearchObject());
+const initialState = { ...defaultQuery, ...queryParams };
+const selectedLayout = localStorage.getItem(SEARCH_SELECTED_LAYOUT) as SearchLayout;
+if (!queryParams.layout?.length && selectedLayout?.length) {
+ initialState.layout = selectedLayout;
+}
+
+const searchQuerySlice = createSlice({
+ name: 'searchQuery',
+ initialState,
+ reducers: {
+ queryChange: (state, action: PayloadAction) => {
+ state.query = action.payload;
+ },
+ removeTag: (state, action: PayloadAction) => {
+ state.tag = state.tag.filter((tag) => tag !== action.payload);
+ },
+ setTags: (state, action: PayloadAction) => {
+ state.tag = action.payload;
+ },
+ addTag: (state, action: PayloadAction) => {
const tag = action.payload;
- return tag && !state.tag.includes(tag) ? { ...state, tag: [...state.tag, tag] } : state;
- }
- case DATASOURCE_CHANGE:
- return { ...state, datasource: action.payload };
- case TOGGLE_STARRED:
- return { ...state, starred: action.payload };
- case REMOVE_STARRED:
- return { ...state, starred: false };
- case CLEAR_FILTERS:
- return { ...state, query: '', tag: [], starred: false, sort: null };
- case TOGGLE_SORT: {
+ if (tag && !state.tag.includes(tag)) {
+ state.tag.push(tag);
+ }
+ },
+ datasourceChange: (state, action: PayloadAction) => {
+ state.datasource = action.payload;
+ },
+ toggleStarred: (state, action: PayloadAction) => {
+ state.starred = action.payload;
+ },
+ removeStarred: (state) => {
+ state.starred = false;
+ },
+ clearFilters: (state) => {
+ state.tag = [];
+ state.starred = false;
+ state.sort = null;
+ state.query = '';
+ },
+ toggleSort: (state, action: PayloadAction) => {
const sort = action.payload;
if (state.layout === SearchLayout.Folders) {
- return { ...state, sort, layout: SearchLayout.List };
+ state.sort = sort;
+ state.layout = SearchLayout.List;
+ } else {
+ state.sort = sort;
}
- return { ...state, sort };
- }
- case LAYOUT_CHANGE: {
+ },
+ layoutChange: (state, action: PayloadAction) => {
const layout = action.payload;
if (state.sort && layout === SearchLayout.Folders) {
- return { ...state, layout, sort: null, prevSort: state.sort };
+ state.layout = layout;
+ state.prevSort = state.sort;
+ state.sort = null;
+ } else {
+ state.layout = layout;
+ state.sort = state.prevSort;
}
- return { ...state, layout, sort: state.prevSort };
- }
- default:
- return state;
- }
+ },
+ },
+});
+
+export const {
+ queryChange,
+ removeTag,
+ setTags,
+ addTag,
+ datasourceChange,
+ toggleStarred,
+ removeStarred,
+ clearFilters,
+ toggleSort,
+ layoutChange,
+} = searchQuerySlice.actions;
+export const searchQueryReducer = searchQuerySlice.reducer;
+
+export default {
+ searchQuery: searchQueryReducer,
};