mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: Refactor state and logic to be less fragmented and spread out (#57973)
* Started search state refactor * Things are working * Move more to statemanger * minor tweaks * Fixed name of hook * revert yarn.lock changes * Harderning StateManagerBase * More tests and refinements * Fixed unit test * More polish * fixing tests * fixed test * Fixed test
This commit is contained in:
parent
94d9baa9ff
commit
915ebcf832
@ -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"]
|
||||
],
|
||||
|
@ -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<Omit<EchoEvent, 'meta'>> = [];
|
||||
|
||||
flush(): void {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
addBackend(backend: EchoBackend): void {}
|
||||
|
||||
addEvent<T extends EchoEvent>(event: Omit<T, 'meta'>, meta?: {} | undefined): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
<FilterInput
|
||||
onClick={onOpenSearch}
|
||||
placeholder={t('nav.search.placeholder', 'Search Grafana')}
|
||||
value={query.query ?? ''}
|
||||
value={state.query ?? ''}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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<TState> {
|
||||
subject = new Subject<TState>();
|
||||
state: TState;
|
||||
private _subject = new Subject<TState>();
|
||||
private _state: TState;
|
||||
|
||||
constructor(state: TState) {
|
||||
this.state = state;
|
||||
this.subject.next(state);
|
||||
this._state = state;
|
||||
}
|
||||
|
||||
useState() {
|
||||
@ -17,12 +16,23 @@ export class StateManagerBase<TState> {
|
||||
return useLatestState(this);
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
setState(update: Partial<TState>) {
|
||||
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<Observer<TState>>): Subscription {
|
||||
return this._subject.subscribe(observerOrNext);
|
||||
}
|
||||
}
|
||||
/**
|
||||
@ -33,7 +43,7 @@ function useLatestState<TState>(model: StateManagerBase<TState>): TState {
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
const s = model.subject.subscribe(forceUpdate);
|
||||
const s = model.subscribeToState({ next: forceUpdate });
|
||||
return () => s.unsubscribe();
|
||||
}, [model, forceUpdate]);
|
||||
|
||||
|
@ -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<boolean>(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
|
||||
if (!config.featureToggles.panelTitleSearch) {
|
||||
includePanels = false;
|
||||
}
|
||||
|
||||
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onQueryChange(e.currentTarget.value);
|
||||
};
|
||||
useEffect(() => stateManager.initStateFromUrl(), [stateManager]);
|
||||
|
||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||
|
||||
@ -35,9 +26,9 @@ export function DashboardSearch({}: Props) {
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
||||
value={query.query ?? ''}
|
||||
onChange={onSearchQueryChange}
|
||||
placeholder={state.includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
||||
value={state.query ?? ''}
|
||||
onChange={(e) => stateManager.onQueryChange(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
spellCheck={false}
|
||||
className={styles.input}
|
||||
@ -45,16 +36,11 @@ export function DashboardSearch({}: Props) {
|
||||
</div>
|
||||
|
||||
<div className={styles.closeBtn}>
|
||||
<IconButton name="times" onClick={onCloseSearch} size="xxl" tooltip="Close search" />
|
||||
<IconButton name="times" onClick={stateManager.onCloseSearch} size="xxl" tooltip="Close search" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.search}>
|
||||
<SearchView
|
||||
showManage={false}
|
||||
includePanels={includePanels!}
|
||||
setIncludePanels={setIncludePanels}
|
||||
keyboardEvents={keyboardEvents}
|
||||
/>
|
||||
<SearchView showManage={false} keyboardEvents={keyboardEvents} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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<HTMLDivElement>(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<boolean>(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
|
||||
if (!config.featureToggles.panelTitleSearch) {
|
||||
includePanels = false;
|
||||
}
|
||||
|
||||
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onQueryChange(e.currentTarget.value);
|
||||
};
|
||||
|
||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||
|
||||
useEffect(() => stateManager.initStateFromUrl(), [stateManager]);
|
||||
|
||||
return (
|
||||
<OverlayContainer>
|
||||
<CSSTransition nodeRef={backdropRef} appear in timeout={ANIMATION_DURATION} classNames={animStyles.underlay}>
|
||||
<div ref={backdropRef} onClick={onCloseSearch} className={styles.underlay} {...underlayProps} />
|
||||
<div ref={backdropRef} onClick={stateManager.onCloseSearch} className={styles.underlay} {...underlayProps} />
|
||||
</CSSTransition>
|
||||
<CSSTransition
|
||||
nodeRef={ref}
|
||||
@ -63,9 +51,11 @@ export function DashboardSearchModal({ isOpen }: Props) {
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
||||
value={query.query ?? ''}
|
||||
onChange={onSearchQueryChange}
|
||||
placeholder={
|
||||
state.includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'
|
||||
}
|
||||
value={state.query ?? ''}
|
||||
onChange={(e) => stateManager.onQueryChange(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
spellCheck={false}
|
||||
@ -74,17 +64,12 @@ export function DashboardSearchModal({ isOpen }: Props) {
|
||||
</div>
|
||||
|
||||
<div className={styles.closeBtn}>
|
||||
<IconButton name="times" onClick={onCloseSearch} size="xl" tooltip="Close search" />
|
||||
<IconButton name="times" onClick={stateManager.onCloseSearch} size="xl" tooltip="Close search" />
|
||||
</div>
|
||||
</div>
|
||||
{animationComplete && (
|
||||
<div className={styles.search}>
|
||||
<SearchView
|
||||
showManage={false}
|
||||
includePanels={includePanels!}
|
||||
setIncludePanels={setIncludePanels}
|
||||
keyboardEvents={keyboardEvents}
|
||||
/>
|
||||
<SearchView showManage={false} keyboardEvents={keyboardEvents} />
|
||||
</div>
|
||||
)}
|
||||
</FocusScope>
|
||||
|
@ -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(
|
||||
<Provider store={store}>
|
||||
<ManageDashboardsNew folder={folder} />
|
||||
</Provider>
|
||||
)
|
||||
);
|
||||
const { rerender } = await waitFor(() => render(<ManageDashboardsNew folder={folder} />));
|
||||
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ManageDashboardsNew folder={{ canEdit: true } as FolderDTO} />
|
||||
</Provider>
|
||||
)
|
||||
);
|
||||
await waitFor(() => rerender(<ManageDashboardsNew folder={{ canEdit: true } as FolderDTO} />));
|
||||
|
||||
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
|
||||
});
|
||||
|
@ -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<boolean>(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
|
||||
if (!config.featureToggles.panelTitleSearch) {
|
||||
includePanels = false;
|
||||
}
|
||||
|
||||
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onQueryChange(e.currentTarget.value);
|
||||
};
|
||||
useEffect(() => stateManager.initStateFromUrl(folder?.uid), [folder?.uid, stateManager]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.actionBar, 'page-action-bar')}>
|
||||
<div className={cx(styles.inputWrapper, 'gf-form gf-form--grow m-r-2')}>
|
||||
<Input
|
||||
value={query.query ?? ''}
|
||||
onChange={onSearchQueryChange}
|
||||
value={state.query ?? ''}
|
||||
onChange={(e) => 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 ? <Spinner /> : 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}
|
||||
/>
|
||||
</>
|
||||
|
@ -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<DashboardQuery>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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,
|
||||
};
|
||||
};
|
@ -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<Props> = ({
|
||||
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 (
|
||||
<div className={styles.actionRow}>
|
||||
<HorizontalGroup spacing="md" width="auto">
|
||||
<TagFilter isClearable={false} tags={query.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
|
||||
<TagFilter isClearable={false} tags={state.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
|
||||
{config.featureToggles.panelTitleSearch && (
|
||||
<Checkbox
|
||||
data-testid="include-panels"
|
||||
disabled={layout === SearchLayout.Folders}
|
||||
value={includePanels}
|
||||
onChange={() => setIncludePanels(!includePanels)}
|
||||
value={state.includePanels}
|
||||
onChange={() => onSetIncludePanels(!state.includePanels)}
|
||||
label="Include panels"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStarredFilter && (
|
||||
<div className={styles.checkboxWrapper}>
|
||||
<Checkbox label="Starred" onChange={onStarredFilterChange} value={query.starred} />
|
||||
<Checkbox label="Starred" onChange={onStarredFilterChange} value={state.starred} />
|
||||
</div>
|
||||
)}
|
||||
{query.datasource && (
|
||||
{state.datasource && (
|
||||
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
|
||||
Datasource: {query.datasource}
|
||||
Datasource: {state.datasource}
|
||||
</Button>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
@ -114,13 +101,13 @@ export const ActionRow: FC<Props> = ({
|
||||
<RadioButtonGroup
|
||||
options={layoutOptions}
|
||||
disabledOptions={disabledOptions}
|
||||
onChange={updateLayoutPreference}
|
||||
onChange={onLayoutChange}
|
||||
value={layout}
|
||||
/>
|
||||
)}
|
||||
<SortPicker
|
||||
onChange={onSortChange}
|
||||
value={query.sort?.value}
|
||||
value={state.sort?.value}
|
||||
getSortOptions={getSortOptions}
|
||||
placeholder={sortPlaceholder}
|
||||
isClearable
|
||||
|
@ -8,9 +8,9 @@ import { Observable } from 'rxjs';
|
||||
import { ArrayVector, DataFrame, DataFrameView, FieldType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { initialState } from '../../reducers/searchQueryReducer';
|
||||
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service';
|
||||
import { DashboardQuery, DashboardSearchItemType, SearchLayout } from '../../types';
|
||||
import { getSearchStateManager, initialState } from '../../state/SearchStateManager';
|
||||
import { DashboardSearchItemType, SearchLayout, SearchState } from '../../types';
|
||||
|
||||
import { SearchView, SearchViewProps } from './SearchView';
|
||||
|
||||
@ -19,26 +19,23 @@ jest.mock('@grafana/runtime', () => {
|
||||
return {
|
||||
...originalModule,
|
||||
reportInteraction: jest.fn(),
|
||||
config: {
|
||||
...originalModule.config,
|
||||
featureToggles: {
|
||||
panelTitleSearch: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const setup = (propOverrides?: Partial<SearchViewProps>, storeOverrides?: Partial<DashboardQuery>) => {
|
||||
const stateManager = getSearchStateManager();
|
||||
|
||||
const setup = (propOverrides?: Partial<SearchViewProps>, stateOverrides?: Partial<SearchState>) => {
|
||||
const props: SearchViewProps = {
|
||||
showManage: false,
|
||||
includePanels: false,
|
||||
setIncludePanels: jest.fn(),
|
||||
keyboardEvents: {} as Observable<React.KeyboardEvent>,
|
||||
...propOverrides,
|
||||
};
|
||||
|
||||
stateManager.setState({ ...initialState, ...stateOverrides });
|
||||
|
||||
const mockStore = configureMockStore();
|
||||
const store = mockStore({ searchQuery: { ...initialState, ...storeOverrides } });
|
||||
const store = mockStore({ searchQuery: { ...initialState } });
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<SearchView {...props} />
|
||||
@ -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<DashboardQueryResult>({ 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();
|
||||
});
|
||||
|
@ -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<React.KeyboardEvent>;
|
||||
};
|
||||
|
||||
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<TermCount[]> => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 <Spinner />;
|
||||
}
|
||||
|
||||
@ -221,14 +81,14 @@ export const SearchView = ({
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (query.query) {
|
||||
onQueryChange('');
|
||||
if (state.query) {
|
||||
stateManager.onQueryChange('');
|
||||
}
|
||||
if (query.tag?.length) {
|
||||
onTagFilterChange([]);
|
||||
if (state.tag?.length) {
|
||||
stateManager.onTagFilterChange([]);
|
||||
}
|
||||
if (query.datasource) {
|
||||
onDatasourceChange(undefined);
|
||||
if (state.datasource) {
|
||||
stateManager.onDatasourceChange(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -246,11 +106,11 @@ export const SearchView = ({
|
||||
section={{ uid: folderDTO.uid, kind: 'folder', title: folderDTO.title }}
|
||||
selection={selection}
|
||||
selectionToggle={toggleSelection}
|
||||
onTagSelected={onTagAdd}
|
||||
onTagSelected={stateManager.onAddTag}
|
||||
renderStandaloneBody={true}
|
||||
tags={query.tag}
|
||||
tags={state.tag}
|
||||
key={listKey}
|
||||
onClickItem={onClickItem}
|
||||
onClickItem={stateManager.onSearchItemClicked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -259,10 +119,10 @@ export const SearchView = ({
|
||||
key={listKey}
|
||||
selection={selection}
|
||||
selectionToggle={toggleSelection}
|
||||
tags={query.tag}
|
||||
onTagSelected={onTagAdd}
|
||||
tags={state.tag}
|
||||
onTagSelected={stateManager.onAddTag}
|
||||
hidePseudoFolders={hidePseudoFolders}
|
||||
onClickItem={onClickItem}
|
||||
onClickItem={stateManager.onSearchItemClicked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -278,10 +138,10 @@ export const SearchView = ({
|
||||
clearSelection,
|
||||
width: width,
|
||||
height: height,
|
||||
onTagSelected: onTagAdd,
|
||||
onTagSelected: stateManager.onAddTag,
|
||||
keyboardEvents,
|
||||
onDatasourceChange: query.datasource ? onDatasourceChange : undefined,
|
||||
onClickItem: onClickItem,
|
||||
onDatasourceChange: state.datasource ? stateManager.onDatasourceChange : undefined,
|
||||
onClickItem: stateManager.onSearchItemClicked,
|
||||
};
|
||||
|
||||
if (layout === SearchLayout.Grid) {
|
||||
@ -299,7 +159,7 @@ export const SearchView = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (folderDTO && !results.loading && !results.value?.totalRows && !query.query.length) {
|
||||
if (folderDTO && !state.loading && !state.result?.totalRows && !state.query.length) {
|
||||
return (
|
||||
<EmptyListCTA
|
||||
title="This folder doesn't have any dashboards yet"
|
||||
@ -320,28 +180,18 @@ export const SearchView = ({
|
||||
<ManageActions items={searchSelection.items} onChange={onChangeItemsList} clearSelection={clearSelection} />
|
||||
) : (
|
||||
<ActionRow
|
||||
onLayoutChange={(v) => {
|
||||
if (v === SearchLayout.Folders) {
|
||||
if (query.query) {
|
||||
onQueryChange(''); // parent will clear the sort
|
||||
}
|
||||
if (query.starred) {
|
||||
onClearStarred();
|
||||
}
|
||||
}
|
||||
onLayoutChange(v);
|
||||
}}
|
||||
onLayoutChange={stateManager.onLayoutChange}
|
||||
showStarredFilter={hidePseudoFolders}
|
||||
onStarredFilterChange={!hidePseudoFolders ? undefined : getStarredItems}
|
||||
onSortChange={onSortChange}
|
||||
onTagFilterChange={onTagFilterChange}
|
||||
getTagOptions={getTagOptions}
|
||||
onStarredFilterChange={!hidePseudoFolders ? undefined : stateManager.onStarredFilterChange}
|
||||
onSortChange={stateManager.onSortChange}
|
||||
onTagFilterChange={stateManager.onTagFilterChange}
|
||||
getTagOptions={stateManager.getTagOptions}
|
||||
getSortOptions={getGrafanaSearcher().getSortOptions}
|
||||
sortPlaceholder={getGrafanaSearcher().sortPlaceholder}
|
||||
onDatasourceChange={onDatasourceChange}
|
||||
query={query}
|
||||
includePanels={includePanels!}
|
||||
setIncludePanels={setIncludePanels}
|
||||
onDatasourceChange={stateManager.onDatasourceChange}
|
||||
state={state}
|
||||
includePanels={state.includePanels!}
|
||||
onSetIncludePanels={stateManager.onSetIncludePanels}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -349,7 +199,7 @@ export const SearchView = ({
|
||||
<PreviewsSystemRequirements
|
||||
bottomSpacing={3}
|
||||
showPreviews={true}
|
||||
onRemove={() => onLayoutChange(SearchLayout.List)}
|
||||
onRemove={() => stateManager.onLayoutChange(SearchLayout.List)}
|
||||
/>
|
||||
)}
|
||||
{renderResults()}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { InspectTab } from 'app/features/inspector/types';
|
||||
|
||||
import { SearchLayout } from '../types';
|
||||
import { EventTrackingNamespace, SearchLayout } from '../types';
|
||||
|
||||
interface QueryProps {
|
||||
layout: SearchLayout;
|
||||
@ -9,28 +9,26 @@ interface QueryProps {
|
||||
sortValue: string;
|
||||
query: string;
|
||||
tagCount: number;
|
||||
includePanels: boolean;
|
||||
includePanels?: boolean;
|
||||
}
|
||||
|
||||
type DashboardListType = 'manage_dashboards' | 'dashboard_search';
|
||||
|
||||
export const reportDashboardListViewed = (dashboardListType: DashboardListType, query: QueryProps) => {
|
||||
reportInteraction(`${dashboardListType}_viewed`, getQuerySearchContext(query));
|
||||
export const reportDashboardListViewed = (eventTrackingNamespace: EventTrackingNamespace, query: QueryProps) => {
|
||||
reportInteraction(`${eventTrackingNamespace}_viewed`, getQuerySearchContext(query));
|
||||
};
|
||||
|
||||
export const reportSearchResultInteraction = (dashboardListType: DashboardListType, query: QueryProps) => {
|
||||
reportInteraction(`${dashboardListType}_result_clicked`, getQuerySearchContext(query));
|
||||
export const reportSearchResultInteraction = (eventTrackingNamespace: EventTrackingNamespace, query: QueryProps) => {
|
||||
reportInteraction(`${eventTrackingNamespace}_result_clicked`, getQuerySearchContext(query));
|
||||
};
|
||||
|
||||
export const reportSearchQueryInteraction = (dashboardListType: DashboardListType, query: QueryProps) => {
|
||||
reportInteraction(`${dashboardListType}_query_submitted`, getQuerySearchContext(query));
|
||||
export const reportSearchQueryInteraction = (eventTrackingNamespace: EventTrackingNamespace, query: QueryProps) => {
|
||||
reportInteraction(`${eventTrackingNamespace}_query_submitted`, getQuerySearchContext(query));
|
||||
};
|
||||
|
||||
export const reportSearchFailedQueryInteraction = (
|
||||
dashboardListType: DashboardListType,
|
||||
eventTrackingNamespace: EventTrackingNamespace,
|
||||
{ error, ...query }: QueryProps & { error?: string }
|
||||
) => {
|
||||
reportInteraction(`${dashboardListType}_query_failed`, { ...getQuerySearchContext(query), error });
|
||||
reportInteraction(`${eventTrackingNamespace}_query_failed`, { ...getQuerySearchContext(query), error });
|
||||
};
|
||||
|
||||
export const reportPanelInspectInteraction = (
|
||||
|
@ -1,112 +0,0 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { SelectableValue, UrlQueryMap } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { SEARCH_SELECTED_LAYOUT } from '../constants';
|
||||
import { DashboardQuery, SearchLayout, SearchQueryParams } from '../types';
|
||||
import { parseRouteParams } from '../utils';
|
||||
|
||||
export const initialState: DashboardQuery = {
|
||||
query: '',
|
||||
tag: [],
|
||||
sort: null,
|
||||
starred: false,
|
||||
layout: SearchLayout.Folders,
|
||||
prevSort: null,
|
||||
};
|
||||
|
||||
export const defaultQueryParams: SearchQueryParams = {
|
||||
sort: null,
|
||||
starred: null,
|
||||
query: null,
|
||||
tag: null,
|
||||
layout: null,
|
||||
};
|
||||
|
||||
const queryParams = parseRouteParams(locationService.getSearchObject());
|
||||
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: {
|
||||
initStateFromUrl(state, action: PayloadAction<UrlQueryMap>) {
|
||||
const queryParams = parseRouteParams(action.payload);
|
||||
Object.assign(state, queryParams);
|
||||
},
|
||||
queryChange: (state, action: PayloadAction<string>) => {
|
||||
state.query = action.payload;
|
||||
},
|
||||
removeTag: (state, action: PayloadAction<string>) => {
|
||||
state.tag = state.tag.filter((tag) => tag !== action.payload);
|
||||
},
|
||||
setTags: (state, action: PayloadAction<string[]>) => {
|
||||
state.tag = action.payload;
|
||||
},
|
||||
addTag: (state, action: PayloadAction<string>) => {
|
||||
const tag = action.payload;
|
||||
if (tag && !state.tag.includes(tag)) {
|
||||
state.tag.push(tag);
|
||||
}
|
||||
},
|
||||
datasourceChange: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.datasource = action.payload;
|
||||
},
|
||||
toggleStarred: (state, action: PayloadAction<boolean>) => {
|
||||
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<SelectableValue | null>) => {
|
||||
const sort = action.payload;
|
||||
if (state.layout === SearchLayout.Folders) {
|
||||
state.sort = sort;
|
||||
state.layout = SearchLayout.List;
|
||||
} else {
|
||||
state.sort = sort;
|
||||
}
|
||||
},
|
||||
layoutChange: (state, action: PayloadAction<SearchLayout>) => {
|
||||
const layout = action.payload;
|
||||
if (state.sort && layout === SearchLayout.Folders) {
|
||||
state.layout = layout;
|
||||
state.prevSort = state.sort;
|
||||
state.sort = null;
|
||||
} else {
|
||||
state.layout = layout;
|
||||
state.sort = state.prevSort;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
queryChange,
|
||||
removeTag,
|
||||
setTags,
|
||||
addTag,
|
||||
datasourceChange,
|
||||
toggleStarred,
|
||||
removeStarred,
|
||||
clearFilters,
|
||||
toggleSort,
|
||||
layoutChange,
|
||||
initStateFromUrl,
|
||||
} = searchQuerySlice.actions;
|
||||
export const searchQueryReducer = searchQuerySlice.reducer;
|
||||
|
||||
export default {
|
||||
searchQuery: searchQueryReducer,
|
||||
};
|
48
public/app/features/search/state/SearchStateManager.test.ts
Normal file
48
public/app/features/search/state/SearchStateManager.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { DataFrameView } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { DashboardQueryResult, getGrafanaSearcher } from '../service';
|
||||
import { SearchLayout } from '../types';
|
||||
|
||||
import { getSearchStateManager } from './SearchStateManager';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const originalModule = jest.requireActual('@grafana/runtime');
|
||||
return {
|
||||
...originalModule,
|
||||
};
|
||||
});
|
||||
|
||||
describe('SearchStateManager', () => {
|
||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue({
|
||||
isItemLoaded: jest.fn(),
|
||||
loadMoreItems: jest.fn(),
|
||||
totalRows: 0,
|
||||
view: new DataFrameView<DashboardQueryResult>({ fields: [], length: 0 }),
|
||||
});
|
||||
|
||||
it('Can get search state manager with initial state', async () => {
|
||||
const stm = getSearchStateManager();
|
||||
expect(stm.state.layout).toBe(SearchLayout.Folders);
|
||||
});
|
||||
|
||||
describe('initStateFromUrl', () => {
|
||||
it('should read and set state from URL and trigger search', async () => {
|
||||
const stm = getSearchStateManager();
|
||||
locationService.partial({ query: 'test', tag: ['tag1', 'tag2'] });
|
||||
stm.initStateFromUrl();
|
||||
expect(stm.state.folderUid).toBe(undefined);
|
||||
expect(stm.state.query).toBe('test');
|
||||
expect(stm.state.tag).toEqual(['tag1', 'tag2']);
|
||||
});
|
||||
|
||||
it('should init or clear folderUid', async () => {
|
||||
const stm = getSearchStateManager();
|
||||
stm.initStateFromUrl('asdsadas');
|
||||
expect(stm.state.folderUid).toBe('asdsadas');
|
||||
|
||||
stm.initStateFromUrl();
|
||||
expect(stm.state.folderUid).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
269
public/app/features/search/state/SearchStateManager.ts
Normal file
269
public/app/features/search/state/SearchStateManager.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { debounce } from 'lodash';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
|
||||
import { StateManagerBase } from 'app/core/services/StateManagerBase';
|
||||
import store from 'app/core/store';
|
||||
|
||||
import { SEARCH_PANELS_LOCAL_STORAGE_KEY, SEARCH_SELECTED_LAYOUT } from '../constants';
|
||||
import {
|
||||
reportDashboardListViewed,
|
||||
reportSearchFailedQueryInteraction,
|
||||
reportSearchQueryInteraction,
|
||||
reportSearchResultInteraction,
|
||||
} from '../page/reporting';
|
||||
import { getGrafanaSearcher, SearchQuery } from '../service';
|
||||
import { SearchLayout, SearchQueryParams, SearchState } from '../types';
|
||||
import { parseRouteParams } from '../utils';
|
||||
|
||||
export const initialState: SearchState = {
|
||||
query: '',
|
||||
tag: [],
|
||||
sort: null,
|
||||
starred: false,
|
||||
layout: SearchLayout.Folders,
|
||||
prevSort: null,
|
||||
eventTrackingNamespace: 'dashboard_search',
|
||||
};
|
||||
|
||||
export const defaultQueryParams: SearchQueryParams = {
|
||||
sort: null,
|
||||
starred: null,
|
||||
query: null,
|
||||
tag: null,
|
||||
layout: null,
|
||||
};
|
||||
|
||||
export class SearchStateManager extends StateManagerBase<SearchState> {
|
||||
updateLocation = debounce((query) => locationService.partial(query, true), 300);
|
||||
doSearchWithDebounce = debounce(() => this.doSearch(), 300);
|
||||
lastQuery?: SearchQuery;
|
||||
|
||||
initStateFromUrl(folderUid?: string) {
|
||||
const stateFromUrl = parseRouteParams(locationService.getSearchObject());
|
||||
|
||||
stateManager.setState({
|
||||
...stateFromUrl,
|
||||
folderUid: folderUid,
|
||||
eventTrackingNamespace: folderUid ? 'manage_dashboards' : 'dashboard_search',
|
||||
});
|
||||
|
||||
this.doSearch();
|
||||
}
|
||||
/**
|
||||
* Updates internal and url state, then triggers a new search
|
||||
*/
|
||||
setStateAndDoSearch(state: Partial<SearchState>) {
|
||||
// Set internal state
|
||||
this.setState(state);
|
||||
|
||||
// Update url state
|
||||
this.updateLocation({
|
||||
query: this.state.query.length === 0 ? null : this.state.query,
|
||||
tag: this.state.tag,
|
||||
datasource: this.state.datasource,
|
||||
starred: this.state.starred ? this.state.starred : null,
|
||||
sort: this.state.sort,
|
||||
});
|
||||
|
||||
// issue new search query
|
||||
this.doSearchWithDebounce();
|
||||
}
|
||||
|
||||
onCloseSearch = () => {
|
||||
this.updateLocation({
|
||||
search: null,
|
||||
folder: null,
|
||||
...defaultQueryParams,
|
||||
});
|
||||
};
|
||||
|
||||
onQueryChange = (query: string) => {
|
||||
this.setStateAndDoSearch({ query });
|
||||
};
|
||||
|
||||
onRemoveTag = (tagToRemove: string) => {
|
||||
this.setStateAndDoSearch({ tag: this.state.tag.filter((tag) => tag !== tagToRemove) });
|
||||
};
|
||||
|
||||
onTagFilterChange = (tags: string[]) => {
|
||||
this.setStateAndDoSearch({ tag: tags });
|
||||
};
|
||||
|
||||
onAddTag = (newTag: string) => {
|
||||
if (this.state.tag && this.state.tag.includes(newTag)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStateAndDoSearch({ tag: [...this.state.tag, newTag] });
|
||||
};
|
||||
|
||||
onDatasourceChange = (datasource: string | undefined) => {
|
||||
this.setStateAndDoSearch({ datasource });
|
||||
};
|
||||
|
||||
onStarredFilterChange = (e: FormEvent<HTMLInputElement>) => {
|
||||
const starred = (e.target as HTMLInputElement).checked;
|
||||
this.setStateAndDoSearch({ starred });
|
||||
};
|
||||
|
||||
onClearStarred = () => {
|
||||
this.setStateAndDoSearch({ starred: false });
|
||||
};
|
||||
|
||||
onSortChange = (sort: SelectableValue | null) => {
|
||||
if (this.state.layout === SearchLayout.Folders) {
|
||||
this.setStateAndDoSearch({ sort, layout: SearchLayout.List });
|
||||
} else {
|
||||
this.setStateAndDoSearch({ sort });
|
||||
}
|
||||
};
|
||||
|
||||
onLayoutChange = (layout: SearchLayout) => {
|
||||
localStorage.setItem(SEARCH_SELECTED_LAYOUT, layout);
|
||||
|
||||
if (this.state.sort && layout === SearchLayout.Folders) {
|
||||
this.setStateAndDoSearch({ layout, prevSort: this.state.sort, sort: null });
|
||||
} else {
|
||||
this.setStateAndDoSearch({ layout, sort: this.state.prevSort });
|
||||
}
|
||||
};
|
||||
|
||||
onSetIncludePanels = (includePanels: boolean) => {
|
||||
this.setStateAndDoSearch({ includePanels });
|
||||
store.set(SEARCH_PANELS_LOCAL_STORAGE_KEY, includePanels);
|
||||
};
|
||||
|
||||
getSearchQuery() {
|
||||
const q: SearchQuery = {
|
||||
query: this.state.query,
|
||||
tags: this.state.tag as string[],
|
||||
ds_uid: this.state.datasource as string,
|
||||
location: this.state.folderUid, // This will scope all results to the prefix
|
||||
sort: this.state.sort?.value,
|
||||
explain: this.state.explain,
|
||||
withAllowedActions: this.state.explain, // allowedActions are currently not used for anything on the UI and added only in `explain` mode
|
||||
starred: this.state.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 (!this.state.includePanels && !q.kind) {
|
||||
q.kind = ['dashboard', 'folder']; // skip panels
|
||||
}
|
||||
|
||||
if (q.query === '*' && !q.sort?.length) {
|
||||
q.sort = 'name_sort';
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
private doSearch() {
|
||||
const trackingInfo = {
|
||||
layout: this.state.layout,
|
||||
starred: this.state.starred,
|
||||
sortValue: this.state.sort?.value,
|
||||
query: this.state.query,
|
||||
tagCount: this.state.tag?.length,
|
||||
includePanels: this.state.includePanels,
|
||||
};
|
||||
|
||||
reportSearchQueryInteraction(this.state.eventTrackingNamespace, trackingInfo);
|
||||
|
||||
this.lastQuery = this.getSearchQuery();
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
if (this.state.starred) {
|
||||
getGrafanaSearcher()
|
||||
.starred(this.lastQuery)
|
||||
.then((result) => this.setState({ result, loading: false }))
|
||||
.catch((error) => {
|
||||
reportSearchFailedQueryInteraction(this.state.eventTrackingNamespace, {
|
||||
...trackingInfo,
|
||||
error: error?.message,
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
} else {
|
||||
getGrafanaSearcher()
|
||||
.search(this.lastQuery)
|
||||
.then((result) => this.setState({ result, loading: false }))
|
||||
.catch((error) => {
|
||||
reportSearchFailedQueryInteraction(this.state.eventTrackingNamespace, {
|
||||
...trackingInfo,
|
||||
error: error?.message,
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// This gets the possible tags from within the query results
|
||||
getTagOptions = (): Promise<TermCount[]> => {
|
||||
return getGrafanaSearcher().tags(this.lastQuery!);
|
||||
};
|
||||
|
||||
/**
|
||||
* When item is selected clear some filters and report interaction
|
||||
*/
|
||||
onSearchItemClicked = () => {
|
||||
// Clear some filters
|
||||
this.setState({ tag: [], starred: false, sort: null, query: '', folderUid: undefined });
|
||||
this.onCloseSearch();
|
||||
|
||||
reportSearchResultInteraction(this.state.eventTrackingNamespace, {
|
||||
layout: this.state.layout,
|
||||
starred: this.state.starred,
|
||||
sortValue: this.state.sort?.value,
|
||||
query: this.state.query,
|
||||
tagCount: this.state.tag?.length,
|
||||
includePanels: this.state.includePanels,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Caller should handle debounce
|
||||
*/
|
||||
onReportSearchUsage() {
|
||||
reportDashboardListViewed(this.state.eventTrackingNamespace, {
|
||||
layout: this.state.layout,
|
||||
starred: this.state.starred,
|
||||
sortValue: this.state.sort?.value,
|
||||
query: this.state.query,
|
||||
tagCount: this.state.tag?.length,
|
||||
includePanels: this.state.includePanels,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let stateManager: SearchStateManager;
|
||||
|
||||
export function getSearchStateManager() {
|
||||
if (!stateManager) {
|
||||
const selectedLayout = localStorage.getItem(SEARCH_SELECTED_LAYOUT) as SearchLayout;
|
||||
const layout = selectedLayout ?? initialState.layout;
|
||||
|
||||
let includePanels = store.getBool(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
|
||||
if (includePanels) {
|
||||
includePanels = false;
|
||||
}
|
||||
|
||||
stateManager = new SearchStateManager({ ...initialState, layout: layout, includePanels });
|
||||
}
|
||||
|
||||
return stateManager;
|
||||
}
|
@ -2,6 +2,8 @@ import { Action } from 'redux';
|
||||
|
||||
import { SelectableValue, WithAccessControlMetadata } from '@grafana/data';
|
||||
|
||||
import { QueryResponse } from './service';
|
||||
|
||||
export enum DashboardSearchItemType {
|
||||
DashDB = 'dash-db',
|
||||
DashHome = 'dash-home',
|
||||
@ -68,7 +70,9 @@ export interface SearchAction extends Action {
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
export interface DashboardQuery {
|
||||
export type EventTrackingNamespace = 'manage_dashboards' | 'dashboard_search';
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
tag: string[];
|
||||
starred: boolean;
|
||||
@ -78,6 +82,11 @@ export interface DashboardQuery {
|
||||
// Save sorting data between layouts
|
||||
prevSort: SelectableValue | null;
|
||||
layout: SearchLayout;
|
||||
result?: QueryResponse;
|
||||
loading?: boolean;
|
||||
folderUid?: string;
|
||||
includePanels?: boolean;
|
||||
eventTrackingNamespace: EventTrackingNamespace;
|
||||
}
|
||||
|
||||
export type OnToggleChecked = (item: DashboardSectionItem | DashboardSection) => void;
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { UrlQueryMap } from '@grafana/data';
|
||||
|
||||
import { SECTION_STORAGE_KEY } from './constants';
|
||||
import { DashboardQuery } from './types';
|
||||
import { SearchState } from './types';
|
||||
|
||||
/**
|
||||
* Check if search query has filters enabled. Excludes folderId
|
||||
* @param query
|
||||
*/
|
||||
export const hasFilters = (query: DashboardQuery) => {
|
||||
export const hasFilters = (query: SearchState) => {
|
||||
if (!query) {
|
||||
return false;
|
||||
}
|
||||
@ -37,7 +37,7 @@ export const parseRouteParams = (params: UrlQueryMap) => {
|
||||
return { ...obj, sort: { value: val } };
|
||||
}
|
||||
return { ...obj, [key]: val };
|
||||
}, {} as Partial<DashboardQuery>);
|
||||
}, {} as Partial<SearchState>);
|
||||
|
||||
if (params.folder) {
|
||||
const folderStr = `folder:${params.folder}`;
|
||||
|
@ -34,7 +34,6 @@ let mockObservable: () => Observable<any>;
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
return {
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
getBackendSrv: () => ({
|
||||
fetch: mockObservable,
|
||||
_request: mockObservable,
|
||||
|
@ -9,7 +9,6 @@ import { CompletionProvider } from './autocomplete';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('CompletionProvider', () => {
|
||||
|
Loading…
Reference in New Issue
Block a user