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"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
"public/app/features/search/hooks/useSearchQuery.ts:5381": [
|
"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.", "0"]
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/search/page/components/MoveToFolderModal.tsx:5381": [
|
"public/app/features/search/page/components/MoveToFolderModal.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[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, "Unexpected any. Specify a different type.", "4"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "5"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
[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 { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { GrafanaTheme2, NavSection } from '@grafana/data';
|
import { GrafanaTheme2, NavSection } from '@grafana/data';
|
||||||
|
import { locationService } from '@grafana/runtime';
|
||||||
import { Dropdown, FilterInput, Icon, Tooltip, useStyles2, toIconName } from '@grafana/ui';
|
import { Dropdown, FilterInput, Icon, Tooltip, useStyles2, toIconName } from '@grafana/ui';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
|
import { useSearchQuery } from 'app/features/search/hooks/useSearchQuery';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
import { enrichConfigItems, enrichWithInteractionTracking } from '../NavBar/utils';
|
import { enrichConfigItems, enrichWithInteractionTracking } from '../NavBar/utils';
|
||||||
@ -18,12 +20,24 @@ import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
|||||||
export function TopSearchBar() {
|
export function TopSearchBar() {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { query, onQueryChange } = useSearchQuery({});
|
||||||
const navBarTree = useSelector((state: StoreState) => state.navBarTree);
|
const navBarTree = useSelector((state: StoreState) => state.navBarTree);
|
||||||
const navTree = cloneDeep(navBarTree);
|
const navTree = cloneDeep(navBarTree);
|
||||||
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
|
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
|
||||||
const toggleSwitcherModal = () => {
|
const toggleSwitcherModal = () => {
|
||||||
setShowSwitcherModal(!showSwitcherModal);
|
setShowSwitcherModal(!showSwitcherModal);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onOpenSearch = () => {
|
||||||
|
locationService.partial({ search: 'open' });
|
||||||
|
};
|
||||||
|
const onSearchChange = (value: string) => {
|
||||||
|
onQueryChange(value);
|
||||||
|
if (value) {
|
||||||
|
onOpenSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const configItems = enrichConfigItems(
|
const configItems = enrichConfigItems(
|
||||||
navTree.filter((item) => item.section === NavSection.Config),
|
navTree.filter((item) => item.section === NavSection.Config),
|
||||||
location,
|
location,
|
||||||
@ -42,7 +56,13 @@ export function TopSearchBar() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.searchWrapper}>
|
<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>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Tooltip placement="bottom" content="Help menu (todo)">
|
<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 panelsReducers from 'app/features/panel/state/reducers';
|
||||||
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';
|
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';
|
||||||
import userReducers from 'app/features/profile/state/reducers';
|
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 serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers';
|
||||||
import teamsReducers from 'app/features/teams/state/reducers';
|
import teamsReducers from 'app/features/teams/state/reducers';
|
||||||
import usersReducers from 'app/features/users/state/reducers';
|
import usersReducers from 'app/features/users/state/reducers';
|
||||||
@ -42,6 +43,7 @@ const rootReducers = {
|
|||||||
...panelEditorReducers,
|
...panelEditorReducers,
|
||||||
...panelsReducers,
|
...panelsReducers,
|
||||||
...templatingReducers,
|
...templatingReducers,
|
||||||
|
...searchQueryReducer,
|
||||||
plugins: pluginsReducer,
|
plugins: pluginsReducer,
|
||||||
[alertingApi.reducerPath]: alertingApi.reducer,
|
[alertingApi.reducerPath]: alertingApi.reducer,
|
||||||
};
|
};
|
||||||
|
@ -16,6 +16,7 @@ import { DashboardSettings } from './DashboardSettings';
|
|||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
locationService: {
|
locationService: {
|
||||||
|
getSearchObject: jest.fn().mockResolvedValue({}),
|
||||||
partial: jest.fn(),
|
partial: jest.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { useDebounce, useLocalStorage } from 'react-use';
|
import { useLocalStorage } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
@ -11,27 +11,21 @@ import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
|
|||||||
import { useSearchQuery } from '../hooks/useSearchQuery';
|
import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||||
import { SearchView } from '../page/components/SearchView';
|
import { SearchView } from '../page/components/SearchView';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {}
|
||||||
onCloseSearch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardSearch({ onCloseSearch }: Props) {
|
export function DashboardSearch({}: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const { query, onQueryChange } = useSearchQuery({});
|
const { query, onQueryChange, onCloseSearch } = useSearchQuery({});
|
||||||
|
|
||||||
let [includePanels, setIncludePanels] = useLocalStorage<boolean>(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
|
let [includePanels, setIncludePanels] = useLocalStorage<boolean>(SEARCH_PANELS_LOCAL_STORAGE_KEY, true);
|
||||||
if (!config.featureToggles.panelTitleSearch) {
|
if (!config.featureToggles.panelTitleSearch) {
|
||||||
includePanels = false;
|
includePanels = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState(query.query ?? '');
|
|
||||||
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault();
|
onQueryChange(e.currentTarget.value);
|
||||||
setInputValue(e.currentTarget.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
|
|
||||||
|
|
||||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -42,7 +36,7 @@ export function DashboardSearch({ onCloseSearch }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
|
||||||
value={inputValue}
|
value={query.query ?? ''}
|
||||||
onChange={onSearchQueryChange}
|
onChange={onSearchQueryChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -58,11 +52,7 @@ export function DashboardSearch({ onCloseSearch }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.search}>
|
<div className={styles.search}>
|
||||||
<SearchView
|
<SearchView
|
||||||
onQueryTextChange={(newQueryText) => {
|
|
||||||
setInputValue(newQueryText);
|
|
||||||
}}
|
|
||||||
showManage={false}
|
showManage={false}
|
||||||
queryText={query.query}
|
|
||||||
includePanels={includePanels!}
|
includePanels={includePanels!}
|
||||||
setIncludePanels={setIncludePanels}
|
setIncludePanels={setIncludePanels}
|
||||||
keyboardEvents={keyboardEvents}
|
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 { css, cx } from '@emotion/css';
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { useDebounce, useLocalStorage } from 'react-use';
|
import { useLocalStorage } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
@ -38,19 +38,16 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
|||||||
|
|
||||||
const { isEditor } = contextSrv;
|
const { isEditor } = contextSrv;
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState(query.query ?? '');
|
|
||||||
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onSearchQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault();
|
onQueryChange(e.currentTarget.value);
|
||||||
setInputValue(e.currentTarget.value);
|
|
||||||
};
|
};
|
||||||
useDebounce(() => onQueryChange(inputValue), 200, [inputValue]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cx(styles.actionBar, 'page-action-bar')}>
|
<div className={cx(styles.actionBar, 'page-action-bar')}>
|
||||||
<div className={cx(styles.inputWrapper, 'gf-form gf-form--grow m-r-2')}>
|
<div className={cx(styles.inputWrapper, 'gf-form gf-form--grow m-r-2')}>
|
||||||
<Input
|
<Input
|
||||||
value={inputValue}
|
value={query.query ?? ''}
|
||||||
onChange={onSearchQueryChange}
|
onChange={onSearchQueryChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -73,10 +70,6 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => {
|
|||||||
<SearchView
|
<SearchView
|
||||||
showManage={isEditor || hasEditPermissionInFolders || canSave}
|
showManage={isEditor || hasEditPermissionInFolders || canSave}
|
||||||
folderDTO={folder}
|
folderDTO={folder}
|
||||||
queryText={query.query}
|
|
||||||
onQueryTextChange={(newQueryText) => {
|
|
||||||
setInputValue(newQueryText);
|
|
||||||
}}
|
|
||||||
hidePseudoFolders={true}
|
hidePseudoFolders={true}
|
||||||
includePanels={includePanels!}
|
includePanels={includePanels!}
|
||||||
setIncludePanels={setIncludePanels}
|
setIncludePanels={setIncludePanels}
|
||||||
|
@ -1,22 +1,24 @@
|
|||||||
import React, { FC, memo } from 'react';
|
import React, { FC, memo } from 'react';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { useUrlParams } from 'app/core/navigation/hooks';
|
import { useUrlParams } from 'app/core/navigation/hooks';
|
||||||
|
|
||||||
import { defaultQueryParams } from '../reducers/searchQueryReducer';
|
|
||||||
|
|
||||||
import { DashboardSearch } from './DashboardSearch';
|
import { DashboardSearch } from './DashboardSearch';
|
||||||
|
import { DashboardSearchModal } from './DashboardSearchModal';
|
||||||
|
|
||||||
export const SearchWrapper: FC = memo(() => {
|
export const SearchWrapper: FC = memo(() => {
|
||||||
const [params, updateUrlParams] = useUrlParams();
|
const [params] = useUrlParams();
|
||||||
const isOpen = params.get('search') === 'open';
|
const isOpen = params.get('search') === 'open';
|
||||||
|
const isTopnav = config.featureToggles.topnav;
|
||||||
|
|
||||||
const closeSearch = () => {
|
return isOpen ? (
|
||||||
if (isOpen) {
|
isTopnav ? (
|
||||||
updateUrlParams({ search: null, folder: null, ...defaultQueryParams });
|
<DashboardSearchModal isOpen={isOpen} />
|
||||||
}
|
) : (
|
||||||
};
|
// TODO: remove this component when we turn on the topnav feature toggle
|
||||||
|
<DashboardSearch />
|
||||||
return isOpen ? <DashboardSearch onCloseSearch={closeSearch} /> : null;
|
)
|
||||||
|
) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
SearchWrapper.displayName = 'SearchWrapper';
|
SearchWrapper.displayName = 'SearchWrapper';
|
||||||
|
@ -1,87 +1,104 @@
|
|||||||
import { debounce } from 'lodash';
|
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 { SelectableValue } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
import { SEARCH_SELECTED_LAYOUT } from '../constants';
|
|
||||||
import {
|
import {
|
||||||
ADD_TAG,
|
defaultQueryParams,
|
||||||
CLEAR_FILTERS,
|
queryChange,
|
||||||
LAYOUT_CHANGE,
|
setTags,
|
||||||
QUERY_CHANGE,
|
addTag,
|
||||||
SET_TAGS,
|
datasourceChange,
|
||||||
TOGGLE_SORT,
|
toggleStarred,
|
||||||
TOGGLE_STARRED,
|
removeStarred,
|
||||||
DATASOURCE_CHANGE,
|
clearFilters,
|
||||||
} from '../reducers/actionTypes';
|
toggleSort,
|
||||||
import { defaultQuery, defaultQueryParams, queryReducer } from '../reducers/searchQueryReducer';
|
layoutChange,
|
||||||
|
} from '../reducers/searchQueryReducer';
|
||||||
import { DashboardQuery, SearchLayout } from '../types';
|
import { DashboardQuery, SearchLayout } from '../types';
|
||||||
import { hasFilters, parseRouteParams } from '../utils';
|
import { hasFilters } from '../utils';
|
||||||
|
|
||||||
const updateLocation = debounce((query) => locationService.partial(query, true), 300);
|
const updateLocation = debounce((query) => locationService.partial(query, true), 300);
|
||||||
|
|
||||||
export const useSearchQuery = (defaults: Partial<DashboardQuery>) => {
|
export const useSearchQuery = (defaults: Partial<DashboardQuery>) => {
|
||||||
const queryParams = parseRouteParams(locationService.getSearchObject());
|
const query = useSelector((state: StoreState) => state.searchQuery);
|
||||||
const initialState = { ...defaultQuery, ...defaults, ...queryParams };
|
const dispatch = useDispatch();
|
||||||
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 onQueryChange = useCallback((query: string) => {
|
const onQueryChange = (query: string) => {
|
||||||
dispatch({ type: QUERY_CHANGE, payload: query });
|
dispatch(queryChange(query));
|
||||||
updateLocation({ query });
|
updateLocation({ query });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onTagFilterChange = useCallback((tags: string[]) => {
|
const onCloseSearch = () => {
|
||||||
dispatch({ type: SET_TAGS, payload: tags });
|
locationService.partial(
|
||||||
updateLocation({ tag: tags });
|
{
|
||||||
}, []);
|
search: null,
|
||||||
|
folder: null,
|
||||||
const onDatasourceChange = useCallback((datasource?: string) => {
|
...defaultQueryParams,
|
||||||
dispatch({ type: DATASOURCE_CHANGE, payload: datasource });
|
|
||||||
updateLocation({ datasource });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onTagAdd = useCallback(
|
|
||||||
(tag: string) => {
|
|
||||||
dispatch({ type: ADD_TAG, payload: tag });
|
|
||||||
updateLocation({ tag: [...query.tag, tag] });
|
|
||||||
},
|
},
|
||||||
[query.tag]
|
true
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const onClearFilters = useCallback(() => {
|
const onSelectSearchItem = () => {
|
||||||
dispatch({ type: CLEAR_FILTERS });
|
dispatch(queryChange(''));
|
||||||
|
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);
|
updateLocation(defaultQueryParams);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onStarredFilterChange = useCallback((e: FormEvent<HTMLInputElement>) => {
|
const onStarredFilterChange = (e: FormEvent<HTMLInputElement>) => {
|
||||||
const starred = (e.target as HTMLInputElement).checked;
|
const starred = (e.target as HTMLInputElement).checked;
|
||||||
dispatch({ type: TOGGLE_STARRED, payload: starred });
|
dispatch(toggleStarred(starred));
|
||||||
updateLocation({ starred: starred || null });
|
updateLocation({ starred: starred || null });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onClearStarred = useCallback(() => {
|
const onClearStarred = () => {
|
||||||
dispatch({ type: TOGGLE_STARRED, payload: false });
|
dispatch(removeStarred());
|
||||||
updateLocation({ starred: null });
|
updateLocation({ starred: null });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onSortChange = useCallback((sort: SelectableValue | null) => {
|
const onSortChange = (sort: SelectableValue | null) => {
|
||||||
dispatch({ type: TOGGLE_SORT, payload: sort });
|
dispatch(toggleSort(sort));
|
||||||
updateLocation({ sort: sort?.value, layout: SearchLayout.List });
|
updateLocation({ sort: sort?.value, layout: SearchLayout.List });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onLayoutChange = useCallback((layout: SearchLayout) => {
|
const onLayoutChange = (layout: SearchLayout) => {
|
||||||
dispatch({ type: LAYOUT_CHANGE, payload: layout });
|
dispatch(layoutChange(layout));
|
||||||
if (layout === SearchLayout.Folders) {
|
if (layout === SearchLayout.Folders) {
|
||||||
updateLocation({ layout, sort: null });
|
updateLocation({ layout, sort: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateLocation({ layout });
|
updateLocation({ layout });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
@ -95,5 +112,7 @@ export const useSearchQuery = (defaults: Partial<DashboardQuery>) => {
|
|||||||
onSortChange,
|
onSortChange,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onDatasourceChange,
|
onDatasourceChange,
|
||||||
|
onCloseSearch,
|
||||||
|
onSelectSearchItem,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
|
|||||||
|
|
||||||
import { ArrayVector, DataFrame, DataFrameView, FieldType } from '@grafana/data';
|
import { ArrayVector, DataFrame, DataFrameView, FieldType } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
import { defaultQuery } from '../../reducers/searchQueryReducer';
|
import { defaultQuery } from '../../reducers/searchQueryReducer';
|
||||||
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service';
|
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service';
|
||||||
@ -28,15 +29,23 @@ jest.mock('@grafana/runtime', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('../../reducers/searchQueryReducer', () => {
|
const setup = (propOverrides?: Partial<SearchViewProps>, storeOverrides?: Partial<StoreState>) => {
|
||||||
const originalModule = jest.requireActual('../../reducers/searchQueryReducer');
|
const props: SearchViewProps = {
|
||||||
return {
|
showManage: false,
|
||||||
...originalModule,
|
includePanels: false,
|
||||||
defaultQuery: {
|
setIncludePanels: jest.fn(),
|
||||||
...originalModule.defaultQuery,
|
keyboardEvents: {} as Observable<React.KeyboardEvent>,
|
||||||
},
|
...propOverrides,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
const mockStore = configureMockStore();
|
||||||
|
const store = mockStore({ searchQuery: defaultQuery, ...storeOverrides });
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<SearchView {...props} />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
describe('SearchView', () => {
|
describe('SearchView', () => {
|
||||||
const folderData: DataFrame = {
|
const folderData: DataFrame = {
|
||||||
@ -60,15 +69,6 @@ describe('SearchView', () => {
|
|||||||
view: new DataFrameView<DashboardQueryResult>(folderData),
|
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(() => {
|
beforeAll(() => {
|
||||||
jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult);
|
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 () => {
|
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));
|
await waitFor(() => expect(screen.queryAllByRole('checkbox')).toHaveLength(0));
|
||||||
expect(screen.queryByTestId('manage-actions')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('manage-actions')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows checkboxes if showManage is true', async () => {
|
it('shows checkboxes if showManage is true', async () => {
|
||||||
render(<SearchView {...baseProps} showManage={true} />);
|
setup({ showManage: true });
|
||||||
await waitFor(() => expect(screen.queryAllByRole('checkbox')).toHaveLength(2));
|
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 () => {
|
it('shows the manage actions if show manage is true and the user clicked a checkbox', async () => {
|
||||||
//Mock store
|
setup({ showManage: true });
|
||||||
const mockStore = configureMockStore();
|
|
||||||
const store = mockStore({ dashboard: { panels: [] } });
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<SearchView {...baseProps} showManage={true} />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
await waitFor(() => userEvent.click(screen.getAllByRole('checkbox')[0]));
|
await waitFor(() => userEvent.click(screen.getAllByRole('checkbox')[0]));
|
||||||
|
|
||||||
expect(screen.queryByTestId('manage-actions')).toBeInTheDocument();
|
expect(screen.queryByTestId('manage-actions')).toBeInTheDocument();
|
||||||
@ -110,7 +102,12 @@ describe('SearchView', () => {
|
|||||||
totalRows: 0,
|
totalRows: 0,
|
||||||
view: new DataFrameView<DashboardQueryResult>({ fields: [], length: 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());
|
await waitFor(() => expect(screen.queryByText('No results found for your query.')).toBeInTheDocument());
|
||||||
expect(screen.getByRole('button', { name: 'Clear search and filters' })).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 () => {
|
it('should be enabled when layout is list', async () => {
|
||||||
config.featureToggles.panelTitleSearch = true;
|
config.featureToggles.panelTitleSearch = true;
|
||||||
defaultQuery.layout = SearchLayout.List;
|
defaultQuery.layout = SearchLayout.List;
|
||||||
render(<SearchView {...baseProps} />);
|
setup();
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument());
|
||||||
expect(screen.getByTestId('include-panels')).toBeEnabled();
|
expect(screen.getByTestId('include-panels')).toBeEnabled();
|
||||||
});
|
});
|
||||||
it('should be disabled when layout is folder', async () => {
|
it('should be disabled when layout is folder', async () => {
|
||||||
config.featureToggles.panelTitleSearch = true;
|
config.featureToggles.panelTitleSearch = true;
|
||||||
render(<SearchView {...baseProps} />);
|
setup();
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByLabelText(/include panels/i)).toBeInTheDocument());
|
||||||
expect(screen.getByTestId('include-panels')).toBeDisabled();
|
expect(screen.getByTestId('include-panels')).toBeDisabled();
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
import debounce from 'debounce-promise';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useAsync, useDebounce } from 'react-use';
|
import { useAsync, useDebounce } from 'react-use';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
@ -31,11 +32,9 @@ import { SearchResultsGrid } from './SearchResultsGrid';
|
|||||||
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable';
|
import { SearchResultsTable, SearchResultsProps } from './SearchResultsTable';
|
||||||
|
|
||||||
export type SearchViewProps = {
|
export type SearchViewProps = {
|
||||||
queryText: string; // odd that it is not from query.query
|
|
||||||
showManage: boolean;
|
showManage: boolean;
|
||||||
folderDTO?: FolderDTO;
|
folderDTO?: FolderDTO;
|
||||||
hidePseudoFolders?: boolean; // Recent + starred
|
hidePseudoFolders?: boolean; // Recent + starred
|
||||||
onQueryTextChange: (newQueryText: string) => void;
|
|
||||||
includePanels: boolean;
|
includePanels: boolean;
|
||||||
setIncludePanels: (v: boolean) => void;
|
setIncludePanels: (v: boolean) => void;
|
||||||
keyboardEvents: Observable<React.KeyboardEvent>;
|
keyboardEvents: Observable<React.KeyboardEvent>;
|
||||||
@ -44,8 +43,6 @@ export type SearchViewProps = {
|
|||||||
export const SearchView = ({
|
export const SearchView = ({
|
||||||
showManage,
|
showManage,
|
||||||
folderDTO,
|
folderDTO,
|
||||||
queryText,
|
|
||||||
onQueryTextChange,
|
|
||||||
hidePseudoFolders,
|
hidePseudoFolders,
|
||||||
includePanels,
|
includePanels,
|
||||||
setIncludePanels,
|
setIncludePanels,
|
||||||
@ -55,6 +52,7 @@ export const SearchView = ({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
query,
|
query,
|
||||||
|
onQueryChange,
|
||||||
onTagFilterChange,
|
onTagFilterChange,
|
||||||
onStarredFilterChange,
|
onStarredFilterChange,
|
||||||
onTagAdd,
|
onTagAdd,
|
||||||
@ -62,8 +60,8 @@ export const SearchView = ({
|
|||||||
onSortChange,
|
onSortChange,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onClearStarred,
|
onClearStarred,
|
||||||
|
onSelectSearchItem,
|
||||||
} = useSearchQuery({});
|
} = useSearchQuery({});
|
||||||
query.query = queryText; // Use the query value passed in from parent rather than from URL
|
|
||||||
|
|
||||||
const [searchSelection, setSearchSelection] = useState(newSearchSelection());
|
const [searchSelection, setSearchSelection] = useState(newSearchSelection());
|
||||||
const layout = getValidQueryLayout(query);
|
const layout = getValidQueryLayout(query);
|
||||||
@ -74,7 +72,7 @@ export const SearchView = ({
|
|||||||
|
|
||||||
const searchQuery = useMemo(() => {
|
const searchQuery = useMemo(() => {
|
||||||
const q: SearchQuery = {
|
const q: SearchQuery = {
|
||||||
query: queryText,
|
query: query.query,
|
||||||
tags: query.tag as string[],
|
tags: query.tag as string[],
|
||||||
ds_uid: query.datasource as string,
|
ds_uid: query.datasource as string,
|
||||||
location: folderDTO?.uid, // This will scope all results to the prefix
|
location: folderDTO?.uid, // This will scope all results to the prefix
|
||||||
@ -104,7 +102,7 @@ export const SearchView = ({
|
|||||||
q.sort = 'name_sort';
|
q.sort = 'name_sort';
|
||||||
}
|
}
|
||||||
return q;
|
return q;
|
||||||
}, [query, queryText, folderDTO, includePanels]);
|
}, [query, folderDTO, includePanels]);
|
||||||
|
|
||||||
// Search usage reporting
|
// Search usage reporting
|
||||||
useDebounce(
|
useDebounce(
|
||||||
@ -131,9 +129,12 @@ export const SearchView = ({
|
|||||||
tagCount: query.tag?.length,
|
tagCount: query.tag?.length,
|
||||||
includePanels,
|
includePanels,
|
||||||
});
|
});
|
||||||
|
onSelectSearchItem();
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = useAsync(() => {
|
const doSearch = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((query, searchQuery, includePanels, eventTrackingNamespace) => {
|
||||||
const trackingInfo = {
|
const trackingInfo = {
|
||||||
layout: query.layout,
|
layout: query.layout,
|
||||||
starred: query.starred,
|
starred: query.starred,
|
||||||
@ -158,7 +159,11 @@ export const SearchView = ({
|
|||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
|
reportSearchFailedQueryInteraction(eventTrackingNamespace, { ...trackingInfo, error: error?.message })
|
||||||
);
|
);
|
||||||
}, [searchQuery]);
|
}, 300),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = useAsync(() => doSearch(query, searchQuery, includePanels, eventTrackingNamespace), [searchQuery]);
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
searchSelection.items.clear();
|
searchSelection.items.clear();
|
||||||
@ -184,7 +189,7 @@ export const SearchView = ({
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
setListKey(Date.now());
|
setListKey(Date.now());
|
||||||
// trigger again the search to the backend
|
// trigger again the search to the backend
|
||||||
onQueryTextChange(query.query);
|
onQueryChange(query.query);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStarredItems = useCallback(
|
const getStarredItems = useCallback(
|
||||||
@ -210,7 +215,7 @@ export const SearchView = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (query.query) {
|
if (query.query) {
|
||||||
onQueryTextChange('');
|
onQueryChange('');
|
||||||
}
|
}
|
||||||
if (query.tag?.length) {
|
if (query.tag?.length) {
|
||||||
onTagFilterChange([]);
|
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 (
|
return (
|
||||||
<EmptyListCTA
|
<EmptyListCTA
|
||||||
title="This folder doesn't have any dashboards yet"
|
title="This folder doesn't have any dashboards yet"
|
||||||
@ -311,7 +316,7 @@ export const SearchView = ({
|
|||||||
onLayoutChange={(v) => {
|
onLayoutChange={(v) => {
|
||||||
if (v === SearchLayout.Folders) {
|
if (v === SearchLayout.Folders) {
|
||||||
if (query.query) {
|
if (query.query) {
|
||||||
onQueryTextChange(''); // parent will clear the sort
|
onQueryChange(''); // parent will clear the sort
|
||||||
}
|
}
|
||||||
if (query.starred) {
|
if (query.starred) {
|
||||||
onClearStarred();
|
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 {
|
import { SelectableValue } from '@grafana/data';
|
||||||
ADD_TAG,
|
import { locationService } from '@grafana/runtime';
|
||||||
CLEAR_FILTERS,
|
|
||||||
LAYOUT_CHANGE,
|
import { SEARCH_SELECTED_LAYOUT } from '../constants';
|
||||||
QUERY_CHANGE,
|
import { DashboardQuery, SearchQueryParams, SearchLayout } from '../types';
|
||||||
REMOVE_STARRED,
|
import { parseRouteParams } from '../utils';
|
||||||
REMOVE_TAG,
|
|
||||||
SET_TAGS,
|
|
||||||
DATASOURCE_CHANGE,
|
|
||||||
TOGGLE_SORT,
|
|
||||||
TOGGLE_STARRED,
|
|
||||||
} from './actionTypes';
|
|
||||||
|
|
||||||
export const defaultQuery: DashboardQuery = {
|
export const defaultQuery: DashboardQuery = {
|
||||||
query: '',
|
query: '',
|
||||||
@ -30,41 +24,84 @@ export const defaultQueryParams: SearchQueryParams = {
|
|||||||
layout: null,
|
layout: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
|
const queryParams = parseRouteParams(locationService.getSearchObject());
|
||||||
switch (action.type) {
|
const initialState = { ...defaultQuery, ...queryParams };
|
||||||
case QUERY_CHANGE:
|
const selectedLayout = localStorage.getItem(SEARCH_SELECTED_LAYOUT) as SearchLayout;
|
||||||
return { ...state, query: action.payload };
|
if (!queryParams.layout?.length && selectedLayout?.length) {
|
||||||
case REMOVE_TAG:
|
initialState.layout = selectedLayout;
|
||||||
return { ...state, tag: state.tag.filter((t) => t !== action.payload) };
|
}
|
||||||
case SET_TAGS:
|
|
||||||
return { ...state, tag: action.payload };
|
const searchQuerySlice = createSlice({
|
||||||
case ADD_TAG: {
|
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;
|
const tag = action.payload;
|
||||||
return tag && !state.tag.includes(tag) ? { ...state, tag: [...state.tag, tag] } : state;
|
if (tag && !state.tag.includes(tag)) {
|
||||||
|
state.tag.push(tag);
|
||||||
}
|
}
|
||||||
case DATASOURCE_CHANGE:
|
},
|
||||||
return { ...state, datasource: action.payload };
|
datasourceChange: (state, action: PayloadAction<string | undefined>) => {
|
||||||
case TOGGLE_STARRED:
|
state.datasource = action.payload;
|
||||||
return { ...state, starred: action.payload };
|
},
|
||||||
case REMOVE_STARRED:
|
toggleStarred: (state, action: PayloadAction<boolean>) => {
|
||||||
return { ...state, starred: false };
|
state.starred = action.payload;
|
||||||
case CLEAR_FILTERS:
|
},
|
||||||
return { ...state, query: '', tag: [], starred: false, sort: null };
|
removeStarred: (state) => {
|
||||||
case TOGGLE_SORT: {
|
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;
|
const sort = action.payload;
|
||||||
if (state.layout === SearchLayout.Folders) {
|
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 };
|
},
|
||||||
}
|
layoutChange: (state, action: PayloadAction<SearchLayout>) => {
|
||||||
case LAYOUT_CHANGE: {
|
|
||||||
const layout = action.payload;
|
const layout = action.payload;
|
||||||
if (state.sort && layout === SearchLayout.Folders) {
|
if (state.sort && layout === SearchLayout.Folders) {
|
||||||
return { ...state, layout, sort: null, prevSort: state.sort };
|
state.layout = layout;
|
||||||
}
|
state.prevSort = state.sort;
|
||||||
return { ...state, layout, sort: state.prevSort };
|
state.sort = null;
|
||||||
}
|
} else {
|
||||||
default:
|
state.layout = layout;
|
||||||
return state;
|
state.sort = state.prevSort;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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