diff --git a/.betterer.results b/.betterer.results index b61bae1357e..089366bfc15 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4701,9 +4701,6 @@ exports[`better eslint`] = { "public/app/features/search/hooks/useSearchKeyboardSelection.ts:5381": [ [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"] - ], "public/app/features/search/page/components/MoveToFolderModal.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -4718,10 +4715,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/search/page/components/SearchView.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], "public/app/features/search/page/components/columns.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -4730,9 +4723,6 @@ 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"], @@ -4741,6 +4731,12 @@ exports[`better eslint`] = { "public/app/features/search/service/sql.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/features/search/state/SearchStateManager.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.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"] + ], "public/app/features/search/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/packages/grafana-runtime/src/services/EchoSrv.ts b/packages/grafana-runtime/src/services/EchoSrv.ts index 7609c9fe85e..4bde55d82ac 100644 --- a/packages/grafana-runtime/src/services/EchoSrv.ts +++ b/packages/grafana-runtime/src/services/EchoSrv.ts @@ -130,6 +130,10 @@ export function setEchoSrv(instance: EchoSrv) { * @public */ export function getEchoSrv(): EchoSrv { + if (!singletonInstance) { + singletonInstance = new FakeEchoSrv(); + } + return singletonInstance; } @@ -142,3 +146,17 @@ export function getEchoSrv(): EchoSrv { export const registerEchoBackend = (backend: EchoBackend) => { getEchoSrv().addBackend(backend); }; + +export class FakeEchoSrv implements EchoSrv { + events: Array> = []; + + flush(): void { + this.events = []; + } + + addBackend(backend: EchoBackend): void {} + + addEvent(event: Omit, meta?: {} | undefined): void { + this.events.push(event); + } +} diff --git a/public/app/core/components/AppChrome/TopSearchBarInput.tsx b/public/app/core/components/AppChrome/TopSearchBarInput.tsx index 35cc8a157d8..059a736dc90 100644 --- a/public/app/core/components/AppChrome/TopSearchBarInput.tsx +++ b/public/app/core/components/AppChrome/TopSearchBarInput.tsx @@ -4,11 +4,12 @@ import { locationService } from '@grafana/runtime'; import { FilterInput, ToolbarButton, useTheme2 } from '@grafana/ui'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import { t } from 'app/core/internationalization'; -import { useSearchQuery } from 'app/features/search/hooks/useSearchQuery'; +import { getSearchStateManager } from 'app/features/search/state/SearchStateManager'; export function TopSearchBarInput() { const theme = useTheme2(); - const { query, onQueryChange } = useSearchQuery({}); + const stateManager = getSearchStateManager(); + const state = stateManager.useState(); const breakpoint = theme.breakpoints.values.sm; const [isSmallScreen, setIsSmallScreen] = useState(window.matchMedia(`(max-width: ${breakpoint}px)`).matches); @@ -25,7 +26,7 @@ export function TopSearchBarInput() { }; const onSearchChange = (value: string) => { - onQueryChange(value); + stateManager.onQueryChange(value); if (value) { onOpenSearch(); } @@ -39,7 +40,7 @@ export function TopSearchBarInput() { ); diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 3c1c7e0b188..b4203991570 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -16,7 +16,6 @@ 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'; @@ -44,7 +43,6 @@ const rootReducers = { ...panelEditorReducers, ...panelsReducers, ...templatingReducers, - ...searchQueryReducer, plugins: pluginsReducer, [alertingApi.reducerPath]: alertingApi.reducer, [publicDashboardApi.reducerPath]: publicDashboardApi.reducer, diff --git a/public/app/core/services/StateManagerBase.ts b/public/app/core/services/StateManagerBase.ts index e749f2e529d..7cff1229843 100644 --- a/public/app/core/services/StateManagerBase.ts +++ b/public/app/core/services/StateManagerBase.ts @@ -1,15 +1,14 @@ import { useEffect } from 'react'; -import { Subject } from 'rxjs'; +import { Observer, Subject, Subscription } from 'rxjs'; import { useForceUpdate } from '@grafana/ui'; export class StateManagerBase { - subject = new Subject(); - state: TState; + private _subject = new Subject(); + private _state: TState; constructor(state: TState) { - this.state = state; - this.subject.next(state); + this._state = state; } useState() { @@ -17,12 +16,23 @@ export class StateManagerBase { return useLatestState(this); } + get state() { + return this._state; + } + setState(update: Partial) { - this.state = { - ...this.state, + this._state = { + ...this._state, ...update, }; - this.subject.next(this.state); + this._subject.next(this._state); + } + + /** + * Subscribe to the scene state subject + **/ + subscribeToState(observerOrNext?: Partial>): Subscription { + return this._subject.subscribe(observerOrNext); } } /** @@ -33,7 +43,7 @@ function useLatestState(model: StateManagerBase): TState { const forceUpdate = useForceUpdate(); useEffect(() => { - const s = model.subject.subscribe(forceUpdate); + const s = model.subscribeToState({ next: forceUpdate }); return () => s.unsubscribe(); }, [model, forceUpdate]); diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx index 48d55e8f193..aa66dc703ff 100644 --- a/public/app/features/search/components/DashboardSearch.tsx +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -1,30 +1,21 @@ import { css } from '@emotion/css'; -import React from 'react'; -import { useLocalStorage } from 'react-use'; +import React, { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { IconButton, stylesFactory, 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'; +import { getSearchStateManager } from '../state/SearchStateManager'; export interface Props {} export function DashboardSearch({}: Props) { const styles = useStyles2(getStyles); - const { query, onQueryChange, onCloseSearch } = useSearchQuery({}); + const stateManager = getSearchStateManager(); + const state = stateManager.useState(); - 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); - }; + useEffect(() => stateManager.initStateFromUrl(), [stateManager]); const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); @@ -35,9 +26,9 @@ export function DashboardSearch({}: Props) {
stateManager.onQueryChange(e.currentTarget.value)} onKeyDown={onKeyDown} spellCheck={false} className={styles.input} @@ -45,16 +36,11 @@ export function DashboardSearch({}: Props) {
- +
- +
diff --git a/public/app/features/search/components/DashboardSearchModal.tsx b/public/app/features/search/components/DashboardSearchModal.tsx index 298c37c436e..86875c7f3d0 100644 --- a/public/app/features/search/components/DashboardSearchModal.tsx +++ b/public/app/features/search/components/DashboardSearchModal.tsx @@ -2,18 +2,15 @@ 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 React, { useEffect, 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'; +import { getSearchStateManager } from '../state/SearchStateManager'; const ANIMATION_DURATION = 200; @@ -24,30 +21,21 @@ export interface Props { export function DashboardSearchModal({ isOpen }: Props) { const styles = useStyles2(getStyles); const animStyles = useStyles2((theme) => getAnimStyles(theme, ANIMATION_DURATION)); - const { query, onQueryChange, onCloseSearch } = useSearchQuery({}); + const stateManager = getSearchStateManager(); + const state = stateManager.useState(); const ref = useRef(null); const backdropRef = useRef(null); const [animationComplete, setAnimationComplete] = useState(false); - - const { overlayProps, underlayProps } = useOverlay({ isOpen, onClose: onCloseSearch }, ref); - + const { overlayProps, underlayProps } = useOverlay({ isOpen, onClose: stateManager.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(); + useEffect(() => stateManager.initStateFromUrl(), [stateManager]); + return ( -
+
stateManager.onQueryChange(e.currentTarget.value)} onKeyDown={onKeyDown} tabIndex={0} spellCheck={false} @@ -74,17 +64,12 @@ export function DashboardSearchModal({ isOpen }: Props) {
- +
{animationComplete && (
- +
)} diff --git a/public/app/features/search/components/ManageDashboards.test.tsx b/public/app/features/search/components/ManageDashboards.test.tsx index b8aa0e25abf..efc97f732e5 100644 --- a/public/app/features/search/components/ManageDashboards.test.tsx +++ b/public/app/features/search/components/ManageDashboards.test.tsx @@ -1,9 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { Provider } from 'react-redux'; import { contextSrv } from 'app/core/services/context_srv'; -import { configureStore } from 'app/store/configureStore'; import { FolderDTO } from 'app/types'; import ManageDashboardsNew from './ManageDashboardsNew'; @@ -23,17 +21,10 @@ jest.mock('app/core/services/context_srv', () => { const setup = async (options?: { folder?: FolderDTO }) => { const { folder = {} as FolderDTO } = options || {}; - const store = configureStore(); - const { rerender } = await waitFor(() => - render( - - - - ) - ); + const { rerender } = await waitFor(() => render()); - return { rerender, store }; + return { rerender }; }; jest.spyOn(console, 'error').mockImplementation(); @@ -42,21 +33,16 @@ describe('ManageDashboards', () => { beforeEach(() => { (contextSrv.hasAccess as jest.Mock).mockClear(); }); + it("should hide and show dashboard actions based on user's permissions", async () => { (contextSrv.hasAccess as jest.Mock).mockReturnValue(false); - const { rerender, store } = await setup(); + const { rerender } = await setup(); expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument(); (contextSrv.hasAccess as jest.Mock).mockReturnValue(true); - await waitFor(() => - rerender( - - - - ) - ); + await waitFor(() => rerender()); expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); }); diff --git a/public/app/features/search/components/ManageDashboardsNew.tsx b/public/app/features/search/components/ManageDashboardsNew.tsx index 117747cbfd7..44c3f69f0e5 100644 --- a/public/app/features/search/components/ManageDashboardsNew.tsx +++ b/public/app/features/search/components/ManageDashboardsNew.tsx @@ -1,17 +1,14 @@ import { css, cx } from '@emotion/css'; -import React from 'react'; -import { useLocalStorage } from 'react-use'; +import React, { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { Input, useStyles2, Spinner } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import { FolderDTO, AccessControlAction } from 'app/types'; -import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants'; import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection'; -import { useSearchQuery } from '../hooks/useSearchQuery'; import { SearchView } from '../page/components/SearchView'; +import { getSearchStateManager } from '../state/SearchStateManager'; import { DashboardActions } from './DashboardActions'; @@ -22,7 +19,8 @@ export interface Props { export const ManageDashboardsNew = React.memo(({ folder }: Props) => { const styles = useStyles2(getStyles); // since we don't use "query" from use search... it is not actually loaded from the URL! - const { query, onQueryChange } = useSearchQuery({}); + const stateManager = getSearchStateManager(); + const state = stateManager.useState(); const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); // TODO: we need to refactor DashboardActions to use folder.uid instead @@ -38,26 +36,19 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => { : contextSrv.hasAccess(AccessControlAction.DashboardsCreate, canCreateDashboardsFallback); const viewActions = (folder === undefined && canCreateFolders) || canCreateDashboards; - 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); - }; + useEffect(() => stateManager.initStateFromUrl(folder?.uid), [folder?.uid, stateManager]); return ( <>
stateManager.onQueryChange(e.currentTarget.value)} onKeyDown={onKeyDown} autoFocus spellCheck={false} - placeholder={includePanels ? 'Search for dashboards and panels' : 'Search for dashboards'} + placeholder={state.includePanels ? 'Search for dashboards and panels' : 'Search for dashboards'} className={styles.searchInput} suffix={false ? : null} /> @@ -75,8 +66,6 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => { showManage={Boolean(isEditor || hasEditPermissionInFolders || canSave)} folderDTO={folder} hidePseudoFolders={true} - includePanels={includePanels!} - setIncludePanels={setIncludePanels} keyboardEvents={keyboardEvents} /> diff --git a/public/app/features/search/hooks/useSearchQuery.ts b/public/app/features/search/hooks/useSearchQuery.ts deleted file mode 100644 index 55d7543ff6d..00000000000 --- a/public/app/features/search/hooks/useSearchQuery.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { debounce } from 'lodash'; -import { FormEvent, useEffect } from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { useDispatch, useSelector } from 'app/types'; - -import { - defaultQueryParams, - queryChange, - setTags, - addTag, - datasourceChange, - toggleStarred, - removeStarred, - clearFilters, - toggleSort, - layoutChange, - initStateFromUrl, -} from '../reducers/searchQueryReducer'; -import { DashboardQuery, SearchLayout } from '../types'; -import { hasFilters } from '../utils'; - -const updateLocation = debounce((query) => locationService.partial(query, true), 300); - -export const useSearchQuery = (defaults: Partial) => { - const query = useSelector((state) => state.searchQuery); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(initStateFromUrl(locationService.getSearchObject())); - }, [dispatch]); - - const onQueryChange = (query: string) => { - dispatch(queryChange(query)); - updateLocation({ query }); - }; - - const onCloseSearch = () => { - locationService.partial( - { - search: null, - folder: null, - ...defaultQueryParams, - }, - true - ); - }; - - const onSelectSearchItem = () => { - dispatch(clearFilters()); - locationService.partial( - { - search: null, - folder: null, - ...defaultQueryParams, - }, - true - ); - }; - - const onTagFilterChange = (tags: string[]) => { - dispatch(setTags(tags)); - updateLocation({ tag: tags }); - }; - - const onDatasourceChange = (datasource?: string) => { - dispatch(datasourceChange(datasource)); - updateLocation({ datasource }); - }; - - const onTagAdd = (tag: string) => { - dispatch(addTag(tag)); - updateLocation({ tag: [...query.tag, tag] }); - }; - - const onClearFilters = () => { - dispatch(clearFilters()); - updateLocation(defaultQueryParams); - }; - - const onStarredFilterChange = (e: FormEvent) => { - const starred = (e.target as HTMLInputElement).checked; - dispatch(toggleStarred(starred)); - updateLocation({ starred: starred || null }); - }; - - const onClearStarred = () => { - dispatch(removeStarred()); - updateLocation({ starred: null }); - }; - - const onSortChange = (sort: SelectableValue | null) => { - dispatch(toggleSort(sort)); - updateLocation({ sort: sort?.value, layout: SearchLayout.List }); - }; - - const onLayoutChange = (layout: SearchLayout) => { - dispatch(layoutChange(layout)); - if (layout === SearchLayout.Folders) { - updateLocation({ layout, sort: null }); - return; - } - updateLocation({ layout }); - }; - - return { - query, - hasFilters: hasFilters(query), - onQueryChange, - onClearFilters, - onTagFilterChange, - onStarredFilterChange, - onClearStarred, - onTagAdd, - onSortChange, - onLayoutChange, - onDatasourceChange, - onCloseSearch, - onSelectSearchItem, - }; -}; diff --git a/public/app/features/search/page/components/ActionRow.tsx b/public/app/features/search/page/components/ActionRow.tsx index 176545f8738..415941bdbb0 100644 --- a/public/app/features/search/page/components/ActionRow.tsx +++ b/public/app/features/search/page/components/ActionRow.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React, { FC, FormEvent, useEffect } from 'react'; +import React, { FC, FormEvent } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -7,8 +7,7 @@ import { HorizontalGroup, RadioButtonGroup, useStyles2, Checkbox, Button } from import { SortPicker } from 'app/core/components/Select/SortPicker'; import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter'; -import { SEARCH_SELECTED_LAYOUT } from '../../constants'; -import { DashboardQuery, SearchLayout } from '../../types'; +import { SearchLayout, SearchState } from '../../types'; export const layoutOptions = [ { value: SearchLayout.Folders, icon: 'folder', ariaLabel: 'View by folders' }, @@ -29,13 +28,13 @@ interface Props { sortPlaceholder?: string; onDatasourceChange: (ds?: string) => void; includePanels: boolean; - setIncludePanels: (v: boolean) => void; - query: DashboardQuery; + onSetIncludePanels: (v: boolean) => void; + state: SearchState; showStarredFilter?: boolean; hideLayout?: boolean; } -export function getValidQueryLayout(q: DashboardQuery): SearchLayout { +export function getValidQueryLayout(q: SearchState): SearchLayout { const layout = q.layout ?? SearchLayout.Folders; // Folders is not valid when a query exists @@ -60,51 +59,39 @@ export const ActionRow: FC = ({ getSortOptions, sortPlaceholder, onDatasourceChange, - query, + onSetIncludePanels, + state, showStarredFilter, hideLayout, - includePanels, - setIncludePanels, }) => { const styles = useStyles2(getStyles); - const layout = getValidQueryLayout(query); + const layout = getValidQueryLayout(state); // Disabled folder layout option when query is present - const disabledOptions = query.query ? [SearchLayout.Folders] : []; - - const updateLayoutPreference = (layout: SearchLayout) => { - localStorage.setItem(SEARCH_SELECTED_LAYOUT, layout); - onLayoutChange(layout); - }; - - useEffect(() => { - if (includePanels && layout === SearchLayout.Folders) { - setIncludePanels(false); - } - }, [layout, includePanels, setIncludePanels]); + const disabledOptions = state.query ? [SearchLayout.Folders] : []; return (
- + {config.featureToggles.panelTitleSearch && ( setIncludePanels(!includePanels)} + value={state.includePanels} + onChange={() => onSetIncludePanels(!state.includePanels)} label="Include panels" /> )} {showStarredFilter && (
- +
)} - {query.datasource && ( + {state.datasource && ( )}
@@ -114,13 +101,13 @@ export const ActionRow: FC = ({ )} { return { ...originalModule, reportInteraction: jest.fn(), - config: { - ...originalModule.config, - featureToggles: { - panelTitleSearch: false, - }, - }, }; }); -const setup = (propOverrides?: Partial, storeOverrides?: Partial) => { +const stateManager = getSearchStateManager(); + +const setup = (propOverrides?: Partial, stateOverrides?: Partial) => { const props: SearchViewProps = { showManage: false, - includePanels: false, - setIncludePanels: jest.fn(), keyboardEvents: {} as Observable, ...propOverrides, }; + stateManager.setState({ ...initialState, ...stateOverrides }); + const mockStore = configureMockStore(); - const store = mockStore({ searchQuery: { ...initialState, ...storeOverrides } }); + const store = mockStore({ searchQuery: { ...initialState } }); + render( @@ -61,6 +58,7 @@ describe('SearchView', () => { ], length: 1, }; + const mockSearchResult: QueryResponse = { isItemLoaded: jest.fn(), loadMoreItems: jest.fn(), @@ -77,18 +75,18 @@ describe('SearchView', () => { }); it('does not show checkboxes or manage actions if showManage is false', async () => { - setup({}, { layout: SearchLayout.Folders }); + setup(); await waitFor(() => expect(screen.queryAllByRole('checkbox')).toHaveLength(0)); expect(screen.queryByTestId('manage-actions')).not.toBeInTheDocument(); }); it('shows checkboxes if showManage is true', async () => { - setup({ showManage: true }, { layout: SearchLayout.Folders }); + 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 () => { - setup({ showManage: true }, { layout: SearchLayout.Folders }); + setup({ showManage: true }); await waitFor(() => userEvent.click(screen.getAllByRole('checkbox')[0])); expect(screen.queryByTestId('manage-actions')).toBeInTheDocument(); @@ -100,10 +98,9 @@ describe('SearchView', () => { totalRows: 0, view: new DataFrameView({ fields: [], length: 0 }), }); - setup(undefined, { - query: 'asdfasdfasdf', - layout: SearchLayout.Folders, - }); + + setup(undefined, { query: 'asdfasdfasdf' }); + await waitFor(() => expect(screen.queryByText('No results found for your query.')).toBeInTheDocument()); expect(screen.getByRole('button', { name: 'Clear search and filters' })).toBeInTheDocument(); }); diff --git a/public/app/features/search/page/components/SearchView.tsx b/public/app/features/search/page/components/SearchView.tsx index 860e4bd7249..116f5536525 100644 --- a/public/app/features/search/page/components/SearchView.tsx +++ b/public/app/features/search/page/components/SearchView.tsx @@ -1,26 +1,18 @@ import { css } from '@emotion/css'; -import debounce from 'debounce-promise'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useAsync, useDebounce } from 'react-use'; +import React, { useCallback, useState } from 'react'; +import { useDebounce } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; import { Observable } from 'rxjs'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Spinner, Button } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { TermCount } from 'app/core/components/TagFilter/TagFilter'; import { FolderDTO } from 'app/types'; import { PreviewsSystemRequirements } from '../../components/PreviewsSystemRequirements'; -import { useSearchQuery } from '../../hooks/useSearchQuery'; -import { getGrafanaSearcher, SearchQuery } from '../../service'; +import { getGrafanaSearcher } from '../../service'; +import { getSearchStateManager } from '../../state/SearchStateManager'; import { SearchLayout } from '../../types'; -import { - reportDashboardListViewed, - reportSearchResultInteraction, - reportSearchQueryInteraction, - reportSearchFailedQueryInteraction, -} from '../reporting'; import { newSearchSelection, updateSearchSelection } from '../selection'; import { ActionRow, getValidQueryLayout } from './ActionRow'; @@ -35,142 +27,22 @@ export type SearchViewProps = { showManage: boolean; folderDTO?: FolderDTO; hidePseudoFolders?: boolean; // Recent + starred - includePanels: boolean; - setIncludePanels: (v: boolean) => void; keyboardEvents: Observable; }; -export const SearchView = ({ - showManage, - folderDTO, - hidePseudoFolders, - includePanels, - setIncludePanels, - keyboardEvents, -}: SearchViewProps) => { +export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardEvents }: SearchViewProps) => { const styles = useStyles2(getStyles); - - const { - query, - onQueryChange, - onTagFilterChange, - onStarredFilterChange, - onTagAdd, - onDatasourceChange, - onSortChange, - onLayoutChange, - onClearStarred, - onSelectSearchItem, - } = useSearchQuery({}); + const stateManager = getSearchStateManager(); // State is initialized from URL by parent component + const state = stateManager.useState(); const [searchSelection, setSearchSelection] = useState(newSearchSelection()); - const layout = getValidQueryLayout(query); + const layout = getValidQueryLayout(state); const isFolders = layout === SearchLayout.Folders; const [listKey, setListKey] = useState(Date.now()); - const eventTrackingNamespace = folderDTO ? 'manage_dashboards' : 'dashboard_search'; - - const searchQuery = useMemo(() => { - const q: SearchQuery = { - 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 - sort: query.sort?.value, - explain: query.explain, - withAllowedActions: query.explain, // allowedActions are currently not used for anything on the UI and added only in `explain` mode - starred: query.starred, - }; - - // Only dashboards have additional properties - if (q.sort?.length && !q.sort.includes('name')) { - q.kind = ['dashboard', 'folder']; // skip panels - } - - if (!q.query?.length) { - q.query = '*'; - if (!q.location) { - q.kind = ['dashboard', 'folder']; // skip panels - } - } - - if (!includePanels && !q.kind) { - q.kind = ['dashboard', 'folder']; // skip panels - } - - if (q.query === '*' && !q.sort?.length) { - q.sort = 'name_sort'; - } - return q; - }, [query, folderDTO, includePanels]); // Search usage reporting - useDebounce( - () => { - reportDashboardListViewed(eventTrackingNamespace, { - layout: query.layout, - starred: query.starred, - sortValue: query.sort?.value, - query: query.query, - tagCount: query.tag?.length, - includePanels, - }); - }, - 1000, - [] - ); - - const onClickItem = () => { - reportSearchResultInteraction(eventTrackingNamespace, { - layout: query.layout, - starred: query.starred, - sortValue: query.sort?.value, - query: query.query, - tagCount: query.tag?.length, - includePanels, - }); - onSelectSearchItem(); - }; - - 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); - - 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 }) - ); - }, 300), - [] - ); - - const results = useAsync(() => { - // No need to query all dashboards if we are in search folder view - if (layout === SearchLayout.Folders && !folderDTO) { - return Promise.resolve(); - } - - return doSearch(query, searchQuery, includePanels, eventTrackingNamespace); - }, [searchQuery, layout]); + useDebounce(stateManager.onReportSearchUsage, 1000, []); const clearSelection = useCallback(() => { searchSelection.items.clear(); @@ -185,32 +57,20 @@ export const SearchView = ({ [searchSelection] ); - // This gets the possible tags from within the query results - const getTagOptions = (): Promise => { - return getGrafanaSearcher().tags(searchQuery); - }; - // function to update items when dashboards or folders are moved or deleted const onChangeItemsList = async () => { // clean up search selection clearSelection(); setListKey(Date.now()); // trigger again the search to the backend - onQueryChange(query.query); + stateManager.onQueryChange(state.query); }; - const getStarredItems = useCallback( - (e: React.FormEvent) => { - onStarredFilterChange(e); - }, - [onStarredFilterChange] - ); - const renderResults = () => { - const value = results.value; + const value = state.result; if ((!value || !value.totalRows) && !isFolders) { - if (results.loading && !value) { + if (state.loading && !value) { return ; } @@ -221,14 +81,14 @@ export const SearchView = ({