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:
Ashley Harrison 2022-09-09 11:01:31 +01:00 committed by GitHub
parent ad19f018a9
commit a861c10f1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 493 additions and 211 deletions

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import { DashboardSettings } from './DashboardSettings';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
locationService: {
getSearchObject: jest.fn().mockResolvedValue({}),
partial: jest.fn(),
},
}));

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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