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:
Torkel Ödegaard 2022-11-03 08:29:39 +01:00 committed by GitHub
parent 94d9baa9ff
commit 915ebcf832
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 497 additions and 606 deletions

View File

@ -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"]
],

View File

@ -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);
}
}

View File

@ -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}
/>
);

View File

@ -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,

View File

@ -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]);

View File

@ -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>

View File

@ -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>

View File

@ -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();
});

View File

@ -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}
/>
</>

View File

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

View File

@ -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

View File

@ -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();
});

View File

@ -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()}

View File

@ -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 = (

View File

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

View 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);
});
});
});

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

View File

@ -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;

View File

@ -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}`;

View File

@ -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,

View File

@ -9,7 +9,6 @@ import { CompletionProvider } from './autocomplete';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
describe('CompletionProvider', () => {