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, };