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